Skip to content

streams: detach ArrayBuffers supplied to byte stream reads and enqueues - #32406

Closed
robobun wants to merge 9 commits into
mainfrom
farm/e664ebe5/byob-detach-buffer
Closed

streams: detach ArrayBuffers supplied to byte stream reads and enqueues#32406
robobun wants to merge 9 commits into
mainfrom
farm/e664ebe5/byob-detach-buffer

Conversation

@robobun

@robobun robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes #32402. A ReadableStream byte source (type: "bytes") did not transfer (detach) the caller-supplied ArrayBuffer, so after a BYOB read(view) the original view and its buffer stayed attached and readable.

const stream = new ReadableStream({
  type: "bytes",
  pull(controller) { controller.byobRequest.respond(1); },
});
const reader = stream.getReader({ mode: "byob" });
const view = new Uint8Array(1);
await reader.read(view);
view.byteLength;          // bun: 1   node: 0
view.buffer.byteLength;   // bun: 1   node: 0
new Uint8Array(view.buffer); // bun: ok   node: throws (detached)

Cause

transferBufferToCurrentRealm in src/js/builtins/ReadableByteStreamInternals.ts was a no-op stub that returned the buffer unchanged, so the spec operation TransferArrayBuffer was never performed. Every call site that should detach (BYOB read, controller.enqueue, byobRequest.respondWithNewView) was affected.

Fix

  • Implement TransferArrayBuffer as a non-overridable native $transferArrayBuffer (in ZigGlobalObject.cpp, modeled on the existing $createUninitializedArrayBuffer). It detaches the source buffer and returns a new ArrayBuffer owning the same data block via ArrayBuffer::transferTo (zero-copy move).
  • Transfer the view's buffer up front in readableByteStreamControllerPullInto so every path (pending pull-into, closed stream, queue-satisfied, async) detaches the caller's view, and transfer in respondWithNewView and the enqueue paths. Buffer geometry (byteOffset/byteLength) is read before the transfer, since detaching zeroes it. The now-redundant transfers in the respond paths are removed.

Internal native/direct streams (Bun.file().stream(), Response.body, fetch/serve bodies) are unaffected: they use the ReadableStreamDefaultController path, which never routed through this helper. The native-reader enqueue case is left as a no-op as before.

Verification

New tests in test/js/web/streams/streams.test.js cover BYOB read, queue-satisfied BYOB read, closed-stream BYOB read, respondWithNewView, controller.enqueue, and enqueue with a waiting default reader. Each asserts the supplied buffer is detached and the delivered bytes are correct. All fail on the released binary and pass with the fix; the full streams suite (76 tests) and the HTTP body-stream suite (9086 tests) stay green.

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:25 PM PT - Jun 16th, 2026

@robobun, your commit 551db26 has 1 failures in Build #62888 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32406

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

bun-32406 --bun

@coderabbitai

coderabbitai Bot commented Jun 16, 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

Adds a native $transferArrayBuffer JSC host function that zero-copy transfers an ArrayBuffer via transferTo, detaching the original. Registers it as a private builtin, declares its TypeScript signature, and updates ReadableByteStreamInternals.ts to transfer/detach buffers at the correct spec-mandated points—capturing geometry before detachment and removing redundant late transfers. Comprehensive tests verify all BYOB and enqueue detachment paths including edge cases.

Changes

ReadableStream BYOB ArrayBuffer Detachment

Layer / File(s) Summary
Native functionTransferArrayBuffer host function and builtin registration
src/jsc/bindings/ZigGlobalObject.cpp, src/js/builtins/BunBuiltinNames.h, src/js/builtins.d.ts
Implements functionTransferArrayBuffer with type, sharing-mode, and detachment validation using transferTo for a zero-copy move; registers it as transferArrayBufferPrivateName on the global object; adds transferArrayBuffer to the BunBuiltinNames macro list; and declares the $transferArrayBuffer(buffer: ArrayBuffer): ArrayBuffer TypeScript intrinsic.
ReadableByteStream internals: buffer transfer timing and geometry capture
src/js/builtins/ReadableByteStreamInternals.ts
Updates transferBufferToCurrentRealm to invoke $transferArrayBuffer; updates readableByteStreamControllerEnqueue to capture byteOffset and byteLength before transfer, threading them through default-reader and BYOB enqueue paths; updates readableByteStreamControllerRespondWithNewView to validate and transfer view.buffer early with pre-captured length; updates readableByteStreamControllerRespond to validate before transfer and remove post-validation transfers; updates readableByteStreamControllerPullInto to transfer upfront and capture geometry before detachment; removes redundant late transfers from BYOB readable-state and closed-state commit paths.
Detachment semantics test suite
test/js/web/streams/streams.test.js
Adds describe("ReadableStream byte source detaches supplied ArrayBuffers") with twenty tests covering BYOB async read, BYOB sync read from queued data, BYOB read on closed stream, respondWithNewView double-buffer detachment, enqueue detachment with BYOB reader, enqueue detachment with a waiting default reader, respond() detachment, partial respond(n) detachment, out-of-range respond() validation without detachment, respond(0) validation without detachment, zero-length respondWithNewView() validation, respondWithNewView() exceeding space without detachment, respondWithNewView() with differently-sized backing buffer, SharedArrayBuffer and WebAssembly.Memory rejection, BYOB pull-into pending scenarios, and closed-stream error paths for respond() and respondWithNewView().
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately and specifically describes the primary change: implementing ArrayBuffer detachment in byte stream reads and enqueues operations.
Description check ✅ Passed The PR description fully covers both required template sections with comprehensive detail about what was fixed, how it was verified, including code examples and test coverage.
Linked Issues check ✅ Passed All coding requirements from issue #32402 are met: ArrayBuffer detachment in BYOB reads, enqueue, and respondWithNewView; proper state validation before transfer; and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing ArrayBuffer detachment in byte streams: type declarations, builtin identifiers, transfer implementation, enqueue/respond logic, and comprehensive tests.

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


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

Comment thread src/js/builtins/ReadableByteStreamInternals.ts
Comment thread src/js/builtins/ReadableByteStreamInternals.ts
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated

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

Caution

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

⚠️ Outside diff range comments (1)
src/js/builtins/ReadableByteStreamInternals.ts (1)

378-382: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate readable-zero and closed-nonzero responses before detaching.

Line 381 and Line 404 transfer the buffer before checking the state-specific byte-count rules. As written, respond(0) / a zero-length respondWithNewView() while readable can invalidate the BYOB request and leave the read pending, while a non-zero response after close is transferred and then ignored by the closed-state path.

🐛 Proposed fix
 export function readableByteStreamControllerRespondWithNewView(controller, view) {
   $assert($getByIdDirectPrivate(controller, "pendingPullIntos").isNotEmpty());
 
   let firstDescriptor: PullIntoDescriptor | undefined = $getByIdDirectPrivate(controller, "pendingPullIntos").peek();
@@
   // Spec: transfer the supplied view's buffer (detaching it). Capture byteLength
   // first, since detaching zeroes it.
   const viewByteLength = view.byteLength;
+  const stream = $getByIdDirectPrivate(controller, "controlledReadableStream");
+  const state = $getByIdDirectPrivate(stream, "state");
+  if (state === $streamClosed) {
+    if (viewByteLength !== 0) throw $makeTypeError("view.byteLength must be 0 when the stream is closed");
+  } else if (viewByteLength === 0) {
+    throw $makeTypeError("view.byteLength must be greater than 0");
+  }
   firstDescriptor!.buffer = $transferBufferToCurrentRealm(view.buffer);
   $readableByteStreamControllerRespondInternal(controller, viewByteLength);
 }
@@
   const firstDescriptor = $getByIdDirectPrivate(controller, "pendingPullIntos").peek();
+  const stream = $getByIdDirectPrivate(controller, "controlledReadableStream");
+  const state = $getByIdDirectPrivate(stream, "state");
   // Validate before transferring so an out-of-range respond does not detach the
   // buffer (matches the spec, which checks the range before TransferArrayBuffer).
-  if (
-    $getByIdDirectPrivate($getByIdDirectPrivate(controller, "controlledReadableStream"), "state") === $streamReadable &&
-    firstDescriptor.bytesFilled + bytesWritten > firstDescriptor.byteLength
-  )
-    throw new RangeError("bytesWritten value is too great");
+  if (state === $streamClosed) {
+    if (bytesWritten !== 0) throw $makeTypeError("bytesWritten must be 0 when the stream is closed");
+  } else {
+    if (bytesWritten === 0) throw $makeTypeError("bytesWritten must be greater than 0");
+    if (firstDescriptor.bytesFilled + bytesWritten > firstDescriptor.byteLength)
+      throw new RangeError("bytesWritten value is too great");
+  }
 
   // Spec (ReadableByteStreamControllerRespond step 6): transfer the descriptor's
   // buffer, detaching the view that was vended through byobRequest.
   firstDescriptor.buffer = $transferBufferToCurrentRealm(firstDescriptor.buffer);

Also applies to: 393-404

🤖 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/js/builtins/ReadableByteStreamInternals.ts` around lines 378 - 382, The
buffer transfer via $transferBufferToCurrentRealm should occur only after
validating the byte-length response against state-specific rules. Move the
validation logic that checks for invalid zero-length responses in readable state
and non-zero responses in closed state to occur before the
$transferBufferToCurrentRealm call and before capturing viewByteLength. This
prevents the buffer from being detached when the response would be invalid.
Apply this fix at both affected sites: the first location near line 378-382 in
the respond method path and the second location near lines 393-404 in the
respondWithNewView method path, ensuring validation happens before any buffer
transfer in both cases.
🤖 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.

Outside diff comments:
In `@src/js/builtins/ReadableByteStreamInternals.ts`:
- Around line 378-382: The buffer transfer via $transferBufferToCurrentRealm
should occur only after validating the byte-length response against
state-specific rules. Move the validation logic that checks for invalid
zero-length responses in readable state and non-zero responses in closed state
to occur before the $transferBufferToCurrentRealm call and before capturing
viewByteLength. This prevents the buffer from being detached when the response
would be invalid. Apply this fix at both affected sites: the first location near
line 378-382 in the respond method path and the second location near lines
393-404 in the respondWithNewView method path, ensuring validation happens
before any buffer transfer in both cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dd0feafc-505e-429b-a3d1-3e6cf14b49b8

📥 Commits

Reviewing files that changed from the base of the PR and between f77c219 and 7805e67.

📒 Files selected for processing (3)
  • src/js/builtins/ReadableByteStreamInternals.ts
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/web/streams/streams.test.js

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review finding on `respond()`/`respondWithNewView()` in 5077025: both now apply the spec's state-specific byte-count checks before `TransferArrayBuffer`. A `respond(0)` (or zero-length `respondWithNewView()`) on a readable stream, and a non-zero response after close, now throw a TypeError without detaching the buffer, matching the spec and Node. Added regression tests for the readable `respond(0)` and zero-length `respondWithNewView()` cases.

@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/js/builtins/ReadableByteStreamInternals.ts:393-404 — The new validate-before-transfer block implements only one of the three pre-transfer checks the spec performs in ReadableByteStreamControllerRespond: it has step 4c (bytesFilled + bytesWritten > byteLength → RangeError) but not step 3a (closed + bytesWritten !== 0 → TypeError) or step 4b (readable + bytesWritten === 0 → TypeError). So byobRequest.respond(0) on a readable stream — which per spec must throw TypeError without side effects — now reaches line 404 and detaches the vended byobRequest.view, then silently returns; same shape for respond(n>0) after close(). The missing throws themselves are pre-existing, but since this commit's explicit goal is "validate before transferring so a rejected respond does not detach the buffer", consider adding the two missing checks alongside the range check.

    Extended reasoning...

    What the bug is

    Commit 7805e67 added a pre-transfer guard to readableByteStreamControllerRespond with the comment "Validate before transferring so an out-of-range respond does not detach the buffer (matches the spec, which checks the range before TransferArrayBuffer)", plus an unconditional firstDescriptor.buffer = $transferBufferToCurrentRealm(firstDescriptor.buffer) at line 404. However, the spec's ReadableByteStreamControllerRespond has three checks before the TransferArrayBuffer step, not one:

    • Step 3a (state = "closed"): if bytesWritten is not 0, throw a TypeError.
    • Step 4b (state = "readable"): if bytesWritten is 0, throw a TypeError.
    • Step 4c (state = "readable"): if firstDescriptor.bytesFilled + bytesWritten > firstDescriptor.byteLength, throw a RangeError. ← only this one was added.

    Because steps 3a/4b are absent (and have never existed in Bun), calls the spec rejects now fall through to the new transfer at line 404 and detach the vended byobRequest.view.

    The code path that triggers it

    For respond(0) on a readable stream:

    1. ReadableStreamBYOBRequest.prototype.respond(0) checks only $isReadableStreamBYOBRequest and that the controller is set, then forwards to $readableByteStreamControllerRespond(controller, 0). No bytesWritten === 0 guard.
    2. Line 388: 0 is not NaN/Infinity/negative — passes.
    3. Lines 396–400: state is $streamReadable, but 0 + 0 > byteLength is false — passes.
    4. Line 404: firstDescriptor.buffer = $transferBufferToCurrentRealm(firstDescriptor.buffer) runs unconditionally, detaching the buffer that the source's saved byobRequest.view points at.
    5. RespondInternalRespondInReadableState: InvalidateBYOBRequest, bytesFilled += 0, then if (bytesFilled < elementSize) return — early-returns without throwing.

    For respond(n > 0) on a closed stream: the line-397 guard is gated on state === $streamReadable, so it is skipped entirely; line 404 detaches; RespondInClosedState ignores bytesWritten and never throws.

    Why existing code doesn't prevent it

    There is no bytesWritten === 0 / bytesWritten !== 0 check anywhere on the respond() path — not in ReadableStreamBYOBRequest.prototype.respond, not in readableByteStreamControllerRespond, and RespondInClosedState ignores the argument. Those checks have simply never been implemented in Bun (a pre-existing spec gap). What changed in this PR is that line 404 now performs a real transfer; before this PR, transferBufferToCurrentRealm was a no-op stub and there was no transfer call in respond() at all, so respond(0) was a pure no-op aside from invalidating the BYOB request.

    Step-by-step proof

    let savedView, lenAfter;
    const stream = new ReadableStream({
      type: "bytes",
      pull(c) {
        savedView = c.byobRequest.view;     // Uint8Array over firstDescriptor.buffer
        c.byobRequest.respond(0);            // spec: TypeError (step 4b). Bun: no throw.
        lenAfter = savedView.byteLength;     // pre-PR: 4. post-PR: 0 (buffer was detached at line 404)
        c.error(new Error("stop"));
      },
    });
    const reader = stream.getReader({ mode: "byob" });
    await reader.read(new Uint8Array(4)).catch(() => {});
    console.log(lenAfter);                   // pre-PR: 4   post-PR: 0   spec/Node: never reached (respond(0) threw)

    Trace: pullInto transfers the caller's buffer → firstDescriptor.buffer = B1. The byobRequest getter creates savedView over B1. respond(0) passes both guards, line 404 transfers B1 (detaching savedView), RespondInReadableState does bytesFilled += 0 and early-returns. No throw; savedView.byteLength is now 0.

    Impact

    The missing TypeError throws are a pre-existing spec deviation; this PR neither introduced nor removed them. The PR-introduced change is that the vended view's buffer is now detached on these spec-invalid calls, where pre-PR it stayed attached. That's a minor observable difference (a saved byobRequest.view becomes unusable vs. staying live) and the trigger is already-incorrect user code, so practical impact is small. But it's squarely in the theme this commit explicitly addressed — "a rejected respond does not detach the buffer" — and it added a test for exactly that property in the over-fill case, so completing the set seems in scope.

    How to fix

    Add the two missing checks alongside the existing one, before the transfer:

    const state = $getByIdDirectPrivate($getByIdDirectPrivate(controller, "controlledReadableStream"), "state");
    if (state === $streamClosed) {
      if (bytesWritten !== 0)
        throw new TypeError("bytesWritten must be 0 when responding on a closed stream");
    } else {
      $assert(state === $streamReadable);
      if (bytesWritten === 0)
        throw new TypeError("bytesWritten must be greater than 0 when responding on a readable stream");
      if (firstDescriptor.bytesFilled + bytesWritten > firstDescriptor.byteLength)
        throw new RangeError("bytesWritten value is too great");
    }
    firstDescriptor.buffer = $transferBufferToCurrentRealm(firstDescriptor.buffer);

Comment thread src/js/builtins/ReadableByteStreamInternals.ts Outdated
Comment thread src/js/builtins/ReadableByteStreamInternals.ts Outdated

@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 (1)
src/js/builtins/ReadableByteStreamInternals.ts (1)

338-342: ⚠️ Potential issue | 🟠 Major

The NativeReader enqueue path needs implementation or clarification.

readableStreamReaderKind() explicitly returns case 3 when a reader has both readRequests and $bunNativePtr (line 290: return reader.$bunNativePtr ? 3 : 1). The shouldCallPull() function also checks for this condition (line 278: if (reader && ($getByIdDirectPrivate(reader, "readRequests")?.isNotEmpty() || !!reader.$bunNativePtr))), confirming that native readers can be attached to byte streams alongside read requests.

However, case 3 in readableByteStreamControllerEnqueue() (lines 339-342) has no implementation—only a commented-out reader.$enqueueNative() call and a bare break. Meanwhile, cases 1, 2, and the default path all transfer the chunk's buffer via $transferBufferToCurrentRealm(), upholding the spec's detachment guarantee. If a native reader is attached, the buffer is never transferred, violating this invariant.

Either implement native reader buffer delivery (uncommenting or completing the $enqueueNative call and ensuring buffer transfer), or add an explicit guard preventing case 3 from being reached with byte stream enqueue operations.

🤖 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/js/builtins/ReadableByteStreamInternals.ts` around lines 338 - 342, The
case 3 NativeReader branch in the `readableByteStreamControllerEnqueue()`
function (lines 339-342) lacks implementation for handling native reader buffer
enqueue operations. The commented-out `reader.$enqueueNative()` call must be
either uncommented and completed to properly deliver the chunk to the native
reader while ensuring the buffer is transferred via
`$transferBufferToCurrentRealm()` (consistent with cases 1, 2, and the default
path to maintain the spec's buffer detachment guarantee), or alternatively, add
an explicit guard or condition earlier in the function to prevent native readers
(case 3) from reaching this enqueue path if native reader handling should be
managed elsewhere. Choose the implementation approach based on the intended
design for native reader support in byte streams.
🤖 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/streams.test.js`:
- Around line 1490-1538: Add test cases for closed-state validation of invalid
responses that are currently untested in the implementation. Create two new test
cases (similar in structure to the existing "respond(0)" and
"respondWithNewView(0)" tests) that: establish a pending BYOB read on a
ReadableStream, call controller.close() to close the stream, attempt to call
respond() or respondWithNewView() with non-zero values on the closed stream, and
verify that a TypeError is thrown without detaching the buffer (checking that
vendoredView.byteLength remains unchanged). These tests mirror the
readable-state tests but validate the closed-state branches in the respond and
respondWithNewView implementations.

---

Outside diff comments:
In `@src/js/builtins/ReadableByteStreamInternals.ts`:
- Around line 338-342: The case 3 NativeReader branch in the
`readableByteStreamControllerEnqueue()` function (lines 339-342) lacks
implementation for handling native reader buffer enqueue operations. The
commented-out `reader.$enqueueNative()` call must be either uncommented and
completed to properly deliver the chunk to the native reader while ensuring the
buffer is transferred via `$transferBufferToCurrentRealm()` (consistent with
cases 1, 2, and the default path to maintain the spec's buffer detachment
guarantee), or alternatively, add an explicit guard or condition earlier in the
function to prevent native readers (case 3) from reaching this enqueue path if
native reader handling should be managed elsewhere. Choose the implementation
approach based on the intended design for native reader support in byte streams.
🪄 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: 10723c5f-fd9d-4176-84df-54b0051d70d6

📥 Commits

Reviewing files that changed from the base of the PR and between 7805e67 and 5077025.

📒 Files selected for processing (2)
  • src/js/builtins/ReadableByteStreamInternals.ts
  • test/js/web/streams/streams.test.js

Comment thread test/js/web/streams/streams.test.js
Comment thread src/js/builtins/ReadableByteStreamInternals.ts Outdated
Comment thread src/js/builtins/ReadableByteStreamInternals.ts
Comment thread src/js/builtins/ReadableByteStreamInternals.ts Outdated
Comment thread src/js/builtins/ReadableByteStreamInternals.ts
Comment thread src/js/builtins/ReadableByteStreamInternals.ts Outdated
Comment thread src/js/builtins/ReadableByteStreamInternals.ts Outdated
Comment thread src/js/builtins/ReadableByteStreamInternals.ts

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

Can you do this without causing it to slowDownAndWasteMemory()?

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Short answer: slowDownAndWasteMemory() only fires for a view whose bytes live inline in the cell (FastTypedArray / OversizeTypedArray, i.e. new Uint8Array(n) with no explicit buffer). Detaching such a view per the Streams spec requires reading its .buffer, and that getter is what materializes the bytes. A view created over an explicit ArrayBuffer (the normal BYOB pattern, new Uint8Array(buffer, ...)) is a WastefulTypedArray, so .buffer is a plain getter and the transfer is a zero-copy m_contents.detach() — no slowdown. That .buffer read in pullInto is not new here either; the pull-into descriptor was always built from view.buffer.

What this PR adds, in byte terms, is the transfer itself: it detaches the caller's view in pullInto, and respond() detaches the view that was vended through byobRequest (spec step 6, so a source that retains byobRequest.view cannot keep mutating what the reader already received). Both are zero byte-copy; each allocates one ArrayBuffer wrapper, so a read+respond is two wrapper allocations.

Two levers to cut that, both your call:

  1. Collapse to a single detach at respond() time (leave the caller's buffer aliased in pullInto, transfer once in respond() which detaches the caller's view and the vended view together). The caller's view is still detached by the time read() resolves; the only observable change is that it is not detached synchronously at the read() call, which diverges slightly from other engines.
  2. Drop the respond()-side detach entirely and keep only the pullInto one. That removes the per-read allocation but lets a source mutate the reader's bytes via a retained byobRequest.view.

The inline-storage copy for a bare new Uint8Array(n) can't go to zero without not detaching those views at all. Happy to implement whichever tradeoff you prefer.

robobun added 9 commits June 16, 2026 21:17
transferBufferToCurrentRealm in ReadableByteStreamInternals was a no-op
stub, so a ReadableStream byte source never performed the spec's
TransferArrayBuffer. As a result a BYOB read(view), controller.enqueue(),
and byobRequest.respondWithNewView() all left the caller-supplied
ArrayBuffer attached and accessible, diverging from the WHATWG Streams
spec and Node.

Implement TransferArrayBuffer as a non-overridable native (),
which detaches the source buffer and returns a new ArrayBuffer that owns
the same bytes (zero-copy move). Transfer the view's buffer up front in
readableByteStreamControllerPullInto so every path (pending, closed,
queue-satisfied, async) detaches, and transfer in respondWithNewView and
the enqueue paths. Geometry (byteOffset/byteLength) is read before the
transfer since detaching zeroes it.

Fixes #32402
Follow-up to the byte-stream detach work addressing three gaps:

- byobRequest.respond(n) now performs the spec's step-6 TransferArrayBuffer
  on the descriptor's buffer, detaching the view vended through byobRequest
  (including on a partial respond). The range is validated before the
  transfer so an out-of-range respond throws without detaching.

- A BYOB read() whose view is backed by a non-transferable buffer
  (SharedArrayBuffer, WebAssembly.Memory) now returns a rejected promise
  instead of throwing synchronously, matching the Streams spec and Node.

- $transferArrayBuffer reports distinct errors for a non-ArrayBuffer
  argument, a SharedArrayBuffer, and a detached buffer, and guards
  WebAssembly.Memory buffers the way ArrayBuffer.prototype.transfer does.
readableByteStreamControllerRespond and respondWithNewView now apply the
spec's state-specific byte-count checks before TransferArrayBuffer: a
respond(0) (or zero-length respondWithNewView) on a readable stream, and a
non-zero response after close, throw a TypeError without detaching the
buffer. Previously the buffer was transferred first and the invalid
response either left the read pending or was silently ignored. Matches the
Streams spec and Node.
…thNewView

- enqueue() on a BYOB reader with a pending pull-into now transfers that
  descriptor's buffer (spec step 8), detaching the view vended through
  byobRequest when a source enqueues instead of responding.
- respondWithNewView() now rejects a view that exceeds the remaining space
  (accounting for bytes already filled, spec step 9) before the transfer, so
  a failed call does not detach the supplied buffer.

Adds tests for both, plus closed-state validation of respond()/
respondWithNewView() (a non-zero response on a closed stream throws a
TypeError without detaching).
Adds the spec step-8 check to readableByteStreamControllerRespondWithNewView:
if the supplied view's buffer is not the same byte length as the pending
descriptor's buffer, throw a RangeError before the transfer. Without it, a
mismatched buffer was transferred into the descriptor, leaving its
byteOffset/byteLength geometry invalid and making the next byobRequest getter
throw out of bounds. Matches the spec and Node.
… per spec

Two follow-ups to the byte-stream detach work:

- respondWithNewView() compared the live firstDescriptor.buffer.byteLength
  against the new view's buffer. When a source transfers the vended buffer out
  and responds with a view over it (the canonical pattern), that buffer is
  detached (byteLength 0) and the check wrongly threw. Cache the descriptor's
  buffer byte length at creation (spec's "buffer byte length" field) and
  compare against that, so the check survives the caller detaching the buffer.

- enqueue() on a BYOB reader transferred the pending descriptor's buffer and
  invalidated the byobRequest before transferring the chunk's buffer. Since the
  chunk transfer is the fallible step (spec step 7, before step 8), a chunk that
  cannot be transferred (SharedArrayBuffer) detached the vended view as a side
  effect of a failed enqueue. Transfer the chunk first so a failed enqueue has
  no side effects.

Both match the spec and Node.
…View over-fill

Two more spec-ordering/consistency fixes from review:

- enqueue() case 2 now transfers the pending descriptor's buffer (spec step
  8.d, which throws on an already-detached buffer per step 8.b) before
  invalidating the byobRequest (step 8.c). A source that detaches the vended
  buffer itself and then enqueues a valid chunk now gets the throw with the
  byobRequest still valid for recovery, instead of a pre-invalidated request.

- respondWithNewView()'s over-fill check (spec step 9) now throws a RangeError
  instead of a TypeError, matching the spec, Node, and the identical check in
  respond().

Both verified against Node; tests updated/added.
…d over-fill

- respondWithNewView() now captures view.buffer into a local and uses it for
  both the size check and the transfer, so a tampered buffer getter cannot pass
  validation with one buffer and transfer a different one (matches the spec's
  single [[ViewedArrayBuffer]] read).

- The over-fill check at the top of readableByteStreamControllerRespondInReadableState
  is unreachable now that both callers validate before transferring, so it is a
  spec Assert rather than a throw.

Added a test that the supplied view's buffer is read only once.
@robobun
robobun force-pushed the farm/e664ebe5/byob-detach-buffer branch from fd82b00 to 551db26 Compare June 16, 2026 21:25
@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (now includes the WebKit upgrade in #31796 and #32407); the branch is mergeable again.

Only conflict was in test/js/web/streams/streams.test.js: main added a BYOB-cancel test and this PR added the detach-suite describe block, both appended at the same spot. Resolved by keeping both.

Verified against the upgraded WebKit: debug build is clean and the byte-source detach suite passes (23/23). The two streams.test.js failures seen locally (read text from large file, handles exceptions during empty stream creation) are pre-existing and unrelated: both pass on the release binary in ~100-150ms and only exceed the 5s per-test budget under debug+ASAN on a capped machine. Neither touches the byte-controller path this PR changes.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Covered by the web streams rewrite in #33193, which landed on main and closed #32402. Main now detaches the supplied buffer on BYOB read() and on byte stream enqueue(), and the builtins this PR patches (ReadableByteStreamInternals.ts) no longer exist. This PR's tests pass against main as-is, apart from one that only checks the wording of the SharedArrayBuffer error message (main still throws a TypeError there). Closing.

@robobun robobun closed this Aug 12, 2026
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.

ReadableStream BYOB read() does not detach supplied ArrayBuffer

2 participants