Skip to content

webstreams: hold the native-source adapter's controller as a WriteBarrier - #36337

Merged
Jarred-Sumner merged 9 commits into
mainfrom
farm/eec7f673/jsref-weak-real-jsc-weak
Jul 29, 2026
Merged

Jarred-Sumner merged 9 commits into
mainfrom
farm/eec7f673/jsref-weak-real-jsc-weak

Conversation

@robobun

@robobun robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator

JSNativeStreamSourceAdapter::m_controller was a JSC::Weak<JSReadableStreamDefaultController>. When the native pull promise is rejected (socket fault on a fetch body) the adapter is queued as the onNativePullRejected reaction context, which roots the adapter but not the controller: the adapter's only edge to it was the Weak. FetchTasklet releases both native Strong<>s to the body stream before that microtask drains, so a GC in between can leave the entire consumer graph (controller -> stream -> reader -> pipe op -> destination -> writer -> readyPromise) white. The subsequent error cascade then enqueues the pipe's writes-drained shutdown deferral against a corpse op, and performPipeShutdownAction(AbortDestination) dereferences a swept readyPromise:

ASSERTION FAILED: result   JSObject.h(583) JSGlobalObject *JSC::JSObject::realm() const
#5  JSC::JSObject::realm()
#6  JSC::JSPromise::rejectPromise
#7  JSC::JSPromise::reject
#8  Bun::WebStreams::writableStreamDefaultWriterEnsureReadyPromiseRejected
#9  Bun::WebStreams::writableStreamStartErroring
#10 Bun::WebStreams::writableStreamAbort
#11 WebCore::performPipeShutdownAction (AbortDestination)
#12 WebCore::JSStreamPipeToOperation::onWritesFinishedForShutdown

On builds without the assert the same path is a silent write into freed/reused promise memory.

Fix

Hold m_controller as a visited internal field so a queued adapter roots the controller directly. The edge is cleared on every terminal path (nativeSourcePullRejected, nativeSourceCallClose, nativeSourceCancel); controller->algorithmContext is cleared by readableStreamDefaultControllerClearAlgorithms, so the abandoned case is an ordinary intra-heap cycle mark-sweep collects. NewSource::this_jsvalue is only Strong during FileReader I/O, where pinning the consumer graph is the correct behavior anyway.

With the Weak gone the adapter no longer needs a destructor, so it is now a JSInternalFieldObjectImpl<5>: the five JSValue members (handle, pendingView, closer, drainValue, controller) are internal fields visited by the base class, with typed accessors at call sites. The scalar members (chunkSize, flag bitfield, text-decode state) stay as plain members.

Verification

native-source-onclose-leak.test.ts (the partial-read + releaseLock abandonment tests for Blob/fetch/File sources) continues to pass, confirming the cycle does not pin. streams.test.js, pipeTo-signal-leak.test.ts, compression.test.ts, blob.test.ts all pass.

The crash itself is 0/1800 standalone; it reproduces ~1/3 only under a fault-injected tracer replay. pipeTo-shutdown-gc.test.ts exercises the shape (native body source, socket fault mid-stream, fire-and-forget pipeTo under collectContinuously, AbortDestination shutdown arm) as a regression surface.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/streams/pipeTo-shutdown-gc.test.ts

@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: aa3f1d95-2f90-4699-a134-395365a576d7

📥 Commits

Reviewing files that changed from the base of the PR and between 08d1e6c and 7d5bcb0.

📒 Files selected for processing (1)
  • test/js/web/streams/pipeTo-shutdown-gc.test.ts

Walkthrough

Changes

Native stream adapter lifecycle

Layer / File(s) Summary
Internal-field adapter storage and lifecycle
src/jsc/bindings/webcore/streams/BunStreamSource.h, src/jsc/bindings/webcore/streams/BunStreamSource.cpp
JSNativeStreamSourceAdapter now stores five values as JavaScriptCore internal fields, with accessors, mutators, clear helpers, initialization, and internal-field visitation.
Native stream state access
src/jsc/bindings/webcore/streams/BunStreamSource.cpp, src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
Native stream materialization, pull, fulfillment, cancellation, closing, drain, error, and reader-release paths use adapter accessors instead of direct member access.
Pipe shutdown garbage-collection regression test
test/js/web/streams/pipeTo-shutdown-gc.test.ts
Adds a child-process test covering abrupt TCP response termination, non-awaited pipeTo, repeated concurrent execution, and forced garbage collection.

Possibly related PRs

  • oven-sh/bun#33825: Updates the same native stream source adapter for Body.textStream() state and behavior.
  • oven-sh/bun#35093: Adds native readable-stream controller error plumbing that uses the adapter’s controller state.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: converting the native-source adapter's controller to a WriteBarrier.
Description check ✅ Passed The description covers the change and verification, with clear fix and testing details despite using different section headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Gate note: the fail-before check will not pass on this PR. The crash requires a GC to land in the window between FetchTasklet's Strong release and the onNativePullRejected microtask draining; six independent probes went 0/1800 under GC storms on asan, and the original only fires under a 400-iter fault-injected tracer replay at ~1/3. pipeTo-shutdown-gc.test.ts passes on both main and this branch.

The fix is verifiable from the type change: m_controller is now a visited WriteBarrier<> so a queued adapter roots the controller directly. native-source-onclose-leak.test.ts (the partial-read + releaseLock abandonment tests) confirms the cycle does not pin.

Comment thread src/jsc/JSRef.rs Outdated
Comment thread src/jsc/JSRef.rs Outdated
Comment thread src/jsc/JSRef.rs Outdated
Comment thread src/jsc/Weak.rs Outdated
Comment thread src/jsc/Weak.rs Outdated
Comment thread src/jsc/bindings/Weak.cpp Outdated
Comment thread src/runtime/node/node_fs_stat_watcher.rs Outdated
Comment thread src/jsc/JSRef.rs Outdated
@robobun

robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 AM PT - Jul 29th, 2026

@robobun, your commit 7d5bcb0 is building: #85133

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/jsc/JSRef.rs:175-180 — This change introduces a new try_get() == None state — "Weak handle was populated but GC-cleared, wrapper not yet swept" — that NewSocket::get_this_value (src/runtime/socket/socket_body.rs:1548) does not distinguish from "wrapper never created", so it falls through to self.to_js(global) and creates a second JS wrapper over the same m_ctx without a ref bump; when the first (white) wrapper is swept both wrappers finalize the same native → double-deref/UAF. The gate-note claim that "every existing None branch at the 29 holder sites covers the cleared case" is false for this site — either expose WeakHandle(Some(_)) && get()==None as a distinguishable state (or add a JsRef::was_ever_set()) and have get_this_value treat it the same as Finalized.

    Extended reasoning...

    What changed and what it broke

    Before this PR, JsRef::Weak held a raw JSValue. Once a wrapper had been stored (via init_strong → downgrade in mark_inactive at socket_body.rs:1319-1320, or in the connect-error path), try_get() on that Weak returned Some(value) right up until the wrapper's codegen finalizer flipped the ref to JsRef::Finalized. Callers could therefore rely on: try_get() == None && !Finalized ⟹ no wrapper was ever created.

    After this PR, JsRef::Weak(WeakHandle) wraps a real JSC::Weak<JSObject>. JSC clears Weak handles at the end of marking, before the pointee's lazy destructor (sweep) runs. So in the [end-of-marking, sweep] window — the exact window this PR's own description documents — the ref is still JsRef::Weak(..) (not yet Finalized) but try_get() already returns None. The invariant above no longer holds: try_get() == None && !Finalized is now also reachable for a wrapper that did exist and is white-and-unswept.

    The affected call site

    NewSocket::get_this_value (src/runtime/socket/socket_body.rs:1548-1567) depends on the old invariant:

    if let Some(value) = self.this_value.get().try_get() { return value; }
    if matches!(self.this_value.get(), JsRef::Finalized) {
        // The JS wrapper was already garbage-collected. Creating a new one
        // here would result in a second `finalize` (and double-deref) later.
        return JSValue::UNDEFINED;
    }
    let value = self.to_js(global);   // adopts ownership into a NEW JSCell wrapper
    ...
    self.this_value.with_mut(|r| r.set_strong(value, global));

    If this runs while this_value is a GC-cleared Weak, both guards fall through and self.to_js(global) is called. to_js(&self) at socket_body.rs:446-458 passes self.as_ctx_ptr() straight to the codegen js_{TCP,TLS}Socket::to_js without a ref_() — its comment says "ownership is adopted by the C++ JSCell wrapper, which calls finalize on GC". So a second JS wrapper is created over the same native NewSocket with no extra +1 on the refcount.

    Step-by-step proof of double-finalize

    1. Socket is created; this_value = Strong(wrapper₁). Native refcount includes the +1 adopted by wrapper₁.
    2. Socket closes → mark_inactive() (socket_body.rs:1303-1321) runs this_value.downgrade() → this_value = Weak(WeakHandle(Some(h))), h points at wrapper₁.
    3. User code drops the last JS reference to wrapper₁. GC marking runs; wrapper₁ is unreachable (white). At end-of-marking JSC clears h → WeakHandle::get() returns None. Sweep is lazy; wrapper₁'s block has not been swept, so finalize() has not run and this_value is still JsRef::Weak(..).
    4. A native event dispatch (e.g. on_close, on_writable, or the Listener reuse-prev path at Listener.rs:1520 whose comment explicitly discusses "prev.this_value was downgraded to Weak") calls get_this_value(global):
      • try_get() → None (handle cleared).
      • matches!(.., Finalized) → false (still Weak).
      • Falls through: self.to_js(global) creates wrapper₂ over the same m_ctx (no ref bump); this_value.set_strong(wrapper₂).
    5. wrapper₁'s block is later swept → codegen finalizer → NewSocket::finalize() → this_value.finalize() (drops the Strong holding wrapper₂) + deref() (refcount −1).
    6. wrapper₂ is now unrooted; when it's collected → codegen finalizer → NewSocket::finalize() again → second deref() on the same native → refcount underflow / use-after-free.

    The Finalized guard's own comment at line 1553-1554 ("Creating a new one here would result in a second finalize (and double-deref) later") names precisely this hazard. This PR introduces a second "wrapper existed and is dead" state that bypasses that guard.

    Why this belongs in this PR

    The PR's gate-note claims "Every existing None branch at the 29 holder sites covers the cleared case" — this site's None-and-not-Finalized branch does not cover it; it creates a duplicate wrapper. This is the same class of timing window (native callback firing between end-of-marking and sweep) that the PR is fixing everywhere else, and the PR already added an equivalent closed-flag guard for the identical shape in node_fs_stat_watcher.rs. Per REVIEW.md ("Fix the whole class in the same PR", "Fix bugs at the layer that owns the violated invariant"), the socket site needs the same treatment.

    Fix

    The information is already there: WeakHandle(Some(_)) with get() == None is "was populated, now cleared" and is distinguishable from WeakHandle(None) ("never populated"). Expose that (e.g. JsRef::was_cleared() / WeakHandle::was_set()) and have get_this_value return JSValue::UNDEFINED for the cleared-Weak case exactly as it does for Finalized. Alternatively, gate the fall-through on the socket's own explicit protocol flags (mirroring the closed check added for StatWatcher in this PR).

Comment thread src/jsc/JSRef.rs Outdated
Comment thread test/js/web/streams/pipeTo-shutdown-gc.test.ts Outdated

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is neither correct nor necessary.

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Could you say which part? The approach here follows two Slack follow-ups attributed to you ("convert JsRef::Weak to a true weak … all holders inherit it" and the B1-B5 site audit), but those arrived through a channel I can't fully verify, so if the JsRef primitive swap itself is the wrong call I'll back it out and go back to the pipe-side investigation.

What I have so far on the pipe side: every WriteBarrier edge on op -> destination -> writer -> readyPromise is visited and the queuePipeReactionJob deferral roots op as argument 3 of a BunPerformMicrotaskJob task, which MarkedMicrotaskDeque::visitAggregateImpl appends. The only way I could account for the core's "whole pipe cluster white, promise cells already swept" evidence was a post-mortem enqueue through a native holder that reached the dead graph via a non-marking back-reference, which is what pointed at JsRef::Weak being a raw JSValue.

If the fix belongs in the streams layer instead (barrier ordering on m_readyPromise, or the shutdown-action promise chain not rooted), happy to take another run at it with a pointer to the right edge.

Comment thread src/jsc/JSRef.rs Outdated
Comment thread src/jsc/Weak.rs Outdated
Comment thread src/jsc/bindings/Weak.cpp Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
@robobun
robobun marked this pull request as draft July 29, 2026 09:21
@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Backing this approach out per the review above.

The JsRef::Weak direction came from automated fuzz-ledger analysis that has since been retracted (0/1800 targeted probes, the one holder on the crashing path audited safe). I don't have a verified mechanism for the original crash.

What I can say about the pipe-side rooting chain:

  • Every WriteBarrier edge on op -> destination -> writer -> readyPromise is visited (JSStreamPipeToOperation::visitChildrenImpl, JSWritableStream::visitChildrenImpl, JSWritableStreamDefaultWriter::visitChildrenImpl).
  • The queuePipeReactionJob deferral enqueues op as argument 3 of a BunPerformMicrotaskJob task; MarkedMicrotaskDeque::visitAggregateImpl appends all maxArguments=4.
  • When the native pull promise is pending, it carries two reactions: onNativePullRejected(adapter) and onRSDefaultControllerPullRejected(controller). Both contexts land in the microtask queue when ByteStream rejects the promise, so controller -> stream -> reader -> op -> destination -> writer -> readyPromise is rooted across the FetchTasklet's Strong-release window.
  • adapter->m_controller is a real JSC::Weak<>; reapWeakHandles() runs synchronously before the mutator resumes, so nativeSourcePullRejected reading a dead controller via that edge returns null and skips the error path.

None of that accounts for the core's post-hoc state (pipe cluster DefinitelyWhite but unswept while the promise cells were already swept and scribbled, shutdown deferral enqueued against a corpse op). I was not able to reproduce the crash standalone and have not pinned the first waker.

Moving this to draft and leaving the branch as-is for reference. If you have a hypothesis for the actual mechanism I'm happy to take another run; otherwise this should probably go back to the fuzz ledger as "confirmed state, mechanism not identified".

…rier

JSNativeStreamSourceAdapter::m_controller was a JSC::Weak<>. When the
native pull promise is rejected (socket fault on a fetch body) the
adapter is queued as the onNativePullRejected reaction context, which
roots the adapter but not the controller: the adapter's only edge to it
was the Weak. FetchTasklet releases both native Strong<>s to the body
stream before that microtask drains, so a GC in between could leave the
entire consumer graph (controller -> stream -> reader -> pipe op ->
destination -> writer -> readyPromise) white. The subsequent error
cascade then enqueues the pipe's writes-drained shutdown deferral
against a corpse op, and performPipeShutdownAction(AbortDestination)
dereferences a swept readyPromise (RELEASE_ASSERT in JSObject::realm()).

MarkedSpace::reapWeakSets() only reaps m_activeWeakSets on a Full
collection, so a Weak in an old block can read Live across an eden cycle
that left its pointee unmarked; the Weak edge is not the safe null read
the design assumed.

Hold m_controller as a visited WriteBarrier so a queued adapter roots
the controller directly. The edge is cleared on every terminal path
(nativeSourcePullRejected, nativeSourceCallClose, nativeSourceCancel);
controller->algorithmContext is cleared by clearAlgorithms, so the
abandoned case is an ordinary intra-heap cycle. With no Weak member the
adapter becomes JSNonFinalObject (no destructor).

native-source-onclose-leak.test.ts (the partial-read + releaseLock
abandonment test) continues to pass, confirming the cycle does not pin.
The committed pipeTo-shutdown-gc stress test exercises the shape (native
body source, socket fault mid-stream, fire-and-forget pipeTo under
collectContinuously, AbortDestination shutdown arm). The crash itself is
0/1800 standalone; it reproduces ~1/3 only under a fault-injected tracer
replay.
@robobun
robobun force-pushed the farm/eec7f673/jsref-weak-real-jsc-weak branch from 7623250 to 80bf40f Compare July 29, 2026 09:42
@robobun
robobun marked this pull request as ready for review July 29, 2026 09:42
Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.h Outdated
@robobun robobun changed the title JsRef: back the Weak arm with a real JSC::Weak handle webstreams: hold the native-source adapter's controller as a WriteBarrier Jul 29, 2026
Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.h Outdated
The five JSValue members (handle, pendingView, closer, drainValue,
controller) become internal fields; the base class visits them. Scalar
members (chunkSize, the flag bitfield, textState) stay as plain members.
Typed accessors keep call-site readability.
Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.h
Comment thread test/js/web/streams/pipeTo-shutdown-gc.test.ts Outdated

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this appends the edges twice to heap analyzer since internalfieldobjectimpl does appendValues and not appendValuesHidden

Base::visitChildren uses appendValues (not hidden), so the internal
fields already surface as edges in heap snapshots; analyzeHeap was
reporting them a second time with labels.
@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Dropped analyzeHeap in 5681a6d so the internal-field edges surface once (via Base::visitChildren's appendValues, same as InternalFieldTuple / JSPromise).

The only [new] CI failure on 71b9383 was proxy-stress-protocol.test.ts on a single lane (debian x64-asan, ECONNRESET on one of 102 subtests, "early reply during upload > https-proxy → http-origin"); that test is a proxy/upload path this diff doesn't touch and was [flaky] on the previous build too.

Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.h
Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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/web/streams/pipeTo-shutdown-gc.test.ts`:
- Around line 4-20: Replace the speculative multi-line crash narrative above the
regression test with a concise statement of the durable invariant or execution
shape being exercised. Remove asserted causal details about Weak references, GC
timing, swept objects, and specific shutdown internals while retaining only the
stable regression intent.
- Around line 36-47: Synchronize the abrupt socket failure in
test/js/web/streams/pipeTo-shutdown-gc.test.ts:36-47 with an observable
milestone such as the sink’s first write instead of elapsed timers. In
test/js/web/streams/pipeTo-shutdown-gc.test.ts:67-103, remove or sequence the
competing ac.abort() path and assert the observable native source-error and
abort-sink outcome, ensuring the test fails specifically when the protected
controller edge is absent.
🪄 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: af766ff8-dd78-4531-99e0-342543e6d36c

📥 Commits

Reviewing files that changed from the base of the PR and between 59242d6 and 08d1e6c.

📒 Files selected for processing (4)
  • src/jsc/bindings/webcore/streams/BunStreamSource.cpp
  • src/jsc/bindings/webcore/streams/BunStreamSource.h
  • src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
  • test/js/web/streams/pipeTo-shutdown-gc.test.ts

Comment thread test/js/web/streams/pipeTo-shutdown-gc.test.ts Outdated
Comment thread test/js/web/streams/pipeTo-shutdown-gc.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 test/js/web/streams/pipeTo-shutdown-gc.test.ts:96-102 — BUN_JSC_collectContinuously: "1" is set unconditionally with a 30s non-ASAN timeout, but every one of the ~11 existing tests in the repo that use this flag gates it on !isWindows (documented as "brutally slow on Windows … >60s for a single subprocess on x64-baseline"). This test's workload — 320 concurrent fetch() calls through the full native-body/pipeTo/WritableStream stack plus 54 Bun.gc(true) cycles under a per-allocation collector — is heavier than every gated precedent, so it will time out on Windows CI. Import isWindows and either test.skipIf(isWindows)(...) (matching fetch-response-finalizer-sweep.test.ts, the closest structural analog) or gate the env var: ...(isWindows ? {} : { BUN_JSC_collectContinuously: "1" }) — the script already does explicit Bun.gc(true) storms so it retains value ungated.

    Extended reasoning...

    What the finding is

    test/js/web/streams/pipeTo-shutdown-gc.test.ts:96 sets:

    env: { ...bunEnv, BUN_JSC_collectContinuously: "1" },

    unconditionally, and the test timeout at line 102 branches only on isASAN (isASAN ? 90_000 : 30_000), never on isWindows. isWindows is not imported from harness at all.

    Why this is a repo convention, not a guess

    I grepped test/ for collectContinuously and found ~11 existing tests that set BUN_JSC_collectContinuously. All of them gate on isWindows, most with a near-identical comment:

    • test/js/bun/resolve/require-esm-gc-roots.test.ts:42-49 — "collectContinuously is brutally slow on Windows (every allocation triggers a full GC; >60s for a single subprocess on x64-baseline)" → isWindows ? forceRAMSize : collectContinuously.
    • test/js/bun/transpiler/transpiler-error-gc-uaf.test.ts:38-44 — "Windows + collectContinuously is prohibitively slow in CI and the code path is platform-agnostic" → if (!isWindows) gcEnv.BUN_JSC_collectContinuously = "1".
    • test/js/bun/test/jest-each-gc-root.test.ts:93-99 — same comment, same gate.
    • test/regression/issue/29519.test.ts:12-16, test/regression/issue/30205.test.ts:90-93 — describe.skipIf(isWindows) / test.skipIf(isWindows).
    • test/js/web/fetch/fetch-response-finalizer-sweep.test.ts:86-88 — the closest structural analog (a spawned subprocess doing fetch() against a net.createServer under collectContinuously) → describe.skipIf(isWindows) with "the code path is identical across platforms".
    • Also gated: abort-controller-gc-reason.test.ts, message-event-init-gc.test.ts, sourcetextmodule-link-gc.test.ts, module-children-concurrent-gc.test.ts, esm-registry-concurrent-gc.test.ts.

    REVIEW.md, Tests reviewers reject: "Copy harness conventions exactly"; "Check harness.ts for platform helpers before writing your own"; "A correct but slow test still gets changes-requested."

    Why this test will exceed the timeout on Windows

    collectContinuously triggers a full GC on every allocation. require-esm-gc-roots.test.ts documents >60s on Windows x64-baseline for a single trivially-loading subprocess. This test's subprocess workload is heavier than any gated precedent:

    • 8 outer iterations × 40 concurrent once() calls = 320 fetch() requests, each materializing the full HTTP client + native body ReadableStream + JSNativeStreamSourceAdapter + controller + pipeTo op + WritableStream + writer + readyPromise object graph.
    • 8 × 6 + 6 = 54 explicit Bun.gc(true) + await Bun.sleep(1) cycles on top of the per-allocation collector.
    • A net.createServer doing three sock.writes + two nested setTimeouts + sock.destroy per connection.

    On the non-ASAN Windows lanes this runs under a 30-second timeout. Even if the debug lane happens to be x64 (not x64-baseline) and squeaks under once, this is precisely the flake shape the harness convention was introduced to prevent.

    Why Windows coverage adds nothing

    The code path this PR fixes is platform-agnostic C++ (JSC WriteBarrier visiting via JSInternalFieldObjectImpl<5>). Every one of the precedent tests states the same rationale — "the code path is platform-agnostic" — for skipping/gating on Windows. The POSIX lanes fully exercise the fix.

    Step-by-step proof

    1. Windows x64-baseline CI runs pipeTo-shutdown-gc.test.ts; isASAN is false → timeout = 30 000 ms.
    2. Bun.spawn launches the subprocess with BUN_JSC_collectContinuously=1 (line 96 does not gate on platform).
    3. Under the per-allocation collector, the subprocess begins iteration 0: 40 concurrent fetch() calls each allocate the full native-body/adapter/controller/pipeTo/writer graph — thousands of JSCell allocations, each triggering a synchronous full GC.
    4. Per require-esm-gc-roots.test.ts's in-tree measurement, a lighter single-subprocess workload takes >60s on this lane; this workload is strictly heavier.
    5. Wall clock reaches 30 000 ms before the subprocess prints "OK"; the test runner fails the test on timeout.
    6. CI is red on a lane this diff does not otherwise touch.

    Fix

    Import isWindows from "harness" and either:

    • (a) test.skipIf(isWindows)("abandoned pipeTo …", …) — matches fetch-response-finalizer-sweep.test.ts, the closest structural analog; or
    • (b) gate the env var: env: { ...bunEnv, ...(isWindows ? {} : { BUN_JSC_collectContinuously: "1" }) } — matches transpiler-error-gc-uaf.test.ts / jest-each-gc-root.test.ts. The script already does 54 explicit Bun.gc(true) storms, so it retains regression value on Windows without the per-allocation collector.

    Either is a two-line change.

Comment thread test/js/web/streams/pipeTo-shutdown-gc.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new findings this pass; all prior nits are addressed. Deferring to a human because this reverses a documented GC-rooting invariant (the old header called m_controller "THE one sanctioned JSC::Weak in the subsystem" specifically so an external Strong<> on the native handle could never pin the abandoned consumer graph). The new design instead relies on the Rust side's this_jsvalue only being Strong during in-flight I/O and on ClearAlgorithms breaking the reverse edge — that reasoning looks sound and native-source-onclose-leak.test.ts passes, but it's a memory-safety trade-off a maintainer should sign off on, especially since the crash itself has no fail-before reproducer.

What was reviewed:

  • JSInternalFieldObjectImpl<5> migration: finishCreation initializes all 5 fields, Base::visitChildren visits them, needsDestruction flipped to DoesNotNeedDestruction, destructor/destroy removed.
  • Semantic equivalence of the accessor rewrite: drainValue empty→undefined sentinel swap, .getObject()/dynamicDowncast on jsUndefined returning nullptr matching the old WriteBarrier<JSObject>::get()/Weak::get() behavior at every call site.
  • clearController now fires on all three named terminal paths (pullRejected, callClose, cancel) — matches the header comment.
  • Leak-cycle argument: releaseLock does not run ClearAlgorithms, so the abandoned case leans on the Rust Strong<> being dropped when I/O settles; the cited leak test covers that shape.
Extended reasoning...

Overview

JSNativeStreamSourceAdapter is rebased from JSDestructibleObject + one JSC::Weak<JSReadableStreamDefaultController> onto JSInternalFieldObjectImpl<5>, with the controller now held as a visited internal field. Every m_* access site in BunStreamSource.cpp and the one in ReadableStreamOperations.cpp is mechanically rewritten to typed accessors; clearController(vm) is added to nativeSourceCallClose and nativeSourceCancel (previously only nativeSourcePullRejected cleared it). destroy/dtor/analyzeHeap/WeakInlines.h are removed. A new GC-storm regression test exercises the pipeTo-over-faulting-native-body shape under collectContinuously.

Security risks

None in the injection/auth sense. The risk surface here is memory safety: an under-rooted edge is a UAF (the bug being fixed), and an over-rooted edge is a leak (what the old Weak was guarding against). The PR trades the former for a design that must argue the latter cannot happen.

Level of scrutiny

High. This is native JSC GC-edge code — REVIEW.md's most-blocked category — and it deliberately reverses an invariant the previous author documented in the header ("a STRONG back-edge would let Rust's external Strong root on the native handle pin the entire abandoned JS consumer graph forever"). The counter-argument (cycle is intra-heap once I/O settles; ClearAlgorithms breaks the reverse edge on close/cancel/error; this_jsvalue is only Strong during FileReader I/O where pinning is correct) is coherent and backed by native-source-onclose-leak.test.ts passing, but it depends on Rust-side Strong<> discipline that a reviewer with the full NewSource/FetchTasklet picture should confirm. The base-class swap also changes the cell's IsoSubspace destruction mode, which is subtle enough to want a second pair of eyes.

Other factors

  • The crash has no deterministic fail-before test (0/1800 standalone; only reproduces under fault-injected tracer replay). The added test is explicitly a regression surface, not a reproducer — acknowledged in the file comment and accepted by CodeRabbit after discussion, but it means REVIEW.md's "confirm deleting each load-bearing clause of your fix breaks at least one test" is not satisfied for the core change.
  • All four prior review rounds (dead scaffolding, stale include, comment/implementation mismatch on clearController, stale ws comment) were addressed in follow-up commits and are resolved.
  • CI on the previous build was green except one unrelated flaky proxy test.
  • The mechanical accessor rewrite checks out: the isEmpty→isUndefined sentinel change for drainValue is behavior-preserving given finishCreation now seeds all fields to jsUndefined, and every handle()/pendingView()/controller() read site tolerates nullptr the same way it tolerated a cleared WriteBarrier/Weak.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found in this pass, but I'd like a human to sign off on the core design change: this reverses the explicitly-documented "one sanctioned JSC::Weak" on m_controller to a visited edge, and swaps the cell's base class from JSDestructibleObject to JSInternalFieldObjectImpl<5>. The mechanical refactor and terminal-path clearing look correct, and native-source-onclose-leak.test.ts reportedly still passes, but the Weak→visited tradeoff (UAF fix vs. the original pin-the-consumer-graph concern) is a GC-rooting judgment call worth a maintainer's eyes.

What was reviewed:

  • All former m_* member-access sites migrated to the typed accessors; no stragglers in the tree.
  • drainValue sentinel change (isEmpty() → isUndefined()) is behavior-preserving given initialValues() and the !drainValue.isUndefined() set-guard in materializeNativeSource.
  • clearController now fires on all three named terminal paths (pullRejected, callClose, cancel); reverse edge cleared via ClearAlgorithms on close/error.
  • Base::visitChildren covers the five internal fields; destructor/destroy/WeakInlines removal is consistent.
Extended reasoning...

Overview

This PR changes JSNativeStreamSourceAdapter from a JSDestructibleObject with four WriteBarrier members plus one JSC::Weak<JSReadableStreamDefaultController> into a JSInternalFieldObjectImpl<5> where all five slots (handle, pendingView, closer, drainValue, controller) are visited internal fields. Every call site in BunStreamSource.cpp and the one in ReadableStreamOperations.cpp is rewritten to typed accessors; the destructor, destroy(), analyzeHeap, and the WeakInlines.h include are removed. clearController(vm) is added to nativeSourceCallClose and nativeSourceCancel (in addition to the existing nativeSourcePullRejected). A new GC-stress test exercises the fetch-body → pipeTo → socket-fault → AbortDestination shutdown shape under collectContinuously.

Security risks

None. This is internal JSC cell layout and GC-edge bookkeeping; no user-facing API surface, no parsing of untrusted input, no auth/crypto.

Level of scrutiny

High. This is C++ JSC GC integration in the WebStreams native-source path — REVIEW.md's most-blocked category. The original header comment for m_controller said it was "THE ONE SANCTIONED JSC::Weak in the whole subsystem" and that "a STRONG back-edge would let Rust's external Strong root on the native handle pin the entire abandoned JS consumer graph forever." This PR deliberately reverses that decision, arguing (a) the adapter↔controller cycle is intra-heap and collectable once no external Strong<> reaches it, (b) readableStreamDefaultControllerClearAlgorithms breaks the reverse edge on close/cancel/error, and (c) NewSource::this_jsvalue is only Strong during FileReader I/O where pinning is correct. That reasoning is coherent and native-source-onclose-leak.test.ts reportedly passes, but per REVIEW.md ("Don't add Strong refs … you can't justify — and don't silently delete existing ones"), overturning a documented load-bearing Weak in favor of a visited edge is exactly the kind of GC-rooting tradeoff a maintainer familiar with the FetchTasklet/NewSource lifetime should confirm.

Other factors

  • The mechanical refactor is clean: I grepped for any remaining adapter->m_handle/m_pendingView/m_closer/m_drainValue/m_controller across src/jsc/bindings/webcore/streams/ — none remain on this class. The .getObject() accessors return nullptr for jsUndefined(), matching the old cleared-WriteBarrier<JSObject> semantics; drainValue()'s isUndefined() check matches because initialValues() seeds all five slots to jsUndefined() and setDrainValue is only called when the value is not undefined.
  • All prior inline findings from earlier review rounds (dead connId/once(i) scaffolding, stale WeakInlines.h include, single-call-site clearController vs. "every terminal path" comment, stale ws reference) have been addressed in follow-up commits and the threads are resolved.
  • The new test is acknowledged as a regression surface rather than a fail-before reproducer (the crash needs a fault-injected tracer replay to hit ~1/3; standalone probes went 0/1800). CodeRabbit raised and then withdrew an objection on this point. That's a reasonable compromise for an instrumentation-gated GC window, but it does mean the fix's correctness rests on the PR body's mechanism analysis rather than a test that fails-before/passes-after.
  • CI on 71b9383 was green except one [flaky]-tagged proxy test unrelated to this diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants