JSPropertyIterator: drop Ref<VM>; free on trap-in-window so worker ~VM runs - #34574
JSPropertyIterator: drop Ref<VM>; free on trap-in-window so worker ~VM runs#34574robobun wants to merge 3 commits into
Conversation
…M runs The C++ JSPropertyIterator held a Ref<JSC::VM>. When a worker was terminated in the handful of instructions between Bun__JSPropertyIterator__create's own RETURN_IF_EXCEPTION and the Rust caller's post-call trap check, from_js_host_call_generic returned Err and dropped the raw pointer (no-op), leaking the iterator and its +1 VM ref. WebWorker__teardownJSCVM's single deref then left the refcount at 1, so ~VM never ran and every IsoSubspace cell that worker had allocated (H2FrameParser, ImmediateObject, Blob, FileInternalReadableStreamSource boxes) leaked. Store a raw VM& instead (the Rust owner is stack-scoped with a lifetime tied to the global), and hoist the allocation out of the check so it is freed if the trap window is hit.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughChangesThe property iterator now cleans up native allocations when host-call exception handling fails, uses a non-owning VM reference, and adds an ASAN regression test that terminates workers during HTTP/2 property iteration. Property Iterator Termination
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 1:46 AM PT - Jul 18th, 2026
❌ @robobun, your commit 3a716f6 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 34574That installs a local version of the PR into your bun-34574 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
I didn't find any bugs, but this changes VM refcounting semantics on a worker-teardown path, so it's worth a human look.
What was reviewed:
Ref<JSC::VM>→JSC::VM&: confirmed the RustJSPropertyIterator<'a>is lifetime-bound to&'a JSGlobalObjectand freed inDrop, so the VM outlives every C++ iterator instance;auto& vm = iter->vmand&vmat thePropertySlotsite both still bind correctly.JSPropertyIteratorImpl::init: the hoistedraw+ post-call free matchescall_check_slow'sreturn_if_exception()→exception_including_traps()semantics; no double-free path (only freed onErr, otherwise ownership passes to the caller'sDrop).- Test fixture:
../../../leaksan.suppresolves totest/leaksan.supp(exists); pipes drained concurrently;bunEnvspread preserved.
Extended reasoning...
Overview
The PR fixes a leaked JSPropertyIterator (and through it a leaked Ref<JSC::VM>) that prevents ~VM from running when a worker's terminate() trap lands between the C++ RETURN_IF_EXCEPTION in Bun__JSPropertyIterator__create and the Rust caller's post-call trap check. Two independent fixes: (1) drop the strong Ref<JSC::VM> on the C++ JSPropertyIterator in favor of a raw JSC::VM&, and (2) hoist the returned raw pointer out of the from_js_host_call_generic closure so it can be freed if the post-call check reports an exception. Adds an ASAN-only probabilistic leak test driving 6×42 workers through http2 request loops under detect_leaks=1.
Security risks
None identified. No user-controlled input reaches new code paths; the change narrows a resource leak.
Level of scrutiny
High. This is JSC-binding memory-lifetime code on the worker VM teardown path — exactly the "most-blocked category" per REVIEW.md. Dropping a Ref<VM> for a raw reference is a lifetime-semantics change: I verified the Rust wrapper is stack-scoped with a 'a lifetime bound to &JSGlobalObject and that the C++ object is only ever created/destroyed through the Rust FFI (Bun__JSPropertyIterator__create/deinit), so the VM provably outlives every instance. But someone who owns the worker teardown / derefSuppressingSaferCPPChecking path should confirm this doesn't interact with any other VM-refcount assumption, and weigh in on whether this fully supersedes #34563's h2 thread-exit sweep.
Other factors
- The root-cause trace in the PR description is convincing and matches what I read in
call_check_slow_at/return_if_exception→exception_including_traps. - ~25 Rust files construct
JSPropertyIterator; all go through the sameinit/Droppair, so the ownership change is centralized. - The new test is probabilistic with a 180s timeout and is ASAN-gated; the PR is upfront that fail-before is 3/3 but not guaranteed. That's acceptable for a narrow race, but a maintainer should confirm the timeout budget is OK for CI.
- No prior human reviews on the timeline.
|
Self-review addressed: the parent fixture now fails loudly on worker error or a non-terminate exit (instead of swallowing and passing vacuously), and the 6-spawn loop has a comment explaining it exists for fail-fast LSan on regression. CI on build 75181 is complete. The new test passed on the x64-asan lane (the only one it runs on). All failures are either The sibling Ready for a maintainer. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.
Inline comments:
In `@test/js/node/worker_threads/worker_threads.test.ts`:
- Line 1786: Update the stderr assertion in the worker-thread leak test to
reject any LeakSanitizer report by checking for the generic “LeakSanitizer”
marker, rather than relying on the symbol-name regex. Preserve the existing
negative assertion structure while making the sanitizer marker the primary
invariant.
🪄 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: 9f281d47-fe79-43f5-80e0-f16ad6e6f7b7
📒 Files selected for processing (5)
src/jsc/JSPropertyIterator.rssrc/jsc/bindings/JSPropertyIterator.cpptest/js/node/worker_threads/worker-terminate-propiter-parent-fixture.jstest/js/node/worker_threads/worker-terminate-propiter-worker-fixture.jstest/js/node/worker_threads/worker_threads.test.ts
What
Terminating a worker while it is driving http2 requests sometimes leaks every native-backed cell that worker allocated:
H2FrameParser(the lshpack state +HashMap<u32, Stream>backing),ImmediateObject,Blob,FileInternalReadableStreamSource. Seen in #34424's build 74745; #34563 works around the h2-specific allocations with a thread-exit sweep. The handoff that prompted this described it asMarkedSpace::lastChanceToFinalize/BlockDirectory::forEachBlockskipping IsoSubspace cells.Cause
It isn't a sweep miss: for the one worker that leaks,
~VMnever runs at all. InstrumentingWebWorker__teardownJSCVMshowed that worker reaches the finalvm.derefSuppressingSaferCPPChecking()withvm.refCount() == 2, so the deref leaves it at 1 andheap.lastChanceToFinalize()is never reached. Every other worker in the same run reached it withrefCount() == 1and cleanly destroyed.The stray ref is a leaked
JSPropertyIterator. Its C++ side held aRef<JSC::VM>:and the Rust wrapper obtained the raw pointer through
from_js_host_call_generic:Bun__JSPropertyIterator__createchecksRETURN_IF_EXCEPTIONaftergetPropertyNames, then allocates the iterator and returns.from_js_host_call_generic's own post-call check isscope.exception_including_traps(), which also handles traps. A parent'sterminate()(VMTraps::fireTrap(NeedTermination)) landing in the handful of instructions between those two checks makes the Rust side observe a termination exception aftercreatehas already succeeded;?propagates, the raw pointer is dropped (a no-op), and the heap-allocatedJSPropertyIteratorleaks its +1 VM ref.http2's
request()walks the headers object viaJSPropertyIteratoron every call, so asetImmediate-driven loop of requests re-enters that window every tick.Per-run trace of the leaking worker:
Fix
JSPropertyIteratorstoresJSC::VM&instead ofRef<JSC::VM>. The Rust owner is stack-scoped with a lifetime tied to its&JSGlobalObject, so the VM always outlives it; a strong ref serves no purpose and, when leaked, keeps a terminated worker's VM alive past teardown.JSPropertyIteratorImpl::inithoists the returned pointer out of the closure and frees it if the post-call check reports an exception, so the iterator itself no longer leaks either.Verification
New ASAN-only test
terminate() during native property iteration still runs the worker VM's finalizersinworker_threads.test.tsruns 6 rounds of 42 workers each terminated mid-request()underdetect_leaks=1. Fail-before 3/3 runs (first failing iteration at 28s/49s/111s), pass-after 1/1 plus 0/30 LSan hits on the raw fixture.worker_threads.test.ts92 pass;node-http2.test.js305 pass / 6 skip.The trap window is a few instructions wide and the test is probabilistic; 3/3 fail-before on this container does not guarantee the gate always fires, so the instrumentation trace above is the deterministic evidence.
Supersedes the workaround in #34563; related to #34448 (same "refcount > 1 at final deref" failure mode on the main-thread
destructOnExitpath).no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/worker_threads/worker_threads.test.ts