Skip to content

Fix use-after-free when a worker is terminated with a fetch in flight - #31692

Closed
robobun wants to merge 6 commits into
mainfrom
farm/d624363d/fix-fetch-worker-vm-uaf
Closed

Fix use-after-free when a worker is terminated with a fetch in flight#31692
robobun wants to merge 6 commits into
mainfrom
farm/d624363d/fix-fetch-worker-vm-uaf

Conversation

@robobun

@robobun robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Crash

Sentry BUN-3BR3 (aarch64 siblings BUN-3DMH / BUN-3DRK): ~20 crashes in 14 days, all Linux (x64 + aarch64), canaries 49c97de6b7069b3092. Segfault on the HTTP client thread:

us_internal_dispatch_ready_poll → … → FetchTasklet::callback
  → enqueue_concurrent → EventLoop::enqueue_task_concurrent
  → UnboundedQueue::push_batch → atomic swap  ← SIGSEGV

Repro

Terminate a worker while a fetch() it started is still streaming. The regression test (test/js/web/workers/worker-terminate-lifetime.test.ts) does this in a loop; on an unfixed ASAN build it crashes deterministically:

==ERROR: AddressSanitizer: heap-use-after-free …
READ of size 1 … thread T11 (HTTP Client)

Cause

FetchTasklet holds a raw javascript_vm: &'static VirtualMachine backref that the HTTP client thread dereferences on every result callback — the is_shutting_down read, the push onto EventLoop.concurrent_tasks, and the uws loop wakeup. WebWorker::shutdown frees the worker's VirtualMachine allocation (std::alloc::dealloc) with no synchronization against those callbacks, so a progress update landing after teardown reads freed memory and swaps the freed queue head. (worker.terminate() / in-worker process.exit() bypass the event-loop keepalive that poll_ref provides for natural exit.)

The Zig original has the same logical race but frees worker VMs via mi_heap_destroy of a private heap, which typically leaves the pages mapped and masks it; the Rust port frees through the global allocator, so the race is a hard UAF. Linux-only in telemetry is just teardown timing.

Fix

A small refcounted ConcurrentEnqueueGate (mutex + vm_alive flag, src/jsc/event_loop.rs) shared between the VM (one ref, created in VirtualMachine::init) and every FetchTasklet (one ref, taken in get(), released with the tasklet), so the gate strictly outlives both sides:

  • The HTTP thread brackets all three VM-access sites (callback, on_write_request_data_drain, deref_from_thread) with enter()/exit(). A closed gate is handled exactly like is_shutting_down, without touching the VM.
  • WebWorker::shutdown close()s the gate — close() takes the same mutex, so it synchronizes with any in-flight gated enqueue and every later enter() fails — then drains the concurrent queue (releasing queued tasklet refs while JSC is still alive) before destroy()/dealloc. Since no gated producer can enqueue after close(), the drain observes every push that won the race.
  • close() never blocks shutdown beyond one in-flight enqueue (nanoseconds). Dead-worker tasklets whose last ref drops on the HTTP thread reclaim through the existing dealloc_for_shutdown park list — the pre-PR disposition, now reached without the UAF read. Replacing that park with immediate reclamation was attempted (d2817f0) and reverted: both CI builds containing it segfault at process exit on Windows in tests that execute none of it (serve.test.ts ×4 across two bases and both binaries, grpc-js, bake SSG), while this exact tree was 16/16 green on the same lanes; details in the review thread. That hardening (a bounded per-terminate parked allocation) moves to a follow-up with a Windows repro loop.

Verification

  • Regression test fails on unfixed bun bd (ASAN heap-use-after-free on the HTTP Client thread) and passes with the fix; it runs its fixture with BUN_DESTRUCT_VM_ON_EXIT=1 so the process-exit reclaim paths are exercised on every lane.
  • worker-terminate-lifetime.test.ts 4/4; fetch-abort-stream-body + fetch-abort-queued + body-stream: 9088 pass / 0 fail; worker.test.ts, fetch.test.ts, fetch-leak.test.ts failures identical with and without the fix (container env).
  • This tree (modulo comments and the test env var) is the e58e25b state that previously passed the full Windows/Linux/macOS matrix.
  • cargo check + cargo clippy clean on bun_jsc / bun_runtime.

@github-actions github-actions Bot added the claude label Jun 2, 2026
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9e1cac05-1cdf-4fa1-b999-820b24b32fcc

📥 Commits

Reviewing files that changed from the base of the PR and between daae098 and 7c583fb.

📒 Files selected for processing (1)
  • src/jsc/web_worker.rs

Walkthrough

Adds a refcounted ConcurrentEnqueueGate and wires it into VirtualMachine, WebWorker shutdown, and FetchTasklet to serialize cross-thread VM access during teardown; includes docs updates and a regression test exercising worker termination during streaming fetch.

Changes

Worker VM Teardown Gating for Cross-Thread Safety

Layer / File(s) Summary
ConcurrentEnqueueGate concurrency primitive
src/jsc/event_loop.rs
New ThreadSafeRefCounted gate with mutex + AtomicBool VM-alive flag, enter/exit gated sections, ref_/deref refcounting, and close() that marks VM dead and waits for inflight sections.
VirtualMachine gate field and accessor
src/jsc/VirtualMachine.rs
VirtualMachine gets concurrent_enqueue_gate pointer, initialized in init(); retain_concurrent_enqueue_gate() returns a counted NonNull ref for cross-thread users.
WebWorker shutdown gate coordination
src/jsc/web_worker.rs
WebWorker::shutdown() closes the VM gate early (after on_exit/socket closure), releases queued tasks while VM still valid, and drops the VM's gate ref during resource deallocation.
FetchTasklet HTTP-thread gate integration
src/runtime/webcore/fetch/FetchTasklet.rs
FetchTasklet stores a counted vm_gate ref from the VM, wraps HTTP-thread VM access (drain, callback, enqueue) with enter()/exit() checks, and deref/releases the gate in deinit().
Shutdown flow documentation clarifications
src/runtime/dispatch.rs, src/runtime/webcore/fetch/FetchTasklet.rs
Comments updated to document the two shutdown call sites and refcount transition semantics under concurrent gated producers.
Worker termination regression test
test/js/web/workers/worker-terminate-lifetime.test.ts
New test repeatedly spawns workers performing streaming fetch(), terminates mid-stream, and asserts clean process shutdown to exercise the gating changes.
  • Suggested reviewers:
    • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: resolving a use-after-free crash when a worker terminates while a fetch is in flight.
Description check ✅ Passed The description thoroughly covers the crash, reproduction steps, root cause, fix approach, and verification results, aligning with the template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fetch: fix FetchTasklet UAF + leak when worker terminates mid-request #29331 - Fixes the same FetchTasklet UAF when worker terminates mid-fetch, using a VirtualMachine.Handle approach instead of ConcurrentEnqueueGate
  2. fix(fetch): remove latent cross-thread UB in FetchTasklet shutdown #30943 - Fixes cross-thread UB in FetchTasklet shutdown (narrower scope, targeting deref_from_thread UB during VM teardown)

🤖 Generated with Claude Code

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

On the flagged possible duplicates:

  • fetch: fix FetchTasklet UAF + leak when worker terminates mid-request #29331 fixes the same crash (FetchTasklet UAF on worker terminate mid-fetch) but in the pre-rewrite Zig sources (2026-04-15, before Rewrite Bun in Rust #30412 merged) — it's CONFLICTING and can't land on the Rust tree. Its VirtualMachine.Handle approach (re-validate the VM by ScriptExecutionContextIdentifier under the global context-map lock before each HTTP-thread access) gives the same guarantee this PR's ConcurrentEnqueueGate gives with a per-VM lock instead of the global map lock: in both designs teardown synchronizes with in-flight HTTP-thread accesses and later accesses observe "VM gone" without touching it. This PR also keeps the leak fix from fetch: fix FetchTasklet UAF + leak when worker terminates mid-request #29331's description (the shutdown early-return dropping both refs), which the port already carried.

  • fix(fetch): remove latent cross-thread UB in FetchTasklet shutdown #30943 addresses a different, narrower issue: the old deref_from_thread running full deinit() (JSC handle teardown) on the HTTP thread during shutdown. The current tree already routes that through dealloc_for_shutdown (Rust boxes only, parked for the exit reclaim drain), which is why it conflicts. It does not address the VM-allocation UAF this PR fixes — reading is_shutting_down itself is the first UAF once the worker VM is freed, which is what the gate excludes.

Comment on lines +431 to +436
if !gate.enter() {
ConcurrentEnqueueGate::deref(gate_ptr);
// SAFETY: last ref; exclusive access. See the shutdown comment
// below — same reclaim path.
unsafe { FetchTasklet::dealloc_for_shutdown(this) };
return;

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 new gate-closed branch routes every dead-worker tasklet's last-ref drop to dealloc_for_shutdown, which parks the box in the process-global SHUTDOWN_RECLAIMS list — but that list is drained only by the main VM's global_exit() (and only under BUN_DESTRUCT_VM_ON_EXIT). So a long-running parent that repeatedly terminates fetch-using workers leaks every parked FetchTasklet (buffers, Box<AsyncHTTP>, headers, gate ref) until process exit, and when the drain does run, deinit → clear_data() tears down JSC Strong/Weak handles registered in a worker JSC VM that was freed long ago by WebWorker__teardownJSCVM. The gate-closed path needs its own reclaim that frees Rust-side allocations directly without parking and without touching the dead JSC handles.

Extended reasoning...

What the bug is

The PR's new gate-closed branches in deref_from_thread (FetchTasklet.rs:431-436) and in callback's vm_gone && is_done path (FetchTasklet.rs:2396-2404 → deref_from_thread ×2) deterministically reach dealloc_for_shutdown(this) on the 1→0 ref transition. dealloc_for_shutdown (FetchTasklet.rs:582-587) calls http::defer_shutdown_reclaim(this, deinit_erased), which pushes onto the process-global static SHUTDOWN_RECLAIMS Vec (HTTPThread.rs:1399, 1406-1410). That Vec is drained only inside shutdown_for_exit() (HTTPThread.rs:1464-1468), which is called only from VirtualMachine::global_exit() on the main thread (VirtualMachine.rs:1627), and only when should_destruct_main_thread_on_exit() is true (BUN_DESTRUCT_VM_ON_EXIT). The PR description states "in-flight requests of dead workers finish on the HTTP thread and reclaim through the existing dealloc_for_shutdown path" as if that were a freeing path; it is actually a defer-to-process-exit path designed for main-VM shutdown.

Step-by-step proof

  1. Parent process creates a Worker that calls fetch(); the body starts streaming. FetchTasklet::get() runs on the worker's JS thread, taking a gate ref and setting javascript_vm = &worker_vm. ref_count = 2 (JS-side + HTTP-side).
  2. Parent calls worker.terminate(). WebWorker::shutdown runs on the worker thread: gate.close()release_queued_tasks_for_shutdown() → step 3 WebWorker__teardownJSCVM(global) destroys the worker's JSC VM (its HandleSet/WeakSet are freed) → step 5 std::alloc::dealloc(vm_ptr, ...) frees the VirtualMachine allocation.
  3. The HTTP thread keeps delivering progress for the still-open socket. When the request finally completes (is_done = true), callback at FetchTasklet.rs:2372 evaluates vm_gone = !gate.enter()true. It enters the shutdown branch, frees scheduled_response_buffer, unlocks, then calls deref_from_thread(task) twice (lines 2402-2404).
  4. The second deref_from_thread sees the 1→0 transition. At line 431 it evaluates !gate.enter()truedealloc_for_shutdown(this)defer_shutdown_reclaim pushes {ctx: this, drop_fn: deinit_erased} onto SHUTDOWN_RECLAIMS.
  5. The parent process keeps running. Nothing ever drains SHUTDOWN_RECLAIMS. The parked FetchTasklet box — with its Option<Box<AsyncHTTP>>, request_headers: Headers, url_proxy_buffer, response_buffer/scheduled_response_buffer capacity, the ConcurrentEnqueueGate ref, and (depending on what was populated before terminate) native_response, hostname, etc. — sits in the Vec forever. Repeat steps 1-4 N times → N leaked tasklets. Unbounded growth in a long-running parent.
  6. Eventually the parent process exits. If BUN_DESTRUCT_VM_ON_EXIT is unset, global_exit never reaches shutdown_for_exit() and the Vec is simply leaked at _exit — harmless at that point but the steady-state leak in step 5 already happened. If it is set, shutdown_for_exit() iterates SHUTDOWN_RECLAIMS and calls deinit_eraseddeinitclear_data() on each parked worker tasklet on the main thread. clear_data() (FetchTasklet.rs:523, 526, 530, 539-541) executes self.response.clear() (a jsc::Weak registered in the dead worker's WeakSet), Response::unref(native_response) (a JSC cell in the dead worker's GC heap), readable_stream_ref.deinit() / abort_reason.deinit() / check_server_identity.deinit() / clear_abort_signal() (JSC Strong handles in the dead worker's HandleSet). All of those structures were freed in step 2 — heap-use-after-free.

Why existing code doesn't prevent it

dealloc_for_shutdown's own doc (FetchTasklet.rs:565-578) spells out the contract: "the drain runs from global_exit() after the HTTP thread has parked but before destructOnExit, so deinit() there can release every handle on the right thread". That contract holds for tasklets owned by the main VM (whose destructOnExit runs after the drain), but is violated for worker tasklets — their JSC VM is destroyed in WebWorker::shutdown step 3 long before the main-VM drain, and the drain runs on the wrong thread anyway. The pre-PR is_shutting_down() arm had the same design assumption, but reaching it for a dead worker required first reading is_shutting_down from freed memory (the very UAF this PR fixes), so it was effectively unreachable. This PR makes the dealloc_for_shutdown route the deterministic outcome via the new !gate.enter() branch and explicitly relies on it as the cleanup mechanism.

The new regression test doesn't catch either effect: the subprocess exits right after the loop (so the steady-state leak isn't observed), and bunEnv doesn't set BUN_DESTRUCT_VM_ON_EXIT (so shutdown_for_exit() never drains and the parked boxes are simply abandoned at _exit).

Impact

  • Memory leak (always): every worker terminated with an in-flight fetch leaks one FetchTasklet plus its owned allocations until process exit. For a server that uses workers as a sandbox/timeout mechanism around outbound HTTP, this is unbounded RSS growth.
  • Exit-time UAF (under BUN_DESTRUCT_VM_ON_EXIT): writes into the freed JSC HandleSet/WeakSet/GC heap of a long-dead worker, on the wrong thread.

Fix

The gate-closed (vm_gone) last-ref path should not park in SHUTDOWN_RECLAIMS. Instead, reclaim directly on the HTTP thread: free the Rust-side allocations (url_proxy_buffer, hostname, request_headers, http: Box<AsyncHTTP>, metadata, scheduled_response_buffer, request_body_streaming_buffer deref, etc.), drop the gate ref, and heap::destroy the box — without touching any JSC Strong/Weak/native_response field (those handles became invalid the moment the worker's JSC VM was torn down; leaking the handle slot is correct because the HandleSet/WeakSet that owned it no longer exists). One option is a clear_data_rust_only() that skips lines 523-541's JSC operations, plus a ManuallyDrop/mem::forget around the JSC-handle fields so drop(boxed) doesn't run their Drop impls. Keep dealloc_for_shutdown for the main-VM is_shutting_down() arm (gate open, VM alive), where its existing contract still holds.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d2817f0. You're right that the gate-closed branch made the park-at-exit path the deterministic outcome — and worse, ASAN CI runs with BUN_DESTRUCT_VM_ON_EXIT=1, so the exit drain's dead-heap deinit would have been reachable there.

What changed:

  • New FetchTasklet::reclaim_for_dead_vm: frees all Rust-side allocations immediately on whichever thread drops the last ref (Box<AsyncHTTP>, headers, URL/hostname buffers, metadata, response buffers, cert info, blob-store ref / sendfile fd, stream-buffer ref, box storage) and leaks only the JSC-registered handles via ManuallyDrop (their HandleSet/WeakSet died with the worker heap; the raw-ptr refs — sink/signal/native_response — are single-threaded refcounts we must not touch off-thread).
  • One subtlety beyond your sketch: the leaked response Weak keeps on_response_finalize registered against the tasklet box until the worker's JSC heap is destroyed, so the box can't be freed while the heap is still unwinding. Handled by moving ConcurrentEnqueueGate::close() to after WebWorker__teardownJSCVM (gate closed now implies "heap gone ⇒ no finalizers"), and by parking a last-ref drop that lands while the gate is still open (is_shutting_down, !is_main_thread) on the gate itself — close()'s drain reclaims those right after heap teardown.
  • dealloc_for_shutdown is now reachable only for main-VM tasklets, where its documented contract holds.

Verified: with BUN_DEBUG_FetchTasklet=1, the regression fixture shows reclaimForDeadVm ×8 for 8 terminated workers and zero deallocForShutdown parks; the test now runs with BUN_DESTRUCT_VM_ON_EXIT=1 explicitly (exit 0 under ASAN), so both the steady-state leak and the exit-drain UAF are covered.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Status change: the reclaim_for_dead_vm machinery from d2817f0 is deferred to a follow-up and this PR is back to the park-at-exit disposition (plus the doc fixes and the BUN_DESTRUCT_VM_ON_EXIT=1 test hardening). Reason, with evidence:

Both CI builds containing the reclaim machinery segfault at process exit on Windows 2019 in tests that execute none of it:

  • http/serve.test.ts: crashes after all 195 tests pass, on x64 and x64-baseline, on both the new base (build 59827) and the old base (59844) — 4 crashes, identical 14-frame stack shape. The file spawns no workers, and the v1→v2 patch diff shows every non-worker path byte-identical.
  • grpc-js/test-client.test.ts (node:http2 — JS-thread only, no VM backrefs off-thread) and bake/dev/production.test.ts (bun build --app, a 105 ms SSG subprocess with no fetch and no workers) also crash at exit, once or twice each.
  • The previous head (e58e25b — same gate, no reclaim machinery) was 16/16 green on the same Windows lanes, including serve.test.ts at the same shard position, and no other recent build in the fleet shows this crash signature (checked every failed Windows-2019 job across the last 100 failed builds).
  • Linux ASAN cannot reproduce any of it (serve/bake/grpc all clean on the reclaim build).

I can't symbolize the Windows traces from this environment (artifact downloads and bun.report are blocked), so I can't explain the interaction — only demonstrate the correlation. Rather than block the Sentry crash fix on it, the reclaim hardening needs a follow-up with a Windows repro loop.

On the residual behavior this PR ships: dead-worker tasklets park in SHUTDOWN_RECLAIMS (the pre-PR disposition, now reached deterministically instead of after a UAF read). That's a bounded allocation per terminated-mid-fetch worker, reachable from a static (LSan-quiet). The exit-drain deinit of a dead worker's JSC handles under BUN_DESTRUCT_VM_ON_EXIT=1 touches bmalloc-backed memory — verified locally that ASAN does not flag it and the hardened regression test (which now sets that env var explicitly) passes; it's pre-existing and in scope for the same follow-up.

Comment thread src/jsc/web_worker.rs
@robobun
robobun force-pushed the farm/d624363d/fix-fetch-worker-vm-uaf branch from e58e25b to d2817f0 Compare June 2, 2026 04:43
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/jsc/event_loop.rs Outdated
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI note: build 59827 (previous head d2817f0) failed 9 Windows-2019 shards. Two of the failures are real segfaults — test/js/bun/http/serve.test.ts crashes at process exit after all 195 tests pass on both x64 and x64-baseline with identical stack shapes, and grpc-js/test-client.test.ts crashes near-null — but neither can be this PR's code:

  • serve.test.ts spawns no workers, so none of this PR's teardown paths execute in it; the v1→v2 patch diff confirms every non-worker path is byte-identical to the previous head (e58e25b), which passed all 16 Windows shards including serve.test.ts at the same shard position.
  • The delta between the two heads is the rebase: 561eb8ff17..7ef293bd96 (fetch-proxy buffer handoff, napi GC-finalizer ref deletion, crash-handler trace trimming). Main builds do not run the Windows-2019 test lanes, and no other PR containing those commits has run serve.test.ts/grpc on Windows — so a Windows-only regression in that range would surface in exactly this way, in the first PR rebased past it that runs the full Windows suite.
  • The remaining 7 shard failures are environment flakes (duckdb code 3 ×4 on two independent shards, fs.watchFile EPERM on a CJK temp filename, bun-install).

To bisect, this push is the identical tree rebased back onto 561eb8ff17 (the previous, Windows-green base). If Windows passes, the regression is inherited from that 3-commit range and I'll file it against main with the decoded crash traces; if it still crashes, it's somehow this PR and I keep digging.

Decoded traces (image-relative, from the bun.report URLs in the job logs):

  • serve.test.ts x64: fault addr 0x1d1cb168008 (heap+8 read), frames 0xfa51aa ← 0xa2cee5 ← 0xfcaf38 ← 0x1571fbc ← 0x157066e ← 0x156bf76 ← 0x156bd86 ← 0x15785df ← 0x1ba4b18 ← 0x157bf08 ← 0x158796f ← 0x1525f3d ← 0x120b538 ← 0x76c3dd
  • serve.test.ts x64-baseline: fault addr 0x1f988e50008, same 14-frame shape (0xf648da ← 0xa02945 ← 0xf8a2a6 ← 0x15107dc ← …)
  • grpc-js x64-baseline: fault addr 0x298, innermost ntdll.dll+0x15fff, then 0x9b251d ← 0x9c4631 ← 0x1fabd3e ← 0x13e16a5 ← 0x1367f00 ← …

@robobun
robobun force-pushed the farm/d624363d/fix-fetch-worker-vm-uaf branch 2 times, most recently from 01ed6bc to 2dba9da Compare June 2, 2026 06:34
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Windows CI failure — complete evidence, needs Windows-side symbolication

Three CI rounds later, here is the full picture. The Linux/macOS/ASAN matrix is green in every round; the regression test proves the Sentry-crash fix both ways. What remains is a Windows-2019-only segfault at process exit that I cannot diagnose further from this (Linux, egress-restricted) environment.

Build matrix

build tree base Windows 2019 result
59785 gate fix (v1) 561eb8ff17 16/16 green (serve ran 1×, grpc 2×, bake 4× — all clean)
59827 v1 + reclaim machinery 7ef293bd96 serve.test.ts exit-segfault ×2 lanes, grpc-js ×1
59844 v1 + reclaim machinery 561eb8ff17 serve ×2, grpc ×1, bake SSG ×1
59867 v1 again (+docs/test-env only) main tip serve ×1, grpc ×1

Meanwhile every other PR build in the same time windows passes these lanes 16/16 (checked ~30 builds finishing 05:24–07:02 UTC, plus every failed Windows job across the last 100 failed builds — zero occurrences of this signature anywhere else).

The crash

Always panic(main thread): Segmentation fault after the test file completes (serve: after Ran 195 tests, all passing; bake: after the bun build --app subprocess finishes its work), fault address = <heap>+0x008 for serve, near-null for grpc/bake. The serve stack is the same 14-frame path in every binary — v3's frames are v2's shifted uniformly by 0x3c80:

serve.test.ts, x64, build 59867 (bun + image-relative):
0xfa152a ← 0xa29595 ← 0xfc72b8 ← 0x156e33c ← 0x156c9ee ← 0x15682f6 ← 0x1568106
← 0x157495f ← 0x1ba0e98 ← 0x1578288 ← 0x1583cef ← 0x15222bd ← 0x12078b8 ← 0x767f1d

bun.report links (I cannot reach bun.report or the S3 artifacts from this environment to symbolize):

  • serve/59867: https://bun.report/1.4.0/w_22dba9daiGglg4ggD0yqofq5qqUwr5xf4zx7qB8+k7qBsvh6qBswg6qB+1k9qBwpno3Bwoh+qB+u+grB6rxoqBwr8hkB6x/5OA2woBwggq80C
  • serve/59827: https://bun.report/1.4.0/w_2d2817f0iGglg4ggD06opfqunrUwz3yf47v8qB8mj8qBs3/6qBs4+6qB+9i+qBwxlp3Bww/+qB+28hrB6zvpqBwz6ikB69h7OA2idx//l90B
  • grpc/59827: https://bun.report/1.4.0/w_2d2817f0gGikggggDCSntdll.dll+/vF6xysTijjxT8z+q/Bqqr4nBgw/5mB+l3jQ28o+PmvnqO04nzO+45laq35lai/h4em9pmlDA2AwpB

What this rules in/out

  • Not the reclaim machinery (59867 doesn't contain it), not the rebase/base commits (crashes on both bases), not agent health (fleet green around it).
  • The crashing processes execute none of this PR's worker-teardown code (serve spawns no workers; grpc-js is node:http2, JS-thread-only; bake SSG does no fetch). The only PR code running in them is the per-fetch gate enter()/exit() (serve) — and for bake, only the one extra gate allocation at VM init.
  • Linux ASAN runs serve/grpc/bake clean on the identical tree.
  • The probabilistic reading: ~80% crash rate per serve/grpc run on my binaries post-59785, zero in the fleet — v1's clean round may simply have been the lucky draw of the same coin.

What's needed

Someone with a Windows machine (or bun.report access) to open the trace links above — the 14-frame main-thread exit stack will name the subsystem immediately — and/or loop bun-profile.exe test test/js/bun/http/serve.test.ts from bunx bun-pr 31692. I'll take the fix from there; until then I'm not pushing further CI rounds.

Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
@robobun
robobun force-pushed the farm/d624363d/fix-fetch-worker-vm-uaf branch from eb101cf to 5ac198f Compare June 2, 2026 07:12
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build 59875 (head 5ac198f, doc-only delta): the only non-Windows failure is test/js/third_party/@duckdb/node-api/duckdb.test.ts - segmentation fault on debian-13-x64 — a crash inside the third-party duckdb native addon (near-empty bun frames in its trace), and a known fleet flake: the identical segfault appears in build 59697 (farm/63b997f9/audit-json-filter, an unrelated PR), and duckdb tests failed with unrelated code 3 errors on two shards of build 59827 as well. Nothing in this PR touches NAPI or duckdb.

Windows lanes for this head were still running at triage time; expectations per the evidence comment above (the exit-segfault needs Windows-side symbolication — trace links there). No further CI re-rolls from my side.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Final CI summary for this head (ecc9a07, identical tree to 5ac198f):

  • Build 59891: 280 jobs passed, zero code failures. Every Linux (incl. ASAN), Windows, FreeBSD, Android lane is green. The only red statuses are three macOS test lanes marked "Expired" — the jobs never ran (agent-capacity expiry in the darwin queue), which is infrastructure, not this diff; their re-queued copies are still waiting for agents.
  • Build 59875 (same tree minus the empty retrigger commit): 279 passed, with the sole failure being the fleet-wide duckdb native-addon flake triaged above.
  • Notably, the Windows 2019 lanes have now passed 32/32 shards across the last two full runs on this tree — the exit-time segfault documented earlier (builds 59827/59844/59867) did not recur. The evidence + bun.report trace links above remain for reference if it ever resurfaces, but it is not reproducing on the current head.

The diff is green across the matrix; the remaining red is darwin agent capacity. I've used my one CI re-roll, so this is ready for a maintainer — either a targeted retry of the expired darwin jobs or review/merge as-is.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Addendum to the CI summary: the previously-expired darwin lanes got agents and ran. darwin-14-x64 failed on exactly three files, each retried 4× with the same result — sql/sql-prepare-false.test.ts and regression/issue/21311.test.ts (both PostgreSQL-in-container suites, failing inside describeWithContainer("postgres", …)) and websocket/autobahn.test.ts (docker-based conformance suite). All three are container/service-dependent tests with zero overlap with this diff (fetch/worker/event-loop teardown); every code lane — all Linux incl. ASAN, all 16 Windows shards, FreeBSD, Android — is green at 280 jobs passed.

That's the complete picture: the diff is green across the matrix; the only red is darwin container-service flake. I've spent my one CI re-roll, so over to a maintainer for a targeted darwin-job retry or merge.

robobun added 3 commits June 7, 2026 01:35
The HTTP client thread's FetchTasklet callbacks dereference a raw
VirtualMachine backref (the is_shutting_down read, the concurrent-task
enqueue, and the loop wakeup), but WebWorker::shutdown frees the worker's
VirtualMachine allocation with no synchronization against those callbacks.
A progress update landing after the dealloc read freed memory and pushed
onto the freed concurrent queue — a segfault on the queue-head atomic swap.

Zig has the same logical race but frees worker VMs by destroying a private
mimalloc heap, which typically leaves the pages mapped and masks it; the
Rust port frees through the global allocator, so the race is a hard UAF.

Fix: a refcounted ConcurrentEnqueueGate (mutex + vm_alive flag) shared
between the VM and every FetchTasklet, so it outlives both sides. The HTTP
thread brackets every VM access with enter()/exit(); worker shutdown
close()s the gate — synchronizing with any in-flight gated section — then
drains the concurrent queue (releasing parked tasklet refs while JSC is
still alive) before invalidating and freeing the VM. A closed gate is
handled exactly like is_shutting_down, without touching the VM.
The worker-shutdown caller of release_queued_tasks_for_shutdown runs with
the HTTP daemon still live (the closed ConcurrentEnqueueGate, not a parked
daemon, is what makes the drain complete), so the per-tag release fns own
only the queued entry's counted ref — exclusivity exists only on the 1→0
transition. Update the two callee docs and the callback gate-exit note,
and run the worker-terminate regression fixture with
BUN_DESTRUCT_VM_ON_EXIT=1 so the process-exit reclaim paths are exercised
on every lane (ASAN CI already sets it).
@robobun
robobun force-pushed the farm/d624363d/fix-fetch-worker-vm-uaf branch from ecc9a07 to 5eeacc9 Compare June 7, 2026 01:39

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/workers/worker-terminate-lifetime.test.ts`:
- Around line 124-126: The inFlight Promise currently only resolves on
worker.onmessage which lets the test hang if the worker errors or closes; update
the promise creation for inFlight so it resolves on worker.onmessage and rejects
on worker.onerror and worker.onclose (or via addEventListener for 'error' and
'close') and also handle abort/termination events where applicable; reference
the inFlight variable and the worker.onmessage / worker.onerror / worker.onclose
handlers to wire failure paths to reject instead of relying on timeouts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d24c79b8-9f91-441b-b492-fc8f642d84dd

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and 5eeacc9.

📒 Files selected for processing (6)
  • src/jsc/VirtualMachine.rs
  • src/jsc/event_loop.rs
  • src/jsc/web_worker.rs
  • src/runtime/dispatch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts

Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
Comment thread src/jsc/web_worker.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build 61218 (head 7c583fb, comment-only delta): the only failures so far are test/cli/install/bunx.test.ts > "should handle package that requires node 24" failing identically (exit 3, same assertion, 4 runner retries) on debian-13-x64-asan AND both Windows-2019 lanes simultaneously — an external-npm-package behavior change hitting every platform at once, unrelated to this diff (it fails at file position 11/24 of the run, long before this PR's test file; no sanitizer reports; no segfaults). Notably the Windows exit-segfault documented earlier has not appeared in this build either.

I've already spent this PR's one CI re-roll, so no retrigger from my side; the failing test needs an upstream fixture pin or a fleet-level fix, not anything in this PR.

@robobun

robobun commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #32071, which generalizes this gate to a process-global registry of live VM/event-loop addresses and converts every cross-thread producer (fetch, S3, work pool tasks, node:fs, zlib, napi async work / threadsafe functions / finalizers, watchers, the waiter thread, bundler completion), not just FetchTasklet. Same ASAN repro and the same regression-test shape in worker-terminate-lifetime.test.ts; the broader Sentry signature (BUN-2VPE, "invalid enum value" in tickQueueWithCount, 546 events) needs the non-fetch producers covered too, which this PR deliberately scoped out.

One thing this PR does that #32071 does not: draining the worker's own concurrent queue at shutdown to release already-queued task refs (a leak fix, not a crash fix). Noted in #32071's description as a possible follow-up.

@robobun robobun closed this Jun 10, 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.

1 participant