Skip to content

streams: eagerly clear ReadableStream source WriteBarriers once terminal - #36666

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/41ed3e5b/streams-clear-terminal-barriers
Aug 1, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/41ed3e5b/streams-clear-terminal-barriers

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Once a ReadableStream reaches a state where no more user source callbacks (pull / cancel / close) will ever run, several WriteBarriers on the stream and its controller are dead weight. They keep whatever they point at (notably the construction-time AsyncLocalStorage snapshot and, for type: "direct", the user's underlyingSource object with its closures) alive for as long as the stream itself is reachable.

What gets cleared

JSReadableStream (via new readableStreamClearSourceBarriers):

  • m_asyncContext: the ALS snapshot restored around pull/cancel. StreamAsyncContextScope is only entered from the controller's algorithm dispatch, so once those algorithms are cleared this slot is never read again.
  • m_directUnderlyingSource: only consulted before materialization (setUpDirectStreamController, readDirectStream, consumeDirectStreamToArrayBuffer). On cancel/error it is dead.

JSDirectStreamController (via new directStreamControllerClearSource):

  • m_underlyingSource, m_pull, m_deferCloseReason: once m_closed is set (or the stream has left Readable), onPull's state check guarantees callDirectPull and callUnderlyingSourceClose never run again.

Where it is called

  • readableStreamDefaultControllerClearAlgorithms / readableByteStreamControllerClearAlgorithms: the spec's existing "no more callbacks" point for default and byte controllers. Covers controller.close(), controller.error(), and cancelSteps.
  • readableStreamError: covers ControllerKind::None / ControllerKind::NativeSink streams errored via webStreamControllerError.
  • Tail of readableStreamCancel: covers cancel of None / Direct / NativeSink streams, where no ClearAlgorithms runs. For the Direct arm, also clears the direct controller's own slots (its onClose early-returns because the stream is already Closed).
  • JSDirectStreamController::onClose / handleError: right after callUnderlyingSourceClose, the last user callback.

All call sites are after the last read of the slot and the helper is idempotent.

Verification

test/js/web/streams/readable-stream-terminal-barrier-release.test.ts spawns a child per case that creates 200 streams under an als.run({ probe }) (or, for the direct-pending case, registers the underlyingSource itself), retains the stream objects, drives them to a terminal state, then GC-storms and counts live probes via a FinalizationRegistry.

Before this change every case reported alive == 200. With it, probes are collected.

USE_SYSTEM_BUN=1 bun test <file>    0 pass / 7 fail (Received: 200)
bun bd test <file>                  7 pass / 0 fail

WPT streams suite (1175 tests) and the existing stream/ALS tests still pass; a sanity script confirms als.getStore() inside pull and cancel still sees the captured store.

Once a ReadableStream reaches a state where no more user source callbacks
(pull/cancel/close) can run, the WriteBarriers that exist only to feed those
callbacks are dead weight that keeps whatever they point at alive for as long
as the stream object itself is reachable.

Clear them at the same points the spec controllers already clear their
algorithm slots, and at the equivalent terminal points for Bun's direct and
native controllers:

  JSReadableStream
    m_asyncContext            snapshotted ALS context restored around
                              pull/cancel; useless once those cannot run
    m_directUnderlyingSource  held only until materialize or cancel

  JSDirectStreamController
    m_underlyingSource / m_pull / m_deferCloseReason
                              drop once m_closed is set or the stream
                              left Readable

New tests hold the stream (and controller) across GC and watch a
FinalizationRegistry for a probe reachable only through the cleared slot;
before this change all 200 probes survived, after it they are collected.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 14 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: 4ab55afc-dccb-4759-be14-2ec3f702cd7e

📥 Commits

Reviewing files that changed from the base of the PR and between 65c47c8 and ada0343.

📒 Files selected for processing (6)
  • src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp
  • src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp
  • src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
  • src/jsc/bindings/webcore/streams/WebStreamsInternals.h
  • test/js/web/streams/readable-stream-terminal-barrier-release.test.ts

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

@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:05 AM PT - Aug 1st, 2026

@robobun, your commit ada0343 is building: #86871

@github-actions github-actions Bot added the claude label Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. Native RSS grows linearly under sustained AWS SDK v3 Kinesis GetRecords; identical Node 22 workload is flat #30415 - RSS grows linearly (~1.3 GB/hr) under sustained AWS SDK v3 Kinesis GetRecords using fetch-based streams; clearing WriteBarrier slots on completed fetch response streams would release retained objects
  2. Memory Leak When Streaming Responses via fetch in Bun ≥ 1.1.27 #18488 - Memory leak when streaming/piping fetch responses in Hono.js app; completed streams retain source callbacks and async context via WriteBarrier slots
  3. Likely memoryleak inside bun runtime on service http requests #14065 - Long-running HTTP server shows RSS growth while JS heap stays flat across millions of requests; stream WriteBarrier retention on completed request/response cycles is a plausible contributor
  4. Memory leak if accessing body of fetch() response before consuming the stream #13678 - Memory leak when accessing response.body before consuming the stream; ReadableStream WriteBarrier slots retain underlying source after terminal state

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #30415
Fixes #18488
Fixes #14065
Fixes #13678

🤖 Generated with Claude Code

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun can we also clear the native m_directUnderlyingSource after materialization or will that break $bunNativePtr

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

m_directUnderlyingSource and m_nativePtr ($bunNativePtr) are separate slots, so clearing one does not touch the other.

For m_directUnderlyingSource: it is already cleared at every direct-materialize entry point today (setUpDirectStreamController, readDirectStream, consumeDirectStreamToArrayBuffer each .clear() it once they have copied the pointer). What this PR adds is clearing it on the terminal-state paths (cancel / controller error) for a DirectPending stream that never got that far.

If the question is really about m_nativePtr after materializeNativeSource: that slot is still read post-materialize, so clearing it there would break things:

  • the $bunNativePtr DOMAttribute getter (nativePtrForJS()) which native-readable.ts, tty.ts, webstreams_adapters.ts and ProcessObjectInternals.ts read off the WHATWG stream
  • ReadableStreamTag__tagged, which Rust calls to classify the source as Blob/File/Bytes
  • the reader-release path in readableStreamReaderGenericRelease that gates handle.updateRef(false) on stream->m_nativePtr && kind == Native

The adapter already holds its own reference to the handle (adapter->setHandle in materializeNativeSource), so dropping the stream's copy would not let the handle itself die while the controller is alive anyway. Left it alone in this PR; happy to look at routing those three readers through the adapter instead as a follow-up if that is what you want.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun if we can route it through the adapter when needed that would be better. The fewer references to these JSCell objects the better.

@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.

Beyond the inline nits, I also traced the two other readers of m_asyncContext after a clear — StreamAsyncContextScope (WebStreamsMisc.cpp:358) and wrapWithAsyncContext (BunStreamSource.cpp:263) both early-return on an empty slot, and readableStreamCancel's Default/Byte arms run the user cancel under StreamAsyncContextScope before ClearAlgorithms clears it, so ALS is still visible in cancel().

Extended reasoning...

This PR eagerly clears WriteBarrier slots on JSReadableStream and JSDirectStreamController once the stream is provably terminal. The change is small in line count but the correctness argument spans every controller kind's state machine, and one inline finding documents a real (if arguably-improving) observable behavior change in the cancel-then-async-pull-rejects race. That plus the GC-lifetime sensitivity puts it outside auto-approval; deferring to a human.

Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
@Jarred-Sumner
Jarred-Sumner merged commit a7838c5 into main Aug 1, 2026
41 of 48 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/41ed3e5b/streams-clear-terminal-barriers branch August 1, 2026 07:58
robobun added a commit that referenced this pull request Aug 1, 2026
…st consolidation

Clearing m_nativePtr (at materialize or at ClearAlgorithms) breaks
PendingValue::size_hint(): Bun.inspect(response) re-derives *mut ByteStream
via ReadableStreamTag__tagged to read bytes.size_hint, and FetchTasklet's
readable_stream_ref path only updates the ByteStream's size_hint (not the
PendingValue fallback field). With the slot cleared, tag returns JavaScript
and inspect falls back to a stale first-chunk value
(test/js/web/fetch/fetch.stream.test.ts 'response inspected size').

m_nativePtr keeps the handle alive past terminal state so inspect can report
the final byte count; that is intentional, so leave it alone. What remains:

  readableStreamCancel Direct arm: set m_closed and call the shared
  directStreamControllerClearSource helper instead of three inline .clear()s,
  so a late onDirectPullRejected short-circuits in handleError.

  directStreamControllerClearSource: declared in WebStreamsInternals.h and
  defined in Bun::WebStreams so ReadableStreamOperations.cpp can call it.

  readableStreamReaderGenericRelease: drop the redundant m_nativePtr gate;
  kind == Native already implies a live adapter with a handle.

  readable-stream-terminal-barrier-release.test.ts: one subprocess, one GC
  storm, no per-test timeout.
robobun added a commit that referenced this pull request Aug 1, 2026
…ancel)

The barrier-release scenarios test behavior that #36666 already shipped, so the
gate's without-src build passes them. Add a case for the one new observable
change in this PR: readableStreamCancel's Direct arm now sets m_closed, so a
captured controller's write() throws 'closed' instead of silently writing into
the cleared sink (main returns the byte count, 12).
Jarred-Sumner added a commit that referenced this pull request Aug 2, 2026
…ect cancel (#36703)

Follow-up to #36666 addressing the review notes there, plus a test
consolidation.

## Changes

- **`readableStreamCancel` Direct arm**: set `controller->m_closed =
true` and call the shared `directStreamControllerClearSource` helper
instead of three inline `.clear()`s. `onClose` early-returns in this arm
because the stream is already Closed, so `m_closed` was left false; a
late `onDirectPullRejected` would then re-enter `handleError` and reach
`callUnderlyingSourceClose` on the now-null `m_underlyingSource`.
Setting `m_closed` makes the stated invariant hold and short-circuits
that path.
- **`directStreamControllerClearSource`**: moved into `Bun::WebStreams`
and declared in `WebStreamsInternals.h` so
`ReadableStreamOperations.cpp` can reuse it.
- **`readableStreamReaderGenericRelease`**: drop the redundant
`stream->m_nativePtr` gate; `kind == Native` already implies a live
adapter whose `handle()` the body reads.
- **`readable-stream-terminal-barrier-release.test.ts`**: collapsed into
a single subprocess with one shared GC storm and no per-test timeout
override (the previous per-case subprocess shape brushed the default
timeout under debug+ASAN).

## Why `m_nativePtr` is left alone

The first revision of this PR cleared `m_nativePtr` once the adapter
owned the handle. That broke `test/js/web/fetch/fetch.stream.test.ts`
"response inspected size should reflect stream state":
`Bun.inspect(response)` reports the body byte count via
`PendingValue::size_hint()`, which re-derives `*mut ByteStream` through
`ReadableStreamTag__tagged` on every call;
`FetchTasklet::on_body_received` only updates `bytes.size_hint` on the
ByteStream (not the `PendingValue` fallback field), so once the slot is
cleared inspect falls back to a stale first-chunk value. Adding an
adapter fallback to `ReadableStreamTag__tagged` fixed the
fetch-body-delivery path but not this one, because the slot is also
cleared at ClearAlgorithms (when the adapter has already severed).
Handing the handle off cleanly needs the Rust side to cache the final
size somewhere tag-independent; left for a separate change.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 8 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/streams/readable-stream-terminal-barrier-release.test.ts
bun test v1.4.0 (eab4848)

test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:
(pass) ReadableStream releases source-only WriteBarriers once terminal [2752.80ms]
113 |   });
114 |   const reader = rs.getReader();
115 |   reader.read().catch(() => {});
116 |   await pullStarted.promise;
117 |   await reader.cancel();
118 |   expect(() => ctrl.write("after-cancel")).toThrow(/closed/);
                                                 ^
error: expect(received).toThrow(expected)

Expected pattern: /closed/

Received function did not throw
Received value: 12

      at <anonymous> (/workspace/bun/test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:118:44)
(fail) direct controller write() after reader.cancel() throws closed [43.42ms]

 1 pass
 1 fail
 4 expect() calls
Ran 2 tests across 1 file. [4.79s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.0-canary.1 (5589d58)

test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:
(pass) ReadableStream releases source-only WriteBarriers once terminal [27.64ms]
(pass) direct controller write() after reader.cancel() throws closed [0.68ms]

 2 pass
 0 fail
 4 expect() calls
Ran 2 tests across 1 file. [163.00ms]
__F:0:S:0
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/streams/readable-stream-terminal-barrier-release.test.ts
bun test v1.4.0 (eab4848)

test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:
(pass) ReadableStream releases source-only WriteBarriers once terminal [2665.85ms]
(pass) direct controller write() after reader.cancel() throws closed [43.81ms]

 2 pass
 0 fail
 4 expect() calls
Ran 2 tests across 1 file. [4.72s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 711ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/36] gen cpp.rs (cppbind)
[1/36] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
.../webcore/streams/JSDirectStreamController.cpp   |  20 +-
 .../webcore/streams/ReadableStreamOperations.cpp   |   7 +-
 .../bindings/webcore/streams/WebStreamsInternals.h |   3 +
 ...eadable-stream-terminal-barrier-release.test.ts | 243 ++++++++-------------
 4 files changed, 108 insertions(+), 165 deletions(-)
```

</details>

**gate history** · 4 passed · 1 rejected · iteration 8

<details><summary>evidence per changed file</summary>

```
file                                                      reads  edits  tests
…c/bindings/webcore/streams/JSDirectStreamController.cpp      7      6      0
…c/bindings/webcore/streams/ReadableStreamOperations.cpp      8      8      0
src/jsc/bindings/webcore/streams/WebStreamsInternals.h        5      3      0
…treams/readable-stream-terminal-barrier-release.test.ts      4     15      0
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
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