worker: drain queued cross-thread tasks on termination - #29917
Conversation
StatusClosed as superseded. The worker-side concurrent-queue drain this PR adds landed on main via #31216 -> #34278 -> #37075 ( Verified on unpatched |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
WalkthroughImplements cleanup for pending event-loop tasks during worker termination. Adds a C++ function to delete EventLoopTask objects, an event-loop drain routine to clear cancelled tasks, and updates worker exit logic to call this cleanup. Includes a regression test verifying no memory leaks when workers terminate with pending postMessage calls. Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/js/web/workers/worker-terminate-pending-message-leak.test.ts`:
- Around line 71-74: The test currently asserts that proc.stderr is empty (using
the stderr variable) but on ASAN/debug shards a known startup warning can
appear; update the assertion to first split stderr into lines, remove any line
that begins with "WARNING: ASAN interferes" (filter out that known ASAN startup
noise), then assert that the remaining stderr lines are empty before checking
stdout contains "PASS" and exitCode; update the block around the Promise.all
result/expect calls (variables proc, stdout, stderr, exitCode) to perform this
filtering step.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: aec74410-f483-42cb-bc5e-221317393d2c
📒 Files selected for processing (5)
src/bun.js/bindings/ZigGlobalObject.cppsrc/bun.js/event_loop.zigsrc/bun.js/event_loop/CppTask.zigsrc/bun.js/web_worker.zigtest/js/web/workers/worker-terminate-pending-message-leak.test.ts
There was a problem hiding this comment.
I didn't find any bugs, but this adds a new step to the worker-thread teardown sequence (exitAndDeinit) with cross-thread ordering invariants around ScriptExecutionContext removal and concurrent-queue draining, plus a new C++ delete path for EventLoopTask — worth a human look to confirm the race-freedom claim and that ~EventLoopTask releases the captured SerializedScriptValue as expected.
Extended reasoning...
Overview
This PR fixes a memory leak where EventLoopTask + ConcurrentTask pairs (and their captured SerializedScriptValue payloads) leak when a worker terminates with cross-thread messages still queued. It adds Bun__deleteEventLoopTask (C++), CppTask.deinit() (Zig binding), EventLoop.drainCancelledTasks() / freeUnrunTask(), and inserts a drain call into WebWorker.exitAndDeinit immediately after WebWorker__teardownJSCVM. A new RSS-growth regression test is included.
Security risks
None apparent — this is internal memory cleanup on a teardown path with no user-controlled input beyond what was already accepted by postMessage. No auth, crypto, or permission surfaces touched.
Level of scrutiny
High. exitAndDeinit is a carefully-ordered, heavily-documented teardown sequence with explicit threading invariants (see the ~90-line file header in web_worker.zig). The fix's correctness rests on the claim that ~GlobalObject → removeFromContextsMap() (inside WebWorker__teardownJSCVM) plus allScriptExecutionContextsMapLock in postTaskTo together guarantee no new enqueues race the drain — that's a cross-language (C++/Zig), cross-thread invariant that deserves a human reviewer who knows the ScriptExecutionContext locking model. Additionally, freeUnrunTask only frees CppTask and ManagedTask variants and silently skips other Task union members; that's likely intentional for this code path but is a design choice worth confirming. The new delete task path also assumes EventLoopTask's destructor (not its performTask) is sufficient to release the SerializedScriptValue lambda capture.
Other factors
- The drain reads
vm.event_loopafterWebWorker__teardownJSCVMhas torn down the JSC VM but beforevm.deinit()— the ZigEventLoopstruct should still be valid here, but it's another ordering subtlety. - CodeRabbit flagged that the new test's
expect(stderr).toBe("")may flake on ASAN shards without filtering the knownWARNING: ASAN interferesline; that's unaddressed. - The change is well-motivated, well-documented, and includes a regression test with before/after numbers, but the blast radius (worker teardown for every worker in every Bun process) and concurrency subtlety put it outside the auto-approve bar.
There was a problem hiding this comment.
I didn't find any bugs, but this touches concurrency-sensitive worker teardown and event loop internals — the correctness argument hinges on postTaskTo holding allScriptExecutionContextsMapLock across the enqueue and on ~GlobalObject having already run removeFromContextsMap() before the drain, so it's worth a human look at those ordering invariants.
Extended reasoning...
Overview
This PR fixes a memory leak where EventLoopTask + ConcurrentTask pairs (and their captured SerializedScriptValue) leak when a worker terminates with cross-thread messages still queued. It adds:
Bun__deleteEventLoopTaskinZigGlobalObject.cpp(a one-linedelete task)CppTask.deinit()wrapper inCppTask.zigEventLoop.drainCancelledTasks()+freeUnrunTask()inevent_loop.zig— pops the concurrent batch and walks thetasksFIFO, freeingCppTask/ManagedTaskpayloads and auto_deleteConcurrentTaskwrappers- A call to
drainCancelledTasks()inWebWorker.exitAndDeinitimmediately afterWebWorker__teardownJSCVM - A new RSS-based regression test (skipped on Windows, ASAN-stderr filtered)
Security risks
None identified. This is internal memory cleanup on the worker-thread teardown path; no user-controlled input flows into the new code beyond what was already being processed.
Level of scrutiny
High. The change sits at the intersection of cross-thread task queues, JSC VM teardown, and worker lifecycle ordering. Correctness depends on non-local invariants:
- That
~GlobalObject(run insideWebWorker__teardownJSCVM) has already calledremoveFromContextsMap()so no new concurrent enqueues can race withpopBatch(). - That
postTaskTo/postTaskConcurrentlyholdsallScriptExecutionContextsMapLockacross the push, closing the window between the map lookup and the enqueue. - That
vm.event_loop(a Zig-side struct onVirtualMachine) remains valid after JSC teardown but beforevm.deinit()— which appears true since only the JSCVMis torn down at that point, but is worth confirming. - That the deferred-destroy pattern for
ConcurrentTasknodes (mirroringtickConcurrentWithCount) is correct for the iterator semantics.
A subtle bug here could manifest as a use-after-free or double-free under load rather than a simple test failure.
Other factors
freeUnrunTaskonly freesCppTaskandManagedTaskpayloads; other task variants in the queue at teardown are silently dropped. That's strictly no worse than the status quo (they'd have leaked before too), but a reviewer may want to confirm no other heap-owning task type can land in a worker's queue via this path.- The new test is RSS-threshold-based (100 MB headroom vs ~20-40 MB observed noise) and skipped on Windows; the description notes 3 pre-existing debug-build flakes in
worker.test.tsthat are unrelated. - The CodeRabbit ASAN-stderr comment was addressed in commit 37a7698.
- No CODEOWNERS cover these files.
Given the cross-thread ordering subtleties, I'm deferring rather than approving.
37a7698 to
72b325b
Compare
There was a problem hiding this comment.
I didn't find any bugs, but this touches concurrency-sensitive worker termination and cross-thread event-loop task lifecycle (including a new free path for C++ EventLoopTask that bypasses performTask), so it's worth a human verifying the no-new-enqueues-after-removeFromContextsMap invariant and that freeUnrunTask's coverage of only CppTask/ManagedTask is sufficient.
Extended reasoning...
Overview
This PR fixes a memory leak where EventLoopTask + ConcurrentTask pairs (and their captured SerializedScriptValue/Ref<Worker>) leak when a worker terminates with cross-thread messages still queued. It adds:
Bun__deleteEventLoopTask(C++): a baredelete taskwrapper exported to Zig.CppTask.deinit()(Zig): calls the above.EventLoop.drainCancelledTasks()+freeUnrunTask()(Zig): pops the concurrent batch and walks thetasksFIFO, freeing auto-deleteConcurrentTaskwrappers and innerCppTask/ManagedTaskpayloads without running them.- A call site in
WebWorker.shutdown()immediately afterWebWorker__teardownJSCVM. - An RSS-delta regression test (skipped on Windows, ASAN-stderr filtered).
Security risks
None identified — this is a teardown-path memory cleanup with no input parsing, auth, or crypto involvement.
Level of scrutiny
High. The change sits at the intersection of cross-thread task queues, worker lifecycle ordering, and C++/Zig heap ownership. Correctness depends on a subtle invariant: after ~GlobalObject → removeFromContextsMap(), no thread can still be mid-postTaskConcurrently pushing onto this worker's concurrent_tasks. The PR description argues postTaskTo holds allScriptExecutionContextsMapLock across its push, which would make the drain race-free — but that's exactly the kind of invariant a maintainer familiar with the WebKit ScriptExecutionContext bindings should confirm. A race here would be a use-after-free on a ConcurrentTask node, not just a residual leak.
Other factors
freeUnrunTaskonly handlesCppTaskandManagedTask; otherTaskunion variants are silently dropped. That's likely fine for the targeted leak (cross-threadpostMessagealways producesCppTask), but a human should confirm no other heap-owning task variants can be sitting intasks/concurrent_tasksat this point inshutdown().- The deferred-destroy iterator pattern correctly mirrors
tickConcurrentWithCount(), and the call site is placed after JSC teardown but beforedispatchExit(sothisis still valid) and beforevm.deinit()(so the FIFO buffer is still live). - The new RSS-based test has a generous 100 MB threshold vs. ~750 MB+ unfixed delta, uses a SharedArrayBuffer handshake to deterministically hit the teardown window, and follows repo conventions (ASAN filter,
isWindowsskip, exit-code asserted last). - CodeRabbit's one comment (ASAN stderr filter) was addressed; no outstanding human review comments.
72b325b to
7d1c5e0
Compare
There was a problem hiding this comment.
I didn't find any bugs, but this touches concurrency-sensitive worker shutdown ordering and cross-thread task lifecycle (event loop drain after JSC VM teardown), so it's worth a human look — particularly the race-freedom argument that removeFromContextsMap() inside teardownJSCVM fully fences against new postTaskConcurrently enqueues, and whether freeUnrunTask covering only CppTask/ManagedTask is sufficient.
Extended reasoning...
Overview
This PR fixes a memory leak where cross-thread tasks (EventLoopTask + ConcurrentTask wrappers) posted to a worker after it leaves its event loop but before ~GlobalObject removes its ScriptExecutionContext are never freed. It adds:
Bun__deleteEventLoopTask(C++) andCppTask.deinit()(Zig) to free anEventLoopTaskwithout running it.EventLoop.drainCancelledTasks()which pops the concurrent batch and walks thetasksFIFO, freeing auto-deleteConcurrentTaskwrappers and innerCppTask/ManagedTaskpayloads.- A call to
drainCancelledTasks()inWebWorker.shutdown()immediately afterWebWorker__teardownJSCVM. - An RSS-based regression test using a SharedArrayBuffer handshake to deterministically hit the teardown window.
I verified EventLoopTask (src/jsc/bindings/EventLoopTask.h) holds its lambda in a Function<void(ScriptExecutionContext&)> m_task, so delete task correctly destroys captured state (including Ref<Worker>) without needing performTask(). The deferred-destroy pattern in drainCancelledTasks correctly mirrors tickConcurrentWithCount() to avoid reading next from a freed node.
Security risks
None. This is a resource-cleanup change on the worker shutdown path; no auth, crypto, input parsing, or external data handling is involved.
Level of scrutiny
High. This modifies the worker shutdown sequence and event loop task lifecycle — both concurrency-critical. The correctness argument hinges on ordering guarantees:
WebWorker__teardownJSCVM→~GlobalObject→removeFromContextsMap()completes before the drain, andpostTaskToholdsallScriptExecutionContextsMapLockacross its enqueue, so no new tasks race in. This is asserted in the PR description and code comments but should be verified by someone familiar with the C++ side.vm.event_loopis dereferenced afterteardownJSCVMbut beforevm.deinit()— looks safe since the event loop struct lives in the Zig VM, not the JSC heap, but worth confirming.freeUnrunTaskonly handlesCppTaskandManagedTask; otherTaskunion variants in the queue would be silently dropped. That may be intentional (most other variants are not heap-owned by the queue), but a reviewer familiar with the fullTaskunion should confirm nothing else needs explicit cleanup here.- The
EventLoopTasklambda destructor (releasingRef<Worker>) runs on the worker thread during shutdown —WorkerisThreadSafeRefCounted, so this should be fine.
Other factors
- The bug hunting system found no issues.
- The CodeRabbit feedback (ASAN stderr filter, Windows skip) was addressed.
- CI failures in build #49870 (
s3-storage-class,no-orphans,next-pages,fetch-http2-client) appear unrelated to this change. - The test methodology (RSS delta with 100 MB threshold vs ~900 MB observed leak) is reasonable and follows repo conventions for leak tests.
- The change is well-documented with inline comments explaining the ordering constraints, which aids review.
7d1c5e0 to
dabb4bb
Compare
dabb4bb to
fd256a7
Compare
ScriptExecutionContext::postTaskConcurrently heap-allocates an
EventLoopTask and Bun__queueTaskConcurrently wraps it in a heap-
allocated auto_delete ConcurrentTask. Both are freed only when the
target event loop actually ticks the task.
When a cross-thread task (e.g. Worker.postMessage's drain task,
which captures a Ref<Worker> and thus the entire m_toWorker.queue
of buffered messages) lands in a worker's concurrent_tasks after the
worker has left its event loop but before ~GlobalObject removes the
worker's context from the global map, the task — and everything it
transitively references — leaks until process exit.
Call drop_concurrent_cpp_tasks() in WebWorker::shutdown right after
WebWorker__teardownJSCVM removes the context from the map, so no new
enqueues can race with the drain.
The test uses a SharedArrayBuffer handshake to deterministically wait
until the worker is inside process.on('beforeExit') (past its event
loop, before teardown), then floods postMessage. Without the fix this
leaks ~900 MB over 20 rounds; with the fix RSS stays flat.
fd256a7 to
9e9394a
Compare
There was a problem hiding this comment.
Thanks — all three of my earlier points are addressed (drain now precedes teardownJSCVM matching the global_exit ordering, isASAN threshold added, stale comment fixed). I don't see further issues, but given this sits in the worker-thread teardown ordering (where the previous revision had a subtle UAF), I'd like a human to confirm the reasoning around the remaining late-enqueue leak window before merge.
Extended reasoning...
Overview
This PR adds a single call to the existing EventLoop::drop_concurrent_cpp_tasks() inside WebWorker::shutdown() (src/jsc/web_worker.rs), plus a new RSS-delta regression test. It frees cross-thread EventLoopTask/ConcurrentTask pairs that were posted to a worker after it left its event loop but before ~GlobalObject removed its ScriptExecutionContext — previously these (and the Ref<Worker> → buffered SerializedScriptValue queue they hold) leaked until process exit.
Changes since my last review
All three of my prior inline comments have been addressed in the current revision:
- UAF ordering (🔴): the drain call was moved from after
WebWorker__teardownJSCVMto before it (now in step 2, preceding step 3), matching the documented contract and the existingglobal_exitcaller atVirtualMachine.rs:1596. The new comment explicitly documents the nested-workerRef<WorkerB>/~JSEventListenerWeak-handle hazard and the residual late-enqueue leak window. - ASAN threshold (🟡): the test now imports
isASANand uses${isASAN ? 300 : 100}, matching the directory convention. - Stale function name (🟡): test comment now references
drop_concurrent_cpp_tasks.
Security risks
None. This is a memory-leak fix in worker teardown; no auth, crypto, input parsing, or privilege boundaries are touched.
Level of scrutiny
Moderate-to-high. The runtime change is one line calling an existing function, and it now mirrors the already-merged global_exit pattern exactly. However, it sits in the cross-thread shutdown ordering of WebWorker::shutdown(), where the previous revision of this same PR had a real UAF due to mis-ordering relative to ~VM. The current placement looks correct and is well-documented (including the honest acknowledgment that tasks posted between the drain and removeFromContextsMap() still leak), but worker-teardown ordering invariants are subtle enough that a maintainer familiar with the JSC VM lifecycle and the nested-worker "Known gap" should confirm.
Other factors
- No CODEOWNERS coverage for
src/jsc/web_worker.rs. - Bug-hunting system found nothing this round.
- The two CI failures (
bundler_jsxsegfault on macOS aarch64,v8-heap-snapshotSIGKILL on Ubuntu x64) appear unrelated to this change — neither exercises worker teardown. - Test follows repo conventions (ASAN stderr filter,
isWindowsskip,isASANthreshold, exit-code assertion last).
…e drain (#34278) ## Problem `test/js/node/test/parallel/test-worker-stdio-flush.js` went red on the `debian 13 x64-asan` lane of [build 73374](https://buildkite.com/bun/bun/builds/73374) with: ``` ==18202==ERROR: LeakSanitizer: detected memory leaks Direct leak of 32 byte(s) in 1 object(s) allocated from: #9 ConcurrentTask::new src/event_loop/ConcurrentTask.rs:305 #10 ConcurrentTask::create src/event_loop/ConcurrentTask.rs:319 #12 bun_jsc::virtual_machine_exports::queue_task_concurrently src/jsc/virtual_machine_exports.rs:140 #13 ScriptExecutionContext::postTaskConcurrently src/jsc/bindings/ScriptExecutionContext.cpp:266 #14 ScriptExecutionContext::postTaskTo src/jsc/bindings/ScriptExecutionContext.cpp:125 #15 MessagePortPipe::scheduleDrain src/jsc/bindings/webcore/MessagePortPipe.cpp:74 #16 MessagePort::postMessage src/jsc/bindings/webcore/MessagePort.cpp:143 ``` The leaked allocation is a `ConcurrentTask` (and the `EventLoopTask` it wraps) left in an exiting worker's `concurrent_tasks` queue after the queue has been drained for the last time. ## Cause `WebWorker::shutdown()` runs `process.on('exit')` handlers, then drains the worker's concurrent queue via `release_queued_tasks_for_shutdown()`, then enters `WebWorker__teardownJSCVM` which (first thing) calls `ctx->markTerminating()`. `ScriptExecutionContext::postTaskTo` already 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 sees `isTerminating() == false` and enqueues. In the failing test the worker writes to `process.stdout` inside its `exit` handler. The parent's captured-stdout reader acks each chunk with `port.postMessage(true)` (`src/js/node/worker_threads.ts` `makePortReadable._read`), which routes through `MessagePortPipe::scheduleDrain` to `postTaskTo(workerCtxId, ...)`. When the ack lands in that window it is pushed onto the worker's `concurrent_tasks`; nothing drains it again, and the worker's VM box is `dealloc`'d raw, so LSan reports the `ConcurrentTask` as a direct leak. The window is a few assignments plus one FFI call wide, so it hits probabilistically; the `release-asan` build 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()` inside `teardownJSCVM` still leaks") but left it open. ## Fix - `ScriptExecutionContext::markTerminating()` now takes `allScriptExecutionContextsMapLock`, the same lock `postTaskTo` holds across its `isTerminating()` check and `postTaskConcurrently()` enqueue. That makes the flag flip a proper fence against concurrent posters: any `postTaskTo` critical section either runs entirely before `markTerminating()` (its task is visible to the subsequent drain) or entirely after (it observes `true` and drops). - `WebWorker::shutdown()` calls the new `extern "C" ScriptExecutionContext__markTerminating` immediately before `release_queued_tasks_for_shutdown()`, closing the window. The later `markTerminating()` inside `WebWorker__teardownJSCVM` is 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 temporary `std::thread::sleep(5ms)` between `release_queued_tasks_for_shutdown()` and `WebWorker__teardownJSCVM` makes it deterministic: | build | `test-worker-stdio-flush.js` under LSan | 200-port Atomics-synchronized probe | | --- | --- | --- | | unpatched + 5 ms sleep | 5/5 leak (`32 byte(s) ConcurrentTask`) | 5/5 leak | | this PR + 5 ms sleep | 10/10 clean | 5/5 clean | | this PR (no sleep) | 50/50 clean | clean | `test/js/node/worker_threads/worker-shutdown-post-leak.test.ts` runs the worker-stdio-on-exit scenario under `detect_leaks=1` as an ASAN-lane guard (in a fresh file so it actually runs; `worker_destruction.test.ts` is ASAN-quarantined via `test/expectations.txt`). The race is not observable on the debug gate without `src/` instrumentation, so the fail-before half will not fire there; `test-worker-stdio-flush.js` on the release-asan lane remains the primary signal. Related: #31216 (introduced the ordering), #29917 (described but left the remaining window). <!-- robobun:evidence:begin --> --- **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 <!-- robobun:evidence:end -->
|
Closing as superseded. While this PR was open, the worker-side drain it adds landed on main through a different route:
Verified against unpatched |
Problem
ScriptExecutionContext::postTaskConcurrentlyheap-allocates anEventLoopTask, andBun__queueTaskConcurrentlywraps it in a heap-allocated auto_deleteConcurrentTaskthat is pushed onto the worker'sconcurrent_tasksqueue. The only free path for both is the target event loop actually ticking the task.When any cross-thread task lands in a worker's queue after the worker has left its
spin()loop but before~GlobalObjectremoves itsScriptExecutionContextfrom the global map, the task and everything it references leaks until process exit. WithWorker.postMessage's drain task this transitively holds aRef<Worker>, which keeps the entirem_toWorker.queueof bufferedSerializedScriptValues alive. In long-running servers that repeatedly spawn and terminate workers, this accumulates unbounded.Reproduction
Worker signals (via SharedArrayBuffer) when it reaches
process.on('beforeExit')— past its event loop, before JSC teardown. Parent waits for that signal, then floodspostMessage; the first one posts a drain task that the worker will never tick.Repeated 20× this leaks ~890 MB on
main.Fix
Call
EventLoop::drop_concurrent_cpp_tasks()(added in #30875 for the main-threadglobal_exitpath) fromWebWorker::shutdown()at the end of step 2, immediately beforeWebWorker__teardownJSCVM. This matchesglobal_exit's ordering: deleting anEventLoopTaskafter~VMcan run~JSEventListeneragainst freed Weak-handle storage (a nested worker's close task capturesRef<WorkerB>whose listener Weak handles live in this worker's heap).This is the worker-side complement to #30875's main-thread drain: that one frees tasks posted from workers to the main thread on process exit; this one frees tasks posted to a terminating worker. A task posted between this drain and
removeFromContextsMap()insideteardownJSCVMstill leaks — same as before this change, just a much smaller window.Verification
test/js/web/workers/worker-terminate-pending-message-leak.test.ts— 4 warm-up rounds then 20 measured rounds of the reproduction above, asserting RSS grows by less thanisASAN ? 300 : 100MB. Skipped on Windows (Atomics.wait on main thread + RSS accounting not reliable there).