Skip to content

webcore: store ReadableStreamSource onClose in WriteBarrier slot instead of Strong - #32582

Merged
Jarred-Sumner merged 8 commits into
mainfrom
farm/4514e660/readablestream-source-onclose-strong-cycle
Jun 23, 2026
Merged

Jarred-Sumner merged 8 commits into
mainfrom
farm/4514e660/readablestream-source-onclose-strong-cycle

Conversation

@robobun

@robobun robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

NewSource.close_jsvalue was a jsc.Strong, which rooted a cycle through the native heap:

source wrapper (JS{Blob,Bytes,File}InternalReadableStreamSource)
  -> m_ctx NewSource
  -> close_jsvalue (Strong root)
  -> bound #onClose
  -> NativeReadableStreamSource instance
  -> $stream private prop
  -> source wrapper

Because a Strong is a global GC root, the source wrapper survives even after every JS reference (including the outer ReadableStream) is dropped. The cycle only broke when EOF ran the JS-side callClose (which clears $stream) or the cancel algorithm ran #cancel. A stream that is read partially and then dropped (reader.releaseLock() without cancel()) never hits either path, so the source wrapper leaked one per abandoned stream until VM shutdown.

Reproduction

import { heapStats } from "bun:jsc";
const payload = Buffer.alloc(8 * 1024 * 1024, "x");
for (let i = 0; i < 30; i++) {
  const reader = new Blob([payload]).stream().getReader();
  await reader.read();
  reader.releaseLock();
}
Bun.gc(true);
console.log(heapStats().objectTypeCounts.BlobInternalReadableStreamSource);
// before: 35  (one per iteration + warmup)
// after:  1

The same pattern hits fetch() response bodies whose body exceeds one pull buffer.

Fix

The codegen already declares an onCloseCallback WriteBarrier slot in streams.classes.ts (values: ["pendingPromise", "onCloseCallback", "onDrainCallback"]); onDrain already uses its slot. Switch onClose to the same storage and delete the Strong field. The cycle becomes an ordinary intra-heap cycle that mark-sweep collects.

Also take the across-read ref on the Windows non-lazy FileReader.on_start path (fromPipe via Bun.spawn().stdout/.stderr), matching the existing POSIX arm. Without the Strong cycle masking it, the source is now collectable while a uv_read_start IOCP read is pending; the ref keeps it alive until on_reader_done/on_reader_error releases it.

Relation to #29472 / #29440

This re-applies the fix originally landed as #29472 (Zig, reverted in bf2e2ce) and re-opened as #29440 (still targets the uncompiled .zig reference files), to the Rust port. The WindowsBufferedReader.deinit ordering fix that #29440 bundles is already present in src/io/PipeReader.rs.

Verification

# without fix
(fail) native ReadableStream source is collectable after partial read + releaseLock
  Expected: < 8   Received: 30
(fail) fetch body native source is collectable after partial read + releaseLock
  Expected: < 8   Received: 30

# with fix
(pass) native ReadableStream source is collectable after partial read + releaseLock
(pass) fetch body native source is collectable after partial read + releaseLock
(pass) native ReadableStream source is collectable after full consumption
(pass) native source onClose callback still fires after switching to cached slot

…ead of Strong

NewSource.close_jsvalue was a jsc.Strong, which rooted a cycle through
the native heap: source wrapper -> m_ctx NewSource -> Strong(onClose)
-> bound #onClose -> NativeReadableStreamSource -> $stream -> source
wrapper. The cycle only broke when EOF ran callClose or cancel ran
#cancel, both of which clear $stream. A stream that is read partially
and then dropped (releaseLock without cancel) never hits either path,
so every JS{Blob,Bytes,File}InternalReadableStreamSource wrapper and
its NewSource leaked forever.

The codegen already declares an onCloseCallback WriteBarrier slot in
streams.classes.ts (onDrain already uses its equivalent). Switch
onClose to the same storage and delete the Strong field; the cycle
becomes an ordinary intra-heap cycle that mark-sweep collects.

Also take the across-read ref on the Windows non-lazy FileReader path
(fromPipe via Bun.spawn stdout/stderr), matching the existing POSIX
arm. Without the Strong cycle masking it, the source is now
collectable while a uv_read_start is pending.

This re-applies the fix from #29472 to the Rust port.
@robobun

robobun commented Jun 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:43 PM PT - Jun 22nd, 2026

✅ @robobun, your commit b894a15de8cd87fbc07bd8472621163dda474f34 passed in Build #64082! 🎉


🧪   To try this PR locally:

bunx bun-pr 32582

That installs a local version of the PR into your bun-32582 executable, so you can run:

bun-32582 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Memory leak with streaming downloads from azure blob storage #7272 - Memory leak with streaming downloads from azure blob storage: the reporter streams a 2GB fetch response body via pipe and RSS grows to 1.5GB+ instead of staying flat. This matches the GC root cycle in native ReadableStream sources that this PR fixes.

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

Fixes #7272

🤖 Generated with Claude Code

@robobun

robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Not linking #7272: that report is a 2GB streaming download via @azure/storage-blob (node:https + node:stream pipe) where RSS tracks the download size, which points to missing backpressure or buffer accumulation in the pipe path. This PR fixes a per-abandoned-stream leak of the small native source wrapper object (read one chunk, releaseLock() without cancel); it would not account for gigabytes of growth on a single fully-consumed stream.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

ReadableStream's native source onClose callback storage moves from a Strong-rooted close_jsvalue field on NewSource to a GC-traced codegen cached property, breaking a mark-sweep root cycle. The refcount becomes atomic. SourceContext and related codegen traits gain matching cached-accessor hooks. Stream source configuration adds hasPendingActivity for lifecycle management. On Windows, FileReader::on_start increments the parent refcount before IOCP-backed reads begin. Four regression tests validate GC collection and callback delivery.

Changes

ReadableStream onClose GC leak fix

Layer / File(s) Summary
Contract updates for cached callback storage
src/runtime/webcore/ReadableStream.rs
SourceContext gains js_on_close_callback_set_cached and js_on_close_callback_get_cached required methods. close_jsvalue is removed from NewSource and its Default. ref_count becomes AtomicU32. NewSourceCodegen gains matching method declarations.
Codegen wiring and source configuration
src/runtime/api/streams.classes.ts, src/runtime/webcore/ReadableStream.rs
The source_context_codegen! macro emits thunks bound to generated-class accessors for the cached callbacks. impl NewSourceCodegen for NewSource forwards them. Stream source definition adds hasPendingActivity: true.
Runtime callback and refcount logic
src/runtime/webcore/ReadableStream.rs
on_js_close reads the callback from the cached getter, queues it as a microtask when non-undefined, and clears it via the cached setter. decrement_count uses atomic fetch_sub. set_on_close_from_js and get_on_close_from_js interact with the cached slot instead of the removed close_jsvalue field.
Regression tests for GC collection and onClose delivery
test/js/web/streams/native-source-onclose-leak.test.ts
Four tests assert that partial-read/releaseLock/GC cycles do not accumulate BlobInternalReadableStreamSource or BytesInternalReadableStreamSource objects, that fully-consumed streams are still collectable, and that the onClose callback still fires and resolves a pending read after the storage change.

Windows FileReader IOCP refcount fix

Layer / File(s) Summary
Windows IOCP read refcount guard in FileReader::on_start
src/runtime/webcore/FileReader.rs
A #[cfg(windows)] block in FileReader::on_start sets waiting_for_on_reader_done and calls increment_count on the non-lazy fromPipe path when the embedded reader has a non-done source, before queued IO begins.

Suggested reviewers

  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change—replacing Strong storage of onClose with WriteBarrier slot—and is specific and clear.
Description check ✅ Passed The description includes a comprehensive summary with reproduction steps, explanation of the fix, and verification results. Both required template sections are covered.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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: 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/FileReader.rs`:
- Around line 452-462: The Windows block starting at line 452 is missing an
idempotency guard that exists in the POSIX sibling block. Add a
`!self.started.get()` check as part of the condition in the Windows block
(alongside the existing checks on `self.reader().source.is_some()` and
`!self.reader().is_done()`) to prevent `increment_count()` from being called
multiple times if `on_start()` is invoked before `self.started.set(true)` is
executed. This guard ensures the refcount is only incremented once, preventing
the reference leak that would occur if decrement operations do not match the
number of increments.
🪄 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: b4e1de60-4670-46e1-a90c-aaaffe22564b

📥 Commits

Reviewing files that changed from the base of the PR and between 7a5293c and df13e6c.

📒 Files selected for processing (3)
  • src/runtime/webcore/FileReader.rs
  • src/runtime/webcore/ReadableStream.rs
  • test/js/web/streams/native-source-onclose-leak.test.ts

Comment thread src/runtime/webcore/FileReader.rs
Comment thread src/runtime/webcore/FileReader.rs Outdated
Comment thread src/runtime/webcore/ReadableStream.rs Outdated
Comment thread src/runtime/webcore/ReadableStream.rs Outdated
…/O is pending

Removing the close_jsvalue Strong also removed the only GC root that
transitively kept the JS{Blob,Bytes,File}InternalReadableStreamSource
wrapper alive across a pending FileReader read. this_jsvalue is a raw
heap-resident JSValue zeroed only at lazy sweep, so on_js_close (reached
from FileReader::on_reader_done off the event loop, with no JS frame on
the stack) could read m_onCloseCallback from a dead-but-unswept cell and
queue a dead bound function as a microtask.

Add hasPendingActivity: true to streams.classes.ts and implement
NewSource::has_pending_activity as ref_count > 1. Only FileReader bumps
ref_count (for the across-read ref taken in on_start), so Blob/Bytes
sources always return false and File sources return true exactly while
waiting_for_on_reader_done is held. ref_count becomes AtomicU32 so the
GC-thread read is sound.

@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: 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/ReadableStream.rs`:
- Around line 917-929: The ref_count atomic operations use Release and Acquire
orderings without documented justification, which conflicts with the repo's
guideline to default to SeqCst ordering. Fix this by either replacing all
Release and Acquire orderings with SeqCst throughout the increment_count,
decrement_count, and has_pending_activity methods, or by adding an explicit
comment above the ref_count field documenting why the Release/Acquire pair is
safe for the cross-thread access pattern (GC thread reading via
has_pending_activity, JS thread writing via increment_count and
decrement_count).
🪄 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: 8c3d637a-6e3f-40e4-a889-4380bd517e62

📥 Commits

Reviewing files that changed from the base of the PR and between 4f754d7 and cead354.

📒 Files selected for processing (2)
  • src/runtime/api/streams.classes.ts
  • src/runtime/webcore/ReadableStream.rs

Comment thread src/runtime/webcore/ReadableStream.rs 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.

All prior review feedback has been addressed and I have no further findings, but this changes GC rooting for every native ReadableStream source (Strong→WriteBarrier + new hasPendingActivity read from the GC thread) and re-applies a previously-reverted fix, so it warrants a human look before merge.

Extended reasoning...

Overview

This PR swaps NewSource.close_jsvalue: Strong for the existing codegen onCloseCallback WriteBarrier slot to break a GC root cycle that leaked one native source wrapper per abandoned (partially-read, releaseLock()-without-cancel()) stream. To compensate for losing the Strong as an implicit root across pending I/O, it adds hasPendingActivity: true to the {Blob,Bytes,File}InternalReadableStreamSource codegen and implements it as ref_count > 1 (with ref_count made AtomicU32 since it is now read from the GC thread). It also adds a Windows arm in FileReader::on_start to take the across-read ref on the non-lazy fromPipe path, mirroring the existing POSIX arm. Four regression tests cover collectability and onClose delivery.

Security risks

No auth/crypto/permissions surface. The risk class here is memory safety: GC-lifecycle changes that, if wrong, manifest as UAF or heap type-confusion (e.g. the dead-but-unswept this_jsvalue window I flagged earlier, now closed by hasPendingActivity). The fix as it stands looks correct to me, but this category of bug is notoriously hard to verify by inspection alone.

Level of scrutiny

High. This is production-critical runtime code on the hot path for Blob.stream(), fetch() response bodies, and Bun.spawn() stdout/stderr. It alters how three JSC wrapper classes are rooted, introduces a GC-thread read of native state, and re-applies a fix (#29472) that was previously reverted — all of which argue for a human reviewer (the PR description suggests dylan-conway) to confirm the rooting model and the ref_count > 1 ↔ waiting_for_on_reader_done correspondence.

Other factors

All inline review feedback (mine and CodeRabbit's) has been applied: the Windows !self.started.get() idempotency guard, the stale accessor-count doc comment, the hasPendingActivity rooting fix for the UAF window, and the SeqCst atomic ordering. The bug-hunting pass on the current head found nothing. CI on the most-recently-reported commit shows build failures on FreeBSD/Android build-rust and a Windows agent-creation failure that look infra-related rather than code-related, but the author should confirm a green build on the final commit. Given the change is well-tested and all bot feedback is resolved, the remaining gate is a human sanity-check on the GC-lifecycle reasoning.

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

All earlier review feedback (including the dead-cell deref window) has been addressed; deferring to a human since this changes GC rooting/lifetime for every native ReadableStream source and re-applies a previously-reverted fix.

Extended reasoning...

Overview

The PR swaps NewSource.close_jsvalue: Strong for the codegen'd onCloseCallback WriteBarrier slot on JS{Blob,Bytes,File}InternalReadableStreamSource, breaking a Strong-rooted cycle that pinned one source wrapper per partially-read-then-abandoned stream. To compensate for losing that root while I/O is pending, it adds hasPendingActivity: true to streams.classes.ts, implements NewSource::has_pending_activity() as ref_count > 1, and converts ref_count to AtomicU32 (SeqCst) since the check runs on the GC thread. A new #[cfg(windows)] arm in FileReader::on_start mirrors the POSIX across-read ref for the non-lazy fromPipe (spawn stdout/stderr) path. Four regression tests cover the leak, full-consumption, and onClose delivery.

Security risks

No auth/crypto/permission surfaces. The relevant risk is memory safety: removing a GC root from a native↔JS cycle while async I/O completions still dereference this_jsvalue. My earlier 🔴 finding identified a dead-but-unswept-cell read in on_js_close; the author fixed it with hasPendingActivity keyed on ref_count > 1, which keeps the wrapper marked exactly while waiting_for_on_reader_done holds the extra ref. With that in place I don't see a remaining UAF window, but the correctness depends on the invariant that every path which can reach on_js_close from a non-JS frame holds an outstanding increment_count() — a property that's easy to break in future edits.

Level of scrutiny

High. This is GC-lifetime plumbing on a hot path (every Blob/fetch-body/spawn-pipe stream), it changes cross-thread state (has_pending_activity reads from the marker thread), it adds platform-specific behavior (Windows IOCP ref), and it re-applies a fix that was landed as #29472 and then reverted in bf2e2ce — the prior revert alone is reason enough for a human to confirm the failure mode that triggered it doesn't recur in the Rust port.

Other factors

All four inline review threads (mine and CodeRabbit's) are resolved on the current head: the !self.started.get() idempotency guard on the Windows arm, the stale macro doc-comment count, the hasPendingActivity addition, and the SeqCst ordering convention. The bug-hunting pass on the latest revision found nothing new. Test coverage looks reasonable for the leak itself, though the Windows IOCP path and the GC-thread hasPendingActivity interaction are not directly exercised by the new tests and rely on existing CI suites.

@robobun

robobun commented Jun 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status

Build 64082 (b894a15, the JsRef rework per review): 73 checks passed; the only red is darwin-14-{aarch64,x64}-test-bun reporting Expired (Buildkite agent not available to pick up the job, still scheduled on the Buildkite side). No test failures on any lane that ran. All [warning]-level entries (bake/dev-and-prod.test.ts HMR timing, bun-install.test.ts EBADF, spawn-pipe-leak.test.ts) are Windows retry-passed flakes unrelated to this diff.

Earlier builds:

  • 63865: 229/230 test-bun jobs passed; the single failure was test-tls-client-destroy-soon.js on darwin 14 aarch64 (node:tls byte-count assertion, unrelated to this diff). The prior fetch-leak-test-fixture-6.js flake passed on that retry.
  • 63861 / 63859: fetch-leak-test-fixture-6.js on darwin 14 aarch64, ~3MB over a 5MB RSS threshold. Passed on all other platforms including darwin 26 aarch64 and darwin 14 x64; heap object counts for that fixture's pattern are identical to main.

The new test file native-source-onclose-leak.test.ts passes on every platform in every build.

The diff is ready for review. Remaining CI red across builds is darwin-14 agent availability or unrelated single-lane flake.

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

Don't use hasPendingActivity for this. The sink generator code already uses JSC::Weak. What can we do to simplify the lifetime here? Can we use JSRef and then downgrade/upgrade conditionally?

… ref_count

Per review: drop the hasPendingActivity codegen hook and the AtomicU32
ref_count in favor of the JsRef pattern already used by Subprocess,
Image, Socket, and ResumableSink.

this_jsvalue becomes a JsRef. increment_count() upgrades it to Strong
so the wrapper is rooted while a native I/O ref is held (in practice
the FileReader waiting_for_on_reader_done ref). decrement_count()
downgrades back to Weak when only the wrapper's own ref remains.
finalize() flips it to Finalized so on_js_close reads None instead of
a dead-but-unswept cell.

The redundant per-host-fn this_jsvalue = call_frame.this() writes are
dropped: the wrapper JSValue is set once in to_readable_stream and
never changes, and keeping them would have to preserve the
Weak/Strong state explicitly.
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

@Jarred-Sumner done in b894a15. Dropped hasPendingActivity / the AtomicU32 ref_count and switched this_jsvalue: JSValue to JsRef:

  • increment_count() upgrades to Strong (roots the wrapper while a native I/O ref is held; in practice only FileReader's waiting_for_on_reader_done ref)
  • decrement_count() downgrades to Weak when only the wrapper's own ref remains
  • finalize() flips to Finalized so on_js_close reads None instead of a dead cell
  • on_js_close uses try_get() and bails if None

Same pattern as Subprocess::update_has_pending_activity, Image, Socket, ResumableSink. The redundant per-host-fn this_jsvalue = call_frame.this() field writes are gone since the wrapper JSValue is set once in to_readable_stream and never changes.

streams.classes.ts is back to what it was on main.

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