jsc: release the cached Bun.main path in VirtualMachine::destroy() - #38178
jsc: release the cached Bun.main path in VirtualMachine::destroy()#38178robobun wants to merge 1 commit into
Conversation
The Bun.main getter stores the resolved entry path in vm.main_resolved_path as a +1 on a WTF string. Only the entry-point reload paths released it; worker VM teardown did not, and the VM storage is freed without running field drops, so every worker_threads Worker started from a file (whose bootstrap reads Bun.main for process.mainModule) leaked one string.
|
Warning Review limit reached
Next review available in: 29 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 (2)
Comment |
|
Status: reproduced on main (debug ASAN build, |
|
Found 1 issue this PR may fix:
Note: #28318 is an umbrella issue whose main measured symptom is 100 MB-1 GB per worker of RSS-invisible bmalloc/pas slab memory, plus
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — a two-line release in VirtualMachine::destroy() that mirrors the exact main_resolved_path.deref() + reset-to-empty idiom already used at the three reload sites in the same file.
What was reviewed:
- Confirmed
String::deref()is a no-op on the empty tag (src/bun_core/string/mod.rs:512), so the unconditional call is safe whenBun.mainwas never read or a reload already cleared it. - Confirmed
destroy()runs on the VM's own thread (worker-thread teardown), so releasing an atom here targets the correct per-thread table. - Checked the new LSan tests:
tempDir+using, concurrent pipe drain, combined{stdout, stderr, exitCode}assertion,test.concurrentfor independent spawns, and both the atom andclone_utf8branches are covered. The 90s timeout and the temporaryWebCore::eventNamessuppression are each justified in comments.
Extended reasoning...
Overview
The PR adds a two-line release of self.main_resolved_path (a bun_core::String holding a +1 WTF string reference) in VirtualMachine::destroy() at src/jsc/VirtualMachine.rs:4744-4745, placed immediately after the existing overridden_main.deinit(). It also adds two LSan-gated regression tests (ASCII/atom and non-ASCII/clone_utf8 paths) in test/js/node/worker_threads/worker-shutdown-post-leak.test.ts, extracts the shared leaksan.supp path into a const, and gives all three tests in the file an explicit 90-second timeout.
Security risks
None. This is a resource release on a teardown path; no user input handling, no auth/crypto/permissions surface. Worst-case failure mode of a bug here would be double-free/UAF, but the field is only ever populated once by get_main and every other site that derefs it resets to empty() afterward, so at destroy() time it is either empty (deref is a no-op — verified at src/bun_core/string/mod.rs:512, which only acts on Tag::WTFStringImpl) or holds exactly one +1. Nothing reads the field after destroy(); the VM storage is raw-dealloc'd.
Level of scrutiny
Low-to-medium. The source change is mechanical: it copies verbatim the self.main_resolved_path.deref(); self.main_resolved_path = bun_core::String::empty(); pair used at lines 2630-2631, 4806-4807, and 5069-5070 of the same file, into the one lifecycle exit that was missing it. Placement next to overridden_main.deinit() matches the reload paths' ordering. destroy() is documented and called as worker-thread teardown, satisfying the atom-string same-thread release requirement noted in the PR description and in src/CLAUDE.md.
Other factors
- No CODEOWNERS entry covers these files.
- Tests follow the repo's harness conventions to the letter:
tempDirwithusing,await usingonBun.spawn,Promise.alldrain of stdout/stderr/exited, single combined-object assertion,test.concurrentfor independent subprocess cases,skipIf(!isASAN || isWindows)gating. - The 90s timeout is justified in a comment (LSan symbolization on the debug binary), and applying it to the pre-existing test is a strict improvement over the marginal 5s default.
- The temporary
leak:WebCore::eventNamessuppression is scoped to a per-test suppressions file (built from the shared one), documented as covering the separate #38164 leak, and does not weaken the sharedtest/leaksan.supp. - The PR description demonstrates the tests fail on the unfixed tree with the expected LSan attributions, satisfying the "prove the test fails for the right reason" requirement.
|
Updated 11:18 AM PT - Aug 13th, 2026
✅ @robobun, your commit 0eeb9afb5e3e1513dfcaa25d5dc01846d9fc4c5d passed in 🧪 To try this PR locally: bunx bun-pr 38178That installs a local version of the PR into your bun-38178 --bun |
Problem
node:worker_threadsWorker started from a file leaks one WTF string after the worker thread exits. LSan (ASAN build,Malloc=1):Direct leak of 44 byte(s) in 1 object(s)allocated fromBunString__tryCreateAtom (BunString.cpp:80), called frombun_runtime::api::bun_object::get_main. The size is 20 bytes plus the length of the resolved entry path; it scales with the number of workers.eval: trueworkers do not hit it.Bun.maingetter (src/runtime/api/BunObject.rs:841-896) resolves the entry path once and caches it invm.main_resolved_path(src/jsc/VirtualMachine.rs:159) as a +1 on a WTF string: an atom when the path is ASCII, aclone_utf8copy otherwise.reload_entry_point,reload_entry_point_for_test_runner, the test-isolation reset).VirtualMachine::destroy()(src/jsc/VirtualMachine.rs:4681) never did, and a worker's VM storage is freed with a rawdealloc(src/jsc/web_worker.rs:1034), so no field drop could do it either.process.mainModule, whose builder (constructMainModuleProperty,src/jsc/bindings/BunProcess.cpp:4416) readsBun.main. A Web Worker or any other VM whose script readsBun.mainleaked the same way.Fix
VirtualMachine::destroy()derefsmain_resolved_pathand resets it to empty, next to theoverridden_main.deinit()it already does; this is the same two-line release the three reload paths use.destroy()runs on the VM's own thread (worker teardown inWebWorker::shutdown, or the main thread underBUN_DESTRUCT_VM_ON_EXIT), andget_mainonly ever runs on that thread, so an atom is released in the table it was registered in. The JSC VM is already gone at this point, but a default-type JSC VM uses the thread's atom table (VM.cpp:264) and~VMleaves it alone; the table lives until the thread itself exits, aftershutdown()returns.String::deref()is a no-op on the empty tag (a VM that never readBun.main, or whose reload path already released it), and nothing reads the field afterdestroy(); the only reader isget_main, and script is forbidden before teardown reachesdestroy().bun_core::StringisCopywithout aDrop, so the explicit release is the codebase's idiom for this field rather than a type change.test/js/node/worker_threads/worker-shutdown-post-leak.test.ts: two new cases run a file-basedworker_threadsWorker under LSan withMalloc=1, one with an ASCII path (atom branch) and one with a non-ASCII directory in the path (clone_utf8branch). Withsrc/stashed and rebuilt they fail with67 byte(s) ... BunString__tryCreateAtomand114 byte(s) ... BunString__fromUTF8 <- String::clone_utf8 <- get_mainrespectively; with the fix all three tests in the file pass underbun bd test.test/leaksan.supp: the worker thread'sEventNamestable, which every worker leaks today and which worker: free the thread's event name table when the worker thread exits #38164 fixes. That line in the test can go once worker: free the thread's event name table when the worker thread exits #38164 lands; the two PRs are otherwise independent (that PR'sworker_threadscases useeval: trueprecisely to avoid the leak fixed here).worker_threads.test.ts,web/workers/worker.test.tsandworker_destruction.test.tsagainst the fixed build; the only failures are timing assumptions that this slow debug+ASAN container also fails on the unmodified tree (details below).Background
bun_core::String/BunString: the tagged string union shared with C++. When it holds aWTFStringImplit carries a reference count; it isCopyand has noDrop, so whoever receives a +1 from a constructor such astry_create_atomorclone_utf8must callderef()explicitly.AtomStringTableso equal strings share oneStringImpl. When its last reference goes away it removes itself from the current thread's table, which is why it has to be released on the thread that created it, while that table still exists.VirtualMachine::destroy(): the last step ofVirtualMachine::teardown, after the JSC VM and the event loops are gone. It releases the Rust-side per-VM state explicitly because the VM's memory is then freed without running field drops.Malloc=1: makes WebKit's allocator (bmalloc) forward to the system allocator, so WTF strings become visible to LSan. Without it this leak, and any other fastMalloc'd one, is invisible to the ASAN lanes, which is why it went unnoticed.Repro and unrelated local failures
Repro on the unfixed tree (debug ASAN build):
(plus the 88-byte
EventNamesleak from #38164). With this change the run reports only theEventNamesleak; thetryCreateAtomone is gone.A Web Worker whose script reads
Bun.mainleaks the same +1, but LSan attributes that string to the module loader, which interned the path first (JSC::Identifier::fromString, suppressed inleaksan.supp), so it cannot serve as a failing test; the node bootstrap readsBun.mainbefore the entry module is loaded, which is what makes the leak attributable.Pre-existing failures in this container, identical with and without this change (debug+ASAN, slow host):
worker_destruction.test.tshits the 5 second default timeout on its four spawn-based cases, andweb/workers/worker.test.ts"a message flood from a worker does not starve the parent's event loop" sees zero messages within its three 10 ms timer turns because a worker takes longer than that to boot here.