Skip to content

Bun.build: read env from a per-build copy instead of the VM's live loader - #37780

Open
robobun wants to merge 6 commits into
mainfrom
farm/5ab1de87/bun-build-env-snapshot
Open

Bun.build: read env from a per-build copy instead of the VM's live loader#37780
robobun wants to merge 6 commits into
mainfrom
farm/5ab1de87/bun-build-env-snapshot

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.build({ env: "inline" }) (or an env: "PREFIX_*" pattern) whose plugin assigns process.env.HTTPS_PROXY while the build is in flight emits a freed buffer: release inlines "0\x02\x00\x00roxy-at-build-call...", ASAN reports heap-use-after-free in best_quote_char_for_string on Bun Pool 0, freed by Bun__setEnvValue on the JS thread. Bun.serve HTML routes without HMR build through the same task.
  • Cause: the build handed the bundle thread the calling VM's live env loader, and every inlined define borrowed the value bytes stored in that map for the rest of the build.
  • The JS thread keeps writing that map during the build: assigning a proxy variable frees the old value in place, and a new key can grow the map while the build is iterating it. The Zig version had the same design, so this is not a port regression.

Fix

  • The build task now takes its own copy of the loader inside the Bun.build() call, the build's transpiler and resolver read that copy, and it is freed with the task. The build never reads the VM's loader and the VM can never reach the copy.
  • The copy also carries the loaded-files and did-load-process flags, so .env files and environ are not re-read on the copy; derived caches start empty and are rebuilt on demand. Semantically a build's defines are a snapshot anyway, so the output now reflects the env at the moment Bun.build() was called instead of an unspecified later point.
  • The macro VM a pool thread creates on first use is reused by every later build on that thread, so with a per-build env it read a freed copy (caught in review of the first revision). It now clones the loader it is given, the same arrangement Worker VMs have; macros observe the same values as before.
  • Verification: three child-process tests (env: "inline", env: "HTTPS_*", a Bun.serve HTML route) fail on the released build and abort under ASAN without the fix, and pass with it. A fourth test (four builds using a macro with UV_THREADPOOL_SIZE=2) aborted under ASAN with the first revision and passes with the macro change. The copy costs well under a millisecond per build with a 30-variable environment.

Background

  • The env loader is bun's native map of environment variables (process environment plus .env files). Each VM materializes its process.env from it, and only a few assignments (the proxy variables, since fetch: observe runtime process.env proxy var changes #28614) write back through to the native map.
  • The env: "inline" and env: "PREFIX_*" build options turn matching variables into defines, constants substituted into the output. A define points at the loader's stored bytes rather than holding a copy.
  • Bun.build() returns a promise and bundles on a process-wide thread pool while JS, including plugin onLoad callbacks, keeps running on the calling thread, so anything the build reads from the VM must be copied or never written during the build.
  • Macros are functions run at bundle time inside a VM that each pool thread creates the first time a macro runs there and never destroys; that VM keeps whatever env pointer it was created with.
Original description

What

Bun.build() (and Bun.serve HTML routes without HMR, which go through the same JSBundleCompletionTask) handed the calling VM's live dotenv Loader to the bundle thread. The task stored vm.transpiler.env and create_and_configure_transpiler passed it to Transpiler::init, so on the bundle thread configure_defines ran load_process() / load() / load_defines() against it, the resolver (and its worker threads) read NODE_PATH through it, and with env: "inline" or an env: "PREFIX_*" pattern every define was an E::String borrowing the value bytes stored in that map for the rest of the build (defines.rs, env_string_store_put).

The JS thread keeps mutating that same loader while a build is in flight: process.env.HTTPS_PROXY = ... (and HTTP_PROXY / NO_PROXY and the lowercase variants, since #28614) goes through Bun__setEnvValue, whose Map::put assigns over the HashTableValue and frees the Box<[u8]> the build's define points at. A new key can also grow the map under a load_defines that is iterating it. The original Zig code had the same design, so this is not a port regression.

Repro

No thread race is needed; a plugin is enough, because the defines are built before any onLoad runs:

process.env.HTTPS_PROXY = "http://proxy-at-build-call.example:1111/aaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const result = await Bun.build({
  entrypoints: ["./entry.ts"],
  env: "inline",
  plugins: [{
    name: "p",
    setup(build) {
      build.onLoad({ filter: /entry\.ts$/ }, () => {
        process.env.HTTPS_PROXY = "http://proxy-set-during-build.example:2222/bbbbbbbbbbbbbbbbbbbbbbbbbbbb";
        return { loader: "ts", contents: "console.log(process.env.HTTPS_PROXY);" };
      });
    },
  }],
});
console.log(await result.outputs[0].text());

Release build (bun 1.4.0-canary): the inlined string is the freed buffer, with the allocator's free-list pointer in its first bytes:

console.log("0\x02\x00\x00roxy-at-build-call.example:1111/aaaa...");

Debug (ASAN) build:

ERROR: AddressSanitizer: heap-use-after-free ... thread T11 (Bun Pool 0)
    #0 bun_js_printer::best_quote_char_for_string src/js_printer/lib.rs:680
    ...
freed by thread T0 here:
    ... <bun_collections::array_hash_map::StringArrayHashMap<bun_dotenv::env_loader::HashTableValue>>::put
    <bun_dotenv::env_loader::Map>::put
    Bun__setEnvValue src/runtime/api/BunObject.rs
    Bun::jsSetterProxyEnvironmentVariable JSEnvironmentVariableMap.cpp

Fix

JSBundleCompletionTask.env is now a Box<bun_dotenv::Loader> of its own, copied from the VM's loader in create_and_schedule_completion_task (JS thread, inside the Bun.build() call), and create_and_configure_transpiler / configure_bundler point the build's transpiler and resolver at that copy. Nothing the build runs reads the VM's loader anymore, and nothing the VM does can reach the build's copy; the copy is dropped with the task.

Loader::clone (new, bun_dotenv) copies the map together with default_files_loaded / custom_files_loaded / did_load_process / quiet, so load_process() and load() on the copy are the same no-ops (and print the same, or nothing) that they were on the VM's loader; a copy of just the map would have re-read environ over runtime-assigned values and re-read the .env files on every build. The derived caches (S3 credentials, NODE_TLS_REJECT_UNAUTHORIZED) start empty; they are computed from the map on first use and the build path does not use them.

Why a copy at call time is the right semantics, not just a safe one: a build's defines are a snapshot by nature (they are baked into the output), and the only thing the previous code "gained" from reading live was an unspecified point in time between Bun.build() returning its promise and the bundle thread getting around to configure_defines. Now the environment that a build inlines is the environment at the moment Bun.build() was called. The compile step's download settings (proxy / TLS for fetching a cross-compile target) come from the same copy, which is the same call-time semantics #37507 is moving that code to.

The macro VM. One consumer of the transpiler's env pointer outlives a build: Macro::init creates a VM on each bundler thread the first time a macro runs there, created with the env pointer of the build that happened to be running, and reuses that VM for every later build (it is never destroyed; the pool threads are process-wide). With a per-build env, the next build running a macro on that thread read the freed copy (Bun__getEnvValue -> Loader::get on a freed map, ASAN heap-use-after-free on Bun Pool 0, freed by the task drop on the JS thread; this was surfaced in review of the first revision). Before this PR the pointer happened to be the calling VM's loader, which outlives everything on the main thread (a Bun.build() inside a Worker had the same problem, since the worker's loader dies with the worker). Macro::init now gives the VM a clone of the loader it is handed, which lives as long as the VM does, the same arrangement Worker VMs have. Semantically the macro VM's env was already fixed at VM creation (process.env is materialized per VM), so macros observe what they did before; the per-thread macro VM's configure_defines also stops writing .env contents into a loader it shares with other threads (previously the calling VM's live loader, in the first revision the build's copy).

Copying the env is per build, roughly one small allocation per variable; under the ASAN debug build a 500-module Bun.build() takes ~320 ms and this adds well under a millisecond with a 30-variable environment.

Related, independent: #37766 removes the quiet write Transpiler::init did on a passed-in loader (with this change that write would only ever have hit the build's copy); #37775 gives the debugger thread's VM its own loader. The bake dev server inlines env through the same define path against the VM's loader for the server's lifetime; that is a separate fix (copying the bytes in env_string_store_put) and has been filed separately.

Tests

test/bundler/bundler_env.test.ts, "env is copied when the build is scheduled": the scenario above run in a child process for Bun.build({ env: "inline" }), Bun.build({ env: "HTTPS_*" }), and a Bun.serve({ development: false }) HTML route with [serve.static] env = "inline" and the plugin loaded from bunfig. Each asserts the bundle inlines the value that was set when the build was scheduled and not the one assigned mid-build. All three fail on the released build (the inlined string is the clobbered buffer shown above) and on the unfixed debug build (ASAN abort), and pass with this change.

A fourth test covers the macro VM: four sequential Bun.build() calls in one child process with UV_THREADPOOL_SIZE=2, each bundling a file whose macro returns a key of process.env no earlier build has read, so the lookup reaches the VM's loader. With two pool threads the third and fourth builds necessarily reuse a VM created by an earlier build. It passes on the released build (where the VM points at the process-lifetime loader), aborted under ASAN with the first revision of this PR (builds 0 and 1 print, build 2 dies in Loader::get), and passes with the Macro::init change.

Also ran locally with the debug build: the rest of bundler_env.test.ts, bun-build-api.test.ts (the 400-build stress test lands at ~167 s of its 180 s budget on this machine with or without the env copy being a factor), bun-serve-html.test.ts (its five development: true tests fail here with EMFILE while initializing file watcher on the released build as well; the development: false ones pass), and a Bun.build({ compile }) of the host target.

…s live loader

JSBundleCompletionTask stored a pointer to the calling VM's dotenv Loader
and handed it to Transpiler::init on the bundle thread, where
configure_defines (load_process, load, load_defines) and the resolver read
it, and where `env: "inline"` / prefix defines borrowed the map's value
bytes for the whole build. The JS thread keeps mutating that loader:
`process.env.HTTPS_PROXY = ...` goes through Bun__setEnvValue, whose
Map::put frees the value it replaces, so a build in flight read (and
inlined) freed memory, and any such write raced the bundle thread's reads.

The task now owns a Loader copied from the VM's when Bun.build() (or an
HTML route build) is scheduled, on the JS thread, and both the build's
transpiler and its resolver point at that copy. Loader::clone keeps the
loaded-files bookkeeping and `quiet`, so load_process/load on the copy
skip exactly what they skipped on the VM's loader. The compile step's
download settings come from the same copy.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 669a0f72-eabb-4cae-a9e4-3acdadd0039e

📥 Commits

Reviewing files that changed from the base of the PR and between 626034f and 0033f85.

📒 Files selected for processing (5)
  • src/bundler/transpiler.rs
  • src/dotenv/env_loader.rs
  • src/js_parser_jsc/Macro.rs
  • src/runtime/api/js_bundle_completion_task.rs
  • test/bundler/bundler_env.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the released build (bun 1.4.0-canary, USE_SYSTEM_BUN=1 bun test test/bundler/bundler_env.test.ts): the three env-copy cases inline the freed buffer (console.log("0\x02\x00\x00roxy-when-the-build-was-scheduled...")); the debug build aborts with the ASAN report quoted in the description (best_quote_char_for_string reading memory freed by Bun__setEnvValue -> Map::put). The fourth case (macro VM reuse across builds) reproduced as an ASAN abort in Loader::get against the first revision of this branch. With the branch as it stands the file passes under bun bd test (11/11).

Review: everything raised so far is addressed (OOM past the plugin handle 5ebdf02, macro VM env escape 457c64d, copy freed when VM creation fails 0033f85); all threads resolved. Needs a maintainer's look at the ownership change. CI: build for 0033f85 pending; 92977 (an earlier revision) had 192 passes and 0 test failures, 93413 died on github.com 503s while downloading dependencies.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:25 AM PT - Aug 12th, 2026

@robobun, your commit 0033f85 has 2 failures in Build #93491 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37780

That installs a local version of the PR into your bun-37780 executable, so you can run:

bun-37780 --bun

Comment thread src/runtime/api/js_bundle_completion_task.rs
Comment thread src/dotenv/env_loader.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs
Comment thread src/runtime/api/js_bundle_completion_task.rs
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/dotenv/env_loader.rs
Comment thread src/runtime/api/js_bundle_completion_task.rs
Comment thread src/runtime/api/js_bundle_completion_task.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-ups, all pushed:

  • 5ebdf02: the env copy's OOM now aborts through handle_oom instead of returning an error past the plugins handle (the point raised on line 152). An error return there would have leaked the protected JSBundlerPlugin for Bun.build() callers, and the function cannot release it itself because for HTML routes the same pointer is owned by the server's ServePlugins. This keeps the function infallible in practice, as it was before, matching the Box::new on the next line.
  • 3010ed1: comments around the copy trimmed; the comment threads are answered and resolved individually. The two pre-existing comments that were flagged were only re-wrapped.
  • The CodeRabbit comments are rate-limit notices, nothing to address.

bun bd test test/bundler/bundler_env.test.ts still passes on the rebuilt binary after 5ebdf02; 3010ed1 changes comments only.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed after 5ebdf02 and 3010ed1 — the earlier OOM/plugins leak concern is resolved (handle_oom aborts, so create_and_schedule_completion_task stays effectively infallible as before). No new issues found. Given this is a cross-thread memory-safety fix touching JSBundleCompletionTask's ownership model and raw-pointer lifetime through the bundle thread, a human sign-off on the ownership reasoning is still worthwhile.

What was reviewed:

  • Loader::clone copies map + load-state flags so load_process/load stay no-ops on the copy; derived caches left empty is fine (rebuilt from the copied map, and the build path doesn't use them).
  • Box'd env gives a stable heap address; &raw mut *self.env handed to Transpiler::init outlives the transpiler (task is only dropped in deinit/free_released_unstarted, both after the bundle thread hands self back).
  • No proxy_env_storage lock needed at the copy site — same JS thread as the only writer (Bun__setEnvValue); workers write their own loader.
  • Tests cover env: "inline", env: "HTTPS_*", and the Bun.serve HTML-route path; each spawns a subprocess, drains both pipes, asserts the at-call value is inlined and the mid-build value is not.
Extended reasoning...

Overview

Fixes a heap-use-after-free in Bun.build() / Bun.serve HTML routes: the bundle thread previously read the calling VM's live bun_dotenv::Loader, whose Box<[u8]> values can be freed on the JS thread by process.env.HTTPS_PROXY = ... (Bun__setEnvValueMap::put) while a build's inlined defines still borrow them. The fix gives each JSBundleCompletionTask its own Box<bun_dotenv::Loader> cloned at call time; the transpiler and resolver now point at that copy. Touches src/dotenv/env_loader.rs (new Loader::clone), src/runtime/api/js_bundle_completion_task.rs (field type change and wiring), and adds three subprocess tests in test/bundler/bundler_env.test.ts.

Security risks

None introduced. The change tightens isolation (build reads a snapshot instead of shared mutable state). No new user-controlled input paths, no auth/crypto/permissions.

Level of scrutiny

High — this is native memory-safety code on a cross-thread boundary (Send task, non-atomic refcount, raw pointers into a Box field consumed on the bundle thread). The reasoning is subtle enough (why no lock, why the Box address is stable through &raw mut *self.env, why handle_oom instead of ?) that a maintainer should confirm it matches the surrounding lifecycle invariants.

Other factors

  • My earlier inline finding (the .clone()? early return leaking the protected JSBundlerPlugin) was addressed in 5ebdf02 by routing OOM through bun_core::handle_oom, which aborts — matching the pre-PR behavior where nothing before Box::new was fallible. Verified handle_oom calls out_of_memory() on Err.
  • Loader::clone uses the existing fallible Map::clone_with_allocator and StringSet::clone; reject_unauthorized/aws_credentials are correctly reset (they're lazy caches derived from the map).
  • The comment-cop threads were resolved (comments trimmed in 3010ed1; the two flagged pre-existing comments were only re-wrapped).
  • CI build #92977 was still running at the time of the last timeline update; result not yet visible.
  • The PR description's semantics argument (call-time snapshot is the right behavior, not just a safe one) is sound and consistent with how defines work.

…uild's

Macro::init creates one VM per bundler thread the first time a macro runs
there and reuses it for every later build; it was created with the env
loader pointer the current build passed in. Now that a Bun.build owns its
env and frees it with the build, the next build running a macro on that
thread read the freed copy. The VM now clones the loader it is given, and
the clone lives as long as the VM, which is never destroyed.

The new test runs four builds with macros on a two-thread pool, so the later
builds necessarily reuse a VM created by an earlier build.
Comment thread src/bundler/transpiler.rs
Comment thread src/js_parser_jsc/Macro.rs
Comment thread src/js_parser_jsc/Macro.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/js_parser_jsc/Macro.rs
Comment thread src/runtime/api/js_bundle_completion_task.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 457c64d (plus f8f60b5, comments only). Self-review of the first revision found a consumer of the env pointer that outlives a build: the per-thread macro VM that Macro::init creates on a bundler thread the first time a macro runs there, and then reuses for every later build. It was created with whatever loader pointer the current build passed in, so with a per-build env the next build running a macro on that thread read the freed copy (reproduced under ASAN: with UV_THREADPOOL_SIZE=2, builds 0 and 1 print, build 2 dies in Loader::get on Bun Pool 0, freed by the task drop on the JS thread). Before this PR that pointer was the calling VM's loader, which on the main thread is never freed.

Macro::init now gives the VM its own clone of the loader, which lives as long as the VM (never destroyed), the same arrangement Worker VMs have. The macro VM's process.env was already fixed at VM creation, so macros observe what they did before. New test in bundler_env.test.ts ("macros in later builds reuse a VM created during an earlier build"): four builds on a two-thread pool, each reading a fresh env key through a macro. Verified locally: it passes on the released build, aborts on a build of this branch with the Macro.rs hunk reverted, and passes with it; the existing macro suites (transpiler/macro-test, the --no-macros and bun-build-api macro cases, regression 3830/22656/26360) still pass. Description updated with the same.

The CodeRabbit comments are still rate-limit notices; the comment threads are answered and resolved.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js_parser_jsc/Macro.rs:436-447 — Nit: into_raw_nn(Box::new(copy)) leaks the boxed Loader if VirtualMachine::init(...)? returns ErrInitOptions::env_loader is a caller-owned borrow (BORROW_PARAM), so init never frees it, and Option<NonNull<T>> has no Drop. Before this PR the slot held NonNull::new(env) (a borrow), so nothing leaked here. Trivial fix: keep the Box in a local and only Box::leak/into_raw after init succeeds — same pattern you already applied in 5ebdf02 for the plugins handle.

    Extended reasoning...

    What the bug is

    The new-VM branch of Macro::init now heap-allocates the env copy via bun_core::heap::into_raw_nn(Box::new(copy)) (line 440) and stores the resulting Option<NonNull<Loader>> in a local before passing it as InitOptions::env_loader. Immediately after, VirtualMachine::init(...)? runs (lines 442–447). If init returns Err, the ? propagates and the boxed bun_dotenv::Loader is never reclaimed — Option<NonNull<T>> is Copy and has no Drop, and per the InitOptions doc (src/jsc/VirtualMachine.rs, "BORROW_PARAM — caller-owned; the loader outlives the VM") the VM never takes ownership of that pointer on either the success or error path.

    Before this PR the same slot was NonNull::new(env), a borrow of the caller's loader, so nothing leaked on the same error path. This is exactly the REVIEW.md pattern "Arm a Drop/RAII guard before any fallible call" / "New early returns or fallible calls → re-audit everything acquired above them": a newly-introduced acquisition placed ahead of an existing fallible call.

    The specific code path

    1. Macro::init reaches the else branch (!VirtualMachine::is_loaded()) on a bundler worker thread the first time a macro runs there.
    2. Line 436–441: NonNull::new(env).map(|env| { let copy = handle_oom(env.as_ref().clone()); into_raw_nn(Box::new(copy)) }) — the Box<Loader> is created and immediately converted to a raw NonNull, so no Drop guard exists.
    3. Line 442–447: VirtualMachine::init(VirtualMachineInitOptions { env_loader, ... })?. Inside init, (hooks.init_runtime_state)(vm, &mut opts)? at VirtualMachine.rs:2501 can fail — the documented case is "e.g. a deleted cwd → getcwd ENOENT out of Transpiler::init".
    4. The ? returns Err from Macro::init. env_loader: Option<NonNull<Loader>> drops with no destructor. The Box<Loader> (map + custom_files_loaded + bookkeeping) leaks.

    Why existing code doesn't prevent it

    InitOptions::env_loader is documented as a caller-owned borrow that is "forwarded to RuntimeHooks::init_runtime_state"; the VM stores the pointer but never frees it. There is no scopeguard around the raw pointer. handle_oom on the clone() itself only handles the OOM case (aborting), not a subsequent init failure.

    Step-by-step proof

    Concretely, with env non-null:

    • env.as_ref().clone() succeeds → copy: Loader.
    • Box::new(copy) succeeds; into_raw_nn returns NonNull<Loader>; the Box's destructor is disarmed. env_loader = Some(ptr).
    • VirtualMachine::init runs alloc_zeroed for the VM, sets the thread-local VM slot, boxes the console, then calls (hooks.init_runtime_state)(vm, &mut opts)?. Suppose Transpiler::init inside that hook fails with ENOENT (deleted cwd).
    • init returns Err(e). Back in Macro::init, ? desugars to return Err(e.into()).
    • Rust drops the locals in scope: env_loader: Option<NonNull<Loader>>Copy, no Drop, nothing runs. The heap allocation at ptr is unreachable.

    Impact — why this is a nit, not blocking

    • The same error path already leaks larger allocations: VirtualMachine::init at line 2369–2375 alloc_zeroeds the VM struct and never frees it on the ? at 2501, and the boxed ConsoleObject (line 2393–2394) and optional log box are likewise leaked. Adding one Box<Loader> (~30 env-var strings) to a path that already leaks the whole VM struct is not a new bug class.
    • VM.set(Some(vm)) runs at line 2376 before the fallible ?, so is_loaded() returns true afterward and this new-VM branch is reached at most once per bundler worker thread — after which macros on that thread are broken anyway (half-initialized VM in the thread-local).
    • The success path intentionally leaks the same box ("it is never destroyed", per the comment at lines 432–435) — the error path merely extends that policy to an unreachable box.
    • The trigger (VM init failing on a bundler worker thread mid-Bun.build) is rare and effectively fatal to macros on that thread.

    How to fix

    Keep the Box alive across the fallible call and only leak it on success, e.g.:

    let env_loader_box = NonNull::new(env).map(|env| {
        Box::new(bun_core::handle_oom(unsafe { env.as_ref() }.clone()))
    });
    let _vm = VirtualMachine::init(VirtualMachineInitOptions {
        log: Some(NonNull::from(&mut *log)),
        env_loader: env_loader_box.as_deref_mut().map(NonNull::from),
        is_main_thread: false,
        ..Default::default()
    })?;
    // init succeeded — the VM (and this thread) keep the loader forever.
    if let Some(b) = env_loader_box { Box::leak(b); }

    (or equivalently core::mem::forget(env_loader_box) after the ?). This is the same shape as 5ebdf02, where the plugins handle was kept out of an early-return path by making the clone abort on OOM instead of returning past it.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the Macro.rs nit (the env copy is not reclaimed if VirtualMachine::init fails): leaving that as is, deliberately. The copy is tied to the VM's lifetime, and on that path VirtualMachine::init has already installed the VM in the thread-local and leaks the VM allocation, its console and its log when init_runtime_state fails (the one failure there is Transpiler::init, e.g. a deleted cwd), after which macros on that thread are unusable anyway. Reclaiming the loader alone would save a few KB on an already leaking, already broken thread, at the cost of making Macro::init depend on init not having stored the pointer before it failed. If a maintainer would rather have the heap::destroy on the error arm regardless, it is a three-line change and I will add it.

Comment thread src/js_parser_jsc/Macro.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 0033f85: took the Macro.rs suggestion after all, since it turned out to be sound as well as small. If VirtualMachine::init fails, its only fallible step (Transpiler::init inside init_runtime_state) fails before anything has stored the loader pointer, so the error arm can heap::destroy the copy; on success the VM keeps it for the rest of the thread's life as before. bundler_env.test.ts and transpiler/macro-test.test.ts pass on the rebuilt debug binary. The CodeRabbit comment is another rate-limit notice.

The previous CI build (93413) failed in the dependency-download steps with HTTP 503s from github.com on every affected lane (lolhtml, c-ares, mimalloc, libuv, WebKit tarballs), unrelated to the change; this push starts a fresh build.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier findings are addressed — the create_and_schedule_completion_task OOM path now aborts (5ebdf02) and the macro VM's env copy is freed on VirtualMachine::init failure (0033f85). This pass found no further issues. Given this changes cross-thread ownership of the env loader (bundle thread, resolver workers, and the per-thread macro VM all now read a per-build copy that drops with the task), a maintainer's look at the lifetime story is still warranted.

What was reviewed

  • Loader::clone: copies the map and the loaded-files bookkeeping so load_process/load on the copy are no-ops; derived caches (reject_unauthorized, S3) start empty and rebuild from the copied map — no consumer of those caches is reached on the build path.
  • Macro::init error arm: verified against VirtualMachine.rs:2498 — on init_runtime_state failure vm.transpiler is never written, so nothing retains the freed copy; the leaked VM's transpiler.env stays null and later is_loaded() short-circuits.
  • free_released_unstarted / deinit: the boxed env drops with the task on both the bundle-thread free path and the JS-thread refcount-zero path.
  • Tests exercise Bun.build (both env: "inline" and prefix), the Bun.serve HTML-route path, and macro-VM reuse across builds; each is a spawned child asserting the inlined value and exitCode.
Extended reasoning...

Overview

Fixes a heap-use-after-free where Bun.build() handed the calling VM's live bun_dotenv::Loader to the bundle thread by pointer while the JS thread kept mutating it (process.env.HTTPS_PROXY = ...Bun__setEnvValueMap::put frees the Box<[u8]> a build define still borrows). The fix gives JSBundleCompletionTask its own Box<Loader> cloned at call time, points the build's transpiler/resolver at that copy, and gives the per-thread macro VM its own clone (since that VM outlives any one build). A new Loader::clone copies the map plus did_load_process / default_files_loaded / custom_files_loaded / quiet so configure_defines on the copy does not re-read environ or the .env files. Four new subprocess tests cover env: "inline", env: "HTTPS_*", Bun.serve static HTML routes, and macro-VM reuse across sequential builds.

Security risks

None identified. The change narrows what the bundle thread can see (a snapshot instead of live process env), which is strictly safer than before. No auth, crypto, or permission surfaces touched.

Level of scrutiny

High. This is memory-safety work in Rust unsafe code that changes which thread owns and frees a shared resource. It threads a raw *mut Loader through Transpiler::init, for_worker's lifetime-widening transmute, and Macro::init's VM creation, and it adds a boxed field whose drop must be reached on every task-teardown path (normal completion, VM-teardown-while-queued, VM-teardown-while-running). Two revisions were needed during review to close leak paths this change itself introduced (the plugins handle on the OOM early return, and the macro VM's copy on VirtualMachine::init failure); the second was found by the author's own re-audit after the first. That is exactly the kind of change a maintainer who owns the bundler thread's lifecycle should sign off on.

Other factors

  • Prior review threads are all resolved except my note on Macro.rs:451, which 0033f85 addresses; the inspect_err free is sound per VirtualMachine.rs:2498 ("vm.transpiler was never written" on the failure path).
  • The author's status comment explicitly requests a maintainer's look at the ownership change.
  • CI on the previous revision was green (192 passes, 0 failures) with two darwin-26 jobs that expired for lack of an agent; the current revision's build is pending.
  • Test coverage is good: each variant is a spawned fixture that fails on the released build (freed-buffer bytes in the output) and under ASAN on the unfixed debug build, per the description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant