Skip to content

bundler: post Bun.build completions by ScriptExecutionContext id to survive worker.terminate() - #35158

Closed
robobun wants to merge 7 commits into
mainfrom
farm/7993167a/bundle-worker-uaf
Closed

bundler: post Bun.build completions by ScriptExecutionContext id to survive worker.terminate()#35158
robobun wants to merge 7 commits into
mainfrom
farm/7993167a/bundle-worker-uaf

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

worker.terminate() while Bun.build() is in flight inside the worker crashes the whole process. The bundler runs on its own thread and JSBundleCompletionTask::complete_on_bundle_thread posts the completion back into the worker's EventLoop via a raw BackRef<EventLoop>, but WebWorker::shutdown has already dealloc'd the VM (which contains that EventLoop). process.exit() inside the worker and an uncaught throw are equal triggers.

heap-use-after-free  READ of size 8  thread T16 (Bundler)
  #0 vm_ref                                          src/jsc/event_loop.rs:1044
  #1 EventLoop::enqueue_task_concurrent              src/jsc/event_loop.rs:1000
  #2 JSBundleCompletionTask::complete_on_bundle_thread   src/runtime/api/js_bundle_completion_task.rs:987
  #3 BundleThread::thread_main                       src/bundler/BundleThread.rs:233
freed by thread (Worker):  WebWorker::shutdown  src/jsc/web_worker.rs:1390

Reproduced on release canary (SIGSEGV 3/5 runs) and under debug+ASAN (UAF 5/5) with the fixture in the new test: a worker that spins Bun.build in a loop and is terminated after a short jittered delay.

Fixing the completion post uncovered a second UAF one layer down: a build that was queued on the bundle thread but not yet started gets popped after the worker is gone, and create_and_configure_transpilerTranspiler::init dereferences the worker's freed bun_dotenv::Loader (captured as a raw pointer in self.env):

heap-use-after-free  WRITE of size 1  thread T16 (Bundler)
  #0 Transpiler::init_in_place                       src/bundler/transpiler.rs:1250
  #2 JSBundleCompletionTask::create_and_configure_transpiler  src/runtime/api/js_bundle_completion_task.rs:1077
freed by thread (Worker):  WebWorker::shutdown  src/jsc/web_worker.rs:1401

Cause

JSBundleCompletionTask borrows worker-lifetime state (jsc_event_loop: BackRef<EventLoop>, env: *mut Loader, global_this) on the assumption that the owning JS thread outlives the task. That holds for the main thread but not for a worker: nothing in WebWorker::shutdown parks or waits for the bundle thread before freeing the VM and env loader, so the bundle thread's only pointers into the target VM become dangling the moment shutdown() reaches std::alloc::dealloc.

ScriptExecutionContext::postTaskTo / markTerminating() already implement the correct lock-gated-flag-then-drain serialization for C++ cross-thread posters (WebCrypto, MessagePort, Worker.dispatchExit), and WebWorker::shutdown already calls markTerminating() before it drains the concurrent queue and frees the VM. The bundle thread just wasn't using it.

Fix

  • JSBundleCompletionTask now captures the originating ScriptExecutionContextIdentifier (a stable u32) at Bun.build call time.
  • complete_on_bundle_thread and the CompletionDispatch vtable enqueue_task_concurrent post via a new ScriptExecutionContext__postConcurrentTask(id, task): under allScriptExecutionContextsMapLock, look up the context, check isTerminating(), and only then call back into EventLoop::enqueue_task_concurrent. This is the same shape as postTaskTo but for a pre-allocated Rust ConcurrentTaskItem. If the context is gone or terminating, the ConcurrentTaskItem is reclaimed on the bundle thread and the completion task itself is leaked (its deinit touches JS-thread-owned JSPromiseStrong / Plugin / KeepAlive state that no longer exists).
  • BundleThread::thread_main checks is_owner_alive() (new CompletionStruct trait method, backed by ScriptExecutionContext__isAlive) before starting a popped build, so a build queued by a now-dead worker skips create_and_configure_transpiler instead of dereferencing the freed env loader.
  • Added ScriptExecutionContextIdentifier::{post_concurrent_task, is_alive} and JSGlobalObject::script_execution_context_identifier() Rust bindings.

Verification

New test terminating a worker mid-Bun.build() does not crash the process in test/bundler/bun-build-api.test.ts spawns a subprocess that repeatedly creates a worker running two concurrent Bun.build lanes over a multi-hundred-module graph and terminates it mid-bundle.

  • Fails on main: release SIGSEGV at address 0x0 within two rounds; debug+ASAN heap-use-after-free deterministically on round 0.
  • Passes with this change: 8/8 clean runs under debug+ASAN locally; full bun-build-api.test.ts passes (50 pass, 1 pre-existing skip, 1 todo).
  • bun run rust:check-all: 10/10 targets ok.

no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-api.test.ts

…urvive worker.terminate()

worker.terminate() mid-Bun.build() crashed the whole process: the bundle
thread's complete_on_bundle_thread() dereferenced a BackRef<EventLoop>
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.
@coderabbitai

coderabbitai Bot commented Jul 22, 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: 1 minute

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: 443a4aa6-3c1f-4b0e-9b58-91aa456f7335

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and 9a0318f.

📒 Files selected for processing (9)
  • src/bundler/BundleThread.rs
  • src/bundler/bundle_v2.rs
  • src/jsc/JSGlobalObject.rs
  • src/jsc/bindings/ScriptExecutionContext.cpp
  • src/jsc/virtual_machine_exports.rs
  • src/runtime/api/JSBundler.rs
  • src/runtime/api/js_bundle_completion_task.rs
  • src/runtime/server/HTMLBundle.rs
  • test/bundler/bun-build-api.test.ts

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

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced the reported UAF on canary (SIGSEGV 3/5) and under debug+ASAN (5/5) with the fixture in this PR's new test. With the fix, the full test/bundler/bun-build-api.test.ts suite and bundler_plugin.test.ts pass, and rust:check-all is 10/10.

Scope after review (9a0318f):

  • complete_on_bundle_thread posts by ScriptExecutionContextIdentifier under the contexts-map lock (serializes with markTerminating()); when the context is gone it reclaims the ConcurrentTaskItem and drops the large task-owned payloads (result/log/env) on the bundle thread.
  • The env map is cloned into the completion task at creation time (with did_load_process set so run_env_loader stays a no-op), so Transpiler::init and the resolver NODE_PATH lookup dereference task-owned memory for the whole build.
  • is_owner_alive() skips a build queued by a dead worker as an early-out.
  • The plugin-dispatch vtable (enqueue_on_js_loop_for_plugins) stays on the pre-PR direct enqueue: dropping a plugin onLoad/onResolve post would hang the process-wide BundleThread inside wait_for_parse. Plugin builds inside a terminated worker therefore behave exactly as on main (same pre-existing UAF, which Fix use-after-free when worker.terminate() races in-flight fetch/work-pool completions #34154 also lists as a known residual).
  • Plugin OOM cleanup is at the owning Bun.build call site (HTMLBundle::Route borrows its plugin from the server, so the shared error arm doesn't free it).
  • Test uses a combined {stdout, stderr, exitCode} assertion with detect_leaks=0 in its subprocess only (pre-existing bounded worker-termination leaks on main, node:fs Binding / WebWorker box, would otherwise abort it under CI's detect_leaks=1).

CI: bun-build-api.test.ts passes on all lanes across builds 77997, 78036, and 78057. The only remaining non-flaky red is test/js/node/test/parallel/test-net-connect-memleak.js on ubuntu 25.04 x64 (red on all four of this PR's builds), a FinalizationRegistry/GC-timing test that nothing in this diff is reachable from (bundler-only changes, no net/socket/GC paths); reported to main-break triage. The rest are lane flakes. All 7 review threads resolved. Ready for review.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:48 PM PT - Jul 22nd, 2026

@robobun, your commit 9a0318f has 2 failures in Build #78057 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35158

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

bun-35158 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix use-after-free when worker.terminate() races in-flight fetch/work-pool completions #34154 - Also fixes the bundler completion UAF on worker.terminate(), touching the same BundleThread.rs and js_bundle_completion_task.rs files as part of a broader all-producers fix
  2. Don't enqueue to a terminated worker's freed event loop from other threads #32071 - Also fixes the bundler completion UAF on worker.terminate(), touching js_bundle_completion_task.rs via a comprehensive VM-handle registry approach

🤖 Generated with Claude Code

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

On the duplicate flags: #32071 is stale (conflicts with main, clippy failing) and uses an address-keyed registry that the review flagged for ABA on VM-address reuse; the ScriptExecutionContextIdentifier here is a monotonic u32 that is never reused, so it doesn't have that problem.

#34154 is Jarred's broader ShutdownGate / pin-based fix for the whole producer class (fs, fetch, S3, dns, zlib, password, bundler, etc.). The two approaches differ in what happens on terminate: #34154 makes worker.terminate() block until the in-flight build's pin drops; this PR lets terminate proceed and has the bundle thread drop the completion when it observes isTerminating() under the contexts-map lock (the same serialization postTaskTo already uses).

This PR is scoped to the bundler case only (6 files). If #34154 lands first I'll rebase this down to just the test, or close it if #34154's test already covers the Bun.build repro.

Comment thread src/bundler/BundleThread.rs
Comment thread test/bundler/bun-build-api.test.ts Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
robobun and others added 2 commits July 22, 2026 16:56
…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.

@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/runtime/api/js_bundle_completion_task.rs:770-784 — When post_concurrent_task returns false here, the dropped ConcurrentTaskItem may be a plugin onLoad/onResolve dispatch (via enqueue_on_js_loop_for_plugins, bundle_v2.rs:1498-1505), not just the final completion — its graph.pending_items increment is never decremented, so wait_for_parse (predicate pending_items == 0) parks the process-wide BundleThread singleton forever inside generate_in_new_thread, and every subsequent Bun.build() from any thread (including main) hangs. Before this PR the same path UAF-crashed; this converts it to a silent permanent process-wide bundler hang for the plugins case, which the new test (no plugins) does not exercise. The false branch needs to actively abort the build (e.g. flag is_done() / route to the same owner-dead short-circuit as the pre-start check), not just reclaim the heap task.

    Extended reasoning...

    What the bug is

    COMPLETION_VTABLE.enqueue_task_concurrent is not only the final-completion post — it is the JS-loop hop for plugin onLoad/onResolve dispatch mid-build. BundleV2::enqueue_on_js_loop_for_plugins (bundle_v2.rs:1498-1506) routes Resolve::dispatch (bundle_v2.rs:1128), Load::dispatch (bundle_v2.rs:1259), and DeferredBatchTask::schedule (DeferredBatchTask.rs:57) through completion.enqueue_task_concurrent(task) → this vtable thunk. Each such dispatch has a matching graph.pending_items increment (bundle_v2.rs:2873); the counter is only decremented when the plugin's response posts back to the bundle thread's mini loop via on_load_async/on_resolve_asyncdecrement_scan_counter (bundle_v2.rs:2881-2883).

    With this PR, when post_concurrent_task returns false (worker terminated mid-bundle), the thunk drops the ConcurrentTaskItem and returns. run_on_js_thread never runs, the plugin never fires, no response is ever posted back, and pending_items never reaches 0.

    The specific code path that triggers it

    init_and_run constructs AnyEventLoop::default() = Mini(Box<MiniEventLoop>). run_from_js_in_new_thread calls wait_for_parse (bundle_v2.rs:2013), which loops AnyEventLoop::tick_raw with is_done() as the sole predicate; is_done() (bundle_v2.rs:1994-2010) returns true only when graph.pending_items == 0. tick_raw's Mini arm calls MiniEventLoop::tick_once, which — once other WorkPool parse tasks drain and the queue is empty — blocks in the uws loop's tick() waiting for a wakeup that would only come from the plugin response's mini.enqueue_task_concurrent. That wakeup never arrives, so the bundle thread parks forever inside generate_in_new_thread.

    Why existing code doesn't prevent it

    BundleThread is a process-wide singleton (BundleThread.rs mod singleton, OnceLock-backed). thread_main pops one completion at a time and runs generate_in_new_thread synchronously. Once that call is stuck in wait_for_parse, the loop never returns to queue.pop(), so every subsequent Bun.build() from any thread — including the main thread — queues onto BundleThread::queue and is never popped. The is_owner_alive() pre-check added in this PR only guards builds that haven't started yet; it does nothing for a build already inside wait_for_parse when the worker terminates. The complete_on_bundle_thread false-branch handles the final post correctly, but that code is unreachable — generate_in_new_thread never returns to call it.

    Step-by-step proof

    1. Worker calls Bun.build({ plugins: [...] }). Task is enqueued; bundle thread pops it, is_owner_alive() → true, enters generate_in_new_threadinit_and_runrun_from_js_in_new_thread.
    2. Scan phase encounters a module matching a plugin filter. Load::dispatch increments graph.pending_items and calls enqueue_on_js_loop_for_pluginsCOMPLETION_VTABLE.enqueue_task_concurrent(c, task).
    3. Meanwhile the parent calls worker.terminate(). WebWorker::shutdown calls markTerminating() under allScriptExecutionContextsMapLock.
    4. ScriptExecutionContext__postConcurrentTask takes the same lock, sees context->isTerminating(), returns false. The vtable thunk does drop(heap::take(task)) and returns.
    5. No response ever posts back to the mini loop. pending_items stays ≥ 1. wait_for_parse loops on is_done() = false; once the mini loop's task queue drains, tick_once blocks in (*loop_ptr()).tick() with nothing to wake it.
    6. The main thread later calls Bun.build(...). singleton::enqueue pushes onto BundleThread::queue and waker.wake()s — but thread_main is stuck two frames deep inside generate_in_new_thread and never reaches queue.pop(). The main-thread promise never settles.

    Impact

    Before this PR the same path UAF-crashed at jsc_event_loop.enqueue_task_concurrent (the PR's own first ASAN trace). This PR converts that into a silent, permanent, process-wide bundler hang for the plugins case — arguably worse than the crash for diagnosability, since there is no stack trace and the symptom ("all my Bun.build calls hang") appears far from the cause ("a worker with plugins was terminated once, minutes ago"). The new test uses no plugins, so it does not exercise this path. This is precisely REVIEW.md "Every error/abort/timeout path actively completes the operation. Settle every pending promise slot (an unsettled promise pins objects and hangs callers forever)" and "Cover the variant matrix, not just the repro" (the plugin variant is a sibling entry point sharing the same fix).

    How to fix

    Reclaiming the ConcurrentTaskItem is necessary but not sufficient — the false branch must also signal the mini loop that the build is dead. Options:

    • Set an "owner dead" flag on BundleV2 (reachable via from_completion_handle(c) → the completion's stashed transpiler: *mut BundleV2) that is_done() also checks, and post a wakeup to the mini loop so tick_once unblocks; have wait_for_parse's caller bail with an error when the flag is set.
    • Or route this case to the same short-circuit as the pre-start is_owner_alive() check: mark the completion result as Err and post a task back to the mini loop (which is bundle-thread-owned and always alive) that decrements pending_items for each dropped dispatch, so wait_for_parse returns and thread_main proceeds to complete_on_bundle_thread (which will itself observe the dead context and drop the post).

    Either way, the invariant is: every path out of enqueue_task_concurrent must eventually let is_done() return true.

    Not a duplicate of the existing BundleThread.rs:240 comment — that concerns the is_owner_alive() pre-check TOCTOU on env_loader (a UAF during Transpiler::init/resolver, before or outside the plugin round-trip); this is a distinct hang in the mid-build plugin dispatch path via the vtable thunk.

… 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.
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

On the plugin-dispatch hang: confirmed. COMPLETION_VTABLE.enqueue_task_concurrent carries mid-build onLoad/onResolve hops via enqueue_on_js_loop_for_plugins, and dropping one when the worker is terminating strands graph.pending_items so wait_for_parse parks the process-wide BundleThread forever. Reverted that path to the pre-PR direct enqueue in b6722c5 so plugin builds behave exactly as on main (the same pre-existing UAF on termination, which #34154 also lists as a known residual for plugin builds). This PR now changes behavior only for the no-plugins path that was reported; complete_on_bundle_thread keeps the context_id.post_concurrent_task route, and the task-owned env snapshot means the bundle thread never dereferences worker-owned memory there.

@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.

The earlier 🔴 env-loader TOCTOU is closed by the task-owned snapshot in 43ee822Transpiler::init and resolver.env_loader now consume self.env, which is the task's own Loader::init(cloned map), so the mid-bundle NODE_PATH deref no longer touches worker-lifetime memory. is_owner_alive() is now just an early-out, not load-bearing for safety. Remaining findings this pass are nits.

Extended reasoning...

Confirming resolution of the prior blocking finding after 43ee822/b6722c5f. The env-loader lifetime is now task-owned end-to-end (construction → create_and_configure_transpilerconfigure_bundler's resolver.env_loader = NonNull::new(transpiler.env...)do_compilation's to_executable), and the dead-owner branch in complete_on_bundle_thread correctly drops result/log/env/env_map in place. Not restating the three inline nits. This remains a cross-thread memory-safety change with a documented residual (plugin-dispatch path unchanged) and a competing maintainer approach in #34154, so deferring to human review.

Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread test/bundler/bun-build-api.test.ts Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
…ire 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.
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
The HTMLBundle::Route caller passes a plugin borrowed from the server's
ServePluginsState::Loaded(Box<Plugin>); 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).

@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.

All prior review findings have been addressed (env snapshot with did_load_process = true, plugin OOM cleanup moved to the owning Bun.build caller, worker error wired to reject, dead-field cleanup). No new issues this round, but this is cross-thread lifetime / UAF-fix work with a couple of deliberate design tradeoffs — the plugin-dispatch vtable keeps the pre-existing direct enqueue (and its UAF on termination) to avoid hanging wait_for_parse, and the completion-task box + JSPromiseStrong/KeepAlive are intentionally leaked when the target context is dead — so a human should sign off on those.

What was reviewed:

  • ScriptExecutionContext__postConcurrentTask lock shape mirrors postTaskTo (enqueue under allScriptExecutionContextsMapLock, same path already calls enqueue_task_concurrent under that lock — no new deadlock).
  • is_owner_alive() TOCTOU is now only a work-skip optimization; the env-loader UAF it originally guarded is closed by the task-owned snapshot, and the bundle thread touches no other worker-owned state in configure_bundler/init_and_run.
  • deinit null-checks env so the dead-context branch's early free doesn't double-free; HTMLBundle::Route's borrowed plugin is no longer destroyed on the OOM path.
Extended reasoning...

Overview

Fixes a process-wide crash when worker.terminate() races an in-flight Bun.build() inside the worker. The bundle thread previously posted the completion via a raw BackRef<EventLoop> into the worker's freed VM; a second UAF hit the worker's freed bun_dotenv::Loader in Transpiler::init. The fix reroutes complete_on_bundle_thread through a stable ScriptExecutionContextIdentifier posted under the C++ contexts-map lock (same serialization as postTaskTo/markTerminating), snapshots the env map into a task-owned Loader at schedule time, and adds an is_owner_alive() skip in BundleThread::thread_main. New FFI: ScriptExecutionContext__postConcurrentTask, ScriptExecutionContext__isAlive, ScriptExecutionContextIdentifier__forGlobalObject, Bun__EventLoop__enqueueConcurrentTask. Nine files touched across bun_bundler, bun_jsc, bun_runtime, C++ bindings, and one new test.

Security risks

None user-facing. This is internal cross-thread lifetime management; no untrusted-input parsing, auth, or crypto paths are touched.

Level of scrutiny

High. This is exactly the category REVIEW.md flags as most-blocked: cross-thread ownership, UAF fixes, ref-count balance across terminal paths, and a new lock-gated FFI surface. The change is well-reasoned and the ASAN evidence is solid, but it also encodes non-obvious tradeoffs a maintainer should ratify:

  • COMPLETION_VTABLE.enqueue_task_concurrent deliberately keeps the direct jsc_event_loop enqueue (pre-existing UAF on termination) because dropping a plugin onLoad/onResolve post would strand graph.pending_items and hang the process-wide bundle thread. That's documented in-line but is a "known residual UAF" a human should accept.
  • On the dead-context path, the JSBundleCompletionTask box (with its JSPromiseStrong, KeepAlive, plugins handle, BackRef<JSGlobalObject>) is leaked because those point into the dead VM and can't be released off the JS thread. The large payloads (result, log, env/env_map) are reclaimed. Bounded, but a design call.
  • Per-Bun.build env-map clone is a small new allocation cost on every build.

Other factors

Three prior review rounds; every finding was addressed in follow-up commits (43ee822, b6722c5, 17be429, 9a0318f). The new subprocess test asserts a combined {stdout, stderr, exitCode} object, wires the worker error event to reject, scales rounds/modules by build type, and disables detect_leaks with a stated reason. rust:check-all reported 10/10; the file-level suite passes locally per the PR body. The postConcurrentTask path holds the same lock across the same enqueue_task_concurrent call that postTaskTopostTaskConcurrentlyqueueTaskConcurrently already does, so no new lock-ordering is introduced.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Generalised into #35767 (one chokepoint for every cross-thread poster, same ScriptExecutionContext__postConcurrentTask shape as here).

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this is fixed on current main by the Worker teardown rewrite in #37075. JSBundleCompletionTask now registers with the VM, which cancels it in the teardown stop phase (stop_for_vm_teardown) and waits for the bundle thread to hand it back before the VM and its env loader are freed, so neither the completion post into a dead worker nor the unstarted build reading the freed loader can happen any more. The test this PR adds to test/bundler/bun-build-api.test.ts (terminating a worker mid-Bun.build() does not crash the process), which the description says failed deterministically under ASAN on main at the time, passes unmodified against an ASAN debug build of main at 04148c8 in four out of four runs.

@robobun robobun closed this Aug 13, 2026
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.

2 participants