Repository navigation
Conversation
JsRef::Weak held a bare JSValue copy with no GC registration, so try_get() could only check for empty/undefined/null and handed out dangling values between the collection that reaped a wrapper and the sweep that runs its finalizer. Deferred readers (queued tasks, keep-alive dispatch, socket callbacks) dispatching in that window called callbacks out of dead cells. JsRef::Weak now holds a passive bun_jsc::Weak registered against the value's own cell: get() reads None from the moment GC reaps the referent, so every deferred reader degrades to skipping its event instead of touching a dead cell. downgrade() registers the weak handle while the Strong still roots the value, upgrade() of a reaped referent is a no-op, and the enum keeps its existing shape and call sites. The C++ Bun::WeakRef constructor never used its VM argument; the weak handle registers against the cell's own WeakSet, so the globalObject parameter is dropped from the FFI and from Weak::create and Weak::create_passive, and the constructor now tolerates non-object values instead of null-dereferencing.
|
Status: diff ready for review. The weak-after-close UAF class (fs.watchFile #36926, UDP unref #36930, Bun.serve stop #35130, Terminal EOF #36962) is closed at the primitive: JsRef::Weak is now a real JSC::Weak cleared at reap time, so a deferred reader can no longer observe a dead wrapper. Review follow-ups are in: Request/Response js_ref uses the non-registering RawJsRef (fixes the request-clone-leak RSS regression the first push caused), JsRef::is_dead() closes the reaped-vs-empty gap in the socket create-if-missing path, and FetchTasklet's on_body_received now neither reads nor writes wrapper slots unless the wrapper is verified live. CI: no failures attributable to this diff across four builds. The persistent red lane is a pre-existing main failure (AsyncLocalStorage-tracking ASAN leak in node_crypto_binding.rs); build 89215 additionally hit an inotify-exhaustion watcher panic in the cron --hot test on one runner. Both are reported to main-break triage separately; everything else passed on retry. Fail-before proof is structurally unavailable for this race on a debug build (analysis and 20-run probe evidence in the PR body), so the included test is a lifecycle canary rather than a deterministic reproduction. The open per-site PRs #35130 and #36962 remain the functional fixes and do not conflict with this change. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Walkthrough
ChangesPassive weak-handle lifecycle
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Found 4 issues this PR may fix:
Caveat, given this PR's own note that the dead-but-unswept state was not deterministically reproducible: none of these are confirmed — each is a stack-shape match against the mechanism the PR changes, and #21002 is the weakest of the four (a
🤖 Generated with Claude Code |
The js_ref slot on Request and Response is only read synchronously (check_body_stream_ref and the cached-stream accessors) while the wrapper is rooted by the caller, but backing it with a registered JSC::Weak made every construction allocate a weak handle, and wrapper cells are precise-allocated so each handle carries a 1 KB WeakBlock in the cell's own WeakSet. The request-clone-leak RSS test caught the regression on every release lane. Add RawJsRef, a non-registering sibling of JsRef holding the bare JSValue with the synchronous-reader contract documented on the type, and use it for the two js_ref slots. Deferred holders keep the registered JsRef semantics.
|
On the four issues matched above (#22775, #30418, #24033, #21002): the mechanism lines up for the first three. #30418 and #24033 are queued timer dispatch calling through a wrapper's callback slots (the exact deferred-reader shape this PR hardens), and #22775 crashes in the collect-to-finalize handshake window that the weak read now covers. #21002 faults inside the WeakSet iteration machinery itself; this PR touches that machinery but there is no reason to believe it fixes it. None of the four has a deterministic reproduction, so I am not adding auto-close lines; closing on a stack-shape match risks hiding a distinct bug. The honest check is whether these crash signatures disappear from telemetry once a release carries this change. |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/jsc/JSRef.rs:131-136— A reaped-but-unsweptWeakis now indistinguishable fromJsRef::empty(), sosocket_body.rs::get_this_value()(lines 1559-1578) falls through itsFinalizedguard into theto_js()branch and mints a second wrapper over the same native — the exact double-finalize/double-deref its line-1564 comment guards against. Either expose the distinction on the primitive (e.g.JsRef::had_value()checkingWeak.r#ref.is_some()) or haveget_this_value()returnUNDEFINEDfor anytry_get()==None, matching the PR's stated "skip instead of dispatch" intent.Extended reasoning...
What changed and why it matters
Before this PR,
JsRef::Weakheld a bareJSValue, sotry_get()returnedSome(value)right up until the wrapper's destructor flipped the ref toFinalized. After this PR,JsRef::Weakholds a realJSC::Weak, andtry_get()starts returningNonethe momentreapWeakHandles()marks theWeakImpldead — the PR's own new doc atJSRef.rs:65-69says exactly this: "before the lazy sweep runs the wrapper's destructor (which is what flips the ref toFinalized)". That introduces a new observable state: the enum variant is stillJsRef::Weak(finalize hasn't run), buttry_get()isNoneandis_empty()istrue— indistinguishable fromJsRef::empty()(never-had-a-wrapper).The holder that depends on the distinction
src/runtime/socket/socket_body.rs:1559-1578get_this_value():try_get()→Some→ return itmatches!(Finalized)→ returnUNDEFINED(comment: "Creating a new one here would result in a secondfinalize(and double-deref) later")- otherwise →
self.to_js(global)mints a fresh wrapper cell over the same native ptr, thenset_strong()
Before this PR, branch (3) was reachable only from
JsRef::empty(). After this PR, a downgraded socket whose wrapper has been reaped but not yet lazily swept also lands in branch (3): variant is stillWeak(so theFinalizedcheck at (2) misses), butweak.get()isNone(so (1) misses too).Step-by-step to double-deref
Concrete path via
Listener.rs:1549-1566(the reuse-prev reconnect path — its own comment says "prev.this_valuewas downgraded to Weak by the previous close'smark_inactive()"):- Socket closes →
mark_inactive()(socket_body.rs:1320) downgradesthis_valuetoWeak. Native refcount is +1, owned by the existing wrapper. - JS drops its
socket._handlereference; GC runs;reapWeakHandles()marks the wrapper'sWeakImpldead. Lazy sweep hasn't run yet, sothis_valueis stillWeak, notFinalized. - Reconnect calls
socket_ref.get_this_value(global).try_get()→None;matches!(Finalized)→ false; falls through toto_js(). to_js()(socket_body.rs:449-461) hands the native pointer to a new JS cell without bumping the refcount (comment: "ownership is adopted by the C++ JSCell wrapper").set_strong(new_wrapper)overwritesthis_value.- The old wrapper's lazy sweep runs
finalize()(socket_body.rs:3228-3243):this_value.finalize()(drops theStrongon the new wrapper) →deref(). Native refcount +1 → 0 → freed. - The new wrapper (now unrooted) is later collected → its
finalize()runs → secondderef()on freed memory.
Two
deref()s against one +1: exactly the UAF the line-1564 comment guards against.Why the existing guard doesn't catch it
The
Finalizedguard at line 1563-1566 was written precisely to prevent this double-finalize. It catches "swept" but not "reaped-but-unswept" — a state that didn't exist as a distincttry_get()==Nonecase before this PR. The PR silently renders that guard incomplete.Not a strict regression, but contradicts the PR's claim
Pre-PR, the same reap→sweep window was also unsafe at this site: branch (1) returned the dead cell and the caller (
data_set_cached) wrote through it. So this PR trades one UAF (dispatch-through-dead-cell) for another (deferred double-deref) at this holder. But the PR body says "this PR fixes the primitive so the failure mode cannot be memory-unsafe anywhere, including the holders nobody has fuzzed yet" and "the ~30 holders … pick the semantics up without edits" — this holder's create-if-empty pattern is a direct counterexample. PerREVIEW.md("Fix the whole class in the same PR"), it belongs in this diff.Fix
The primitive has the information — a reaped
Weakhasr#ref.is_some()(theWeakImplhandle exists, just reads empty), whileJsRef::empty()hasr#ref.is_none()— but doesn't expose it. Either:- add
JsRef::had_value(&self) -> bool(or similar) that distinguishes reaped-Weak from empty-Weak, and makeget_this_value()returnUNDEFINEDwhen it's true; or - simpler: change
get_this_value()to returnUNDEFINEDfor anytry_get()==Noneon a non-Finalizedref where a wrapper previously existed, matching the PR's stated "a deferred reader skips its event instead of dispatching through a dead wrapper" intent.
The Listener reuse-prev path already
ref_()s the native and would need to keep that balanced with whatever "skip" behavior is chosen there.
…g paths Review follow-ups: - Add JsRef::is_dead() (a registered weak handle reading dead, or Finalized) and use it in the socket get_this_value() guard. With the weak read going dead at reap time, a reaped-but-unswept wrapper was indistinguishable from a never-created one there, so the create branch could mint a second wrapper over the same native socket and double-run its finalizer. - Tighten the weak-path debug_asserts to value.is_object(), matching create_passive's actual precondition so non-object misuse trips loudly in debug builds instead of producing a silently-empty ref. - Drop a redundant js_ref re-set in Response::construct_error; to_js already seeds it. - Widen the GC round budget in the server lifecycle test.
|
Follow-ups from the review findings, in 5462e59:
|
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/bun/http/bun-server.test.ts`:
- Line 693: Name the GC stress limit used in the bun-server test loop by
introducing a dedicated constant and updating the for-loop in the same test to
use it instead of the raw 300 literal. Keep the loop behavior unchanged, and
anchor the new constant near the existing test logic in bun-server.test.ts so
the stress bound is clear and reusable.
🪄 Autofix
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: 257ca672-1c2b-4ff1-b52e-8dd3023b4664
📒 Files selected for processing (8)
src/jsc/JSRef.rssrc/jsc/Weak.rssrc/jsc/lib.rssrc/runtime/server/mod.rssrc/runtime/socket/socket_body.rssrc/runtime/webcore/Request.rssrc/runtime/webcore/Response.rstest/js/bun/http/bun-server.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/jsc/JSRef.rs:246-255— TheRawJsRefdoc says it's for back-pointers "only read synchronously while the wrapper is rooted by the caller", butResponse.js_refhas a deferred reader:FetchTasklet::on_body_received(FetchTasklet.rs:837-841) reaches the Response vianative_response(a native intrusive +1, not a JS root) and callsget_body_readable_stream()→js_ref()→ bare JSValue →stream_get_cached()on the wrapper cell. This is not a regression (pre-PRJsRef::Weak(JSValue)was byte-identical here), but the codebase already flags this exact hazard on the sibling path —BodyAbortListener::on_abort(Response.rs:140-145) bypassesget_body_readable_streamwith the comment "its js_ref() path reads a raw JSValue to a wrapper that may be unmarked but not yet swept". Consider either applying that same bypass at FetchTasklet.rs:841 (readLocked.readabledirectly), or softening theRawJsRefdoc to acknowledge Response's known-open deferred-reader window.Extended reasoning...
What the doc contract states vs. what Response does
The new
RawJsRefdoc comment (JSRef.rs:246-255) says it is for "wrapper back-pointers that are only read synchronously while the wrapper is rooted by the caller (host-fn receiver, or a value just created on the JS stack)", and that "deferred readers (event-loop callbacks, queued tasks) must use [JsRef]". The PR body justifies the Request/Response carve-out on the same basis: "thejs_refslot onRequest/Responseis only ever read synchronously while the wrapper is rooted by the caller".For
Requestthat holds. ForResponseit does not:FetchTasklet::on_body_receivedis a network-driven event-loop callback (i.e., exactly a "deferred reader" in the doc's terms) that readsResponse.js_refthrough a path where the wrapper is not rooted by the caller.The concrete code path
- FetchTasklet.rs:837 —
on_body_receivedcallsself.current_response_mut(). - FetchTasklet.rs:583-587 —
get_current_response()returnsself.native_responsefirst.native_responseis set at line 1898 viaResponse::ref_(response)— an intrusive native +1 on theResponsestruct'sref_count. This keeps the native heap allocation alive; it does not root the JS wrapper cell. Only ifnative_responseisNonedoes the code fall back to the realJSC::Weakself.response. - FetchTasklet.rs:841 —
response.get_body_readable_stream(&global_this)is called on that native-ref'd Response. - Body.rs:1681-1682 —
get_body_readable_streamreadsself.js_ref()→RawJsRef::try_get()→ the bareJSValue(no liveness check, perRawJsRef's design) →Self::stream_get_cached(js_ref), which dereferences them_streamWriteBarrierslot on the JSResponse wrapper cell.
Step-by-step: how the doc's own hazard manifests
fetch()resolves;on_resolve(line 1878-) creates the JSResponse wrapper, stores a realJSC::Weakinself.response, and stores a native +1 inself.native_response(line 1898).response.js_refis nowRawJsRef::Value(js_wrapper).- User JS drops the last reference to the
Responseobject while the body is still streaming (e.g., they fetched but never awaited/consumed the body). - The concurrent collector's mark phase determines the JSResponse wrapper cell is dead. The mutator resumes. Nothing has swept yet.
- A body chunk arrives from the network;
on_body_receivedfires on the JS thread. get_current_response()returnsnative_response— the native struct is alive (intrusive refcount ≥ 1).Response::finalize()has not run yet (the WeakHandleOwnerBun__FetchResponse_finalizefires duringHeap::finalize(), which the mutator hasn't reached yet), sojs_refis stillRawJsRef::Value(dead_jsvalue), notFinalized.get_body_readable_streamreadsjs_ref()→Some(dead_jsvalue)→stream_get_cached(dead_jsvalue)reads a slot on a dead-but-unswept cell.
This is precisely the reap-to-sweep window the PR body describes and closes for
JsRefholders. The PR's own "On fail-before provability" section explains why it isn't deterministically reproducible on debug builds (PreciseAllocation wrappers are swept in the same cycle bysweepInFinalize()), and why the remaining window is the collector-cycle-end → mutator-finalize handshake — but that window is exactly what the PR set out to close, and it remains open forResponseon this one path.Why this isn't hypothetical: the codebase already documents it
BodyAbortListener::on_abort(Response.rs:140-145) is another deferred callback that reaches aResponsevia a non-GC-rooting handle (ParentRef), and it explicitly bypassesget_body_readable_streamwith this comment:Not
get_body_readable_stream: itsjs_ref()path reads a raw JSValue to a wrapper that may be unmarked but not yet swept, reaching aNewSourcebox the source cell's (PreciseAllocation) destructor already freed.Locked.readableis a realJSC::Weakon the stream and readsNoneexactly when the box is gone.So the codebase has already identified this exact hazard on
Response'sjs_refpath from deferred contexts, and works around it by readingLocked.readable(a realJSC::Weak) directly.FetchTasklet::on_body_receivedat line 841 does not apply the same bypass.Why this is a nit, not a blocker
Behavior is byte-for-byte unchanged from before this PR. Pre-PR,
Response.js_refwasJsRef::Weak(JSValue)— a bareJSValuewith identical semantics toRawJsRef::Value(JSValue). Nothing regresses; the reap-to-sweep window on this path is pre-existing. What's new is:- A doc comment that states an invariant ("only read synchronously while the wrapper is rooted by the caller") that one of the type's two users (
Response) violates. - A PR-body claim ("fixes the primitive so the failure mode cannot be memory-unsafe anywhere, including the holders nobody has fuzzed yet") that overstates scope for the
Responsecarve-out.
Merging as-is doesn't make anything worse than main. But since the PR is explicitly about closing this failure mode as a class, it seems worth either (a) applying the
BodyAbortListenerbypass pattern at FetchTasklet.rs:841 — readLocked.readabledirectly instead of routing throughjs_ref()(get_body_readable_streamalready falls through to that at Body.rs:1693-1694 whenjs_ref()isNone, so the change is small), or (b) tightening theRawJsRefdoc to acknowledge thatResponse's deferred reader viaFetchTasklet.native_responseis a known-open window (matching the existingBodyAbortListenercomment), so the type's contract is accurate. - FetchTasklet.rs:837 —
on_body_received is a deferred network callback that reaches the Response through native_response, a native intrusive ref that does not root the JS wrapper, so the get_body_readable_stream js_ref path could read a wrapper cell that GC has unmarked but not yet swept. BodyAbortListener::on_abort already bypasses that path for the same reason; apply the same guard here. Locked.readable is a real JSC::Weak on the stream and reads None exactly when the stream is gone.
|
Good catch on the FetchTasklet path: on_body_received reaches the Response through native_response (a native intrusive ref, no JS root), so its get_body_readable_stream call was exactly the deferred js_ref read the RawJsRef contract forbids, and the codebase had already documented the hazard at BodyAbortListener::on_abort. cf640f6 applies the same bypass there: read Locked.readable (a real JSC::Weak on the stream) directly, which goes None exactly when the stream is gone. With that, both RawJsRef users satisfy the documented contract: the remaining get_body_readable_stream callers are host fns or server render paths entered with the wrapper rooted (receiver on the JS stack, or the RequestContext's response handle). Verified with fetch.stream.test.ts (the multiple-part Content-Length cases drive on_body_received chunk delivery) and body.test.ts. |
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 `@src/runtime/webcore/fetch/FetchTasklet.rs`:
- Around line 847-851: Update the response-body handling around
`ByteStream::on_data` to keep both the readable stream and `bytes` strongly
rooted while the callback may resolve JavaScript promises. Create the strong
root before invoking `on_data`, or re-acquire the weak stream and `ByteStream`
afterward before the `has_more` path calls `drop_backpressure_if_unobserved`;
preserve existing behavior otherwise.
🪄 Autofix
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: e787718c-4bfe-42e3-9e43-7add63315166
📒 Files selected for processing (1)
src/runtime/webcore/fetch/FetchTasklet.rs
The final-chunk branch of on_body_received called detach_readable_stream, which writes ZERO into the wrapper's cached stream slot through the raw js_ref. Reached through native_response the wrapper is not rooted, so in the reap-to-finalize window that is a write barrier on an unmarked cell. Gate the slot write on the tasklet's own weak wrapper handle reading alive; for a dead wrapper only release the native Locked.readable ref, the finalizer clears the slot.
|
The detach half of that path had the same exposure: the final-chunk branch of on_body_received wrote ZERO into the wrapper's cached-stream slot through the raw js_ref (a write barrier on an unmarked cell in the reap-to-finalize window). 003d502 and cf640f6 covered the read side; 8349555 now gates the slot write on the tasklet's own weak wrapper handle (self.response) reading alive, and for a dead wrapper only releases the native Locked.readable ref, the finalizer clears the slot. With that, on_body_received neither reads nor writes wrapper slots unless the wrapper is verified live, matching the on_abort pattern, and the RawJsRef contract holds for both of its users. |
The free-standing PhantomData const claimed to keep JsRef from becoming sendable but had no effect on its auto-traits. Replace it with an ambiguity-based assertion that fails to compile if JsRef ever becomes Send or Sync (verified by temporarily forcing an unsafe impl Send).
downgrade() no longer calls it; set() is what the file uses now.
There was a problem hiding this comment.
All prior findings from earlier passes have been addressed (cf640f6, 8349555, 38a9d7c, 86a6f62), and this pass found nothing new. Deferring to a human because this rewrites a core GC-safety primitive and introduces the RawJsRef carve-out — an unsafe-by-documented-contract type whose one contract violation was already found and fixed mid-review — which is a design trade-off a maintainer should sign off on.
What was reviewed:
JsRefstate transitions (downgrade/upgrade/set_weak/update) for handle leaks and unrooted windows — the Strong is held until afterWeak::create_passiveregisters.Bun__WeakRef__newFFI signature change and the non-object guard in the C++ constructor.FetchTasklet::on_body_receivedafter 8349555 — both the read and the final-chunk slot write are now gated on wrapper liveness.- The
is_dead()addition and its one caller insocket_body.rs(correctly distinguishes reaped-but-registered from never-set).
Extended reasoning...
Overview
The PR replaces JsRef::Weak(JSValue) — a bare, unregistered value copy — with JsRef::Weak(bun_jsc::Weak<()>), a real JSC::Weak handle registered against the referent's cell. try_get() now reads None at reap time rather than at sweep time, closing the reap-to-finalize window in which deferred callbacks could dispatch through a dead wrapper. It also drops the unused JSGlobalObject parameter from Bun__WeakRef__new/Weak::create[_passive], hardens the C++ WeakRef constructor against non-object values, adds Weak::is_registered() and JsRef::is_dead(), and introduces RawJsRef — a non-registering sibling used for Request/Response back-pointers where a per-object WeakBlock regressed the request-clone-leak RSS test. Twelve files across src/jsc/, src/runtime/webcore/, src/runtime/server/, and src/runtime/socket/ are touched, plus a 140-line lifecycle canary test in bun-server.test.ts.
Security risks
None in the classic sense (no auth/crypto/parsing of untrusted input). The risk surface is memory safety: a mistake in the new primitive or in the RawJsRef carve-out audit is a use-after-free reachable from JS. The RawJsRef contract is enforced only by documentation, and one deferred reader (FetchTasklet::on_body_received) already violated it and was fixed mid-review — that is exactly the kind of gap a human should confirm is now closed for the remaining js_ref() callers in Body.rs.
Level of scrutiny
High. This is a load-bearing GC primitive used by every long-lived native holder (server, sockets, timers, SQL clients, watchers, terminal). The change is conceptually simple, but the blast radius is the whole runtime, the failure mode is UAF/type-confusion on release builds, and the PR itself introduces a two-tier design (JsRef vs RawJsRef) whose safety depends on a per-caller audit rather than the type system.
Other factors
Every earlier review finding (the on_body_received read and write halves, the no-op PhantomData const, the stale try_swap comment) was addressed with a targeted commit and is verified fixed at tip. The new test is a scheduling-dependent canary (three subprocess runs, 120s budget, requires at least one child to reach wrapper death) rather than a deterministic reproduction — the PR body explains why deterministic reproduction is not possible without instrumenting src/, but a maintainer should decide whether that test's cost/flake profile is acceptable. The FFI signature change to Bun__WeakRef__new and the removal of the VM& argument look correct (the constructor never used it), but ABI changes to exported symbols warrant a human glance.
|
Closing: superseded by #39334 (merged as 07d38c1), which fixes the same reap-to-sweep window at the primitive by having JsRef::try_get() and upgrade() consult HeapCell::isPendingDestruction() via JSValue::is_live_cell(). That gives the same reap-time answer a registered JSC::Weak does with no per-holder allocation, so it also makes the RawJsRef carve-out here unnecessary (Request/Response keep a plain JsRef::Weak and get the liveness check for free, which covers the FetchTasklet::on_body_received path this review surfaced). One finding from this review still applies to main's design: a reaped-but-unswept Weak reads as try_get() == None while is_finalized() is still false, so the create-if-missing path in socket get_this_value() can mint a second wrapper over the same native and double-run its finalizer. Opening a small follow-up with that guard plus the Bun.serve lifecycle canary and the dead PhantomData const from this branch. |
What
JsRef::Weakwas a bareJSValuecopy with no GC registration.try_get()could only check for empty/undefined/null, so between the collection that determines a wrapper dead and the sweep that runs its destructor (which is what flips the ref toFinalized), every deferred reader got a dangling value back and called callbacks out of a dead cell's slots.Four recent use-after-free fixes hit exactly this shape through different holders: fs.watchFile (#36926), UDP socket unref (#36930), Bun.serve graceful stop (#35130, open), and Bun.Terminal PTY EOF (#36962, open). Those are the per-site functional fixes; this PR fixes the primitive so the failure mode cannot be memory-unsafe anywhere, including the holders nobody has fuzzed yet.
Crash signatures of this class on debug/ASAN builds:
and on release builds, type confusion where an unrelated closure runs as the callback once the cell is reused.
Fix
JsRef::Weaknow holds a passivebun_jsc::Weak(a realJSC::Weakhandle) registered against the value's own cell:try_get()readsNonefrom the moment GC reaps the referent, before any sweep. A deferred reader skips its event instead of dispatching through a dead wrapper.downgrade()registers the weak handle while theStrongstill roots the value, so there is no unrooted window. It keeps its no-argument signature: the handle registers against the cell's ownWeakSet, no global needed.upgrade()of a reaped referent is a no-op instead of resurrecting a danglingJSValueinto aStrong.is_empty()/is_not_empty()now report liveness, not just "a value was stored".The C++
Bun::WeakRefconstructor never used itsVM&argument, so theglobalObjectparameter is dropped fromBun__WeakRef__newand fromWeak::create/Weak::create_passive(three call sites), and the constructor now tolerates non-object values instead of null-dereferencingvalue.getObject().The
JsRefenum shape and method signatures are unchanged, so the holders (server, sockets, timers, SQL, Terminal, watchers) pick the semantics up without edits.ReadableStream'sStreamSlotalready used this exact Held-Strong/real-Weak pattern; this generalizes it.One deliberate carve-out: the
js_refslot onRequest/Responseis only ever read synchronously while the wrapper is rooted by the caller, and wrapper cells are precise-allocated, so registering a weak handle there costs a 1 KBWeakBlockin the cell's ownWeakSeton every construction. The request-clone-leak RSS test caught that regression on every release lane in the first CI run. Those two slots now useRawJsRef, a non-registering sibling type whose synchronous-reader contract is documented on the type; every deferred holder keeps the registeredJsRef.Verification
bun bd testover the heavyJsRefholders:bun-server.test.ts,terminal.test.ts,fs.watchFile.test.ts,dgram.test.ts,fetch.stream.test.ts,setTimeout.test.js,request.test.ts,body.test.ts. Failures match the clean-main baseline exactly (3 bun-server tests and 3 setTimeout RSS-leak tests fail identically on unmodified main in this environment).request-clone-leak.test.tsRSS deltas match the clean-main baseline within noise after theRawJsRefcarve-out (measured side by side locally; the first push regressed the release lanes by 3-6x).bun-server.test.ts: a keep-alive connection keeps a request in flight against a gracefully stopped server while full collections run. It asserts the server answers while the downgraded wrapper is alive, refuses (503/close) once GC collects it, never serves a 200 after the first refusal, and never crashes.On fail-before provability
The dead-but-unswept wrapper state is not reachable from JS sequencing on a debug build: generated wrapper cells are PreciseAllocations, and
Heap::finalize()sweeps those at the end of the same collection cycle that reaps them (sweepInFinalize()->sweepPreciseAllocations()), before the event loop can dispatch anything. The remaining window is the gap between collector cycle-end and the mutator's finalize handshake, which is where the observed crashes live; it cannot be hit deterministically without instrumentation insrc/.Probe evidence: 20/20 runs of the straggler repro against the unfixed debug build complete as 200s, then one 503, then close, exit 0, no sanitizer output. The new test is therefore a canary for the crash signatures above rather than a deterministic reproduction; the fix removes the window by construction (the weak handle is cleared by the same reap that makes the cell dead, with no code path in between that can observe the difference).
Perf
A weak read is one FFI call into
JSC::Weak::get()instead of an inline tag check, and each downgraded holder owns one heap-allocatedBun::WeakRefnode, freed on upgrade/finalize/overwrite. fetch() already uses the same mechanism per response (WeakRefType::FetchResponse). Hot per-object paths (Request/Responseconstruction) pay nothing: their slots use the non-registeringRawJsRef.no test proof · iteration 6 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/bun-server.test.ts