Skip to content

webstreams: make Blob/File/body streams byte streams so BYOB readers attach - #33927

Closed
robobun wants to merge 11 commits into
mainfrom
farm/d4477e7c/blob-stream-byob
Closed

webstreams: make Blob/File/body streams byte streams so BYOB readers attach#33927
robobun wants to merge 11 commits into
mainfrom
farm/d4477e7c/blob-stream-byob

Conversation

@robobun

@robobun robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What

Blob.prototype.stream() (and Bun.file().stream(), fetch/Request/Response bodies, and subprocess pipe streams) are now readable byte streams, so getReader({ mode: "byob" }) and new ReadableStreamBYOBReader(stream) work on them. Previously they threw:

TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController
// before: TypeError; after: prints 64
const blob = new Blob([new Uint8Array(100000)]);
const r = blob.stream().getReader({ mode: "byob" });
const { value } = await r.read(new Uint8Array(64));
console.log(value.byteLength);

The File API requires Blob.prototype.stream() to return a byte stream, and Node.js and every browser already do, so zero-copy BYOB read loops that work on Node/browser Blobs failed only on a Bun Blob.

Why

materializeNativeSource (the lazy materialization path for the $bunNativePtr streams) always installed a ReadableStreamDefaultController, and getReader({ mode: "byob" }) skipped materialization entirely, so m_controllerKind was never Byte.

How

  • materializeNativeSource now installs a ReadableByteStreamController (with autoAllocateChunkSize unset). The native pull(view, closer) already writes into a caller-provided buffer, so when a pull-into descriptor is pending the adapter passes the BYOB request's view through and answers with respond(n); with no pull-into (default reader) it keeps its own scratch buffer and enqueue()s the filled prefix, preserving the existing default-reader behaviour.
  • SourceKind::Native moves from the default controller's pull/cancel dispatch to the byte controller's; JSNativeStreamSourceAdapter::m_controller is retyped accordingly and gains an m_pendingIsBYOB bit so the async-pull fulfilment path knows whether to respond() or enqueue().
  • getReader({ mode: "byob" }) and new ReadableStreamBYOBReader(stream) materialize a NativePending stream before locking it (a DirectPending stream is left alone so it still rejects without running user code).
  • ReadableStream__empty now returns a closed byte stream, so an empty Blob's stream also accepts a BYOB reader and observes done: true.

Tests

Added a Blob.prototype.stream() is a byte stream suite to test/js/web/fetch/blob.test.ts covering: a single BYOB read fills the caller's view, a BYOB loop reassembles the full blob, new ReadableStreamBYOBReader(blob.stream()), Bun.file().stream() with a BYOB reader, default reader still works, release-then-BYOB on the same stream, and an empty Blob's stream.

All six BYOB cases TypeError on main; all pass with this change. The existing test/js/web/streams/streams.test.js, body.test.ts, body-stream.test.ts (9086 tests), native-source-onclose-leak.test.ts, node stream suite, and WPT streams (1175 tests) all pass.

Fixes #6643
Fixes #12908
Fixes #16402


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

The File API requires Blob.prototype.stream() to return a readable byte
stream, and Node.js and browsers do. Bun's lazily-materialized native
sources (Blob, Bun.file, fetch/Request/Response bodies) always installed
a ReadableStreamDefaultController, so getReader({ mode: "byob" }) threw
'ReadableStreamBYOBReader needs a ReadableByteStreamController'.

materializeNativeSource now installs a ReadableByteStreamController (the
native pull already fills a caller-provided buffer, so when a pull-into
descriptor is pending the BYOB view is passed straight through and
answered with respond(n); with no pull-into the adapter keeps its own
scratch buffer and enqueue()s the filled prefix). SourceKind::Native
moves to the byte-controller pull/cancel dispatch, getReader() and
new ReadableStreamBYOBReader() materialize a NativePending stream before
locking, and ReadableStream__empty now returns a closed byte stream so
an empty Blob's stream also accepts a BYOB reader.
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:01 PM PT - Jul 16th, 2026

@Jarred-Sumner, your commit 2c0400f has 8 failures in Build #74295 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33927

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

bun-33927 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Use readable byte stream for Blob.stream() and Response.body #6643 - Requests exactly this change: use ReadableByteStreamController for Blob.stream() and Response.body so BYOB readers work
  2. TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController #12908 - Reports the exact error this PR fixes: "TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController"
  3. Package music-metadata not working - Buffer / Webstream #16402 - music-metadata package fails because it calls getReader({ mode: "byob" }) on Blob/Response streams, hitting the same BYOB error

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

Fixes #6643
Fixes #12908
Fixes #16402

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fetch: support getReader({ mode: 'byob' }) on response bodies #29167 - Also fixes native-backed ReadableStreams (fetch bodies, Blob.stream(), Bun.file().stream()) to use ReadableByteStreamController instead of ReadableStreamDefaultController so BYOB readers can attach; references the same issues (Use readable byte stream for Blob.stream() and Response.body #6643, TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController #12908, Package music-metadata not working - Buffer / Webstream #16402) but takes a JS-layer approach vs. this PR's C++ approach

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 10, 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

Changes

Native sources now use byte-stream controllers throughout pull, BYOB response, enqueue, close, cancellation, and error paths. Native-pending streams materialize before BYOB reader setup, empty streams become byte streams, reader-release cleanup moves to byte-controller handling, and Blob/file stream tests cover reader and memory behavior.

Native byte-stream integration

Layer / File(s) Summary
Byte-controller contracts and dispatch
src/jsc/bindings/webcore/streams/BunStreamSource.h, src/jsc/bindings/webcore/streams/JSReadableByteStreamController.*, src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp, src/jsc/bindings/webcore/streams/WebStreamsInternals.h, src/jsc/bindings/webcore/streams/StreamsForward.h
Native source declarations, adapter state, documentation, and pull/cancel dispatch are aligned with byte controllers.
Native pull and BYOB pipeline
src/jsc/bindings/webcore/streams/BunStreamSource.cpp
Native source setup, result decoding, BYOB response handling, enqueueing, closure, fulfillment, rejection, cancellation, and errors use byte-controller operations.
BYOB reader materialization and empty streams
src/jsc/bindings/webcore/streams/JSReadableStream.cpp, src/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cpp, src/jsc/bindings/webcore/streams/WebStreamsExports.cpp
Native-pending streams materialize before BYOB reader setup, and empty streams are created as byte streams.
Reader release and stream validation
src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp, test/js/web/fetch/blob.test.ts, test/js/web/streams/streams-leak.test.ts
Byte-controller reader release handling is updated, while tests cover BYOB reads, file streams, default readers, lock transitions, empty-stream closure, multi-byte views, and backing memory.

Possibly related PRs

  • oven-sh/bun#33817: Both changes update byte-controller pull/cancel dispatch for native sources.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address all three linked issues by making native-backed Blob, request/response body, and file streams byte streams for BYOB readers.
Out of Scope Changes check ✅ Passed I don't see unrelated code changes; the controller, reader, and test updates all support the byte-stream/BYOB objective.
Title check ✅ Passed The title clearly matches the main change: converting web streams to byte streams so BYOB readers can attach.
Description check ✅ Passed The description is complete and covers purpose, rationale, implementation, and verification, though it uses different headings than the template.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp (2)

115-168: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale doc comment: Native missing from documented reachable-kind set.

The comment above performByteControllerPullAlgorithm still says the reachable SourceKind set on a byte controller is {JavaScript, Nothing, ByteTeeBranch}, but the switch below it now also handles SourceKind::Native. JSReadableByteStreamController.h's analogous comment (lines 81-83) was correctly updated to add Native — this file's copy of the same invariant was not, creating a "one source of truth" mismatch for the same documented contract.

📝 Proposed fix
-// The [[pullAlgorithm]] dispatch. The reachable kind set on a byte controller is exactly
-// {JavaScript, Nothing, ByteTeeBranch}; the switch is total over SourceKind.
+// The [[pullAlgorithm]] dispatch. The reachable kind set on a byte controller is exactly
+// {JavaScript, Nothing, ByteTeeBranch, Native}; the switch is total over SourceKind.
🤖 Prompt for 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.

In `@src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp` around
lines 115 - 168, Update the doc comment above performByteControllerPullAlgorithm
to include Native in the documented reachable SourceKind set, matching the
switch implementation and the analogous comment in
JSReadableByteStreamController.h.

Source: Coding guidelines


170-202: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Same stale-set comment issue for the cancel dispatch.

"Same reachable kind set as the pull dispatch." at line 170 inherits the same staleness once the pull-dispatch comment above is corrected to include Native.

🤖 Prompt for 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.

In `@src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp` around
lines 170 - 202, Update the comment above performByteControllerCancelAlgorithm
to accurately describe the currently reachable algorithm kinds, including
Native, instead of referencing the pull dispatch’s kind set. Keep the comment
aligned with the switch cases and remove the stale cross-reference.
🤖 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/fetch/blob.test.ts`:
- Around line 603-618: Make the reader-handoff test non-vacuous by enlarging the
payload beyond one default read chunk, asserting that the first read is partial,
and verifying the BYOB read returns the expected next bytes from the payload
after r1.releaseLock(). Update the assertions in the test “releaseLock after a
default reader then attach a BYOB reader” so immediate EOF is not accepted when
bytes should remain.

---

Outside diff comments:
In `@src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp`:
- Around line 115-168: Update the doc comment above
performByteControllerPullAlgorithm to include Native in the documented reachable
SourceKind set, matching the switch implementation and the analogous comment in
JSReadableByteStreamController.h.
- Around line 170-202: Update the comment above
performByteControllerCancelAlgorithm to accurately describe the currently
reachable algorithm kinds, including Native, instead of referencing the pull
dispatch’s kind set. Keep the comment aligned with the switch cases and remove
the stale cross-reference.
🪄 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: 9bf958d7-e28d-4f21-b8dc-a6c772f2e7fb

📥 Commits

Reviewing files that changed from the base of the PR and between 5f145d6 and 19633c6.

📒 Files selected for processing (11)
  • src/jsc/bindings/webcore/streams/BunStreamSource.cpp
  • src/jsc/bindings/webcore/streams/BunStreamSource.h
  • src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp
  • src/jsc/bindings/webcore/streams/JSReadableByteStreamController.h
  • src/jsc/bindings/webcore/streams/JSReadableStream.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp
  • src/jsc/bindings/webcore/streams/StreamsForward.h
  • src/jsc/bindings/webcore/streams/WebStreamsExports.cpp
  • src/jsc/bindings/webcore/streams/WebStreamsInternals.h
  • test/js/web/fetch/blob.test.ts
💤 Files with no reviewable changes (1)
  • src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp

Comment thread test/js/web/fetch/blob.test.ts
…ent; strengthen handoff test

- nativeDecodePullResult: only Respond(N) when a pull-into descriptor is
  still present (cancel/release can clear it before an async pull settles),
  and guard on a non-null view before reading its length.
- nativeSourceSever: clear m_pendingIsBYOB alongside the other per-pull
  state so a late fulfilment does not take the BYOB branch after teardown.
- Update the reachable-kind comment above performByteControllerPullAlgorithm
  to include Native, matching the header and the switch.
- Reworked the default-then-BYOB test: use a 40 KB payload so the first
  default read is partial, and assert the BYOB read returns the next 32
  bytes of the payload rather than accepting immediate EOF.
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Re: #29167 as a potential duplicate: that PR modified src/js/builtins/ReadableStream.ts / ReadableStreamInternals.ts, which no longer exist after the streams implementation moved to C++. It is marked conflicting and cannot be rebased onto the current layout. This PR is the equivalent change against the current src/jsc/bindings/webcore/streams/ C++ implementation and additionally lets a BYOB reader attach after a default reader is released, which that PR explicitly left out of scope.

The outside-diff comment in JSReadableByteStreamController.cpp (reachable-kind set missing Native) and the vacuous reader-handoff test are addressed in 7f8af8b.

@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/bindings/webcore/streams/BunStreamSource.cpp:530-536 — On the default-reader path, nativeDecodePullResult computes a tail subarray sharing the same backing ArrayBuffer as toEnqueue, but nativeByteControllerEnqueuereadableByteStreamControllerEnqueue unconditionally calls transferArrayBufferImpl which detaches that shared buffer — so the returned tail is always detached, nativeGetInternalBuffer allocates a fresh 256 KiB–2 MiB buffer on the next pull, and the sliding-tail reuse (which worked before this PR because readableStreamDefaultControllerEnqueue never transferred) is now dead. This is a per-pull allocation regression on the hot default-reader path (Bun.file().stream(), fetch/Response bodies, subprocess pipes) whenever a native pull returns fewer bytes than the scratch buffer; the comment on nativeByteControllerEnqueue ("nothing else aliases") is false for this call site. One fix is to pass autoAllocateChunkSize so default-reader reads route through respond(); alternatively, copy the filled prefix into a fresh buffer before enqueue and keep the original scratch buffer as the pending view.

    Extended reasoning...

    What the bug is

    The PR migrates native streams from a ReadableStreamDefaultController to a ReadableByteStreamController. On the default-reader (non-BYOB) path with autoAllocateChunkSize unset, nativeDecodePullResult handles a partial write (written < view.length) by splitting the scratch buffer into two subarrays over the same backing ArrayBuffer: toEnqueue = view[0..count) is enqueued and tail = view[count..) is returned as the next m_pendingView. The new enqueue path (nativeByteControllerEnqueuereadableByteStreamControllerEnqueue) unconditionally calls transferArrayBufferImpl(*buffer), which detaches the source ArrayBuffer (buffer.transferTo(vm, contents) in WebStreamsMisc.cpp). Because tail shares that buffer, it is detached the moment toEnqueue is enqueued.

    Code path

    1. materializeNativeSource calls setUpReadableByteStreamController(..., std::nullopt) — no autoAllocateChunkSize.
    2. A default reader's read() reaches JSReadableByteStreamController::pullSteps; with m_autoAllocateChunkSize == 0 it appends no pull-into descriptor, so nativePendingPullIntoView returns nullptr and hasBYOBRequest = false.
    3. nativeGetInternalBuffer allocates (or reuses) a 256 KiB Uint8Array; the native pull(view, closer) writes N bytes and returns N.
    4. nativeDecodePullResult (result.isNumber(), hasBYOBRequest=false, N < view.length) creates toEnqueue = uint8Subarray(view, 0, N) and tail = uint8Subarray(view, N, len-N) — both over view.buffer.
    5. nativeByteControllerEnqueue(controller, toEnqueue)readableByteStreamControllerEnqueue does transferArrayBufferImpl(globalObject, *buffer) before any reader-type branch, detaching view.buffer.
    6. tail is returned and stored in adapter->m_pendingView.
    7. On the next pull, nativeGetInternalBuffer checks !view->isDetached()false → allocates a fresh chunkSize Uint8Array.

    Why this didn't happen before

    Pre-PR, this path called readableStreamDefaultControllerEnqueue, which passes the JSValue through to the read request / queue with no transfer or detach. The tail subarray stayed valid, nativeGetInternalBuffer saw a live view over a ≥ chunkSize buffer, and one 256 KiB allocation was reused for the whole stream via sliding subarrays. The comment on nativeByteControllerEnqueue — "The native side hands over a fresh buffer that nothing else aliases, so the byte-controller transfer/detach is harmless" — is true for the onDrain and chunk-return call sites, but false for this partial-write site: tail aliases the same buffer.

    Impact

    Every default-reader pull on a native stream (Bun.file().stream(), fetch/Response bodies, subprocess pipe streams) that returns fewer bytes than the scratch buffer — the common case for file reads, sockets, and pipes — now allocates a fresh 256 KiB–2 MiB buffer instead of reusing the previous one. This affects for await (const chunk of blob.stream()), response.body.getReader(), and the internal readStreamIntoSink/readMany pumps that acquire a default reader. Correctness is preserved (the delivered bytes are right), but the tail-computation at BunStreamSource.cpp:527-529 is effectively dead code and the buffer-reuse optimization that motivated it no longer fires.

    Step-by-step example

    Streaming a 1 MiB Bun.file with a default reader, where each kernel read returns 64 KiB into the 256 KiB scratch buffer:

    • Before: pull #1 allocates one 256 KiB buffer, enqueues [0,64K), keeps [64K,256K) as tail; pull #2 reuses that tail (backing buffer is 256 KiB ≥ chunkSize), and so on — ~1 allocation total.
    • After: pull #1 allocates 256 KiB, enqueues [0,64K) → detaches the buffer, stores detached tail; pull #2's nativeGetInternalBuffer sees isDetached() and allocates a fresh 256 KiB; repeats every pull — ~16 × 256 KiB allocated for a 1 MiB file.

    How to fix

    Two straightforward options:

    • Pass autoAllocateChunkSize = adapter->m_chunkSize to setUpReadableByteStreamController so a default reader creates a pull-into descriptor and the code takes the hasBYOBRequestrespond(N) path (which already handles partial fills correctly and doesn't need the tail hack). Then delete the now-dead tail-subarray branch.
    • Or, on the partial-write default-reader path, copy the N filled bytes into a fresh Uint8Array(N) and enqueue that, returning the original (still-attached) view as the pending view for reuse.

Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.cpp Outdated
Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.cpp Outdated
Comment thread src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp
Comment thread test/js/web/fetch/blob.test.ts
…roller

Two regressions from moving native sources to a byte controller:

- readableByteStreamControllerEnqueue detaches the chunk's backing buffer,
  so subarraying the scratch buffer into (head, tail) left the tail (and
  the scratch buffer) detached and forced a fresh 256KB allocation on
  every pull of the default-reader path. Copy the filled prefix into a
  fresh Uint8Array and keep the scratch buffer as the pending view so it
  is reused from offset 0. The now-unused uint8Subarray helper is deleted.

- readableStreamReaderGenericRelease's updateRef(false) gate lived in the
  ControllerKind::Default arm only, so releasing a native stream's reader
  no longer dropped the event-loop keep-alive. The gate now lives in the
  ControllerKind::Byte arm (SourceKind::Native can only appear there).
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the scratch-buffer-reuse regression in 90adb97: readableByteStreamControllerEnqueue detaches the chunk's backing buffer, so on the default-reader path the filled prefix is now copied into a fresh Uint8Array(N) before enqueue and the scratch buffer stays attached for reuse on the next pull (reading a 2 MB Bun.file() with the default reader leaves the ArrayBuffer count flat after GC). The now-unused uint8Subarray helper is deleted.

The other findings (cancel-vs-late-BYOB-respond, stale reachable-kind comment, vacuous handoff test) were already fixed in 7f8af8b before this review round ran.

The native byte-controller path now copies each pull's bytes into a
fresh Uint8Array (so the scratch buffer stays attached for reuse and is
not detached by readableByteStreamControllerEnqueue). Each delivered
chunk therefore owns its own small backing buffer instead of being a
subarray of one shared 256KB buffer. The test's
distinctBuffers.size < 8 assertion was a proxy for the real contract
(total backing memory, which the original bug blew up to ~16MB); keep
that contract and drop the sharing-count assertion.

@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/bindings/webcore/streams/BunStreamSource.cpp:533-539 — On the default-reader path, nativeByteControllerEnqueue(toEnqueue) now detaches the adapter's scratch buffer (via readableByteStreamControllerEnqueuetransferArrayBufferImpl), but the returned tail is a subarray over that same buffer — so m_pendingView is stored detached, nativeGetInternalBuffer's !view->isDetached() check fails on the next pull, and a fresh 256KB–2MB buffer is allocated per pull instead of reusing the tail. Each default read also hands the user a small view whose .buffer is the full transferred chunkSize buffer. Pre-PR readableStreamDefaultControllerEnqueue did not transfer, so this is a per-chunk memory/allocation regression on Bun.file()/fetch-body/subprocess-pipe streams read with a default reader. Fix: copy the count-byte prefix into a fresh small buffer before enqueue on the non-BYOB path so the scratch buffer stays adapter-owned.

    Extended reasoning...

    What the bug is

    In nativeDecodePullResult's non-BYOB numeric-result branch (hasBYOBRequest == false, result.isNumber(), written < view->length()):

    toEnqueue = uint8Subarray(globalObject, view, 0, count);
    auto* tail = uint8Subarray(globalObject, view, count, view->length() - count);
    newView = tail;
    ...
    nativeByteControllerEnqueue(globalObject, controller, toEnqueue);
    ...
    return newView;   // stored as adapter->m_pendingView

    toEnqueue and tail are subarrays over the same ArrayBuffer — the adapter-owned scratch buffer that nativeGetInternalBuffer allocated. nativeByteControllerEnqueue forwards to readableByteStreamControllerEnqueue, whose first substantive step is transferArrayBufferImpl(globalObject, *buffer) (JSReadableByteStreamController.cpp), which calls buffer.transferTo(...) and detaches the source buffer (WebStreamsMisc.cpp:79-104). Before this PR the call was readableStreamDefaultControllerEnqueue, which stores the JSValue in the queue with no transfer/detach.

    Consequences on the default-reader path

    materializeNativeSource passes std::nullopt for autoAllocateChunkSize, so m_autoAllocateChunkSize == 0 and pullSteps creates no pull-into descriptor for a default reader. nativePendingPullIntoView returns nullptr, hasBYOBRequest is false, and every default-reader pull goes through this scratch-buffer branch.

    1. Tail reuse is dead. After the enqueue detaches the buffer, tail (stored as m_pendingView) is a view over a detached buffer. On the next pull, nativeGetInternalBuffer's !view->isDetached() check fails and it allocates a fresh m_chunkSize (256 KiB default, up to 2 MiB after resize) buffer. The whole "reuse the unfilled tail across pulls" optimisation that m_pendingView exists for is now dead code on the default-reader path.

    2. Each chunk pins a full-size backing buffer. readableByteStreamControllerEnqueue delivers constructViewOfType(TypeUint8, transferredBuffer, byteOffset, byteLength) — a count-byte Uint8Array whose .buffer is the full transferred m_chunkSize buffer. A fetch body delivering 4 KB per pull now gives the user a 4 KB view over its own 256 KB ArrayBuffer, one per read.

    The comment above nativeByteControllerEnqueue — "The native side hands over a fresh buffer that nothing else aliases, so the byte-controller transfer/detach is harmless" — is false for this call site: the tail aliases it, and the detach is not harmless.

    Step-by-step proof

    1. Bun.file(bigPath).stream().getReader()materializeNativeSource installs a byte controller with autoAllocateChunkSize = std::nullopt, m_chunkSize = 256 KiB.
    2. await reader.read()pullSteps: m_autoAllocateChunkSize == 0 so no pull-into is appended; readableStreamAddReadRequest; readableByteStreamControllerCallPullIfNeedednativeSourcePullnativeSourcePullImpl.
    3. nativePendingPullIntoView sees m_pendingPullIntos.isEmpty() → returns nullptr; hasBYOBRequest = false. nativeGetInternalBuffer allocates a fresh 256 KiB Uint8Array and stores it as m_pendingView.
    4. handle.pull(view, closer) returns (or its promise fulfils with) e.g. 4096. nativeDecodePullResult: written = 4096, hasBYOBRequest = false, view->length() = 262144, so count = 4096 < 262144. toEnqueue = view[0..4096), tail = view[4096..262144), both over the same 256 KiB ArrayBuffer.
    5. nativeByteControllerEnqueue(controller, toEnqueue)readableByteStreamControllerEnqueue: buffer = chunk->possiblySharedBuffer() (the 256 KiB scratch buffer); transferredBuffer = transferArrayBufferImpl(*buffer)the scratch buffer is now detached. A pending default read exists, so the user receives constructViewOfType(TypeUint8, transferredBuffer, 0, 4096) — a 4 KB view over a 256 KiB buffer.
    6. nativeDecodePullResult returns tail; nativeStorePendingView stores it as m_pendingView. tail->isDetached() is now true.
    7. Next await reader.read() → step 3 again. nativeGetInternalBuffer: m_pendingView is set but view->isDetached() → the reuse guard fails → another fresh 256 KiB allocation. Repeat for every pull.

    Pre-PR, step 5 called readableStreamDefaultControllerEnqueue, which enqueued toEnqueue as a plain JSValue with no transfer. tail stayed valid and step 7 reused it, so N partial pulls shared one scratch buffer instead of allocating N of them.

    Why the new tests don't catch it

    The new tests either use BYOB readers (which take the respond() path, not this branch) or in-memory new Blob([...]) payloads that hit the chunkSize == 0 fully-buffered fast path (SourceKind::Nothing, one enqueue, no adapter). None exercise a default reader on a chunked native source across multiple pulls. Delivered bytes are correct, so no content assertion fails.

    Fix

    On the non-BYOB partial-fill path, copy the count-byte prefix into a fresh ArrayBuffer before enqueue (e.g. JSC::ArrayBuffer::tryCreate + memcpy, or reuse cloneAsUint8Array) so the scratch buffer is never handed to readableByteStreamControllerEnqueue and remains adapter-owned. That keeps tail reuse working and gives the user a right-sized .buffer. Alternatively, call readableByteStreamControllerEnqueueChunkToQueue / a clone-then-enqueue helper directly for this branch. The nativeByteControllerEnqueue comment should also be corrected — it is only true for the onDrain/start-drain and chunk-result callers, not for the scratch-buffer subarray.

Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.cpp
Matches the sibling non-BYOB arm and nativeAdjustChunkSize above so a
non-positive double is never cast to size_t.
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

The scratch-buffer-detach finding above is against commit 19633c6 and was already fixed in 90adb97 (the default-reader partial-fill path now copies the filled prefix into a fresh Uint8Array(count) and returns the scratch buffer itself as the pending view, so it is never handed to readableByteStreamControllerEnqueue and stays attached for reuse). The written > 0 guard is added in b78855f.

Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.cpp Outdated
… the controller

The prior default-controller path used highWaterMark=1, so after a
satisfied read() the controller immediately pulled again and
ByteStream/FileReader held a pending-pull promise through which
close/error is delivered (those sources have no onClose push path).
The byte controller was set up with highWaterMark=0, so after the first
read emptied the queue desiredSize was 0, no proactive pull fired, and
an abort that arrived with no read in flight stored the error on the
source without ever reaching the controller. reader.closed stayed
pending forever. Restoring highWaterMark=1 matches the old behaviour.

Also drop the unused JSReadableStreamBYOBRequest.h include.
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the stream-related failures (serve-body-leak, streams-leak) are green as of a6d21a1. The remaining hard failure is test/js/web/fetch/fetch-gzip.test.ts "followed redirect with a malformed chunked body rejects fetch()" (4 platforms), which also fails 3/3 on a clean release build of this PR's base commit c1076ce with no stream changes applied: it expects InvalidHTTPResponse but gets ConnectionRefused, and is a consequence of #33613 (redirects now follow on the response head before the 3xx body is parsed, so the malformed body is never seen and the redirect to port 1 fails first). The other failing tests are in the buildkite "flaky" annotation and passed on retry.

Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.cpp
Comment thread src/jsc/bindings/webcore/streams/BunStreamSource.cpp
…t as uncaught

readableByteStreamControllerClose throws a TypeError when the head
pull-into's bytesFilled is not a multiple of its element size (a
Uint32Array BYOB view over a 3-byte source, etc). That throw is spec
behaviour: close() has already errored the stream and rejected the
pending read-into request. nativeSourceCallClose now swallows the
exception when the stream is already Errored rather than routing it
through Bun__reportError, so the user only sees the rejected read()
promise and not an additional uncaughtException.

@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 `@test/js/web/fetch/blob.test.ts`:
- Line 641: Replace the fixed await Bun.sleep(10) in the regression test with a
deterministic completion signal from the reader or stream close path, awaiting
that signal before asserting so late errors are captured reliably.
🪄 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: 2929c2ab-d76f-4307-a428-7926f0174a82

📥 Commits

Reviewing files that changed from the base of the PR and between ca91efa and a0f3d24.

📒 Files selected for processing (2)
  • src/jsc/bindings/webcore/streams/BunStreamSource.cpp
  • test/js/web/fetch/blob.test.ts

Comment thread test/js/web/fetch/blob.test.ts Outdated
robobun added 2 commits July 10, 2026 17:34
Both network-body faces route through materializeNativeSource and are
fixed by this PR's byte-controller change, but were not directly
covered by a test. Added to body-stream.test.ts so they run over
both HTTP/1.1 and HTTP/3.
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Added body-stream.test.ts coverage for the two network-body faces this PR already fixes but did not directly test: fetch() response res.body.getReader({ mode: "byob" }) and Bun.serve handler req.body.getReader({ mode: "byob" }), both over HTTP/1.1 and HTTP/3. All four throw TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController on main and pass on this branch; the full 9090-test body-stream.test.ts suite stays green.

@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 this switches every native-backed ReadableStream (Blob/File/fetch/subprocess) from a default controller to a byte controller — a broad change to memory-sensitive JSC stream bindings that warrants a human look.

Checked: the BYOB respond()/enqueue() split in nativeDecodePullResult including the written > 0 guard; the scratch-buffer copy-before-enqueue so byte-controller detach doesn't defeat reuse; nativeSourceCallClose swallowing the spec partial-element close() throw only when the stream already transitioned to Errored; m_pendingIsBYOB cleared on sever/reject/fulfil so a late async settle can't mis-route; the updateRef(false) release gate moved from the Default arm to the Byte arm in readableStreamReaderGenericRelease; and the weakened streams-leak.test.ts assertion — the dropped distinctBuffers.size < 8 check is a genuine behaviour change (each chunk now owns a small copy), but the retained backing-bytes bound still catches the original 256KB-per-chunk regression.

Extended reasoning...

Overview

This PR changes materializeNativeSource (the lazy materialization path for Bun's $bunNativePtr streams — Blob, Bun.file, fetch/Request/Response bodies, subprocess pipes) to install a ReadableByteStreamController instead of a ReadableStreamDefaultController, so getReader({mode:'byob'}) works on those streams as the File API and Fetch specs require. It touches 11 files in src/jsc/bindings/webcore/streams/: the native-source adapter (BunStreamSource.{cpp,h}), both controller dispatch switches, both BYOB-reader entry points (getReader and the ReadableStreamBYOBReader constructor), the generic-release path in ReadableStreamOperations.cpp, ReadableStream__empty, and header/comment updates. Three test files gain ~210 lines of BYOB coverage; streams-leak.test.ts has one assertion removed and its comment rewritten.

Security risks

None identified. The change is internal to the streams subsystem and does not touch auth, crypto, permissions, or untrusted-input parsing. The buffer handling is between Bun's own native handles and JSC-managed ArrayBuffers; the detach semantics of readableByteStreamControllerEnqueue were traced against every Rust producer in an earlier review round and all hand over fresh/unaliased buffers.

Level of scrutiny

High. This is a behaviour-changing rewrite of a hot, GC-sensitive C++ path that every native stream in the runtime flows through. It interacts with JSC Weak references, WriteBarrier members, pull-into descriptor lifetimes, ArrayBuffer detachment, and the byte-controller's spec-mandated throwing paths. It has already been through five review rounds that surfaced and fixed real issues (scratch-buffer detach defeating reuse, missing written > 0 guard causing UB, spurious Bun__reportError on the partial-element close path, updateRef(false) left on an unreachable arm). The current diff looks correct, and the bug-hunting system found nothing this run, but the blast radius (every Blob/file/body stream) and the memory-safety surface make this exactly the kind of change a maintainer should sign off on.

Other factors

  • Test-weakening: streams-leak.test.ts drops the distinctBuffers.size < 8 assertion. This is a real behaviour change — the default-reader path now copies each pull's filled prefix into a fresh small Uint8Array (because readableByteStreamControllerEnqueue would otherwise detach the shared scratch buffer), so chunks no longer share a backing buffer. The remaining backingBytes < 4 MB assertion still catches the original regression (pre-fix ≈16 MB), so the test's contract is preserved, but a human should confirm the per-chunk allocation is acceptable.
  • CI: an earlier round flagged fetch-gzip.test.ts failing on 4 platforms; robobun demonstrated it also fails on the base commit and is caused by unrelated PR #33613, not this change.
  • All 15 prior inline findings (from claude[bot], robobun, and CodeRabbit) are marked resolved with corresponding fix commits.
  • Test coverage is thorough: BYOB fill, full-blob reassembly, constructor path, Bun.file(), default-reader-still-works, release-then-BYOB handoff, empty blob, partial-element close, and fetch/serve request+response bodies over HTTP/1.1 and HTTP/3.

@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 bugs found in this pass, but this switches the controller type for every native-backed ReadableStream (Blob/File/fetch bodies/subprocess pipes) from default to byte — a runtime-wide behavioural change in GC-sensitive JSC C++ that went through several non-trivial fix rounds. Worth a human look before merge.

What was reviewed:

  • nativeDecodePullResult BYOB vs enqueue paths: the written > 0 guard, pull-into-gone-after-cancel guard, and scratch-buffer copy-before-enqueue all check out.
  • nativeSourceCallClose: the partial-element close throw is now swallowed only when the stream already errored; respond(0) settles a pending BYOB read.
  • readableStreamReaderGenericRelease: the updateRef(false) gate correctly moved to the Byte arm so releasing a native-stream reader still drops the event-loop ref.
  • streams-leak.test.ts: the distinctBuffers.size < 8 assertion was removed — the total-backing-bytes bound still guards the original regression, and the new per-chunk-copy behaviour is documented.
Extended reasoning...

Overview

This PR retypes Bun's lazily-materialized native ReadableStream sources (Blob, Bun.file(), fetch/Request/Response bodies, subprocess pipes) from ReadableStreamDefaultController to ReadableByteStreamController, so getReader({mode:'byob'}) and new ReadableStreamBYOBReader(stream) work on them per the File API spec. Roughly 350 lines of C++ across 11 files in src/jsc/bindings/webcore/streams/, plus new test coverage in blob.test.ts / body-stream.test.ts and an adjusted assertion in streams-leak.test.ts.

Security risks

None identified. No user-controlled parsing, no auth/crypto/permissions surface. The written value fed into the size cast comes from Bun's own native handles, and the added written > 0 guard removes the one UB conversion. Buffer bounds are clamped with std::min against the view length before respond().

Level of scrutiny

High. This is hot-path, GC-sensitive JSC binding code: it touches WriteBarrier members, a JSC::Weak back-edge, pull-into descriptor buffers that get transferred/detached, and the SourceKind dispatch invariant across both controller types. The change alters an observable runtime-wide invariant (default-reader chunks from native streams now come from a byte controller, so their backing buffers are detached on enqueue). It's exactly the class of change REVIEW.md flags for careful human review, and the PR history bears that out — earlier iterations had a scratch-buffer-detach regression, an updateRef(false) event-loop-ref leak, a UB float→size_t cast, and a spurious Bun__reportError on the spec-mandated partial-element close throw, all now fixed.

Other factors

  • All prior inline findings from claude[bot]/robobun/coderabbit are resolved in the current diff.
  • The one remaining CI failure (fetch-gzip.test.ts redirect case) was traced to a pre-existing regression from #33613 that reproduces on the PR's base commit with no stream changes applied.
  • Test coverage is solid (BYOB read/loop/reassemble, constructor path, Bun.file, default-reader-then-BYOB handoff, empty blob, partial-element close, and network bodies over HTTP/1.1 + HTTP/3), and the PR states WPT streams (1175 tests) and the 9090-test body-stream suite pass.
  • streams-leak.test.ts drops the distinctBuffers.size < 8 assertion because the byte-controller path now copies each pull's filled prefix into a fresh small Uint8Array rather than handing out subarrays of the shared scratch buffer. The remaining backingBytes < 4 MB bound still catches the original 16 MB regression, and the change is documented in the test comment — but weakening a leak test is worth a maintainer's explicit sign-off.

Given the scope, the number of subtle fixes it took to converge, and the deliberate loosening of an existing leak assertion, I'm deferring rather than auto-approving.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36643, which carries this change forward against current main (the adapter moved to JSInternalFieldObjectImpl in #36337 and Body.textStream() landed in #33825, so text-mode native streams keep a default controller while binary native streams get a byte controller).

@robobun robobun closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants