From f7901f380cc18c48665dd77686eeada3d6ef0fc5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:25:25 +0000 Subject: [PATCH 1/7] bundler: post Bun.build completions by ScriptExecutionContext id to survive worker.terminate() worker.terminate() mid-Bun.build() crashed the whole process: the bundle thread's complete_on_bundle_thread() dereferenced a BackRef into a VM the worker thread had already dealloc'd (heap-use-after-free under ASAN, SIGSEGV on release). A second queued build then dereferenced the worker's freed env loader in Transpiler::init. Route both the completion post and the CompletionDispatch vtable enqueue through ScriptExecutionContext::postConcurrentTask, which holds the contexts-map lock across the lookup + isTerminating() check + enqueue and so serializes with WebWorker::shutdown's existing markTerminating() the same way postTaskTo() already does for C++ callers. Queued builds whose owning context has begun shutdown are skipped before create_and_configure_transpiler touches worker-owned state. --- src/bundler/BundleThread.rs | 17 ++++++ src/jsc/JSGlobalObject.rs | 41 ++++++++++++++ src/jsc/bindings/ScriptExecutionContext.cpp | 28 ++++++++++ src/jsc/virtual_machine_exports.rs | 19 +++++++ src/runtime/api/js_bundle_completion_task.rs | 46 ++++++++++++---- test/bundler/bun-build-api.test.ts | 58 ++++++++++++++++++++ 6 files changed, 198 insertions(+), 11 deletions(-) diff --git a/src/bundler/BundleThread.rs b/src/bundler/BundleThread.rs index 18c41f02c3e..0c636d075ee 100644 --- a/src/bundler/BundleThread.rs +++ b/src/bundler/BundleThread.rs @@ -57,6 +57,13 @@ pub struct BundleThread { /// The trait accessors keep the generic `BundleThread` /// layout-agnostic. The concrete impl lives in T6 (`bun_bundler_jsc`). pub trait CompletionStruct: Node + Send + 'static { + /// Whether the JS context that scheduled this build is still alive. When + /// `false`, the bundle thread must not run `generate_in_new_thread`: the + /// task borrows worker-owned resources (env loader, event loop) that may + /// already be freed. Defaults to `true` so non-worker callers stay no-op. + fn is_owner_alive(&self) -> bool { + true + } /// `bump` is the per-build mimalloc heap that backs `transpiler`, so the /// two share lifetime `'a` (option fields like `optimize_imports: &'a /// StringSet` borrow from `bump`). @@ -221,6 +228,16 @@ impl BundleThread { // SAFETY: queue stores non-null *mut C pushed via enqueue(); owner keeps it alive // until complete_on_bundle_thread() signals completion. let completion = unsafe { &mut *completion }; + if !completion.is_owner_alive() { + // The worker that scheduled this build has begun shutdown. + // `create_and_configure_transpiler` would dereference + // worker-owned state (env loader) that is already freed. + // `complete_on_bundle_thread` observes the same dead + // context and drops the post. + completion.complete_on_bundle_thread(); + has_bundled = true; + continue; + } // SAFETY: `generation` is only read/written on this (bundle) thread. let generation = unsafe { (*instance).generation }; // `panic = "abort"` → a Rust panic on this thread enters the diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index d80cdb52057..df41ca2da09 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1567,6 +1567,22 @@ unsafe extern "C" { } impl ScriptExecutionContextIdentifier { + /// Enqueue a heap-allocated [`ConcurrentTaskItem`] onto this context's JS + /// event loop from any thread. Serializes with worker termination on the + /// C++ contexts-map lock (the same lock `markTerminating()` takes before + /// the worker's VM is freed), so this is safe to call without holding any + /// pointer into the target VM. + /// + /// Returns `true` if the task was enqueued (ownership transferred). + /// Returns `false` if the context is gone or terminating; the caller + /// retains ownership of `task` and must free it. + pub fn post_concurrent_task( + self, + task: core::ptr::NonNull, + ) -> bool { + ScriptExecutionContext__postConcurrentTask(self.0, task.as_ptr().cast::()) + } + /// Returns `None` if the context referred to by `self` no longer exists. pub fn global_object(self) -> Option { // FFI call returns a valid pointer or null; the JSGlobalObject is owned @@ -1587,9 +1603,34 @@ impl ScriptExecutionContextIdentifier { pub fn valid(self) -> bool { self.global_object().is_some() } + + /// `true` if the context still exists and has not been marked terminating. + /// Serializes with `markTerminating()` on the C++ contexts-map lock. + pub fn is_alive(self) -> bool { + ScriptExecutionContext__isAlive(self.0) + } } unsafe extern "C" { // safe: by-value `u32` in, raw nullable pointer out (caller checks before deref). safe fn ScriptExecutionContextIdentifier__getGlobalObject(id: u32) -> *mut JSGlobalObject; + // safe: by-value `u32` in, opaque pointer C++ never dereferences (it only + // passes it back to Rust under the contexts-map lock); bool out. + safe fn ScriptExecutionContext__postConcurrentTask(id: u32, task: *mut c_void) -> bool; + // safe: by-value `u32` in; bool out. + safe fn ScriptExecutionContext__isAlive(id: u32) -> bool; + // safe: `&JSGlobalObject` is a live opaque handle; returns the context's id. + safe fn ScriptExecutionContextIdentifier__forGlobalObject( + global: &JSGlobalObject, + ) -> u32; +} + +impl JSGlobalObject { + /// The stable identifier for this global's `ScriptExecutionContext`. + /// Safe to capture for later use from another thread via + /// [`ScriptExecutionContextIdentifier::post_concurrent_task`]. + #[inline] + pub fn script_execution_context_identifier(&self) -> ScriptExecutionContextIdentifier { + ScriptExecutionContextIdentifier(ScriptExecutionContextIdentifier__forGlobalObject(self)) + } } diff --git a/src/jsc/bindings/ScriptExecutionContext.cpp b/src/jsc/bindings/ScriptExecutionContext.cpp index 60f4ca58b9e..d01774afa70 100644 --- a/src/jsc/bindings/ScriptExecutionContext.cpp +++ b/src/jsc/bindings/ScriptExecutionContext.cpp @@ -302,6 +302,34 @@ extern "C" JSC::JSGlobalObject* ScriptExecutionContextIdentifier__getGlobalObjec return context->globalObject(); } +// Checks under the contexts-map lock whether the context still exists and has +// not been marked terminating. Used by off-thread work (bundle thread) that +// borrows worker-owned resources, to skip a queued unit of work whose owning +// worker has already begun shutdown. +extern "C" bool ScriptExecutionContext__isAlive(ScriptExecutionContextIdentifier id) +{ + Locker locker { allScriptExecutionContextsMapLock }; + auto* context = allScriptExecutionContextsMap().get(id); + return context && !context->isTerminating(); +} + +extern "C" void Bun__EventLoop__enqueueConcurrentTask(JSC::JSGlobalObject*, void* task); + +// postTaskTo() for a Rust-allocated ConcurrentTask. Returns true if the task +// was enqueued (ownership transferred to the target context's concurrent +// queue); false if the context is gone or terminating, in which case the +// caller retains ownership. The map lock is held across the enqueue so this +// serializes with markTerminating() the same way postTaskTo() does. +extern "C" bool ScriptExecutionContext__postConcurrentTask(ScriptExecutionContextIdentifier id, void* task) +{ + Locker locker { allScriptExecutionContextsMapLock }; + auto* context = allScriptExecutionContextsMap().get(id); + if (!context || context->isTerminating()) + return false; + Bun__EventLoop__enqueueConcurrentTask(context->globalObject(), task); + return true; +} + extern "C" void ScriptExecutionContext__markTerminating(JSC::JSGlobalObject* globalObject) { if (auto* context = defaultGlobalObject(globalObject)->scriptExecutionContext()) diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 89258ef3d57..f05e0654ca8 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -141,6 +141,25 @@ pub fn queue_task_concurrently(global: &JSGlobalObject, task: *mut crate::cpp_ta } } +/// Called from `ScriptExecutionContext__postConcurrentTask` with the contexts +/// map lock held, so `global`'s VM and its `EventLoop` are guaranteed live for +/// the duration of this call (worker `markTerminating()` serializes on the +/// same lock before any dealloc). `task` is a heap `ConcurrentTaskItem` the +/// caller has already allocated. +// HOST_EXPORT(Bun__EventLoop__enqueueConcurrentTask, c) +pub fn event_loop_enqueue_concurrent_task( + global: &JSGlobalObject, + task: *mut crate::event_loop::ConcurrentTaskItem, +) { + crate::mark_binding!(); + // SAFETY: see fn doc — VM/EventLoop live under the held contexts-map lock; + // `task` is a non-null heap allocation handed over from the Rust caller. + unsafe { + (*(*global.bun_vm_concurrently()).event_loop()) + .enqueue_task_concurrent(core::ptr::NonNull::new_unchecked(task)); + } +} + // HOST_EXPORT(Bun__handleRejectedPromise, c) pub fn handle_rejected_promise(global: &JSGlobalObject, promise: &mut JSPromise) { crate::mark_binding!(); diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 911d20f849d..1522a3214b5 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -26,6 +26,7 @@ use bun_io::KeepAlive; use bun_jsc::AnyTask::AnyTask; use bun_jsc::WorkPool; use bun_jsc::event_loop::EventLoop; +use bun_jsc::js_global_object::ScriptExecutionContextIdentifier; use bun_jsc::{self as jsc, JSGlobalObject, JSPromise, JSValue}; use bun_options_types::WindowsOptions; use bun_options_types::schema::api; @@ -61,6 +62,11 @@ pub struct JSBundleCompletionTask { // BACKREF — the JS-thread `EventLoop` outlives every completion task; safe // `Deref` so call sites read `self.jsc_event_loop.enqueue_task_concurrent(..)`. pub jsc_event_loop: BackRef, + /// Stable identifier for the originating `ScriptExecutionContext`. The + /// bundle thread posts completions via this id (under the C++ contexts-map + /// lock) instead of dereferencing `jsc_event_loop`, which dangles once a + /// worker owning the build is terminated mid-bundle. + pub context_id: ScriptExecutionContextIdentifier, pub task: AnyTask, pub global_this: BackRef, pub promise: jsc::JSPromiseStrong, @@ -125,6 +131,7 @@ pub(crate) fn create_and_schedule_completion_task( // `event_loop` is the live JS-thread loop (caller derives it from // `vm.event_loop()`); never null once `Bun.build` is reachable. jsc_event_loop: BackRef::from(core::ptr::NonNull::new(event_loop).expect("event_loop")), + context_id: global_this.script_execution_context_identifier(), task: AnyTask::default(), global_this: BackRef::new(global_this), promise: jsc::JSPromiseStrong::default(), @@ -761,13 +768,16 @@ fn from_completion_handle<'a>(c: NonNull) -> &'a JSBundleCo static COMPLETION_VTABLE: dispatch::CompletionDispatch = dispatch::CompletionDispatch { result_is_err: |c| matches!(from_completion_handle(c).result, BundleV2Result::Err(_)), enqueue_task_concurrent: |c, task| { - // `jsc_event_loop` is a `BackRef` — safe Deref. // SAFETY: `task` is a fresh heap-allocated non-null `ConcurrentTaskItem` - // passed through from the bundler vtable; the queue takes ownership. - unsafe { - from_completion_handle(c) - .jsc_event_loop - .enqueue_task_concurrent(core::ptr::NonNull::new_unchecked(task)) + // passed through from the bundler vtable. + let task = unsafe { core::ptr::NonNull::new_unchecked(task) }; + if !from_completion_handle(c).context_id.post_concurrent_task(task) { + // Target context is gone or terminating (worker was terminated + // mid-bundle). The queue would never drain this task; reclaim the + // heap `ConcurrentTaskItem` ourselves. + // SAFETY: `post_concurrent_task` returned false so ownership was + // not transferred; `task` was `ConcurrentTask::create`-allocated. + drop(unsafe { bun_core::heap::take(task.as_ptr()) }); } }, }; @@ -784,6 +794,10 @@ unsafe impl bun_threading::Linked for JSBundleCompletionTask { } impl CompletionStruct for JSBundleCompletionTask { + fn is_owner_alive(&self) -> bool { + self.context_id.is_alive() + } + /// Port of `JSBundleCompletionTask.configureBundler` — the post-init half /// (everything after `transpiler.* = try Transpiler.init(...)`). /// `Transpiler::init` itself is called by `create_and_configure_transpiler` @@ -980,11 +994,21 @@ impl CompletionStruct for JSBundleCompletionTask { } fn complete_on_bundle_thread(&mut self) { - // `jsc_event_loop` is a `BackRef` — safe Deref. - // `ConcurrentTask::create` heap-allocates a fresh task; the - // queue takes ownership of it. - self.jsc_event_loop - .enqueue_task_concurrent(jsc::ConcurrentTask::create(self.task.task())); + // Post by stable context id so a worker terminated mid-bundle cannot + // leave `jsc_event_loop` dangling under us: `post_concurrent_task` + // serializes with `markTerminating()` on the C++ contexts-map lock and + // only dereferences the target VM while that lock is held. + let task = jsc::ConcurrentTask::create(self.task.task()); + if !self.context_id.post_concurrent_task(task) { + // Target context is gone or terminating. `on_complete_anytask` + // will never run; reclaim the heap `ConcurrentTaskItem`. The + // `JSBundleCompletionTask` itself is deliberately leaked: its + // `deinit` touches JS-thread-owned state (`JSPromiseStrong`, + // `Plugin::destroy`, `KeepAlive`) that is already freed. + // SAFETY: `post_concurrent_task` returned false so ownership was + // not transferred; `task` was `ConcurrentTask::create`-allocated. + drop(unsafe { bun_core::heap::take(task.as_ptr()) }); + } } fn set_result(&mut self, result: BundleV2Result) { self.result = result; diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 2b7fa720b65..c3802ee0df7 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1513,3 +1513,61 @@ test("Bun.build can be called thousands of times in one process without crashing expect(stdout.trim()).toBe("OK 400"); expect(exitCode).toBe(0); }, 180_000); + +// Regression: terminating a worker with an in-flight Bun.build() would crash +// the whole process. The bundler runs on its own thread and posted the +// completion back into the terminated worker's freed EventLoop/VM +// (JSBundleCompletionTask::complete_on_bundle_thread -> enqueue_task_concurrent +// -> heap-use-after-free, or SIGSEGV on release builds). A second queued +// build then dereferenced the worker's freed env loader in Transpiler::init. +// Under ASAN (the gate's default build) this was deterministic; on release it +// SIGSEGV'd in a few rounds. +test( + "terminating a worker mid-Bun.build() does not crash the process", + async () => { + // ASAN catches the UAF on the first round; release needs the terminate to + // land mid-bundle, so scale the module graph / round count by build speed. + const slow = isDebug || isASAN; + const modules = slow ? 100 : 400; + const rounds = slow ? 4 : 12; + + const files: Record = {}; + const lines: string[] = []; + for (let i = 0; i < modules; i++) { + files[`m${i}.ts`] = + `export const v${i}: number = ${i};\n` + + `export function f${i}(x: number) { return x + ${i}; }\n`; + lines.push(`import * as m${i} from "./m${i}.ts"; globalThis.k${i} = m${i};`); + } + files["entry.ts"] = lines.join("\n"); + files["run.ts"] = ` + const { Worker } = require("node:worker_threads"); + const entry = process.argv[2]; + const src = \`const { parentPort, workerData } = require("node:worker_threads"); + const lanes = (n, f) => { for (let i = 0; i < n; i++) (async () => { for (;;) { try { await f(i); } catch {} } })(); }; + lanes(2, () => Bun.build({ entrypoints: [workerData.entry], target: "bun", minify: true })); + parentPort.postMessage("up");\`; + for (let r = 0; r < ${rounds}; r++) { + const w = new Worker(src, { eval: true, workerData: { entry } }); + await new Promise(res => w.once("message", res)); + await Bun.sleep(60 + (r * 41) % 220); + await w.terminate(); + } + console.log("OK " + ${rounds}); + `; + using dir = tempDir("bun-build-worker-terminate", files); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "run.ts"), join(String(dir), "entry.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("use-after-free"); + expect(stderr).not.toContain("Segmentation fault"); + expect(stdout.trim()).toBe("OK " + rounds); + expect(exitCode).toBe(0); + }, + isDebug || isASAN ? 120_000 : 45_000, +); From c90de8111cd49eb5cf8cf68d32ed36c0f036d588 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:28:00 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- src/jsc/JSGlobalObject.rs | 4 +--- src/runtime/api/js_bundle_completion_task.rs | 5 ++++- test/bundler/bun-build-api.test.ts | 3 +-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index df41ca2da09..38ec9b0de11 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1620,9 +1620,7 @@ unsafe extern "C" { // safe: by-value `u32` in; bool out. safe fn ScriptExecutionContext__isAlive(id: u32) -> bool; // safe: `&JSGlobalObject` is a live opaque handle; returns the context's id. - safe fn ScriptExecutionContextIdentifier__forGlobalObject( - global: &JSGlobalObject, - ) -> u32; + safe fn ScriptExecutionContextIdentifier__forGlobalObject(global: &JSGlobalObject) -> u32; } impl JSGlobalObject { diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 1522a3214b5..e6cd466d926 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -771,7 +771,10 @@ static COMPLETION_VTABLE: dispatch::CompletionDispatch = dispatch::CompletionDis // SAFETY: `task` is a fresh heap-allocated non-null `ConcurrentTaskItem` // passed through from the bundler vtable. let task = unsafe { core::ptr::NonNull::new_unchecked(task) }; - if !from_completion_handle(c).context_id.post_concurrent_task(task) { + if !from_completion_handle(c) + .context_id + .post_concurrent_task(task) + { // Target context is gone or terminating (worker was terminated // mid-bundle). The queue would never drain this task; reclaim the // heap `ConcurrentTaskItem` ourselves. diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index c3802ee0df7..61332ca8210 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1535,8 +1535,7 @@ test( const lines: string[] = []; for (let i = 0; i < modules; i++) { files[`m${i}.ts`] = - `export const v${i}: number = ${i};\n` + - `export function f${i}(x: number) { return x + ${i}; }\n`; + `export const v${i}: number = ${i};\n` + `export function f${i}(x: number) { return x + ${i}; }\n`; lines.push(`import * as m${i} from "./m${i}.ts"; globalThis.k${i} = m${i};`); } files["entry.ts"] = lines.join("\n"); From 43ee82289a7587002dd8c293f7f547f5d11dd7fa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:56:54 +0000 Subject: [PATCH 3/7] address review: task-owned env snapshot, remove dead jsc_event_loop, fix test assertions The is_owner_alive() pre-check was a TOCTOU that narrowed but did not close the env-loader race: the contexts-map lock is released before Transpiler::init runs, and a running build's resolver holds the raw loader pointer for its whole duration. Clone the env map into the completion task at creation time (on the JS thread, where the VM's loader is guaranteed live) so the bundle thread never dereferences worker-lifetime memory. The pre-check stays as an early-out so a build queued by a dead worker is skipped instead of producing a result that complete_on_bundle_thread would drop anyway. Remove the now-dead jsc_event_loop field (both consumers rerouted to context_id.post_concurrent_task), its stale doc comment, and the event_loop parameter it was fed from. Test: replace the not.toContain panic-string checks with a combined {stdout, stderr, exitCode} object assertion, and disable LSan leak detection for the spawned subprocess (pre-existing worker-termination leaks on main, node:fs Binding / WebWorker box, would abort it under CI's detect_leaks=1; the UAF under test still aborts with a heap-use-after-free report regardless). Also: free result/log/env on the bundle thread when the post fails, so only the small box + dead-VM JSC handle remain; allow clippy::not_unsafe_ptr_arg_deref on the HOST_EXPORT enqueue thunk. --- src/bundler/BundleThread.rs | 17 ++--- src/bundler/bundle_v2.rs | 2 +- src/jsc/virtual_machine_exports.rs | 1 + src/runtime/api/JSBundler.rs | 3 - src/runtime/api/js_bundle_completion_task.rs | 72 +++++++++++++------- src/runtime/server/HTMLBundle.rs | 3 +- test/bundler/bun-build-api.test.ts | 18 +++-- 7 files changed, 70 insertions(+), 46 deletions(-) diff --git a/src/bundler/BundleThread.rs b/src/bundler/BundleThread.rs index 0c636d075ee..2504b008caf 100644 --- a/src/bundler/BundleThread.rs +++ b/src/bundler/BundleThread.rs @@ -58,9 +58,8 @@ pub struct BundleThread { /// layout-agnostic. The concrete impl lives in T6 (`bun_bundler_jsc`). pub trait CompletionStruct: Node + Send + 'static { /// Whether the JS context that scheduled this build is still alive. When - /// `false`, the bundle thread must not run `generate_in_new_thread`: the - /// task borrows worker-owned resources (env loader, event loop) that may - /// already be freed. Defaults to `true` so non-worker callers stay no-op. + /// `false` the bundle thread skips the build, since + /// `complete_on_bundle_thread` would discard the result anyway. fn is_owner_alive(&self) -> bool { true } @@ -81,9 +80,8 @@ pub trait CompletionStruct: Node + Send + 'static { /// `FileMap` layout stays in T6. fn file_map(&mut self) -> Option>; /// Returns a §Dispatch handle (erased owner + `&'static` vtable) the impl - /// provides, so the bundler can read `result == .err` / - /// `jsc_event_loop.enqueueTaskConcurrent` without naming the concrete - /// struct. + /// provides, so the bundler can read `result == .err` / post plugin tasks + /// to the JS event loop without naming the concrete struct. fn as_js_bundle_completion_task(&mut self) -> dispatch::CompletionHandle; /// `Transpiler<'a>` has borrow-carrying fields (`arena: &'a Arena`, @@ -229,11 +227,10 @@ impl BundleThread { // until complete_on_bundle_thread() signals completion. let completion = unsafe { &mut *completion }; if !completion.is_owner_alive() { - // The worker that scheduled this build has begun shutdown. - // `create_and_configure_transpiler` would dereference - // worker-owned state (env loader) that is already freed. + // The worker that scheduled this build has begun shutdown; + // skip the build since its result would be dropped anyway. // `complete_on_bundle_thread` observes the same dead - // context and drops the post. + // context and discards the post. completion.complete_on_bundle_thread(); has_bundled = true; continue; diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index e1a2f1fda10..a2e7987c9a5 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -1501,7 +1501,7 @@ pub mod bv2_impl { ) { debug_assert!(self.plugins.is_some()); if let Some(completion) = self.completion { - // From Bun.build — `completion.jsc_event_loop.enqueueTaskConcurrent(task)`. + // From Bun.build — post to the originating context's JS loop. completion.enqueue_task_concurrent(task); return; } diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index f05e0654ca8..b153fc448fb 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -147,6 +147,7 @@ pub fn queue_task_concurrently(global: &JSGlobalObject, task: *mut crate::cpp_ta /// same lock before any dealloc). `task` is a heap `ConcurrentTaskItem` the /// caller has already allocated. // HOST_EXPORT(Bun__EventLoop__enqueueConcurrentTask, c) +#[allow(clippy::not_unsafe_ptr_arg_deref)] pub fn event_loop_enqueue_concurrent_task( global: &JSGlobalObject, task: *mut crate::event_loop::ConcurrentTaskItem, diff --git a/src/runtime/api/JSBundler.rs b/src/runtime/api/JSBundler.rs index abf14975491..93ea1ed696d 100644 --- a/src/runtime/api/JSBundler.rs +++ b/src/runtime/api/JSBundler.rs @@ -1339,8 +1339,6 @@ pub mod js_bundler { let mut plugins: Option<*mut Plugin> = None; let config = Config::from_js(global_this, arguments[0], &mut plugins)?; - let event_loop = vm.event_loop(); - // `BundleV2.generateFromJavaScript` — the completion-task struct lives in // `crate::api::js_bundle_completion_task` (bun_runtime owns it because its // fields name `Config`/`Plugin`/`HTMLBundle::Route`; lower-tier crates @@ -1350,7 +1348,6 @@ pub mod js_bundler { config, plugins.and_then(core::ptr::NonNull::new), global_this, - event_loop, ) .map_err(|_| JsError::OutOfMemory)?; // SAFETY: `completion` is the freshly-boxed allocation returned above; diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index e6cd466d926..615fa5698ab 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -25,7 +25,6 @@ use bun_core::env::OperatingSystem; use bun_io::KeepAlive; use bun_jsc::AnyTask::AnyTask; use bun_jsc::WorkPool; -use bun_jsc::event_loop::EventLoop; use bun_jsc::js_global_object::ScriptExecutionContextIdentifier; use bun_jsc::{self as jsc, JSGlobalObject, JSPromise, JSValue}; use bun_options_types::WindowsOptions; @@ -59,19 +58,20 @@ pub struct JSBundleCompletionTask { // `unsafe impl Send` below for the thread-affinity constraint this imposes. pub ref_count: RefCount, pub config: JSBundlerConfig, - // BACKREF — the JS-thread `EventLoop` outlives every completion task; safe - // `Deref` so call sites read `self.jsc_event_loop.enqueue_task_concurrent(..)`. - pub jsc_event_loop: BackRef, /// Stable identifier for the originating `ScriptExecutionContext`. The - /// bundle thread posts completions via this id (under the C++ contexts-map - /// lock) instead of dereferencing `jsc_event_loop`, which dangles once a - /// worker owning the build is terminated mid-bundle. + /// bundle thread reaches the JS event loop via this id (under the C++ + /// contexts-map lock), so it never holds a pointer into a worker VM that + /// can be freed mid-bundle. pub context_id: ScriptExecutionContextIdentifier, pub task: AnyTask, pub global_this: BackRef, pub promise: jsc::JSPromiseStrong, pub poll_ref: KeepAlive, + /// Task-owned env loader + map cloned from the VM at creation time, so the + /// bundle thread never dereferences worker-lifetime memory. Freed in + /// `deinit` (loader first; it borrows `env_map`). pub env: *mut bun_dotenv::Loader<'static>, + env_map: *mut bun_dotenv::Map, pub log: bun_ast::Log, pub cancelled: bool, @@ -102,6 +102,16 @@ impl JSBundleCompletionTask { // last-ref drop is the only place that releases it. Plugin::destroy(plugin.as_ptr()); } + // `env`/`env_map` are null only when `complete_on_bundle_thread`'s + // dead-owner branch already freed them. + if !boxed.env.is_null() { + // SAFETY: `heap::into_raw`'d in `create_and_schedule_completion_task`; + // sole owner. Loader borrows `env_map`, so drop it first. + unsafe { + drop(bun_core::heap::take(boxed.env)); + drop(bun_core::heap::take(boxed.env_map)); + } + } // Owned fields (`config`, `log`, `result`, `promise`) drop with the Box. } } @@ -121,22 +131,27 @@ pub(crate) fn create_and_schedule_completion_task( config: JSBundlerConfig, plugins: Option>, global_this: &JSGlobalObject, - event_loop: *mut EventLoop, ) -> crate::Result<*mut JSBundleCompletionTask> { let vm = global_this.bun_vm_ptr(); - let env = global_this.bun_vm().transpiler.env; + // Snapshot the env map now, on the JS thread, so the bundle thread never + // dereferences worker-owned memory (a worker terminated mid-bundle frees + // its VM's loader). `Loader::init` only populates `map`; the bundle-thread + // consumers read the map only (`NODE_PATH`, proxy, reject-unauthorized). + let env_map = + bun_core::heap::into_raw(Box::new(global_this.bun_vm().env_loader().map.clone_with_allocator()?)); + // SAFETY: `env_map` is a fresh heap allocation owned by this task; the + // `'static` borrow is the lifetime erasure for the task-lifetime map. + let env = bun_core::heap::into_raw(Box::new(bun_dotenv::Loader::init(unsafe { &mut *env_map }))); let completion = bun_core::heap::into_raw(Box::new(JSBundleCompletionTask { ref_count: RefCount::init(), config, - // `event_loop` is the live JS-thread loop (caller derives it from - // `vm.event_loop()`); never null once `Bun.build` is reachable. - jsc_event_loop: BackRef::from(core::ptr::NonNull::new(event_loop).expect("event_loop")), context_id: global_this.script_execution_context_identifier(), task: AnyTask::default(), global_this: BackRef::new(global_this), promise: jsc::JSPromiseStrong::default(), poll_ref: KeepAlive::init(), env, + env_map, log: bun_ast::Log::init(), cancelled: false, html_build_task: None, @@ -386,8 +401,8 @@ impl JSBundleCompletionTask { flags |= StandaloneFlags::DISABLE_AUTOLOAD_PACKAGE_JSON; } - // SAFETY: `self.env` is the per-VM `DotEnv.Loader` stashed at - // construction; valid for the lifetime of the VirtualMachine. + // SAFETY: `self.env` is the task-owned loader heap-allocated at + // construction; valid until `deinit`. let env = unsafe { &mut *self.env.cast::() }; let result = match to_executable( @@ -997,20 +1012,29 @@ impl CompletionStruct for JSBundleCompletionTask { } fn complete_on_bundle_thread(&mut self) { - // Post by stable context id so a worker terminated mid-bundle cannot - // leave `jsc_event_loop` dangling under us: `post_concurrent_task` - // serializes with `markTerminating()` on the C++ contexts-map lock and - // only dereferences the target VM while that lock is held. + // Post by stable context id: `post_concurrent_task` serializes with + // `markTerminating()` on the C++ contexts-map lock and only + // dereferences the target VM while that lock is held. let task = jsc::ConcurrentTask::create(self.task.task()); if !self.context_id.post_concurrent_task(task) { // Target context is gone or terminating. `on_complete_anytask` - // will never run; reclaim the heap `ConcurrentTaskItem`. The - // `JSBundleCompletionTask` itself is deliberately leaked: its - // `deinit` touches JS-thread-owned state (`JSPromiseStrong`, - // `Plugin::destroy`, `KeepAlive`) that is already freed. + // will never run; reclaim the `ConcurrentTaskItem` and the large + // task-owned payloads here. The box itself (and its JSC handle + // `promise` / C++ `plugins`, which point into the dead VM) cannot + // be released off the JS thread; that residual is bounded. // SAFETY: `post_concurrent_task` returned false so ownership was // not transferred; `task` was `ConcurrentTask::create`-allocated. drop(unsafe { bun_core::heap::take(task.as_ptr()) }); + self.result = BundleV2Result::Pending; + self.log = bun_ast::Log::init(); + let env = core::mem::replace(&mut self.env, ptr::null_mut()); + let env_map = core::mem::replace(&mut self.env_map, ptr::null_mut()); + // SAFETY: both are the non-null `heap::into_raw`'d allocations + // from `create_and_schedule_completion_task`; sole owner. + unsafe { + drop(bun_core::heap::take(env)); + drop(bun_core::heap::take(env_map)); + } } } fn set_result(&mut self, result: BundleV2Result) { @@ -1077,9 +1101,7 @@ impl CompletionStruct for JSBundleCompletionTask { }; let log: *mut bun_ast::Log = &raw mut self.log; - // SAFETY: `self.env` is the per-VM dotenv loader stashed at - // construction; cast erases `'_` (bun_dotenv::Loader is invariant on - // its arena lifetime, but `Transpiler::init` only stores the pointer). + // `self.env` is the task-owned loader heap-allocated at construction. let env = self.env.cast::>(); let t = Transpiler::init(bump, log, opts, Some(env))?; let transpiler: &'a mut Transpiler<'a> = bump.alloc(t); diff --git a/src/runtime/server/HTMLBundle.rs b/src/runtime/server/HTMLBundle.rs index 937785d53a2..28de23af453 100644 --- a/src/runtime/server/HTMLBundle.rs +++ b/src/runtime/server/HTMLBundle.rs @@ -498,8 +498,7 @@ impl Route { } config.source_map = bundler_options::SourceMapOption::Linked; - let completion_task = - create_and_schedule_completion_task(config, plugins, global, vm.event_loop())?; + let completion_task = create_and_schedule_completion_task(config, plugins, global)?; // SAFETY: `completion_task` is the freshly-boxed allocation (refcount==1); sole owner. unsafe { (*completion_task).started_at_ns = diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 61332ca8210..5e68280ae8d 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1558,15 +1558,23 @@ test( await using proc = Bun.spawn({ cmd: [bunExe(), join(String(dir), "run.ts"), join(String(dir), "entry.ts")], - env: bunEnv, + env: { + ...bunEnv, + // Pre-existing bounded worker-termination leaks on main (per-worker + // node:fs Binding, WebWorker box) would abort the subprocess under + // CI's detect_leaks=1. This test covers the UAF -> crash; the unfixed + // bug aborts with a heap-use-after-free report regardless. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("use-after-free"); - expect(stderr).not.toContain("Segmentation fault"); - expect(stdout.trim()).toBe("OK " + rounds); - expect(exitCode).toBe(0); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "OK " + rounds, + stderr: "", + exitCode: 0, + }); }, isDebug || isASAN ? 120_000 : 45_000, ); From 080d7b67658dfb4f41365c1340939d25af2d44a9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:59:05 +0000 Subject: [PATCH 4/7] [autofix.ci] apply automated fixes --- src/runtime/api/js_bundle_completion_task.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 615fa5698ab..b5caa584c3c 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -137,11 +137,17 @@ pub(crate) fn create_and_schedule_completion_task( // dereferences worker-owned memory (a worker terminated mid-bundle frees // its VM's loader). `Loader::init` only populates `map`; the bundle-thread // consumers read the map only (`NODE_PATH`, proxy, reject-unauthorized). - let env_map = - bun_core::heap::into_raw(Box::new(global_this.bun_vm().env_loader().map.clone_with_allocator()?)); + let env_map = bun_core::heap::into_raw(Box::new( + global_this + .bun_vm() + .env_loader() + .map + .clone_with_allocator()?, + )); // SAFETY: `env_map` is a fresh heap allocation owned by this task; the // `'static` borrow is the lifetime erasure for the task-lifetime map. - let env = bun_core::heap::into_raw(Box::new(bun_dotenv::Loader::init(unsafe { &mut *env_map }))); + let env = + bun_core::heap::into_raw(Box::new(bun_dotenv::Loader::init(unsafe { &mut *env_map }))); let completion = bun_core::heap::into_raw(Box::new(JSBundleCompletionTask { ref_count: RefCount::init(), config, From b6722c5f55bd2cecdef17ddd851140a2f7081c69 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:10:57 +0000 Subject: [PATCH 5/7] keep plugin-dispatch vtable on direct enqueue to avoid wait_for_parse hang COMPLETION_VTABLE.enqueue_task_concurrent carries mid-build plugin onLoad/onResolve dispatches (via enqueue_on_js_loop_for_plugins), not just the final completion. Dropping one when post_concurrent_task returned false left graph.pending_items unbalanced, so wait_for_parse parked the process-wide BundleThread singleton forever and every subsequent Bun.build() from any thread hung. Revert that path to the pre-PR direct enqueue so plugin builds behave exactly as on main; complete_on_bundle_thread (the reported no-plugins crash) keeps the context-id post. --- src/runtime/api/js_bundle_completion_task.rs | 37 +++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index b5caa584c3c..d8f4a84ebfa 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -25,6 +25,7 @@ use bun_core::env::OperatingSystem; use bun_io::KeepAlive; use bun_jsc::AnyTask::AnyTask; use bun_jsc::WorkPool; +use bun_jsc::event_loop::EventLoop; use bun_jsc::js_global_object::ScriptExecutionContextIdentifier; use bun_jsc::{self as jsc, JSGlobalObject, JSPromise, JSValue}; use bun_options_types::WindowsOptions; @@ -59,10 +60,17 @@ pub struct JSBundleCompletionTask { pub ref_count: RefCount, pub config: JSBundlerConfig, /// Stable identifier for the originating `ScriptExecutionContext`. The - /// bundle thread reaches the JS event loop via this id (under the C++ + /// bundle thread posts the completion via this id (under the C++ /// contexts-map lock), so it never holds a pointer into a worker VM that /// can be freed mid-bundle. pub context_id: ScriptExecutionContextIdentifier, + /// Direct event-loop backref for mid-build plugin dispatches + /// (`COMPLETION_VTABLE.enqueue_task_concurrent`). Dropping a plugin + /// `onLoad`/`onResolve` post when the worker is terminating would leave + /// `graph.pending_items` unbalanced and hang the process-wide + /// `BundleThread` inside `wait_for_parse`, so that path keeps the pre-PR + /// direct enqueue (and its pre-existing UAF on termination). + pub jsc_event_loop: BackRef, pub task: AnyTask, pub global_this: BackRef, pub promise: jsc::JSPromiseStrong, @@ -148,10 +156,14 @@ pub(crate) fn create_and_schedule_completion_task( // `'static` borrow is the lifetime erasure for the task-lifetime map. let env = bun_core::heap::into_raw(Box::new(bun_dotenv::Loader::init(unsafe { &mut *env_map }))); + // SAFETY: `event_loop()` is non-null once `Bun.build` is reachable. + let jsc_event_loop = + BackRef::from(unsafe { NonNull::new_unchecked(global_this.bun_vm().event_loop()) }); let completion = bun_core::heap::into_raw(Box::new(JSBundleCompletionTask { ref_count: RefCount::init(), config, context_id: global_this.script_execution_context_identifier(), + jsc_event_loop, task: AnyTask::default(), global_this: BackRef::new(global_this), promise: jsc::JSPromiseStrong::default(), @@ -789,19 +801,18 @@ fn from_completion_handle<'a>(c: NonNull) -> &'a JSBundleCo static COMPLETION_VTABLE: dispatch::CompletionDispatch = dispatch::CompletionDispatch { result_is_err: |c| matches!(from_completion_handle(c).result, BundleV2Result::Err(_)), enqueue_task_concurrent: |c, task| { + // This path carries mid-build plugin `onLoad`/`onResolve` dispatches + // (via `enqueue_on_js_loop_for_plugins`). Silently dropping one when + // the owning worker is terminating would strand `graph.pending_items` + // and hang the process-wide `BundleThread` inside `wait_for_parse`, so + // it stays on the direct enqueue (unchanged from main) rather than the + // `post_concurrent_task` path used by `complete_on_bundle_thread`. // SAFETY: `task` is a fresh heap-allocated non-null `ConcurrentTaskItem` - // passed through from the bundler vtable. - let task = unsafe { core::ptr::NonNull::new_unchecked(task) }; - if !from_completion_handle(c) - .context_id - .post_concurrent_task(task) - { - // Target context is gone or terminating (worker was terminated - // mid-bundle). The queue would never drain this task; reclaim the - // heap `ConcurrentTaskItem` ourselves. - // SAFETY: `post_concurrent_task` returned false so ownership was - // not transferred; `task` was `ConcurrentTask::create`-allocated. - drop(unsafe { bun_core::heap::take(task.as_ptr()) }); + // passed through from the bundler vtable; the queue takes ownership. + unsafe { + from_completion_handle(c) + .jsc_event_loop + .enqueue_task_concurrent(core::ptr::NonNull::new_unchecked(task)) } }, }; From 17be429eac4b4510d9a3e6abb39acb8a65fd88ba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:35:02 +0000 Subject: [PATCH 6/7] address review nits: did_load_process, plugin cleanup on clone OOM, wire worker error Set did_load_process on the task-owned loader so run_env_loader's load_process() early-returns instead of re-walking OS environ (which would clobber JS-set values and alloc per-entry on every Bun.build). Destroy the plugin on the clone_with_allocator error arm so the now-fallible window between receiving plugins and storing it in the box doesn't leak it on OOM. Wire the test fixture's worker 'error' event to reject so a future eval failure surfaces immediately instead of timing out. --- src/runtime/api/js_bundle_completion_task.rs | 32 ++++++++++++-------- test/bundler/bun-build-api.test.ts | 2 +- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index d8f4a84ebfa..4ce5a16f057 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -143,19 +143,25 @@ pub(crate) fn create_and_schedule_completion_task( let vm = global_this.bun_vm_ptr(); // Snapshot the env map now, on the JS thread, so the bundle thread never // dereferences worker-owned memory (a worker terminated mid-bundle frees - // its VM's loader). `Loader::init` only populates `map`; the bundle-thread - // consumers read the map only (`NODE_PATH`, proxy, reject-unauthorized). - let env_map = bun_core::heap::into_raw(Box::new( - global_this - .bun_vm() - .env_loader() - .map - .clone_with_allocator()?, - )); - // SAFETY: `env_map` is a fresh heap allocation owned by this task; the - // `'static` borrow is the lifetime erasure for the task-lifetime map. - let env = - bun_core::heap::into_raw(Box::new(bun_dotenv::Loader::init(unsafe { &mut *env_map }))); + // its VM's loader). `did_load_process` is set so `run_env_loader`'s + // `load_process()` early-returns instead of re-walking OS environ and + // clobbering JS-set values that differ from it. + let env_map = match global_this.bun_vm().env_loader().map.clone_with_allocator() { + Ok(m) => bun_core::heap::into_raw(Box::new(m)), + Err(e) => { + if let Some(p) = plugins { + Plugin::destroy(p.as_ptr()); + } + return Err(e.into()); + } + }; + let env = { + // SAFETY: `env_map` is a fresh heap allocation owned by this task; the + // `'static` borrow is the lifetime erasure for the task-lifetime map. + let mut l = bun_dotenv::Loader::init(unsafe { &mut *env_map }); + l.did_load_process = true; + bun_core::heap::into_raw(Box::new(l)) + }; // SAFETY: `event_loop()` is non-null once `Bun.build` is reachable. let jsc_event_loop = BackRef::from(unsafe { NonNull::new_unchecked(global_this.bun_vm().event_loop()) }); diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 5e68280ae8d..e2858c581e6 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1548,7 +1548,7 @@ test( parentPort.postMessage("up");\`; for (let r = 0; r < ${rounds}; r++) { const w = new Worker(src, { eval: true, workerData: { entry } }); - await new Promise(res => w.once("message", res)); + await new Promise((res, rej) => { w.once("message", res); w.once("error", rej); }); await Bun.sleep(60 + (r * 41) % 220); await w.terminate(); } From 9a0318fa5108dc563756d9d314e96c47c4ed8301 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:21:29 +0000 Subject: [PATCH 7/7] move plugin OOM cleanup to the owning caller (Bun.build) The HTMLBundle::Route caller passes a plugin borrowed from the server's ServePluginsState::Loaded(Box); destroying it in the shared error arm would tombstone + unprotect the server's live plugin. Release at the acquisition site instead: JSBundler.rs owns its plugin and destroys it in .map_err; HTMLBundle.rs does nothing on error (its borrow is still owned by the server). --- src/runtime/api/JSBundler.rs | 9 ++++++++- src/runtime/api/js_bundle_completion_task.rs | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/runtime/api/JSBundler.rs b/src/runtime/api/JSBundler.rs index 93ea1ed696d..32aa713bf52 100644 --- a/src/runtime/api/JSBundler.rs +++ b/src/runtime/api/JSBundler.rs @@ -1349,7 +1349,14 @@ pub mod js_bundler { plugins.and_then(core::ptr::NonNull::new), global_this, ) - .map_err(|_| JsError::OutOfMemory)?; + .map_err(|_| { + // `Bun.build` owns its plugin (unlike `HTMLBundle::Route`, which + // borrows the server's); release on the one fallible early return. + if let Some(p) = plugins { + Plugin::destroy(p); + } + JsError::OutOfMemory + })?; // SAFETY: `completion` is the freshly-boxed allocation returned above; // sole owner on the JS thread until enqueued task runs. unsafe { diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 4ce5a16f057..c0e9a7c22e4 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -145,16 +145,16 @@ pub(crate) fn create_and_schedule_completion_task( // dereferences worker-owned memory (a worker terminated mid-bundle frees // its VM's loader). `did_load_process` is set so `run_env_loader`'s // `load_process()` early-returns instead of re-walking OS environ and - // clobbering JS-set values that differ from it. - let env_map = match global_this.bun_vm().env_loader().map.clone_with_allocator() { - Ok(m) => bun_core::heap::into_raw(Box::new(m)), - Err(e) => { - if let Some(p) = plugins { - Plugin::destroy(p.as_ptr()); - } - return Err(e.into()); - } - }; + // clobbering JS-set values that differ from it. The `plugins` argument is + // owned for `Bun.build` but borrowed for `HTMLBundle::Route`, so on OOM + // release is the caller's responsibility. + let env_map = bun_core::heap::into_raw(Box::new( + global_this + .bun_vm() + .env_loader() + .map + .clone_with_allocator()?, + )); let env = { // SAFETY: `env_map` is a fresh heap allocation owned by this task; the // `'static` borrow is the lifetime erasure for the task-lifetime map.