worker: mark the context terminating before the final concurrent-queue drain - #34278
Conversation
…e drain ScriptExecutionContext::postTaskTo already drops cross-thread posts to a terminating context, but a worker's shutdown() drained its concurrent queue and only afterwards (inside WebWorker__teardownJSCVM) flipped the terminating flag. A parent-side MessagePort post landing between those two points (the stdio-backpressure ack, any scheduleDrain) was enqueued onto a queue that is never drained again and leaked as a 32-byte ConcurrentTask + EventLoopTask under LSan. markTerminating() now takes allScriptExecutionContextsMapLock so it serializes with postTaskTo's check-then-enqueue, and worker shutdown calls it immediately before release_queued_tasks_for_shutdown().
|
Updated 6:31 PM PT - Jul 15th, 2026
❌ @robobun, your commit 85398c0 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 34278That installs a local version of the PR into your bun-34278 --bun |
WalkthroughChangesWorker shutdown now marks the associated Worker termination coordination
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
No bugs found beyond the timeout nit, but this is a cross-thread synchronization change in worker VM teardown — worth a human look at the lock-ordering and shutdown-sequence reasoning.
Checked that the two pre-existing markTerminating() callers (WebWorker__teardownJSCVM, Zig__GlobalObject__destructOnExit) do not hold allScriptExecutionContextsMapLock, so making it acquire that lock does not deadlock.
Verified the second markTerminating() inside teardownJSCVM is idempotent (atomic store of true under the same lock).
Confirmed postTaskTo (both overloads) holds the lock across the isTerminating() check + enqueue, so the new fence closes the described window; ensureOnContextThread does not check isTerminating() but is pre-existing and unchanged here.
Extended reasoning...
Overview
The PR closes a narrow race in worker shutdown where a cross-thread postTaskTo could enqueue a ConcurrentTask onto a worker's concurrent queue after the queue's final drain but before the context was marked terminating, leaking the task under LSan. It touches four files: ScriptExecutionContext.{h,cpp} (moves markTerminating() out-of-line and makes it take allScriptExecutionContextsMapLock; adds an extern "C" shim), src/jsc/web_worker.rs (declares the shim and calls it in shutdown() immediately before release_queued_tasks_for_shutdown()), and adds an ASAN-gated regression test.
Security risks
None identified. This is an internal shutdown-ordering fix; no user-controlled input, auth, or crypto is involved. The change adds a lock acquisition on an existing internal lock and reorders an atomic flag flip relative to a queue drain.
Level of scrutiny
High. This is memory-safety-adjacent concurrency code in the worker VM teardown path — exactly the "most-blocked category" per repo review norms. The reasoning about allScriptExecutionContextsMapLock serializing markTerminating() against postTaskTo's check-then-enqueue is sound on inspection, and I verified neither pre-existing caller (WebWorker__teardownJSCVM in Worker.cpp:649, Zig__GlobalObject__destructOnExit in ZigGlobalObject.cpp:4024) holds the lock, so the new acquisition can't self-deadlock. But subtle lock-ordering and "which thread observes what when" arguments in VM teardown warrant a human maintainer confirming the model matches their understanding.
Other factors
- The PR description is unusually thorough (mechanism, verification table with instrumented sleep, related PRs) and the in-code comments precisely restate the invariant, which raises confidence.
- The new call is placed after
close_all_socket_groups(which fires JS on_close callbacks) and before the drain — correct: JS callbacks may still trigger legitimate cross-thread posts that the drain will then reclaim. ensureOnContextThreadalso callspostTaskConcurrentlyunder the same lock without anisTerminating()gate; that's a pre-existing gap not introduced or widened here, so I did not flag it.- The bug hunt found only a nit (explicit
30_000test timeout violates test/CLAUDE.md); the "Set once" header comment was examined and ruled out as stale (it describes monotonicity, not call-count). - The test is honestly labeled as a guard that won't fail-before on debug; given the race requires
src/instrumentation to reproduce deterministically, that's acceptable but means the test alone doesn't prove the fix.
There was a problem hiding this comment.
No issues found, but this is a cross-thread ordering change in the worker VM teardown path (markTerminating() now takes allScriptExecutionContextsMapLock, and shutdown() flips the flag before the final concurrent-queue drain), so it's worth a human sign-off.
Checked: the two other markTerminating() callers (WebWorker__teardownJSCVM, Zig__GlobalObject__destructOnExit) hold no locks at the call site, so the new lock acquisition can't deadlock. postTaskTo's check-then-enqueue was already under the same lock, so the fence argument holds. The earlier timeout nit was addressed in 6d25f45.
Extended reasoning...
Overview
The PR closes a narrow race in worker shutdown where a cross-thread postTaskTo (parent-side MessagePort ack for worker stdio) lands between release_queued_tasks_for_shutdown() and ctx->markTerminating(), leaking a ConcurrentTask under LSan. It (a) moves ScriptExecutionContext::markTerminating() out-of-line and has it take allScriptExecutionContextsMapLock so the flag flip is a proper fence against postTaskTo's check-then-enqueue critical section, (b) exposes an extern "C" shim, and (c) calls it from WebWorker::shutdown() immediately before the final drain. An ASAN-gated regression test spawns the worker-stdio-on-exit scenario under detect_leaks=1.
Security risks
None. This is a shutdown-ordering / leak fix; no user-controlled input, no auth/crypto/permissions surface.
Level of scrutiny
High. src/jsc/web_worker.rs::shutdown() is one of the most delicately ordered functions in the runtime (every step is commented as a barrier for the next), and markTerminating() gaining a lock changes its contract for every caller. I verified the other two call sites (WebWorker__teardownJSCVM at Worker.cpp:649 and Zig__GlobalObject__destructOnExit at ZigGlobalObject.cpp:4024) hold nothing that could invert against allScriptExecutionContextsMapLock, and that postTaskConcurrently (called under that lock in postTaskTo) doesn't re-enter the map. The reasoning in the PR description is sound and the fix is minimal, but concurrency changes in VM teardown are exactly the category where a maintainer familiar with #31216/#29917 should confirm the ordering.
Other factors
- The new test is honestly labeled as a guard that won't fail-before on debug (the race needs release-asan timing or an injected sleep). It follows harness conventions:
bunEnvspread, combined{stdout, stderr, exitCode}assertion,await usingfor the subprocess, concurrent pipe drains. ensureOnContextThreadalso enqueues cross-thread under the same lock but does not checkisTerminating(); that's a pre-existing gap this PR does not claim to address, and the LSan trace was specificallypostTaskTo.- The prior inline nit (explicit
30_000timeout) was resolved in 6d25f45 and the thread is marked resolved. - CI build #73444 is still in flight per the robobun comment.
…asan lane worker_destruction.test.ts is quarantined on ASAN via test/expectations.txt, so the new case would never have executed on the lane where the leak reproduces (and skips on every other lane via skipIf(!isASAN)).
There was a problem hiding this comment.
No issues found, but this reorders cross-thread synchronization in worker VM teardown (new lock acquisition in markTerminating(), new FFI call in shutdown()'s step 2), so it's worth a human look.
Checked: allScriptExecutionContextsMapLock is not held at any of the three markTerminating() call sites (Worker.cpp:649, ZigGlobalObject.cpp:4024, new FFI export) — no re-entrant/recursive lock. vm_lock is released before the new call in shutdown(), so no lock-ordering inversion. ensureOnContextThread still lacks the isTerminating() gate, but that's pre-existing and not the path in the LSan trace. The earlier timeout nit was addressed in 6d25f45.
Extended reasoning...
Overview
The PR closes a narrow race in WebWorker::shutdown() where a cross-thread postTaskTo could enqueue onto the worker's concurrent queue after the final drain but before markTerminating() flipped the flag inside WebWorker__teardownJSCVM. The fix: (1) move markTerminating() out-of-line and have it take allScriptExecutionContextsMapLock so it serializes against postTaskTo's check-then-enqueue critical section; (2) call it from Rust immediately before release_queued_tasks_for_shutdown(). A new ASAN-only LSan guard test is added.
Security risks
None. This is internal shutdown-ordering; no user-controlled input, auth, or crypto surface is touched.
Level of scrutiny
High. Worker VM teardown is one of the most memory-safety-critical paths in the runtime — it interleaves JSC heap finalization, cross-thread task posting, and raw dealloc of the VM box. Adding a lock acquisition to markTerminating() changes the contract for all three of its callers, and shutdown-sequence reordering is exactly where subtle UAF/deadlock regressions hide. CLAUDE.md flags native memory safety and thread affinity as the most-blocked review category.
Other factors
- Verified no re-entrant lock: none of the existing
markTerminating()callers (Worker.cpp:649inWebWorker__teardownJSCVM,ZigGlobalObject.cpp:4024in global exit) holdallScriptExecutionContextsMapLockwhen calling it, andWTF::Lockis non-recursive, so the new lock is safe there. - Verified no lock-ordering issue at the new Rust call site:
vm_lockis released in shutdown step 1 beforeScriptExecutionContext__markTerminatingruns in step 2. ensureOnContextThread(ScriptExecutionContext.cpp:185) holds the same lock but does not gate onisTerminating(); a task posted through it could still land after the flag flip. That path isn't in the LSan stack trace and is pre-existing, so it's out of scope, but worth a maintainer's awareness.- The previous inline nit (explicit test timeout) was addressed in 6d25f45, and b1a4e05 moved the test to its own file. No outstanding review comments remain.
- The reasoning in the PR description and inline comments is thorough and the fix is minimal, but concurrency ordering in this path deserves a maintainer's sign-off rather than auto-approval.
There was a problem hiding this comment.
I didn't find any correctness issues, but this changes lock semantics in the worker VM teardown path, so it's worth a human look.
What was reviewed:
markTerminating()now takingallScriptExecutionContextsMapLock— checked all three call sites (WebWorker__teardownJSCVM,Zig__GlobalObject__destructOnExit, the new FFI shim); none hold the map lock already, so no self-deadlock, and WTF::Lock re-acquisition on the redundant second call is fine.- Placement in
shutdown()— runs afterclose_all_socket_groups(whose on_close callbacks still seeisTerminating()==false, unchanged) and before the drain, so no behavior change for exit handlers. ensureOnContextThreadstill doesn't gate onisTerminating(); that's a pre-existing separate path, not a regression here.- The earlier nit (explicit test timeout) was addressed in 6d25f45.
Extended reasoning...
Overview
The PR closes a narrow race in WebWorker::shutdown() where a cross-thread postTaskTo could enqueue a ConcurrentTask between the final release_queued_tasks_for_shutdown() drain and the markTerminating() call inside WebWorker__teardownJSCVM, leaking the task under LSan. It moves markTerminating() out-of-line so it takes allScriptExecutionContextsMapLock (the same lock postTaskTo holds across its check-and-enqueue), adds an extern "C" shim, and calls it from Rust immediately before the drain. Four files touched: ScriptExecutionContext.{h,cpp}, web_worker.rs, plus a new ASAN-gated test.
Security risks
None. No user-controlled input, no auth/crypto, no external I/O — this is internal lifetime/ordering during worker teardown.
Level of scrutiny
High. The diff is small (~30 native lines), but it changes locking behavior on a hot cross-thread coordination path (worker VM teardown ↔ parent-thread MessagePort posting). I verified the three existing markTerminating() callers do not already hold the contexts-map lock (WTF::Lock is non-recursive), that the new call site in shutdown() sits after on_exit()/close_all_socket_groups so user-visible exit-handler behavior is unchanged, and that the redundant second call in teardownJSCVM is harmless. The reasoning in the PR description is sound and matches the code. That said, worker teardown ordering has historically been delicate (this PR itself references #31216/#29917), so a maintainer familiar with that history should sign off.
Other factors
The new test is ASAN-lane-only and the PR acknowledges the race is not reproducible on debug builds without instrumentation, so test-worker-stdio-flush.js on the release-asan lane remains the real signal — CI results on build #73470 should be checked. My previous inline nit (explicit 30_000 timeout) was addressed and the thread is resolved. No outstanding reviewer comments.
|
CI on builds #73458 and #73470: the diff is green.
Ready for review. |
* upstream/main: (57 commits) 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 (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (70 commits) 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 (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (52 commits) 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 (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (52 commits) 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 (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ... # Conflicts: # test/js/bun/websocket/websocket-server.test.ts
Resolve conflict in src/jsc/web_worker.rs: both ScriptExecutionContext markTerminating (postTaskTo fence, #34278) and JSCTaskScheduler markShuttingDown (scheduleWorkSoon fence, this PR) are called before the final concurrent-queue drain.
Problem
test/js/node/test/parallel/test-worker-stdio-flush.jswent red on thedebian 13 x64-asanlane of build 73374 with:The leaked allocation is a
ConcurrentTask(and theEventLoopTaskit wraps) left in an exiting worker'sconcurrent_tasksqueue after the queue has been drained for the last time.Cause
WebWorker::shutdown()runsprocess.on('exit')handlers, then drains the worker's concurrent queue viarelease_queued_tasks_for_shutdown(), then entersWebWorker__teardownJSCVMwhich (first thing) callsctx->markTerminating().ScriptExecutionContext::postTaskToalready refuses to enqueue onto a terminating context, but between the drain and the flag flip there is a short window where a cross-thread poster still seesisTerminating() == falseand enqueues.In the failing test the worker writes to
process.stdoutinside itsexithandler. The parent's captured-stdout reader acks each chunk withport.postMessage(true)(src/js/node/worker_threads.tsmakePortReadable._read), which routes throughMessagePortPipe::scheduleDraintopostTaskTo(workerCtxId, ...). When the ack lands in that window it is pushed onto the worker'sconcurrent_tasks; nothing drains it again, and the worker's VM box isdealloc'd raw, so LSan reports theConcurrentTaskas a direct leak.The window is a few assignments plus one FFI call wide, so it hits probabilistically; the
release-asanbuild is fast enough to line up occasionally, debug essentially never.The ordering was introduced in #31216; #29917 described the same gap ("a task posted between this drain and
removeFromContextsMap()insideteardownJSCVMstill leaks") but left it open.Fix
ScriptExecutionContext::markTerminating()now takesallScriptExecutionContextsMapLock, the same lockpostTaskToholds across itsisTerminating()check andpostTaskConcurrently()enqueue. That makes the flag flip a proper fence against concurrent posters: anypostTaskTocritical section either runs entirely beforemarkTerminating()(its task is visible to the subsequent drain) or entirely after (it observestrueand drops).WebWorker::shutdown()calls the newextern "C" ScriptExecutionContext__markTerminatingimmediately beforerelease_queued_tasks_for_shutdown(), closing the window. The latermarkTerminating()insideWebWorker__teardownJSCVMis now redundant but harmless.No behaviour change for
process.on('exit')itself: that runs before the new call, so a parent ack posted while the handler is running is still enqueued and then freed by the drain (never executed, same as before). Only posts that would have landed after the drain are now dropped instead of leaked.Verification
The gap is too narrow to reproduce unassisted against a debug build: 200 iterations of the Node test with the CI LSan env, and 150 worker shutdowns with 64 Atomics-synchronized MessagePorts each, all pass on an unpatched
bun bd. Widening the gap with a temporarystd::thread::sleep(5ms)betweenrelease_queued_tasks_for_shutdown()andWebWorker__teardownJSCVMmakes it deterministic:test-worker-stdio-flush.jsunder LSan32 byte(s) ConcurrentTask)test/js/node/worker_threads/worker-shutdown-post-leak.test.tsruns the worker-stdio-on-exit scenario underdetect_leaks=1as an ASAN-lane guard (in a fresh file so it actually runs;worker_destruction.test.tsis ASAN-quarantined viatest/expectations.txt). The race is not observable on the debug gate withoutsrc/instrumentation, so the fail-before half will not fire there;test-worker-stdio-flush.json the release-asan lane remains the primary signal.Related: #31216 (introduced the ordering), #29917 (described but left the remaining window).
no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/worker_threads/worker-shutdown-post-leak.test.ts