worker: free the thread's event name table when the worker thread exits - #38164
worker: free the thread's event name table when the worker thread exits#38164robobun wants to merge 1 commit into
Conversation
WebCore::eventNames() keeps its table of event-name atoms in a C++ thread_local. Bun compiles with -fno-c++-static-destructors, so no destructor is registered for it and the table (about 270 bytes with its atoms) was leaked once per worker thread; every worker allocates one when it comes online. Reset it explicitly at the end of WebWorker::shutdown, next to the HPACK scratch buffer that is freed the same way, while the thread's AtomStringTable is still alive.
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Status: fix pushed; CI build 94623 ran 177 jobs green, the only non-green jobs are the two
|
|
Heads-up on one hunk: #38157 (opened a few minutes before this PR, test-only) gives the existing test in |
There was a problem hiding this comment.
LGTM — mirrors the existing Bun__freeSharedHeaderBufferForThreadExit pattern one line below it.
What was reviewed:
- Placement in
shutdown(): afterVirtualMachine::teardown(JSC VM gone, script forbidden) and beforeworkerGlobalScopeDestroyed; verified the latter onlypostTaskTos the parent and does not toucheventNames()on the worker thread. eventNames_.reset()is safe to call when never populated (early-terminate paths) and would re-allocate if anything did calleventNames()afterward, so no UAF window.- LSan tests cover all four worker exit routes (drain /
process.exit/ parentterminate()/ Web Worker) and follow the file's existing conventions.
Extended reasoning...
Overview
Adds a one-line C++ helper Bun__destroyEventNamesForThreadExit() that resets the thread_local std::unique_ptr<EventNames> eventNames_, declares it as a safe fn FFI import in web_worker.rs, and calls it in WebWorker::shutdown() immediately before the existing Bun__freeSharedHeaderBufferForThreadExit() sibling. Also corrects the comment on that sibling in c-bindings.cpp to attribute the missing destructor to -fno-c++-static-destructors (the actual cause) rather than to process exit. Four new LSan cases in worker-shutdown-post-leak.test.ts cover natural drain, process.exit(), parent terminate(), and Web Worker drain; the file's existing test gets the same explicit 90s timeout constant.
Security risks
None. This is a per-thread cleanup of an internal atom-string table on worker exit; no user-facing API surface, no untrusted input handling, no auth/crypto/permissions.
Level of scrutiny
Worker teardown is memory-safety-sensitive, so the ordering matters — but the change is a strict addition of unique_ptr::reset() on a thread_local, placed at exactly the point the sibling HPACK-buffer reset already sits. I verified the PR description's ordering claims: workerGlobalScopeDestroyed (the only thing that runs on the worker thread after this call) does nothing but ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, ...); every eventNames() call in WorkerMessagingProxy.cpp runs inside a posted task on the parent's context, not on the exiting worker thread. On the early-terminate paths where the VM was never built the unique_ptr may already be null and .reset() is a no-op. Even if something did call eventNames() after the reset, the getter re-allocates on demand (same as the HPACK sibling), so this cannot introduce a UAF — worst case a re-leak. The AtomStringTable-lifetime argument (atoms must be released on the thread that interned them, before the WTF::Thread's table is destroyed at thread exit) is sound and matches upstream WebCore's ThreadGlobalData::destroy().
Other factors
- The change is a copy of an established, already-merged pattern (
Bun__freeSharedHeaderBufferForThreadExit) applied to the second heap-owning C++ thread_local. - Tests follow harness conventions (
tempDir,bunEnvspread, concurrentPromise.allpipe drain, combined-object assertion,skipIf(!isASAN || isWindows),describe.concurrentfor independent subprocess cases) and were verified to fail on the unfixed tree with the exact 88-byteEventNames::createLSan report and pass with the fix. - The 90s timeout is justified by the LSan symbolization cost against a debug binary and is applied consistently to the file's existing test.
- The rejected alternative (
[[clang::always_destroy]]) is explained: it would run on the main thread atexit()and depend on platform TLS-vs-pthread-key destructor ordering.
Problem
WebCore::EventNamestable when it exits. LSan (ASAN build,Malloc=1):Direct leak of 88 byte(s) ... WebCore::EventNames::create<>() EventNames.h:62 / WebCore::eventNames() EventNames.cpp:42, plus the table's atoms as indirect leaks; about 270 bytes per worker, one report per worker that ever ran.WorkerMessagingProxy::workerGlobalScopeStarted(WorkerMessagingProxy.cpp:392) callseventNames()on the worker thread when the worker comes online, so an empty worker leaks it too.thread_local std::unique_ptr<EventNames> eventNames_(src/jsc/bindings/webcore/EventNames.cpp:37). Bun compiles all C++ with-fno-c++-static-destructors(scripts/build/flags.ts:354), which also covers thread-storage variables, so no destructor is ever registered for it: the TLS wrapper emitted foreventNames_is a bare address computation with no__tls_init/__cxa_thread_atexitcall. When the worker thread returns, nothing frees the table. The main thread's copy is reachable through its TLS until process exit, so only workers leak.Fix
EventNames.cpp: addBun__destroyEventNamesForThreadExit(), which resets the thread'seventNames_.web_worker.rs(WebWorker::shutdown): call it afterVirtualMachine::teardown, next toBun__freeSharedHeaderBufferForThreadExit(), which already frees the other heap-owning C++ thread_local (the http2 HPACK buffer) for exactly this reason.shutdown()is the one exit funnel for every worker: natural drain,process.exit()in the worker,terminate()from the parent, and bothworker_threadsand WebWorkerkinds. On the early-terminate paths that never built a VM the reset is a no-op.AtomStringTable(a Default-type JSC VM shares the thread's table, VM.cpp:264, and~VMleaves it alone), which belongs to theWTF::Threadand is destroyed only when the thread itself exits, so releasing the atoms here removes them from the table they were registered in. No script can run at this point (teardown forbade it and the JSC VM is gone), and nothing that runs later on the thread callseventNames()(workerGlobalScopeDestroyedonly posts a task to the parent), so the table is not re-created. Nothing stores a reference to theEventNamesstruct; every caller readseventNames().xEventtransiently.ThreadGlobalDataand the worker thread callsthreadGlobalData().destroy()before it exits; Bun replacedThreadGlobalDatawith the bare thread_local and dropped that step.[[clang::always_destroy]]on the variable instead: it would also run the destructor duringexit()on the main thread (bun exits throughexit()on macOS and in ASAN builds) and makes the atom release depend on the platform's ordering of C++ TLS destructors versus pthread-key destructors (which is what tears down theWTF::Threadand its atom table). The explicit call runs at one known point, on worker threads only.c-bindings.cpp, which attributed the missing destructor to process exit rather than to the compiler flag.test/js/node/worker_threads/worker-shutdown-post-leak.test.tsgains four LSan cases (Malloc=1so WTF's fastMalloc goes through the system allocator and LSan can see the table):worker_threadsworker draining, callingprocess.exit(), terminated by the parent while listening onparentPort, and a Web Worker draining. Withsrc/stashed and rebuilt all four fail with the 88-byteEventNamesleak above; with the fix all five tests in the file pass (bun bd test). The file's existing test gets the same explicit timeout as the new cases: each of these spawns a worker VM under debug+ASAN and then runs LSan, which already takes 4 to 7 seconds on a loaded machine against the 5 second default.worker_destruction,worker-transfer-terminate-stress,worker-terminate-lifetime,worker-terminate-funnels,message-port-context-destroy-leak,message-port-closed-leak,performance-observer-leakandshell-worker-terminate-leakagainst the fixed build; the only failures were the same five the unmodified tree produces here (listed under details).eval)worker_threadsworker still leaks its resolved entry path string becauseVirtualMachine::destroy()never releasesmain_resolved_path. That is why the newworker_threadscases useeval: true.Background
eventNames(): WebCore's per-thread struct of pre-atomized event type names (closeEvent,messageEvent, ...) used by EventTarget code (MessagePort,AbortSignal,Worker, ...) to compare and dispatch event types without re-atomizing strings.AtomString/AtomStringTable: anAtomStringis a string interned in a per-thread table so equal strings share oneStringImpland compare by pointer. The table holds raw pointers; when the last reference to an atom is dropped, theStringImplremoves itself from the current thread's table, so an atom must be released on the thread that created it while that table still exists.-fno-c++-static-destructors: Clang flag that suppresses exit-time destructor registration for every static-storage and thread-storage variable (the same effect as marking each one[[clang::no_destroy]]). Bun uses it because JSC assumes no destructors run at process exit; the side effect is that athread_localthat owns heap memory must be freed by hand when a thread that used it exits.WebWorker::shutdown()(src/jsc/web_worker.rs): the last thing a worker thread runs: exit handlers,VirtualMachine::teardown(stop phase, JSC VM destruction, loop teardown), freeing of the thread's remaining state, thenworkerGlobalScopeDestroyed, after which the parent joins the thread.Probes and pre-existing failures seen while verifying
TLS wrapper Clang emits for the variable in the debug binary (no init or destructor registration call):
Unfixed tree, three workers (repro from the report),
BUN_DESTRUCT_VM_ON_EXIT=1 Malloc=1withtest/leaksan.supp:With an empty worker the allocating frame is
WorkerMessagingProxy::workerGlobalScopeStarted, called fromWebWorker::spinonce the entry script has run. A table first created by top-level script code is attributed to module evaluation, whichleaksan.suppsuppresses, which is why the new cases register listeners and exit from callbacks.Failures that are identical with and without this change on this machine (debug ASAN build, heavily loaded host):
worker-terminate-lifetime.test.ts"terminate() while dns.lookup() is in flight": LSan reports a 4104 bytenode_fs_binding::Bindingbox; already tracked by node:fs: mark the per-VM Binding box as LSan-ignored (fixes worker-terminate-lifetime.test.ts on main) #35159.worker_destruction.test.ts: four cases (Bun.connect / Bun.listen / fetch in a terminating worker, child process with pending stdin write) hit the 5 second default timeout at 5.0 seconds; they take that long here regardless of this change.