From c2a60c459bece55ca30ef53db325725aad1f1551 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:08:28 +0000 Subject: [PATCH 1/6] Bun.build: give each build its own copy of the env instead of the VM'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. --- src/dotenv/env_loader.rs | 20 ++++ src/runtime/api/js_bundle_completion_task.rs | 42 ++++++--- test/bundler/bundler_env.test.ts | 99 +++++++++++++++++++- 3 files changed, 145 insertions(+), 16 deletions(-) diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 57b4464602a8..08279147548d 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -595,6 +595,26 @@ impl Loader { } } + /// Deep copy of the variables and of the "already loaded" bookkeeping, so + /// `load_process` / `load` on the copy skip exactly what they skip on + /// `self`. The lazily derived caches (S3 credentials, TLS + /// reject-unauthorized) start empty and are rebuilt from the copied map. + /// + /// A loader keeps being mutated on the thread that owns it + /// (`process.env.HTTPS_PROXY = ...` replaces map values in place), so work + /// that reads env on another thread takes one of these instead. + pub fn clone(&self) -> Result { + Ok(Loader { + map: self.map.clone_with_allocator()?, + default_files_loaded: self.default_files_loaded, + custom_files_loaded: self.custom_files_loaded.clone()?, + quiet: self.quiet, + did_load_process: self.did_load_process, + reject_unauthorized: Cell::new(None), + aws_credentials: None, + }) + } + pub fn load_process(&mut self) -> Result<(), AllocError> { if self.did_load_process { return Ok(()); diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 27657f0da198..c5473eb2b9f8 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -59,7 +59,14 @@ pub struct JSBundleCompletionTask { pub global_this: BackRef, pub(crate) promise: jsc::JSPromiseStrong, pub poll_ref: KeepAlive, - pub(crate) env: *mut bun_dotenv::Loader, + /// The calling VM's env as of the `Bun.build()` call. The build reads env + /// (`configure_defines`, `env: "inline"` defines, the resolver's + /// `NODE_PATH`) on the bundle thread and its workers, while the VM's own + /// loader keeps being mutated on the JS thread (`Bun__setEnvValue` for + /// `process.env.HTTPS_PROXY = ...` frees the value it replaces), so the + /// build must not read that one. Boxed so the pointer `Transpiler::init` + /// keeps is to an allocation of its own, not into this struct. + pub(crate) env: Box, pub(crate) log: bun_ast::Log, /// Set by the owner giving up on the result (HTMLBundle route torn down) /// or by the VM's stop phase; read by `on_complete` (skip delivery) and by @@ -120,7 +127,7 @@ impl JSBundleCompletionTask { // last-ref drop is the only place that releases it. Plugin::destroy(plugin.as_ptr()); } - // Owned fields (`config`, `log`, `result`, `promise`) drop with the Box. + // Owned fields (`config`, `env`, `log`, `result`, `promise`) drop with the Box. } } @@ -141,7 +148,10 @@ pub(crate) fn create_and_schedule_completion_task( global_this: &JSGlobalObject, ) -> crate::Result<*mut JSBundleCompletionTask> { let vm = global_this.bun_vm_ptr(); - let env = global_this.bun_vm().transpiler.env; + // Copied here on this VM's JS thread, the thread `Bun__setEnvValue` mutates + // the loader from, so unlike a Worker's clone (web_worker.rs) this one + // needs no `proxy_env_storage` lock. + let env = Box::new(global_this.bun_vm().env_loader().clone()?); let completion = bun_core::heap::into_raw(Box::new(JSBundleCompletionTask { ref_count: RefCount::init(), config, @@ -173,8 +183,8 @@ pub(crate) fn create_and_schedule_completion_task( let _ = WorkPool::get(); // Out on the bundle thread from here until it posts the completion: it - // reads this VM's env loader and the plugin cell, so the VM cancels it at - // teardown (registry) and waits for it (embedded work). + // reads this VM's plugin cell and posts into this VM's queue, so the VM + // cancels it at teardown (registry) and waits for it (embedded work). // SAFETY: `completion` is live (refcount==1), JS thread. unsafe { (*completion).loop_handle.embedded_work_scheduled() }; crate::jsc_hooks::ActiveHandle::Bundle(NonNull::new(completion).expect("completion")) @@ -426,10 +436,7 @@ impl JSBundleCompletionTask { root_dir.fd, module_prefix, outfile_for_executable, - // SAFETY: `self.env` is the per-VM `DotEnv.Loader` stashed at - // construction; valid for the lifetime of the VirtualMachine, and - // nothing inside `to_executable` reaches it otherwise. - unsafe { &mut *self.env }, + &mut self.env, self.config.format, &WindowsOptions { hide_console: compile_options.windows_hide_console, @@ -909,9 +916,9 @@ impl CompletionStruct for JSBundleCompletionTask { core::hint::spin_loop(); } // The VM released everything thread-affine (`stop_for_vm_teardown`); - // what is left — config, log, an empty promise slot, a `Done` - // keep-alive, the handle clone — is ours to drop here. The queue held - // the creation reference. + // what is left (config, the env copy, log, an empty promise slot, a + // `Done` keep-alive, the handle clone) is ours to drop here. The + // queue held the creation reference. // SAFETY: dequeued ⇒ sole owner; nothing JS-affine remains. drop(unsafe { bun_core::heap::take(this) }); } @@ -1105,8 +1112,9 @@ impl CompletionStruct for JSBundleCompletionTask { .conditions .append_slice(&[b"development"])?; } - // `transpiler.env` is the dotenv loader installed by - // `Transpiler::init`; non-null and valid for `'a`. + // `transpiler.env` is this build's env copy (`self.env`), installed by + // `create_and_configure_transpiler`; the resolver's workers read + // `NODE_PATH` through it. transpiler.resolver.env_loader = NonNull::new(transpiler.env); // `Resolver.opts` is the resolver-crate subset // — re-project from the now-mutated `transpiler.options`. @@ -1191,7 +1199,11 @@ impl CompletionStruct for JSBundleCompletionTask { }; let log: *mut bun_ast::Log = &raw mut self.log; - let t = Transpiler::init(bump, log, opts, Some(self.env))?; + // The build's own env copy (see the field doc). Same lifetime argument + // as `log`: every read through the transpiler happens before + // `complete_on_bundle_thread` hands this task back to the JS thread. + let env: *mut bun_dotenv::Loader = &raw mut *self.env; + let t = Transpiler::init(bump, log, opts, Some(env))?; let transpiler: &'a mut Transpiler<'a> = bump.alloc(t); // Post-init field wiring. diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index b87ab47ab605..e269e7867363 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -1,4 +1,5 @@ -import { describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; import { itBundled } from "./expectBundled"; for (let backend of ["api", "cli"] as const) { @@ -118,3 +119,99 @@ for (let backend of ["api", "cli"] as const) { }); }); } + +// A build inlines the env as of the call that started it. The bundler thread +// reads env from its own copy: `process.env.HTTPS_PROXY = ...` (one of the +// few assignments that write through to the native env map, replacing the +// stored value in place) while a build is in flight must not change, or free +// out from under, what the build inlines. +describe.concurrent("env is copied when the build is scheduled", () => { + const atCall = "http://proxy-when-the-build-was-scheduled.example:1111/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const duringBuild = "http://proxy-assigned-while-bundling.example:2222/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + // The plugin's onLoad runs on the JS thread while the bundle is in flight, + // after the bundler has built its defines. + const pluginSource = (filter: string) => /* ts */ ` + export default { + name: "assign-proxy-env-while-bundling", + setup(build) { + build.onLoad({ filter: ${filter} }, () => { + process.env.HTTPS_PROXY = ${JSON.stringify(duringBuild)}; + return { loader: "ts", contents: "console.log(process.env.HTTPS_PROXY);" }; + }); + }, + }; + `; + + test.each(["inline", "HTTPS_*"] as const)("Bun.build({ env: %j })", async env => { + using dir = tempDir("bun-build-env-copy", { + "entry.ts": "export {};", + "plugin.ts": pluginSource("/entry\\.ts$/"), + "build-fixture.ts": /* ts */ ` + import plugin from "./plugin.ts"; + process.env.HTTPS_PROXY = ${JSON.stringify(atCall)}; + const result = await Bun.build({ + entrypoints: ["./entry.ts"], + env: ${JSON.stringify(env)}, + plugins: [plugin], + }); + process.stdout.write(await result.outputs[0].text()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build-fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain(`console.log(${JSON.stringify(atCall)})`); + expect(stdout).not.toContain(duringBuild); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + // Bun.serve's HTML routes (without HMR) are built through the same bundler + // thread, with env behavior and plugins coming from bunfig. + test('Bun.serve HTML route with [serve.static] env = "inline"', async () => { + using dir = tempDir("bun-serve-html-env-copy", { + "bunfig.toml": /* toml */ ` + [serve.static] + env = "inline" + plugins = ["./plugin.ts"] + `, + "index.html": /* html */ ``, + "app.ts": "export {};", + "plugin.ts": pluginSource("/app\\.ts$/"), + "serve-fixture.ts": /* ts */ ` + import index from "./index.html"; + process.env.HTTPS_PROXY = ${JSON.stringify(atCall)}; + using server = Bun.serve({ + port: 0, + development: false, + routes: { "/": index }, + }); + const html = await (await fetch(server.url)).text(); + const script = html.match(/src="([^"]+\\.js)"/)![1]; + process.stdout.write(await (await fetch(new URL(script, server.url))).text()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "serve-fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain(`console.log(${JSON.stringify(atCall)})`); + expect(stdout).not.toContain(duringBuild); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); +}); From 5ebdf0238ddb1914e27a02e3203d8dd2b4cedc43 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:33:57 +0000 Subject: [PATCH 2/6] Abort on OOM while copying the env instead of returning past the plugin handle --- src/runtime/api/js_bundle_completion_task.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index c5473eb2b9f8..59d146d8710a 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -150,8 +150,12 @@ pub(crate) fn create_and_schedule_completion_task( let vm = global_this.bun_vm_ptr(); // Copied here on this VM's JS thread, the thread `Bun__setEnvValue` mutates // the loader from, so unlike a Worker's clone (web_worker.rs) this one - // needs no `proxy_env_storage` lock. - let env = Box::new(global_this.bun_vm().env_loader().clone()?); + // needs no `proxy_env_storage` lock. OOM aborts like the `Box::new` below: + // returning an error would leak the `plugins` Bun.build hands us, which + // only the task's `deinit` releases. + let env = Box::new(bun_core::handle_oom( + global_this.bun_vm().env_loader().clone(), + )); let completion = bun_core::heap::into_raw(Box::new(JSBundleCompletionTask { ref_count: RefCount::init(), config, From 3010ed1165b4a27b8cfd4d251b6cfe8541409332 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:39:33 +0000 Subject: [PATCH 3/6] Trim the comments around the per-build env copy --- src/dotenv/env_loader.rs | 11 +++------ src/runtime/api/js_bundle_completion_task.rs | 25 ++++++-------------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 08279147548d..a1f9370f8652 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -595,14 +595,9 @@ impl Loader { } } - /// Deep copy of the variables and of the "already loaded" bookkeeping, so - /// `load_process` / `load` on the copy skip exactly what they skip on - /// `self`. The lazily derived caches (S3 credentials, TLS - /// reject-unauthorized) start empty and are rebuilt from the copied map. - /// - /// A loader keeps being mutated on the thread that owns it - /// (`process.env.HTTPS_PROXY = ...` replaces map values in place), so work - /// that reads env on another thread takes one of these instead. + /// Copies the variables and the loaded-files bookkeeping (so `load_process` + /// / `load` on the copy are the same no-ops as on `self`); the lazily + /// derived caches are rebuilt from the copied map on demand. pub fn clone(&self) -> Result { Ok(Loader { map: self.map.clone_with_allocator()?, diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 59d146d8710a..b860f53c16f6 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -59,13 +59,9 @@ pub struct JSBundleCompletionTask { pub global_this: BackRef, pub(crate) promise: jsc::JSPromiseStrong, pub poll_ref: KeepAlive, - /// The calling VM's env as of the `Bun.build()` call. The build reads env - /// (`configure_defines`, `env: "inline"` defines, the resolver's - /// `NODE_PATH`) on the bundle thread and its workers, while the VM's own - /// loader keeps being mutated on the JS thread (`Bun__setEnvValue` for - /// `process.env.HTTPS_PROXY = ...` frees the value it replaces), so the - /// build must not read that one. Boxed so the pointer `Transpiler::init` - /// keeps is to an allocation of its own, not into this struct. + /// This build's copy of the calling VM's env, read on the bundle thread + /// while the VM's own loader keeps changing on the JS thread + /// (`Bun__setEnvValue`). Boxed: the build's transpiler holds a pointer to it. pub(crate) env: Box, pub(crate) log: bun_ast::Log, /// Set by the owner giving up on the result (HTMLBundle route torn down) @@ -148,11 +144,8 @@ pub(crate) fn create_and_schedule_completion_task( global_this: &JSGlobalObject, ) -> crate::Result<*mut JSBundleCompletionTask> { let vm = global_this.bun_vm_ptr(); - // Copied here on this VM's JS thread, the thread `Bun__setEnvValue` mutates - // the loader from, so unlike a Worker's clone (web_worker.rs) this one - // needs no `proxy_env_storage` lock. OOM aborts like the `Box::new` below: - // returning an error would leak the `plugins` Bun.build hands us, which - // only the task's `deinit` releases. + // Same thread as `Bun__setEnvValue`, so no `proxy_env_storage` lock (unlike + // web_worker.rs). An error return here would leak `plugins`, so OOM aborts. let env = Box::new(bun_core::handle_oom( global_this.bun_vm().env_loader().clone(), )); @@ -1116,9 +1109,7 @@ impl CompletionStruct for JSBundleCompletionTask { .conditions .append_slice(&[b"development"])?; } - // `transpiler.env` is this build's env copy (`self.env`), installed by - // `create_and_configure_transpiler`; the resolver's workers read - // `NODE_PATH` through it. + // `transpiler.env` is this build's copy (`self.env`), not the VM's loader. transpiler.resolver.env_loader = NonNull::new(transpiler.env); // `Resolver.opts` is the resolver-crate subset // — re-project from the now-mutated `transpiler.options`. @@ -1203,9 +1194,7 @@ impl CompletionStruct for JSBundleCompletionTask { }; let log: *mut bun_ast::Log = &raw mut self.log; - // The build's own env copy (see the field doc). Same lifetime argument - // as `log`: every read through the transpiler happens before - // `complete_on_bundle_thread` hands this task back to the JS thread. + // Like `log`: used only until `complete_on_bundle_thread` hands `self` back. let env: *mut bun_dotenv::Loader = &raw mut *self.env; let t = Transpiler::init(bump, log, opts, Some(env))?; let transpiler: &'a mut Transpiler<'a> = bump.alloc(t); From 457c64d9a91fcbc0db65e260b3f3542652f317de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:08:15 +0000 Subject: [PATCH 4/6] Give the per-thread macro VM its own copy of the env instead of the build'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. --- src/bundler/transpiler.rs | 5 ++- src/js_parser_jsc/Macro.rs | 16 +++++-- src/runtime/api/js_bundle_completion_task.rs | 4 +- test/bundler/bundler_env.test.ts | 45 ++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 536e3ea26598..43694e7fac8c 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -309,8 +309,9 @@ impl<'a> Transpiler<'a> { /// lifetime-carrying borrows in `BundleOptions<'_>` / `Resolver<'_>` /// (`framework`, `optimize_imports`, `standalone_module_graph`, /// `env_loader`) are widened from `from`'s lifetime to `'a` via a - /// layout-preserving transmute — sound because those reference - /// process-lifetime data in every caller, but unprovable to borrowck. + /// layout-preserving transmute — sound because what they point at + /// outlives `from` in every caller (process-lifetime singletons, or for + /// `env_loader` the build that owns `from`), but unprovable to borrowck. pub(crate) unsafe fn for_worker( from: &Transpiler<'_>, arena: &'a Arena, diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index 4d6e8a203f53..08900543f2c1 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -423,15 +423,25 @@ impl Macro { // `RuntimeHooks::init_runtime_state` builds the macro VM's // transpiler from a fresh `TransformOptions` value rather than // borrowing the caller's, so there is nothing to mutate-and-restore - // on `resolver.opts` here. `log`/`env_loader` *are* threaded so the - // CLI-path macro VM uses the caller's log sink and env loader. + // on `resolver.opts` here. `log` *is* threaded so the CLI-path + // macro VM uses the caller's log sink. // JSC needs to be initialized if building from CLI jsc::initialize(false); + // This VM stays on the thread and serves every later build's + // macros too, while `env` is the current build's (Bun.build frees + // its copy with the build), so the VM gets a copy that lives as + // long as it does: it is never destroyed. + let env_loader = NonNull::new(env).map(|env| { + // SAFETY: the caller's loader, live for this call and not + // written to while the build's files are being parsed. + let copy = bun_core::handle_oom(unsafe { env.as_ref() }.clone()); + bun_core::heap::into_raw_nn(Box::new(copy)) + }); let _vm = VirtualMachine::init(VirtualMachineInitOptions { log: Some(NonNull::from(&mut *log)), - env_loader: NonNull::new(env), + env_loader, is_main_thread: false, ..Default::default() })?; diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index b860f53c16f6..923ea8f8226c 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -1194,7 +1194,9 @@ impl CompletionStruct for JSBundleCompletionTask { }; let log: *mut bun_ast::Log = &raw mut self.log; - // Like `log`: used only until `complete_on_bundle_thread` hands `self` back. + // Freed with `self`, so only read until `complete_on_bundle_thread` hands + // `self` back; the per-thread macro VM, which outlives the build, takes + // its own copy (`Macro::init`). let env: *mut bun_dotenv::Loader = &raw mut *self.env; let t = Transpiler::init(bump, log, opts, Some(env))?; let transpiler: &'a mut Transpiler<'a> = bump.alloc(t); diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index e269e7867363..f6d22c65a3aa 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -214,4 +214,49 @@ describe.concurrent("env is copied when the build is scheduled", () => { expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + + // Macros run in a VM that a bundler thread creates the first time a macro + // runs on it and then reuses for every later build, so it must not keep + // reading the env of the build that created it, which is freed with that + // build. Two bundler threads and four builds guarantee that later builds run + // their macro on a thread whose VM an earlier build created. + test("macros in later builds reuse a VM created during an earlier build", async () => { + const builds = 4; + const env: Record = { ...bunEnv, UV_THREADPOOL_SIZE: "2" }; + const files: Record = { + "macro.ts": /* ts */ `export function envValue(name: string) { return process.env[name]; }`, + "build-fixture.ts": /* ts */ ` + for (let i = 0; i < ${builds}; i++) { + const result = await Bun.build({ entrypoints: ["./entry" + i + ".ts"] }); + if (!result.success) throw new AggregateError(result.logs, "build " + i + " failed"); + process.stdout.write(await result.outputs[0].text()); + } + `, + }; + for (let i = 0; i < builds; i++) { + // Each build reads a key no earlier build has read, so the lookup goes + // to the VM's env instead of an already materialized process.env entry. + env[`MACRO_ENV_${i}`] = `value-of-build-${i}`; + files[`entry${i}.ts`] = /* ts */ ` + import { envValue } from "./macro.ts" with { type: "macro" }; + console.log(envValue("MACRO_ENV_${i}")); + `; + } + using dir = tempDir("bun-build-env-macro-vm", files); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build-fixture.ts"], + env, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.match(/console\.log\("(value-of-build-\d)"\)/g)).toEqual( + Array.from({ length: builds }, (_, i) => `console.log("value-of-build-${i}")`), + ); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); From f8f60b539dfc2e1e11ff53db639c9ec7008df151 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:12:09 +0000 Subject: [PATCH 5/6] Shorten the macro VM env copy comments --- src/js_parser_jsc/Macro.rs | 9 +++------ src/runtime/api/js_bundle_completion_task.rs | 5 ++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index 08900543f2c1..1593c00892d6 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -429,13 +429,10 @@ impl Macro { // JSC needs to be initialized if building from CLI jsc::initialize(false); - // This VM stays on the thread and serves every later build's - // macros too, while `env` is the current build's (Bun.build frees - // its copy with the build), so the VM gets a copy that lives as - // long as it does: it is never destroyed. + // The VM outlives this build (later builds' macros reuse it; it is + // never destroyed) and `env` does not, so the VM gets its own copy. let env_loader = NonNull::new(env).map(|env| { - // SAFETY: the caller's loader, live for this call and not - // written to while the build's files are being parsed. + // SAFETY: the caller's loader, live and unwritten during this build. let copy = bun_core::handle_oom(unsafe { env.as_ref() }.clone()); bun_core::heap::into_raw_nn(Box::new(copy)) }); diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 923ea8f8226c..0c538eca2506 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -1194,9 +1194,8 @@ impl CompletionStruct for JSBundleCompletionTask { }; let log: *mut bun_ast::Log = &raw mut self.log; - // Freed with `self`, so only read until `complete_on_bundle_thread` hands - // `self` back; the per-thread macro VM, which outlives the build, takes - // its own copy (`Macro::init`). + // Freed with `self`: read only until `complete_on_bundle_thread`, except + // by the per-thread macro VM, which copies it (`Macro::init`). let env: *mut bun_dotenv::Loader = &raw mut *self.env; let t = Transpiler::init(bump, log, opts, Some(env))?; let transpiler: &'a mut Transpiler<'a> = bump.alloc(t); From 0033f856d51c056fc49c84b42d75063c6c9039af Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:07:37 +0000 Subject: [PATCH 6/6] Free the macro VM's env copy if creating the VM fails --- src/js_parser_jsc/Macro.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index 1593c00892d6..a6628dbc52c8 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -441,6 +441,13 @@ impl Macro { env_loader, is_main_thread: false, ..Default::default() + }) + .inspect_err(|_| { + if let Some(copy) = env_loader { + // SAFETY: a failed `init` never wrote the transpiler that + // would have kept this pointer, so the copy is still ours. + unsafe { bun_core::heap::destroy(copy.as_ptr()) }; + } })?; (_vm, true) };