Skip to content

Fix use-after-free when a terminated worker has children still starting - #31951

Closed
robobun wants to merge 13 commits into
mainfrom
farm/07b3df71/fix-nested-worker-teardown-uaf
Closed

robobun wants to merge 13 commits into
mainfrom
farm/07b3df71/fix-nested-worker-teardown-uaf

Conversation

@robobun

@robobun robobun commented Jun 7, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a use-after-free found by Fuzzilli (fingerprint Address:heap-use-after-free, flaky, on a Worker thread).

Root cause: a worker's children hold a BackRef to the parent's VirtualMachine and read it throughout start_vm() (transform options clone, env map clone, standalone_module_graph in VirtualMachine::init_worker). When the parent is itself a worker and gets terminated, its shutdown() freed that VM immediately. Children still starting up then read freed memory:

READ of size 8 ... thread T42 (Worker)
    #0 <bun_jsc::virtual_machine::VirtualMachine>::init_worker src/jsc/VirtualMachine.rs:3675
    #1 <bun_jsc::web_worker::WebWorker>::start_vm src/jsc/web_worker.rs:914
    #2 <bun_jsc::web_worker::WebWorker>::thread_main src/jsc/web_worker.rs:772
freed by thread T9 (Worker):
    #5 <bun_jsc::web_worker::WebWorker>::shutdown src/jsc/web_worker.rs:1317
0x... is located 28112 bytes inside of 56064-byte region (the parent worker's VirtualMachine)

This was the "Known gap" documented in the web_worker.rs header: the main thread waits for all workers at exit (terminate_all_and_wait), but a worker parent did not wait for its own children. The first regression test below crashes release builds too, so this is reachable in production, not only under ASAN.

The fix: the fixing line is the terminate_children_and_wait(vm_ptr, 10_000) call in shutdown() (step 3.5): before freeing its VM, an exiting worker terminates the workers whose parent VM is its own and futex-waits until each has unlinked from the live-workers registry, which is past every parent-VM read. It reuses the existing terminate_all_and_wait sweep, parameterized with a parent filter. This mirrors Node's Environment::stop_sub_worker_contexts(). The placement after JSC teardown means no JS can run to register new children, so the sweep is complete.

Two more crashes on the same termination race, surfaced by the same repro once the UAF was fixed:

  • flush_logs() hit panic!("unhandled exception") when a child's entry-point resolution failed (revoked blob URL) while its termination was in flight: the pending TerminationException makes the log-to-JS conversion return JsError::Terminated. Termination is a normal event, not an invariant violation, so skip dispatching the error event. A genuine JsError::Thrown from the conversion is reported through report_uncaught_exception instead of crashing.
  • JSC clears the termination request at VM-entry-scope exit while leaving the TerminationException pending (VM.cpp:1799, handleTraps re-arms from the trap bit). Re-entering JS in that state trips ASSERT(vm.hasTerminationRequest()) in VMTraps::deferTerminationSlow (debug builds). The worker death path now clears the stale exception at its JS re-entry points, using the existing clear_termination_exception() (same pattern as the test runner and repl).

How did you verify your code works?

Two regression tests in test/js/web/workers/worker-terminate-lifetime.test.ts, each a spawned fixture where a worker creates children and is terminated while they start:

  • "terminating a worker while its nested children are starting does not UAF": on the unfixed ASAN build, crashes with the heap-use-after-free above in 5/5 runs; also fails on the release build (crash report in stderr). Passes on the fixed build (25/25 stress runs clean).
  • "terminating a worker whose children fail entry resolution does not crash": covers the flush_logs panic and the VMTraps assertion path; crashes 5/5 on the unfixed ASAN build.

Also ran the existing worker suites (worker.test.ts, worker_blob.test.ts, worker-terminate-lifetime.test.ts, worker_threads.test.ts, worker-async-dispose.test.ts, message-port-context-destroy-leak.test.ts) with the debug+ASAN build. The pre-existing failures in worker.test.ts ("worker with event listeners doesn't close event loop", 1000ms budget) and worker_threads.test.ts ("eval does not leak source code") reproduce identically on an unfixed build on this runner; they are unrelated timing flakes on slow debug machines.

The file is also added to test/no-validate-leaksan.txt: the debian-13 x64-asan lane runs tests with detect_leaks=1, and worker teardown intentionally leaks when a parent context is gone before the close task runs (the thread-held Worker ref documented in web_worker.rs), which these tests exercise by design. Reproduced on a local release-asan build (flaky 32-byte leak report in the fixture's stderr, about one run in five); worker.test.ts and worker_blob.test.ts are already excluded for the same reason. AddressSanitizer's use-after-free detection is unaffected by the exclusion.


[review] gate passed · iteration 23 · 4 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/workers/worker-terminate-lifetime.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (3bf4bbccd)

test/js/web/workers/worker-terminate-lifetime.test.ts:
(pass) new Worker with { ref: false } does not keep the parent alive [611.57ms]
(pass) terminate/ref/unref after worker exits naturally does not UAF [1599.22ms]
123 |       stderr: "pipe",
124 |     });
125 | 
126 |     // stderr is drained but not asserted: debug/sanitizer lanes may write to it.
127 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
128 |     expect(stdout).toBe("done\n");
                         ^
error: expect(received).toBe(expected)

- "done
+ "::call
+ /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ops
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (a8fb812b3)

test/js/web/workers/worker-terminate-lifetime.test.ts:
(pass) new Worker with { ref: false } does not keep the parent alive [14.50ms]
(pass) terminate/ref/unref after worker exits naturally does not UAF [145.98ms]
(pass) terminate() while the worker has pending log diagnostics does not abort the process [16.86ms]
(pass) terminating a worker while its nested children are starting does not UAF [28.76ms]
(pass) terminating a worker whose children fail entry resolution does not crash [28.93ms]
(pass) nested worker whose grandchild outlives the middle worker's JSWorker does not assert [53.20ms]

 6 pass
 0 fail
 13 expect() calls
Ran 6 tests across 1 file. [442.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/workers/worker-terminate-lifetime.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (3bf4bbccd)

test/js/web/workers/worker-terminate-lifetime.test.ts:
(pass) new Worker with { ref: false } does not keep the parent alive [586.90ms]
(pass) terminate/ref/unref after worker exits naturally does not UAF [1572.68ms]
(pass) terminate() while the worker has pending log diagnostics does not abort the process [646.43ms]
(pass) terminating a worker while its nested children are starting does not UAF [1344.49ms]
(pass) terminating a worker whose children fail entry resolution does not crash [1394.46ms]
(pass) nested worker whose grandchild outlives the middle worker's JSWorker does not assert [1847.90ms]

 6 pass
 0 fail
 13 expect() calls
Ran 6 tests across 1 file. [9.55s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 714ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[2/21] gen JS modules (bundle-modules)
Preprocess modules (8293ms)
Bundle modules (31ms)
Postprocesss modules (173ms)
Bundle Functions (775ms)
Generate Code (81ms)

[9.37s] Bundled "src/js" for production
  2035 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[2/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightl
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs                          |   6 +-
 src/jsc/web_worker.rs                              | 229 ++++++++++++++++-----
 .../web/workers/worker-terminate-lifetime.test.ts  | 130 +++++++++++-
 test/no-validate-leaksan.txt                       |   6 +
 4 files changed, 322 insertions(+), 49 deletions(-)

gate history · 8 passed · 1 rejected · iteration 23

evidence per changed file
file                                                   reads  edits  tests
src/jsc/VirtualMachine.rs                                  3      1     34
src/jsc/web_worker.rs                                     22     33     35
test/js/web/workers/worker-terminate-lifetime.test.ts      6      7     34
test/no-validate-leaksan.txt                               1      2     39

@robobun

robobun commented Jun 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:23 PM PT - Jul 17th, 2026

✅ @autofix-ci[bot], your commit 3bf4bbccd1a739fbbf5aed68dadc42ed782b4a3f passed in Build #74743! 🎉


🧪   To try this PR locally:

bunx bun-pr 31951

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

bun-31951 --bun

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

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. panic: Segmentation fault at address 0xD — "multiple threads are crashing" under Worker spawn/terminate churn (1.3.14, long-running server) #31880 - Segfault under Worker spawn/terminate churn matches the UAF where parent VM is freed while children are still starting
  2. Worker create+terminate cycle aborts process after ~100k–900k iterations on macOS arm64 #30421 - Worker create+terminate cycle abort matches both the UAF pattern and the flush_logs panic / VMTraps assertion paths
  3. Bun terminates script with "abort" when an error is thrown in an async worker onmessage handler #20911 - Blob URL worker aborting on async error matches the flush_logs panic path fixed by the JsError::Terminated handling

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #31880
Fixes #30421
Fixes #20911

🤖 Generated with Claude Code

@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

Checked all three against this PR's builds before adding any Fixes lines. None of them verify, so I am not claiming them:

@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Stops and waits for nested child workers during parent shutdown (step 3.5), refactors termination into a filtered terminate-and-wait helper, changes futex wake semantics to wake-all, clears stale JSC termination exceptions during shutdown/log flush, and adds two regression tests for termination lifetimes.

Changes

Nested worker termination and shutdown guarantees

Layer / File(s) Summary
Termination lifetime contract and documentation
src/jsc/VirtualMachine.rs, src/jsc/web_worker.rs
Updated documentation to define that parent VM remains valid through termination-and-wait of nested children, and that nested workers are stopped when the parent shuts down. Covers parent_vm() lifetime, file header docs, parent VM validity, shutdown sequence, and related field documentation.
Futex wake and live-worker coordination
src/jsc/web_worker.rs
Changed futex wake calls in live_workers::register and live_workers::mark_exited to wake-all so multiple concurrent waiters re-sweep when workers register or exit.
Filtered termination helper and matching logic
src/jsc/web_worker.rs
Introduced terminate_and_wait(parent_filter, timeout_ms) helper and terminate_children_and_wait(parent_vm, timeout_ms) variant. Added matching-counter tracking and optional parent-VM filtering to the termination sweep loop, allowing selective termination of nested children by parent reference.
Shutdown path integration and exception cleanup
src/jsc/web_worker.rs
Integrated terminate_children_and_wait() into WebWorker::shutdown before VM teardown (shutdown step 3.5). Changed JSC termination cleanup to clear stale termination exceptions on the global object via clear_termination_exception().
Log flushing robustness and error handling
src/jsc/web_worker.rs
Hardened flush_logs by clearing termination exceptions before log conversion and reworking error mapping: explicitly skip error dispatch for JsError::Terminated, safely report exceptions from JsError::Thrown instead of panicking, preventing crashes during termination-path error reporting.
Regression tests for termination lifetimes
test/js/web/workers/worker-terminate-lifetime.test.ts
Added two regression tests: one verifying nested worker termination does not cause use-after-free when children are still spawning, another verifying termination during child entry-resolution failures (revoked blob URLs) does not crash the error path. Both assert clean process exit.

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a worker shutdown use-after-free with starting child workers.
Description check ✅ Passed The description includes both required sections and provides detailed implementation and verification notes.

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

Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/jsc/web_worker.rs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/web_worker.rs (1)

419-436: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Wait on a monotonic wake sequence, not OUTSTANDING itself.

Between Line 420 and Line 436, OUTSTANDING can go n → n-1 → n before this thread actually enters Futex::wait(): one worker exits, a mid-WebWorker__create worker registers, both wake() calls fire, and the counter is back at the original n. At that point Futex::wait(&OUTSTANDING, n, ...) will sleep on a stale value, so the newly registered worker is never re-swept and never gets requested_terminate = true. If the wait then times out, the caller can still tear down the parent/main VM while that worker is in start_vm().

Possible direction
 pub(super) static OUTSTANDING: AtomicU32 = AtomicU32::new(0);
+pub(super) static WAKE_SEQ: AtomicU32 = AtomicU32::new(0);

 pub(super) fn register(worker: *mut WebWorker) {
     MUTEX.lock();
     ...
     OUTSTANDING.fetch_add(1, Ordering::Release);
-    Futex::wake(&OUTSTANDING, u32::MAX);
+    WAKE_SEQ.fetch_add(1, Ordering::AcqRel);
+    Futex::wake(&WAKE_SEQ, u32::MAX);
     MUTEX.unlock();
 }

 pub(super) fn mark_exited() {
     OUTSTANDING.fetch_sub(1, Ordering::Release);
-    Futex::wake(&OUTSTANDING, u32::MAX);
+    WAKE_SEQ.fetch_add(1, Ordering::AcqRel);
+    Futex::wake(&WAKE_SEQ, u32::MAX);
 }

-        let n = live_workers::OUTSTANDING.load(Ordering::Acquire);
+        let n = live_workers::OUTSTANDING.load(Ordering::Acquire);
+        let seq = live_workers::WAKE_SEQ.load(Ordering::Acquire);
         live_workers::MUTEX.unlock();
         ...
-        let _ = Futex::wait(&live_workers::OUTSTANDING, n, Some(deadline_ns - elapsed));
+        let _ = Futex::wait(&live_workers::WAKE_SEQ, seq, Some(deadline_ns - elapsed));

As per coding guidelines, "Rust code: fix the whole bug class in the same PR."

🤖 Prompt for 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.

In `@src/jsc/web_worker.rs` around lines 419 - 436, The wait is currently sleeping
on the mutable counter live_workers::OUTSTANDING (via
Futex::wait(&live_workers::OUTSTANDING, n, ...)), which can change n→n-1→n and
cause a stale-wait race; instead introduce and wait on a monotonic
sequence/version (e.g., live_workers::WAKE_SEQ or similar) that you increment
whenever workers are added/removed or when requested_terminate is toggled, read
that sequence under live_workers::MUTEX, compute the done condition the same way
(using parent_filter or outstanding==0), then call
Futex::wait(&live_workers::WAKE_SEQ, seq, Some(...)) so wakes are not lost (also
update all corresponding wake() calls to futex::wake(&live_workers::WAKE_SEQ)
when registering/unregistering workers or setting requested_terminate, and
ensure WebWorker__create and any code that toggles requested_terminate
increments the sequence while holding live_workers::MUTEX).

Source: Coding guidelines

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

Outside diff comments:
In `@src/jsc/web_worker.rs`:
- Around line 419-436: The wait is currently sleeping on the mutable counter
live_workers::OUTSTANDING (via Futex::wait(&live_workers::OUTSTANDING, n, ...)),
which can change n→n-1→n and cause a stale-wait race; instead introduce and wait
on a monotonic sequence/version (e.g., live_workers::WAKE_SEQ or similar) that
you increment whenever workers are added/removed or when requested_terminate is
toggled, read that sequence under live_workers::MUTEX, compute the done
condition the same way (using parent_filter or outstanding==0), then call
Futex::wait(&live_workers::WAKE_SEQ, seq, Some(...)) so wakes are not lost (also
update all corresponding wake() calls to futex::wake(&live_workers::WAKE_SEQ)
when registering/unregistering workers or setting requested_terminate, and
ensure WebWorker__create and any code that toggles requested_terminate
increments the sequence while holding live_workers::MUTEX).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7892402e-59aa-4913-a414-fb0fff0d8faf

📥 Commits

Reviewing files that changed from the base of the PR and between b6b37a7 and 9e0980f.

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

@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/jsc/web_worker.rs:420-428 — nit: this timeout log still says terminateAllAndWait and prints the global n = OUTSTANDING, but the function is now also reached via terminate_children_and_wait from shutdown() step 3.5 — in that path the relevant figure is matching (the global count always includes the caller itself plus unrelated workers). Consider branching on parent_filter to emit a distinct message with matching, or at least genericizing the prefix.

    Extended reasoning...

    What this is

    This PR refactored terminate_all_and_wait into a shared terminate_and_wait(parent_filter, timeout_ms) with a new filtered caller, terminate_children_and_wait, invoked from every exiting worker's shutdown() step 3.5. The timeout-path debug log was not updated to reflect the generalization:

    if elapsed >= deadline_ns {
        log!("terminateAllAndWait: timed out with {} outstanding", n);
        return;
    }

    When reached via the filtered path, this is misleading on two axes:

    1. Prefix names the wrong entry point — it says terminateAllAndWait even when the caller is terminate_children_and_wait from a worker's shutdown.
    2. Reports the wrong count — n is the global OUTSTANDING count, which in the filtered path always includes the caller worker itself (it unlinks after step 3.5 returns) plus any unrelated workers in the process. The figure the filtered wait actually conditions on is matching.

    Concrete example

    Take Main → M → G, with M terminated and G stuck somewhere past the cooperative checkpoints for 10 s:

    • M's step 3.5 sweep finds G: matching = 1.
    • n = OUTSTANDING.load() under the mutex → n = 2 (M itself + G; M is still registered until after step 3.5).
    • 10 s elapses → log emits terminateAllAndWait: timed out with 2 outstanding.

    A developer with BUN_DEBUG_Worker=1 enabled who is chasing a per-worker child-wait stall sees a message that (a) names the main-thread global-exit sweep and (b) reports 2 outstanding when only 1 child of M is actually blocking the wait. If there were sibling worker trees in the process, n would be larger still while matching stayed at 1.

    Why this isn't entirely academic

    This is a hidden scoped debug log (define_scoped_log!(log, Worker, hidden) — only emits when the Worker debug scope is explicitly enabled via env var) on a 10 s timeout path, so there is zero functional impact and it is invisible to users. The counter-argument that this is below the nit threshold is reasonable.

    That said, this PR's own review history went through two rounds of debugging timeout stalls in exactly this code path (the lost-wakeup snapshot race fixed in b6b37a7, and the wake-one-vs-multiple-waiters issue fixed in 9e0980f — the latter explicitly cited a 10 s deadline stall on the debian-13 ASAN job). Anyone debugging the next stall here with the Worker scope enabled will land on this line, and "terminateAllAndWait timed out with N" pointing at the wrong sweep with an inflated count is a small but real speed bump. Since the PR is what generalized the function, updating the one diagnostic it contains to match seems worth a one-line follow-up.

    Suggested fix

    if elapsed >= deadline_ns {
        match parent_filter {
            Some(_) => log!("terminateChildrenAndWait: timed out with {} matching ({} outstanding)", matching, n),
            None => log!("terminateAllAndWait: timed out with {} outstanding", n),
        }
        return;
    }

    (or just genericize the prefix to terminate_and_wait and print both counts).

@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/jsc/web_worker.rs:710-714 — The parallel nested-worker comment in create() (line 631) still says "parent is itself a worker, not joined on exit" — the PR updated this rationale at the file header, the parent field doc, and set_ref(), but missed this 4th site. With step 3.5 in place, worker parents do terminate-and-wait for children, so this wording now contradicts the rest of the file; it should get the same update set_ref() did.

    Extended reasoning...

    What the issue is

    This PR updates the nested-worker keepalive rationale to reflect that worker parents now terminate-and-wait for their children via the new shutdown() step 3.5. It updates that rationale at three sites:

    • the file header (lines 54-60): "Nested workers ARE stopped when their WORKER parent tears down: shutdown() step 3.5 terminates and waits for them…"
    • the parent field doc (lines 88-94): "When the parent is itself a worker, its shutdown() step 3.5 terminates us and waits for our unlink() before freeing its VM…"
    • the set_ref() comment (lines 710-714): changed from "worker parents aren't joined on exit" to "keeping the parent's loop alive avoids the child being terminated by the parent's natural exit (shutdown() step 3.5)"

    But the parallel comment in create() was missed:

    // src/jsc/web_worker.rs:628-632
    // Keep the parent's event loop alive until the close task releases this.
    // If the user passed `{ ref: false }` we skip — they've opted out of the
    // worker keeping the process alive. Exception: a nested worker (parent is
    // itself a worker, not joined on exit) must hold the parent-loop keepalive
    // regardless, because the child holds a non-owning `BackRef` to the parent VM.

    Why it's now wrong

    The phrase "not joined on exit" was the old rationale: before this PR, a worker parent did not wait for its children, so the keepalive was the only thing standing between the child's BackRef and a freed parent VM. After this PR, step 3.5 does wait for children before freeing the parent VM — so "not joined on exit" is no longer true, and the implied UAF-prevention rationale is no longer the operative one. The keepalive's purpose for nested workers is now to keep the parent's loop alive so the child isn't terminated by the parent's natural exit (exactly what the updated set_ref() comment says).

    Step-by-step

    1. Pre-PR: file header documented "nested workers are not stopped when their WORKER parent's context tears down" → both create() and set_ref() said "not joined on exit" → consistent.
    2. PR adds step 3.5 (terminate_children_and_wait) → worker parents now DO wait for children.
    3. PR updates file header, parent field doc, and set_ref() to match → 3 of 4 sites consistent.
    4. create() at line 631 still says "not joined on exit" → contradicts the other 3 sites and the actual behavior.

    Impact

    Pure documentation inconsistency — no runtime effect. But these comments document a memory-safety invariant (the parent-VM BackRef lifetime), and a future reader who lands on create() first would form the wrong model of why the nested-worker keepalive exists and what guarantees it provides.

    Fix

    Give create() the same wording set_ref() got, e.g.:

    // … Exception: a nested worker (parent is itself a worker) must hold the
    // parent-loop keepalive regardless: the child holds a non-owning `BackRef`
    // to the parent VM, and keeping the parent's loop alive avoids the child
    // being terminated by the parent's natural exit (`shutdown()` step 3.5).

Comment thread src/jsc/web_worker.rs
Comment thread src/jsc/web_worker.rs

@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 four of my earlier findings (snapshot race, wake-one vs wake-all, timeout log, stale create() comment) are addressed and the current WAKE_SEQ approach looks sound, but this reworks the cross-thread worker shutdown/futex protocol enough that a human should sign off.

Extended reasoning...

Overview

This PR fixes a use-after-free where a terminated worker frees its VirtualMachine while nested children are still inside start_vm() reading it via BackRef. The fix adds step 3.5 to WebWorker::shutdown(): terminate_children_and_wait(vm_ptr, 10_000) terminates direct children and futex-waits for each to unlink from the live-workers registry before the parent VM is freed. Supporting changes: terminate_all_and_wait is refactored into a generic terminate_and_wait(parent_filter, timeout); a new monotonic WAKE_SEQ futex word replaces waiting on OUTSTANDING (avoids ABA where exit+register restores the expected value); both register() and mark_exited() now wake-all since multiple threads can wait concurrently; flush_logs and shutdown() step 2 now clear stale TerminationExceptions and handle JsError::Terminated/Thrown instead of panic!. Two regression tests are added, and the test file is added to the LeakSanitizer exclusion list.

Security risks

None identified. This is internal lifetime/synchronization machinery for worker teardown; no auth, crypto, untrusted input parsing, or external surface.

Level of scrutiny

High. The change sits at the intersection of cross-thread raw-pointer lifetime management (extensive unsafe, BackRef validity invariants documented in load-bearing comments), a futex wait/wake protocol that this PR substantially redesigns (new WAKE_SEQ word, wake-one → wake-all, snapshot-under-mutex ordering), and the worker shutdown() sequence whose step ordering is a documented hard constraint. Earlier review rounds on this PR surfaced two real concurrency bugs (lost-wakeup race; wrong-waiter with wake-one) that were fixed, which underscores the subtlety. The 10s timeout fallback bounds any remaining protocol error to a stall rather than a UAF, but that's still user-visible.

Other factors

All four of my prior inline findings are resolved in the current diff. Since my last review the author landed 50fff74 (the WAKE_SEQ monotonic-sequence redesign — a stronger fix than the one I suggested) and c02cb19 (LeakSanitizer exclusion for the new test, with a documented rationale matching the file-header's intentional-leak note). The robobun CI comment for c99f30c shows the new test failing on x64-asan and an unrelated bunx.test.ts failure across all platforms; c02cb19 should address the former, but I haven't seen green CI for HEAD. Given the scope — new synchronization primitive, modified shutdown ordering, and a leaksan exclusion — this warrants human review rather than auto-approval.

@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 four of my earlier findings have been addressed and I didn't spot anything new in the WAKE_SEQ rework, but this is cross-thread futex synchronization on the worker VM teardown path — worth a human sign-off.

Extended reasoning...

Overview

This PR fixes a use-after-free in nested Web Worker teardown by adding a terminate_children_and_wait step (3.5) to WebWorker::shutdown(), so a worker parent terminates and waits for its own children before freeing the VirtualMachine they back-reference. To support this it refactors terminate_all_and_wait into a shared terminate_and_wait(parent_filter, timeout) helper, replaces the futex word with a monotonic WAKE_SEQ counter (avoiding ABA on OUTSTANDING), switches register()/mark_exited() to wake-all, and hardens flush_logs() / the shutdown path against stale JSC TerminationExceptions. Two regression tests and a LeakSan exclusion are added; the VirtualMachine.rs change is doc-only.

Security risks

None in the conventional sense (no auth/crypto/input-handling). The change is memory-safety-relevant though: it edits the cross-thread lifetime contract that justifies every BackRef<VirtualMachine> deref in start_vm(), and the ordering of steps in shutdown() is load-bearing for soundness.

Level of scrutiny

High. This is core runtime concurrency code: an intrusive linked list walked under a process-global mutex, futex wait/wake protocol with a newly-introduced sequence word, and a multi-step teardown where each step is a barrier for the next. The PR already went through four review iterations here (lost-wakeup snapshot race, wake-one vs wake-all with multiple waiters, timeout-log accuracy, stale doc comment) — all fixed — which itself signals the subtlety. The final WAKE_SEQ design looks correct to me (snapshot taken under MUTEX before the sweep; every state change bumps it and wakes all), but a human reviewer familiar with this file's history should confirm the step-3.5 placement (after JSC teardown, before unlink()/dispatchExit) and the 10-second bounded-wait fallback are acceptable.

Other factors

  • All four of my prior inline comments are resolved by commits on this branch, and the current bug-hunting pass found nothing new.
  • Regression tests reproduce the original UAF and the flush_logs panic on unfixed builds per the PR description.
  • The no-validate-leaksan.txt addition is justified (intentional leak documented in the file header) and matches existing exclusions for worker.test.ts/worker_blob.test.ts.
  • I'm not approving because the change is neither simple nor mechanical and sits squarely on a critical, hard-to-test code path.

@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for reviewers: everything this PR touches is green. The worker test suites pass on all lanes, including debian-13 x64-asan after the LeakSanitizer exclusion (that lane's earlier failures were a flaky 32-byte leak report from the intentional worker-teardown leak, reproduced and verified locally on a release-asan build). The remaining red is unrelated: bunx.test.ts fails on every OS in every recent build of this branch, including builds whose diff was a text file or an empty commit, and the two single-platform failures in build 61283 (sql-mysql-bind-blob-borrow on alpine, install migration complex-workspace on ubuntu 25.04) are in suites this PR does not touch.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up from a second, independent investigation of panic!("unhandled exception") in WebWorker::flush_logs (src/jsc/web_worker.rs:1407), reached via a different route. This PR already covers that panic site so I am not opening a separate one, but two findings are worth folding in here.

1. Deterministic, race-free public-API repros for both arms

The missing ingredient this PR's test supplies via nested-worker/blob-URL races is simply that vm.log must be non-empty at the flush. A resolver warning emitted while resolving the worker's entry point does that on every run, because spin() hands vm.log straight to resolve_entry_point_specifier with no log-swap guard. A non-string "type" in the package.json next to the worker file is enough.

Terminated arm, one run, no race, no nesting:

pkg/package.json   { "name": "x", "type": "bogus" }
pkg/worker.ts      setTimeout(() => { postMessage("spinning"); for (;;); }, 0);
// main.ts
const w = new Worker(new URL("./pkg/worker.ts", import.meta.url).href);
w.addEventListener("error", () => {});
const closed = new Promise(r => w.addEventListener("close", r, { once: true }));
await new Promise(r => w.addEventListener("message", r, { once: true }));
w.terminate();
await closed;
console.log("done");

The busy loop is what removes the race: the worker's only way out of JS is the termination trap, so by the time the event-loop break is observable the JSC side is guaranteed to be terminating. On a release build of main this aborts the whole process (panic = "abort", exit 132) on every run. Under bun bd it dies earlier, on ExceptionScope::assertNoException(), because the busy loop's TerminationException is still pending when flush_logs re-enters JS for to_bun_string.

Thrown arm, one run, no terminate() at all:

pkg/package.json   { "name": "x", "type": "bogus", "browser": { "a": 42 } }
pkg/worker.ts      Error.prototype.toString = function () { throw new Error("poisoned"); };

Two warnings make Log::to_js wrap them in an AggregateError, whose stringification goes through the worker-replaceable Error.prototype.toString. (A single warning produces a BuildMessage, whose @@toPrimitive is a non-writable native method, so one warning is not enough.) This aborts at the post-load flush_logs, before the event loop even starts.

Both are checked in under ~1s each in test/js/web/workers/worker.test.ts on farm/290692ce/worker-flush-logs-no-panic (verified failing on main and passing on the fixed build). They should transplant cleanly and would strengthen this PR regardless of which fix shape lands.

2. to_bun_string reports a termination as JsError::Thrown, not JsError::Terminated

JSValue::to_bun_string is bun_string_jsc::from_js, which maps every BunString__fromJS failure to JsError::Thrown unconditionally:

// src/jsc/bun_string_jsc.rs:81
if ok { Ok(out) } else { Err(JsError::Thrown) }

to_bun_string is the only JS entry in the flush_logs closure (Log::to_js only constructs cells), so when terminate() interrupts the flush the TerminationException comes back as Err(JsError::Thrown). With this PR's match:

  • the Err(JsError::Terminated) => return arm is effectively unreachable from the stringification step, and
  • a real terminate() falls into the Err(e @ JsError::Thrown) arm, whose global.take_exception(e) cannot clear a termination exception (JSGlobalObject__tryTakeException routes through ExceptionScope::tryClearException, which refuses them), so report_uncaught_exception runs with the TerminationException still pending on the VM. I did not build this branch to confirm, but I would expect the Terminated repro above to trip assertNoException() in it for that reason; it is a one-file check.

The shape that passed both tests for me branches on the actual pending exception rather than the JsError variant, and refuses to enter JS at all once termination has been requested:

// after the msgs.is_empty() early return
if self.has_requested_terminate() {
    return;
}
...
Err(JsError::Thrown | JsError::Terminated) => {
    // returns false (and leaves the exception) only for a termination exception
    if !global.clear_exception_except_termination() {
        return;
    }
    let mut text = std::string::String::new();
    let _ = vm_log.print_with_enable_ansi_colors::<false>(&mut text);
    (JSValue::UNDEFINED, BunString::clone_utf8(text.as_bytes()))
}

The has_requested_terminate() gate also makes the top-of-flush stale-exception clear unnecessary, and the raw-log fallback keeps the diagnostic flowing to the parent's error event instead of only printing to the worker's stderr. Diff is on the branch above; take whatever is useful.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

The flush_logs panic this fixes also reproduces deterministically on main, with no nested workers, blob URLs, or timing involved:

// app/package.json -> "{invalid"   (any non-fatal resolver diagnostic works)
// app/w.ts         -> postMessage("up"); setInterval(() => {}, 1000);
const w = new Worker("./app/w.ts");
w.addEventListener("error", () => {});
await new Promise(resolve => w.addEventListener("message", resolve, { once: true }));
w.terminate();

Resolving the worker's entry point writes the package.json diagnostics into vm.log (the resolver logs through it), nothing ever clears vm.log, and flush_logs runs once more after the event loop exits, by which point terminate() has armed the termination request. Every JS entry in flush_logs then throws:

panic: unhandled exception

Every run, on the 1.4.0 canary (942c222) and on a debug build of current main.

Two notes on the flush_logs hunk in this PR, from debugging that case:

  • When termination interrupts err.to_bun_string(global), it surfaces as JsError::Thrown, not Terminated: BunString__fromJS returns the Dead tag when toWTFString fails, and bun_string_jsc::from_js maps that to Thrown (same for create_aggregate_error via call_zero_is_throw; only assert_no_exception_except_termination call sites produce Terminated). So on a plain worker.terminate() the new Err(e @ JsError::Thrown) arm is the one that runs, and it reports JSC's TerminationException through report_uncaught_exception.
  • Checking vm.jsc_vm().has_termination_request() before entering any JS sidesteps that entirely, and also means a worker that was just terminate()d does not dispatch a late error event to the parent.

I pushed that variant, with a deterministic regression test (fails on unpatched main, passes with either fix), to e61f92eb714784a0722a64a4f5f04ae6d9cc0519 (farm/23f23790/worker-flush-logs-terminate-panic) in case any of it is useful here. Not opening a separate PR since this one already covers the panic.

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

This still reproduces on today's main (5b55beb) with plain public API: a worker does new Worker(...) and then process.exit(0); my repro crashes on its first iteration. Same ASAN signature as above: the USE is the nested worker thread in VirtualMachine::init_worker reading parent.standalone_module_graph, the FREE is the parent worker's own thread in WebWorker::shutdown.

I have the complementary half of the fix on farm/81bc4778/worker-nested-vm-uaf: snapshot everything start_vm() needs from the parent by value in create() on the creating thread (an Arc clone of the transform options, a deep clone_with_allocator of the env map, the proxy-env slot refs, standalone_module_graph, hot_reload), and delete the WebWorker.parent back-reference entirely so the worker thread can never dereference the parent VirtualMachine again. It adds two regression tests to the same worker-terminate-lifetime.test.ts, one via process.exit() in the middle worker and one via terminate() from the grandparent; both fail with the full ASAN report on the unfixed build and pass on the fixed one, and the existing worker suites are unchanged (the same pre-existing timeout flakes this PR already documented).

The two approaches compose and each covers something the other does not. The terminate-and-wait here also fixes the orphaned-nested-worker behavior gap, which the snapshot does not touch; but its 10s timeout is a last resort, and a child that cannot reach a safepoint in time would still read freed memory. The snapshot removes the cross-thread read structurally with no timeout, but leaves the orphan behavior as is.

One integration note if both land: terminate_children_and_wait keys its child filter on core::ptr::eq(w.parent.as_ptr(), parent_vm), and the snapshot deletes that field. Combining them just means keeping an opaque *const VirtualMachine on WebWorker used only for that ptr::eq under the live_workers mutex and never dereferenced from the worker thread, a strictly narrower contract than the current BackRef.

I did not open a second PR since this one already covers the crash. I can fold the snapshot into this branch if that is preferred, or it can land separately after this one with the small filter-key adjustment.

@robobun
robobun force-pushed the farm/07b3df71/fix-nested-worker-teardown-uaf branch from ab6ba82 to 300094d Compare July 17, 2026 01:53
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (3eaadbe). One conflict in src/jsc/web_worker.rs: main added a drain_closed_sockets() block between shutdown() steps 3 and 4 in the same spot this PR inserts step 3.5 (terminate_children_and_wait). Kept both: the socket drain first (it cleans up after step 3's finalizers), then step 3.5. Both are independent and only need to run before step 5 frees the VM/loop. worker-terminate-lifetime.test.ts 5/5 and the broader worker suites (30/30) pass on the rebased debug+ASAN build. Dropped the empty retrigger commit since the force-push starts a fresh CI run anyway.

Comment thread src/jsc/web_worker.rs Outdated
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build 74278 after the rebase, plus the flush_logs review follow-up:

Fixed in 1dddfe2 (just pushed): the flush_logs review finding is real. The JsError::Terminated arm was unreachable because to_bun_string/Log::to_js report every failure as Thrown, so a plain terminate() racing the flush fell into the Thrown arm and called report_uncaught_exception on JSC's termination sentinel with the exception still pending. Gated on self.has_requested_terminate() before any JS entry (and re-checked in the Thrown arm). Added a deterministic regression test (malformed package.json next to the worker entry puts a resolver diagnostic in vm.log so the final flush always has work; terminate() is called after the worker is running): fails on the unfixed build with ASSERT(vm.hasTerminationRequest()) in VMTraps::deferTerminationSlow, passes with the gate. All worker suites green on the rebased debug build (36 pass / 1 todo across 4 files).

The remaining [new] failures I could not attribute to this PR:

  • worker.test.ts exiting 0xC0000409 on all 3 Windows lanes (all tests pass, then silent crash at process exit): does not reproduce locally on Windows (release, debug, via the CI runner script, with --reporter=dots, with BUN_GARBAGE_COLLECTOR_LEVEL=1). A release build of main (3eaadbe) crashes the same way under BUN_DESTRUCT_VM_ON_EXIT=1 on Windows, so the underlying issue exists on main; CI does not set that env var for this test on Windows, and I could not find what in the CI environment triggers it. Watching the next build to see if it persists after the flush_logs fix.
  • worker-transfer-terminate-stress.test.ts on debian-13 x64-asan: the test's own header documents it as an x64-asan-lane-only intermittent (does not reproduce locally, including with BUN_JSC_validateExceptionChecks=1); added to main by node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed #32488 specifically to collect a stack when it fires.
  • capture-stack-trace.test.js (Windows, WebSocket echo test): unrelated, reported separately.
  • grpc-js/test-client.test.ts (Windows segfault): third-party gRPC, unrelated.
  • timer-heap-race.test.ts, spawn.test.ts, 30205.test.ts: pre-existing/flaky per ci:errors.

Comment thread src/jsc/web_worker.rs Outdated
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

The test/js/node/v8/capture-stack-trace.test.js failure on the Windows lanes in build 74278 is unrelated to this diff.

scripts/vs-shell.ps1 loads the MSVC environment via Enter-VsDevShell, which parses cmd /c set output line by line. BUILDKITE_MESSAGE is the full multi-line commit message, and this PR's commit body contains the line

BUN_DESTRUCT_VM_ON_EXIT=1 unless the file is listed in

which the DevShell loader promotes to a real environment variable (the job log's env dump shows it verbatim). That var flows through bunEnv into the test's spawned fixture; with it set, process.exit(0) runs socket-group teardown during global_exit, which fires the WebSocket close listener, which calls process.exit(1). Hence stdout hi 0 with exit code 1.

Being fixed in #34355; re-running CI after that lands (or amending the commit message so no body line matches ^BUN_\w+=) will clear it.

Comment thread src/jsc/web_worker.rs

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

No bugs found this pass, but this is cross-thread memory-safety code (worker VM lifetime, futex wait/wake redesign, JSC termination-exception handling across three re-entry sites) that has iterated enough times that a maintainer should sign off — in particular on the 10s terminate_children_and_wait fallback (still a UAF if a child can't reach a safepoint in time, per the July 2 note) and whether the complementary snapshot-the-parent-state approach should land alongside it.

What was reviewed:

  • WAKE_SEQ futex vs. the old OUTSTANDING wait — snapshot is taken under MUTEX before the sweep, and every register/mark_exited bumps+wake-alls, so no lost-wake/ABA; a child's unlink() doesn't bump, but any subsequent registry event (or the deadline) wakes the parent to re-sweep and observe matching == 0.
  • Step 3.5 placement after JSC teardown — no JS can run to register a new child, and the filter can't match self (self.parent is the grandparent, never vm_ptr).
  • flush_logs: all three JS-entry sites (top gate, Thrown arm, dispatch error) now re-check has_requested_terminate(); the top gate is keyed on the JSC request so configure_defines-failure diagnostics still dispatch.
Extended reasoning...

Overview

The PR fixes a Fuzzilli-found heap-use-after-free where a terminated worker's shutdown() freed its VirtualMachine while nested children were still inside start_vm() reading it via a BackRef. The fix adds terminate_children_and_wait() (step 3.5 in shutdown()), which reuses the existing terminate_all_and_wait sweep with a parent-VM filter and futex-waits until each matching child has unlinked from the live-workers registry. Supporting changes: the futex word moved from OUTSTANDING (ABA-prone with concurrent waiters) to a monotonic WAKE_SEQ, wake-one became wake-all (multiple waiters are now possible), and flush_logs()'s panic!("unhandled exception") was replaced with termination-aware handling gated on has_requested_terminate() && has_termination_request(). Three regression tests added; the file is added to no-validate-leaksan.txt for the documented intentional teardown leak.

Security risks

None user-facing. This is internal runtime lifetime management. The change reduces a memory-safety hazard (UAF reachable from public Worker API on release builds).

Level of scrutiny

High. This is exactly the category REVIEW.md flags as most-blocked: cross-thread lifetime, futex synchronization, refcounts, and JSC exception-state handling in unsafe Rust. The PR went through 13 iterations; four separate review findings (wake-one → wake-all, stale doc comments, unreachable JsError::Terminated arm, over-broad terminate gate suppressing configure_defines errors, missing re-check in the dispatch error block) were each real and fixed in follow-up commits. That history alone argues for a human maintainer's sign-off rather than bot approval.

Other factors

  • Design alternative on the table: the July 2 thread proposes a complementary structural fix (snapshot everything start_vm() needs by value in create(), delete the parent BackRef entirely) that removes the cross-thread read without a timeout. The author noted the two compose and each covers something the other doesn't; whether to fold that in is a maintainer call.
  • Timeout is a last resort, not a guarantee: if a child cannot reach a cooperative safepoint within 10s, step 5 still frees the VM out from under it. The PR description doesn't claim otherwise, but it's worth a maintainer confirming that's an acceptable residual.
  • All prior inline findings from my earlier passes are addressed and their threads resolved; the current diff matches the fixes described (5acc128, 5a5ff01, 1dddfe2).
  • Tests are spawned fixtures with pipes drained concurrently, assert on {exitCode, signalCode}, and were shown to fail on the unfixed ASAN build; the deterministic flush_logs repro (malformed package.json → resolver diagnostic) was added per the June 26 follow-up.

@robobun
robobun force-pushed the farm/07b3df71/fix-nested-worker-teardown-uaf branch from 5acc128 to 8532647 Compare July 17, 2026 05:49
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
Comment thread src/jsc/web_worker.rs

@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 four rounds of my prior findings are now addressed (3ebddde handles the last two — stderr assertions and the timeout-fallback comment). No new issues found. Deferring to a human reviewer given this is ~220 lines of cross-thread memory-safety work in the worker VM lifecycle (futex/WAKE_SEQ redesign, JSC termination-exception handling at multiple JS re-entry points, shutdown ordering), plus the acknowledged residual 10s-timeout gap with a planned structural follow-up on farm/81bc4778.

Extended reasoning...

Overview

Fixes a Fuzzilli-found heap-use-after-free where a terminated worker frees its VirtualMachine while nested children are still inside start_vm() reading it via BackRef. The fix adds terminate_children_and_wait (step 3.5 in shutdown()), refactors the terminate_all_and_wait futex loop to a shared terminate_and_wait with a parent-VM filter, replaces the futex word with a monotonic WAKE_SEQ (ABA-safe, wake-all for concurrent waiters), and reworks flush_logs()'s error handling around TerminationException re-arming. Three new regression tests plus a LeakSanitizer exclusion. Touches src/jsc/web_worker.rs (~180 net lines), src/jsc/VirtualMachine.rs (comment only), the test file, and no-validate-leaksan.txt.

Security risks

None user-facing. This is internal lifecycle/teardown ordering; no untrusted-input parsing, auth, or crypto surface changes.

Level of scrutiny

High. This is squarely in REVIEW.md's most-blocked category (native memory safety, cross-thread lifetime, refcount/keepalive balance, JSC exception-scope discipline). The change redesigns a futex protocol used by concurrent waiters, introduces a new lock-ordering interaction (live_workers::MUTEX → per-worker vm_lock while a worker parent waits), changes what clear_termination_exception() is called on and where, and has an explicitly documented residual UAF on the timeout fallthrough. These are exactly the areas where a subtle mistake produces a rare production crash — a human with worker-lifecycle context should sign off.

Other factors

  • This PR has been through five iterations of review-and-fix on the flush_logs termination gate alone; each fix was correct but the density of edge cases (atomic-only vs JSC-trap-armed termination, re-arming from the trap bit, three separate JS-entry points needing the same re-check) argues for a human pass over the final shape.
  • The WAKE_SEQ / wake-all change to the pre-existing terminate_all_and_wait path is well-reasoned (ABA + multi-waiter), but it's a behavior change to a pre-existing process-exit path, not just an addition.
  • The 2026-07-02 thread proposes a complementary structural fix (snapshot parent state in create()) that composes with this one; a maintainer should decide whether to land this first or fold both together.
  • Tests look solid (spawned fixtures, ASAN-verified fail-before/pass-after, deterministic repro for the flush_logs case), and CI triage in the thread attributes remaining reds to unrelated/pre-existing issues.

@robobun
robobun force-pushed the farm/07b3df71/fix-nested-worker-teardown-uaf branch from 3ebddde to dcbb4fd Compare July 17, 2026 09:49
robobun added 11 commits July 17, 2026 21:48
A worker's children hold a BackRef to its VirtualMachine and read it
throughout start_vm() (transform options, env clone, standalone module
graph). When the parent worker was terminated, its shutdown() freed that
VM without waiting for children still starting up, a use-after-free
caught by ASAN in VirtualMachine::init_worker.

shutdown() now terminates its own children and waits for each to unlink
from the live-workers registry before freeing the VM, the per-worker
analogue of the main thread's terminate_all_and_wait() and of Node's
Environment::stop_sub_worker_contexts().

Also fix two crashes on the same termination race:
- flush_logs() panicked with "unhandled exception" when the log-to-JS
  conversion hit a pending TerminationException; skip dispatching the
  error event instead.
- A TerminationException left pending after JSC clears the termination
  request at VM-entry-scope exit tripped
  ASSERT(vm.hasTerminationRequest()) in VMTraps::deferTerminationSlow
  when the death path re-entered JS; clear the stale exception at the
  re-entry points (flush_logs and the exit-handler step of shutdown).
The filtered wait derives its done-condition from the list sweep
(matching) but passed a futex expected value loaded after the mutex was
released. If the last matching child ran unlink() and mark_exited() in
that gap, matching stayed stale at nonzero while OUTSTANDING already
equaled the loaded value, so Futex::wait slept through the already-fired
wake until the timeout. Loading OUTSTANDING before releasing the mutex
guarantees every counted child's decrement is still ahead of the
expected value, so the wait either observes the change or is woken.
terminate_children_and_wait means the main thread and any number of
worker parents can wait on OUTSTANDING at the same time. A futex waiter
queued with a stale expected value is only released by a wake or its
timeout, so waking a single waiter can hand the event to a thread whose
own condition is unmet while the one that could make progress stays
asleep until its deadline (e.g. a three-level tree where the
grandchild's exit wakes the grandparent instead of the middle worker).
Wake all waiters in register() and mark_exited(); each re-sweeps under
the mutex and re-checks its own condition, and the waiter count is
bounded by the worker-tree depth.
OUTSTANDING as the futex word is ABA-prone: one worker exit plus one
registration in the gap between a waiter's snapshot and its Futex::wait
restores the exact expected value while both wakes fire with no waiter
queued, so the wait sleeps through events it needed to re-sweep for
(a newly registered worker then misses requested_terminate until the
deadline forces a re-sweep). Add WAKE_SEQ, bumped on register() and
mark_exited(), and wait on that; it only increments, so it cannot alias.
The sequence is snapshotted under the registry mutex before the sweep
and the OUTSTANDING load, so any event invalidating those inputs changes
the futex word afterwards.
The timeout log always named terminateAllAndWait and printed the global
OUTSTANDING count, which on the filtered path includes the calling
worker itself plus unrelated workers; matching is the number the wait
actually conditions on.
Each child is a full JSC VM; 12 children x 4 rounds per test overloaded
saturated ASAN runners into the test timeout. 6 children x 3 rounds
still crashed the unfixed build on every verification run (the race
window spans the whole child VM startup), and the fixed suite drops to
about 2.3s per test locally.

Also update the one remaining create() comment that still described
worker parents as not waiting for their children on exit.
The debian-13 x64-asan lane runs tests with detect_leaks=1 and
BUN_DESTRUCT_VM_ON_EXIT=1 unless the file is listed in
test/no-validate-leaksan.txt. Worker teardown intentionally leaks when a
parent context is gone before the close task runs (the thread-held
Worker ref and detached thread bookkeeping documented in
src/jsc/web_worker.rs), and the new regression tests terminate workers
while nested children are starting, which is exactly that window.
Reproduced on a local release-asan build with the lane environment: a
flaky 32-byte direct leak report in the spawned fixture's stderr failed
the test about one run in five. worker.test.ts and worker_blob.test.ts
are already listed for the same reason. Leak exclusion does not affect
the tests' crash coverage; AddressSanitizer UAF detection stays on.
The JsError::Terminated match arm was unreachable: to_bun_string (via
bun_string_jsc::from_js) and Log::to_js's aggregate-error path both map
every failure, including a re-armed TerminationException, to
JsError::Thrown. So a plain worker.terminate() that raced this flush
fell into the Thrown arm and called report_uncaught_exception on JSC's
termination sentinel with the exception still pending, the opposite of
the skip-dispatch intent.

Gate on self.has_requested_terminate() before entering any JS: a worker
that is being terminated should not dispatch a late error event, and
this avoids the JS re-entry entirely. Also re-check in the Thrown arm
in case terminate() lands mid-call.

Adds a deterministic regression test (malformed package.json next to
the worker entry puts a non-fatal resolver diagnostic in vm.log; the
final flush_logs on the way out then always has work to do, and
terminate() is called after the worker is running). Fails on the
unfixed build with ASSERT(vm.hasTerminationRequest()) in
VMTraps::deferTerminationSlow; passes with the gate.
…tomic

has_requested_terminate() over-captures: start_vm's configure_defines()
failure path sets only the atomic (its comment says "vm.log carries the
error for flushLogs") without arming the JSC trap, so JS entry would
succeed and the error should dispatch. Key the gate on both the atomic
and vm.jsc_vm().has_termination_request(), which is only set by
notify_need_termination() for external terminate(). The re-check in the
Thrown arm stays on the atomic (by that point JS has already been
entered, so if the atomic flipped mid-call it was an external
terminate).
… path too

WebWorker__dispatchError re-enters JS (worker-side 'error' listeners,
SerializedScriptValue::create), and from_js_host_call_generic reports a
TerminationException that lands during that call as JsError::Thrown, not
Terminated. The dispatch error handler lacked the same has_requested_
terminate() re-check as the to_js/to_bun_string Thrown arm, so a
terminate() landing mid-dispatchError would reach take_exception and
report_uncaught_exception on the termination sentinel with the exception
still pending.
- Drop the strict stderr assertions in the two nested-worker tests and
  switch to the combined-object form, matching the third new test and
  REVIEW.md's subprocess-test rule (ASAN/debug lanes may emit benign
  warnings; the regression signal is fully covered by exit code 0).
- Document at the step 3.5 call site that the 10s timeout is a
  should-never-happen fallback whose fallthrough reintroduces the UAF,
  and point at the complementary snapshot-in-create() structural fix
  (farm/81bc4778) that removes the cross-thread parent-VM read.
@robobun
robobun force-pushed the farm/07b3df71/fix-nested-worker-teardown-uaf branch from dcbb4fd to 1a9b9be Compare July 17, 2026 21:52

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

No new findings this pass — the earlier flush_logs termination-gate issues and the stderr-assertion / timeout-doc nits are all addressed. Deferring to a human reviewer given the scope: this reworks the live-workers futex protocol (adds WAKE_SEQ, wake-all), inserts a blocking wait on the worker-thread teardown path, and changes TerminationException handling at three JS re-entry points.

What was reviewed:

  • terminate_and_wait refactor: WAKE_SEQ snapshot is taken under the mutex before the sweep, so a child's unlink+mark_exited cannot land between the condition check and the futex wait; the filtered exit keys on list membership (matching == 0), and the caller's own entry is skipped because self.parent ≠ vm_ptr.
  • flush_logs: top gate now requires has_termination_request() so the configure_defines short-circuit (atomic-only) still dispatches; both the Thrown arm and the dispatchError error handler re-check before take_exception.
  • Step 3.5 placement: after teardownJSCVM (no JS → no new child can register), before unlink/dealloc; timeout fallthrough is documented as a residual gap with the follow-up branch named.
  • Tests: pipes drained concurrently, stderr not asserted empty, signalCode: null covered.
Extended reasoning...

Overview

Fixes a nested-worker use-after-free: a worker parent's shutdown() freed its VirtualMachine while children were still inside start_vm() reading it via BackRef. The fix adds terminate_children_and_wait (step 3.5 in shutdown()), which reuses the existing terminate_all_and_wait sweep parameterized by a parent_filter. Supporting changes: the futex word moves from OUTSTANDING to a monotonic WAKE_SEQ (avoiding an ABA between one exit + one register, and because multiple waiters — main thread + N worker parents — now sleep on it and need wake-all); flush_logs and shutdown() step 2 clear the stale TerminationException before re-entering JS and gate/re-check on termination so the sentinel is never fed to report_uncaught_exception. Three regression tests added; the file is added to no-validate-leaksan.txt alongside the other worker tests.

Security risks

None identified. This is internal lifetime/concurrency plumbing with no user-facing input parsing, auth, or network surface. The change reduces a memory-safety hazard (heap UAF reachable from public API) rather than introducing one.

Level of scrutiny

High. web_worker.rs is production-critical concurrency code — every field carries a threading contract, and the diff touches (a) a process-global futex protocol shared by the main thread and every worker, (b) the ordered teardown sequence whose step numbering exists precisely because reordering is unsafe, and (c) JSC TerminationException semantics that already tripped two follow-up findings during this review. The 10s timeout at step 3.5 is a documented best-effort fallback whose fallthrough reintroduces the UAF; that trade-off (vs. the structural snapshot-in-create() alternative on farm/81bc4778) is a design call a maintainer should sign off on.

Other factors

The PR has been through many iterations; every prior inline finding from this reviewer is resolved and the current bug-hunting pass found nothing. Test coverage is solid (three new spawned-fixture regressions verified to fail on the unfixed ASAN build). CI triage in the thread attributes remaining red to pre-existing/unrelated flakes. Still, ~230 net lines in a file whose header is a hand-maintained lifetime proof is beyond what should land without a human maintainer's eyes.

robobun and others added 2 commits July 17, 2026 22:15
On the unfixed build the subprocess panics and dumps core, which under
ASAN takes about 7s (the panic path prints a full stack then aborts);
the default 5s test timeout fired before the assertion could run,
producing a hang+dangling-process cleanup instead of a clean assertion
failure. Passing the file's timeout (60s on slow lanes) lets the
fail-before assertion land.

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

No further issues found — all four rounds of earlier findings (unreachable JsError::Terminated arm, over-broad has_requested_terminate() gate, missing re-check in the dispatch error handler, stderr assertions / timeout-fallback doc) have been addressed and are visible in the current diff. Deferring to a human for the merge call: this is ~230 lines of cross-thread lifetime and futex-synchronization changes in worker VM teardown, with an acknowledged residual gap (the 10s timeout fallthrough, now documented at the step 3.5 call site) and a competing/complementary structural approach on farm/81bc4778 that a maintainer should weigh in on.

What was reviewed:

  • terminate_and_wait refactor: WAKE_SEQ monotonic futex word (ABA avoidance), wake-all, snapshot-before-sweep ordering, and the parent-filter exit condition (matching == 0 vs OUTSTANDING == 0) — the caller's own registry entry is correctly excluded.
  • shutdown() step 3.5 placement: after JSC teardown (no new children can register), before unlink/dealloc; the filter never dereferences w.parent, only ptr::eq.
  • flush_logs termination handling: gate keyed on the JSC request (so configure_defines failure still dispatches), clear_termination_exception() before JS re-entry, and re-checks in both the Thrown arm and the dispatch error handler.
  • Tests: subprocess pipes drained concurrently, stderr not asserted empty, combined {stdout, exitCode, signalCode} assertions; no-validate-leaksan.txt entry scoped to LSan only.
Extended reasoning...

Overview

The PR fixes a Fuzzilli-found heap-use-after-free where a terminated worker frees its VirtualMachine while nested children are still inside start_vm() reading it via BackRef. The fix adds terminate_children_and_wait (a parent-filtered variant of the existing terminate_all_and_wait) as step 3.5 of shutdown(), refactors the futex wait to a monotonic WAKE_SEQ word with wake-all semantics (needed now that multiple threads can wait concurrently), and hardens flush_logs/shutdown against stale/re-armed TerminationExceptions. Four files touched: web_worker.rs (~230 lines net), a 4-line comment update in VirtualMachine.rs, three new regression tests in worker-terminate-lifetime.test.ts, and a LeakSanitizer exclusion.

Security risks

None in the injection/auth/data-exposure sense. This is memory-safety-critical concurrent native code: the class of risk is UAF/race, which is exactly what the PR is fixing. The change is a strict improvement over the pre-PR state (every nested-termination raced to UAF; now only a >10s stall inside start_vm() — which runs no user JS/FFI — can hit the documented fallback).

Level of scrutiny

High. This touches cross-thread worker VM lifetime, futex synchronization semantics, JSC TerminationException / VMTraps interaction, and shutdown() step ordering — all of which are subtle and have already produced four rounds of real findings during review. Per REVIEW.md's memory-safety guidance ("know the thread affinity of every line", "never let a pointer outlive the memory it points into"), this warrants a maintainer's eyes even though the current state looks correct to me and the bug-hunting system found nothing new.

Other factors

  • Prior review history: I raised four issues over the course of today's review (unreachable Terminated arm; gate catching the internal configure_defines short-circuit; missing re-check after WebWorker__dispatchError; stderr-empty assertions + timeout doc). All are fixed in the current diff and marked resolved.
  • Architectural decision pending: the 2026-07-02 thread proposes a complementary snapshot-in-create() approach on farm/81bc4778 that removes the cross-thread parent-VM read structurally. The step 3.5 comment now references it as follow-up. Whether to land this first and follow up, or fold both together, is a maintainer call.
  • Residual gap acknowledged: the 10s timeout fallthrough is now documented at the call site as reintroducing the UAF under pathological load — honest, but a maintainer should sign off on shipping a bounded-wait-then-free rather than a fail-safe.
  • Test coverage: three new spawned-fixture regression tests, each verified to crash the unfixed ASAN build; existing worker suites reported green on the rebased debug build. Evidence block in the PR description shows the fails-without/passes-with gate.
  • CI: prior triage attributed remaining reds to unrelated pre-existing flakes (Windows worker.test.ts 0xC0000409 reproduces on main; bunx.test.ts; a commit-message env-var leak fixed separately in #34355).

robobun added a commit that referenced this pull request Jul 25, 2026
…t_thrown

- The blanket has_requested_terminate() early-return swallowed the
  configure_defines() failure dispatch (start_vm self-signals via
  set_requested_terminate() alone, without arming the JSC trap, and
  relies on spin()'s first checkpoint to flush_logs the error). Gate on
  has_termination_request() too so only an external terminate (which
  arms the trap via notify_need_termination) skips the dispatch.
- Extract the terminate-check/take_exception/report sequence into a
  report_thrown closure used by both error arms (was duplicated with
  inconsistent None handling).
- Drop the dead flush_logs call in the new spin() checkpoint.
- Add VM::has_termination_request() (the FFI already existed).
- Restructure the stress test to two levels (main spawns failing
  workers directly, no middle worker) so it does not also trip the
  nested-worker parent-VM UAF that #31951 addresses. The earlier
  three-level fixture caught that UAF on the debian-13 x64-asan lane in
  build 81404.
@alii

alii commented Aug 12, 2026

Copy link
Copy Markdown
Member

superseded by #37075, parents now join their child workers before teardown. nested worker + process.exit no longer crashes on main

@alii alii closed this Aug 12, 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