Skip to content

fetch: implement Body.textStream() - #33825

Merged
Jarred-Sumner merged 24 commits into
mainfrom
claude/farm/284405a5/body-textstream
Jul 28, 2026
Merged

fetch: implement Body.textStream()#33825
Jarred-Sumner merged 24 commits into
mainfrom
claude/farm/284405a5/body-textstream

Conversation

@robobun

@robobun robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Implements the textStream() method on the Body mixin (Request and Response), returning a ReadableStream<string> of the body decoded as UTF-8 text.

Approach

Rather than materializing a byte ReadableStream and piping it through a TextDecoderStream, each body backing decodes directly:

  • In-memory bodies (string, bytes, Blob-with-bytes) decode once and enqueue a single string chunk into a closed stream (ReadableStream__fromDecodedText).
  • Native byte sources (fetch response ByteStream, file loader) run in a text-mode JSNativeStreamSourceAdapter that UTF-8-decodes each pulled span before enqueueing. The streaming-decode state (3 held-back bytes + BOM-seen) lives inline on the adapter; no extra GC cells.
  • Bodies that already have a materialized byte stream (user-provided ReadableStream, or .body was accessed first) use a new SourceKind::TextDecode whose pull issues a ReadRequestKind::TextDecode read on the source reader; chunk steps decode and enqueue strings. The decode state is a 4-byte Uint8Array stored in the controller's existing underlyingObject slot.

streamingUTF8Decode (WebStreamsMisc.cpp) joins held-back bytes from a previous chunk, strips a single leading BOM per stream, holds back a trailing incomplete sequence, and decodes via WTF::String::fromUTF8ReplacingInvalidSequences. Held-back bytes are always [lead(>=0xC0), cont*(0x80..0xBF)] so the length is derived from the first zero byte.

Verification

$ bun bd test test/js/web/fetch/body.test.ts -t textStream
56 pass, 0 fail

Covers: string/buffer/null/undefined bodies, user ReadableStream bodies, multi-byte characters split across chunks, leading BOM stripping, invalid-sequence replacement, fetch response bodies (native ByteStream path), body-unusable/locked error handling, error propagation, cancel propagation, Content-Type charset is ignored.


[review] gate passed · iteration 14 · 20 files touched

fails on main (without fix)
ASAN without fix: 94 failed, 4 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/body.test.ts test/js/web/fetch/wpt/textstream-wpt.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (6b1404cce)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [4.97ms]
(pass) Request > constructor > null [3.59ms]
(pass) Request > constructor > string > "" [6.00ms]
(pass) Request > constructor > string > "Hello world" [1.97ms]
(pass) Request > constructor > string > "🫠" [1.55ms]
(pass) Request > constructor > string > "⁉️" [1.27ms]
(pass) Request > constructor > ArrayBuffer > empty buffer [8.73ms]
(pass) Request > constructor > ArrayBuffer > small buffer [3.63ms]
(pass) Request > constructor > ArrayBuffer > large buffer [4.60ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [1.85ms]
(pass) Request > constructor > SharedArrayBuffer > small b
... (truncated)

release without fix: 4 skipped
bun test v1.4.0-canary.1 (40453587f)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [0.07ms]
(pass) Request > constructor > null [0.03ms]
(pass) Request > constructor > string > "" [0.12ms]
(pass) Request > constructor > string > "Hello world" [0.01ms]
(pass) Request > constructor > string > "🫠" [0.01ms]
(pass) Request > constructor > string > "⁉️"
(pass) Request > constructor > ArrayBuffer > empty buffer [0.11ms]
(pass) Request > constructor > ArrayBuffer > small buffer [0.08ms]
(pass) Request > constructor > ArrayBuffer > large buffer [1.72ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [0.03ms]
(pass) Request > constructor > SharedArrayBuffer > small buffer [0.03ms]
(pass) Request > constructor > SharedArrayBuffer > large buffer [1.95ms]
(pass) Request > constructor > Buffer > empty buffer [0.02ms]
(pass) Request > constructor > Buffer > small buffer [0.01ms]
(pass) Request > constructor > Buffer > large buffer [1.59ms]
(pass) Request > constructor > Uint8Array > empty buffer [0.01ms]
(pass) Request > constructor > Uint8Array > small buffer [0.03ms]
(pass) Request > constructor > Uint8Array > large buffer [1.4
... (truncated)
passes on PR (with fix)
ASAN with fix: 4 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/body.test.ts test/js/web/fetch/wpt/textstream-wpt.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (6b1404cce)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [5.01ms]
(pass) Request > constructor > null [3.42ms]
(pass) Request > constructor > string > "" [5.91ms]
(pass) Request > constructor > string > "Hello world" [1.54ms]
(pass) Request > constructor > string > "🫠" [1.77ms]
(pass) Request > constructor > string > "⁉️" [1.19ms]
(pass) Request > constructor > ArrayBuffer > empty buffer [8.63ms]
(pass) Request > constructor > ArrayBuffer > small buffer [3.50ms]
(pass) Request > constructor > ArrayBuffer > large buffer [4.37ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [1.79ms]
(pass) Request > constructor > SharedArrayBuffer > small b
... (truncated)

release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     6b1404cced
  features     (none)

22 deps, 106 codegen, 1169 objects in 674ms

ninja: Entering directory `/workspace/bun/build/release'
[1/59] gen cpp.rs (cppbind)
[2/59] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[3/59] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /wor
... (truncated)
diff hotspot
packages/bun-types/fetch.d.ts                      |  16 +
 .../bindings/webcore/streams/BunStreamSource.cpp   |  62 +++-
 src/jsc/bindings/webcore/streams/BunStreamSource.h |   3 +
 src/jsc/bindings/webcore/streams/JSReadRequest.cpp |  15 +
 .../streams/JSReadableByteStreamController.cpp     |   2 +
 .../bindings/webcore/streams/JSReadableStream.h    |   9 +
 .../streams/JSReadableStreamDefaultController.cpp  |   4 +
 .../webcore/streams/ReadableStreamOperations.cpp   | 136 +++++++
 src/jsc/bindings/webcore/streams/StreamQueue.h     |   2 +
 src/jsc/bindings/webcore/streams/StreamsForward.h  |  26 ++
 .../bindings/webcore/streams/WebStreamsExports.cpp |  43 +++
 .../bindings/webcore/streams/WebStreamsInternals.h |  20 ++
 .../bindings/webcore/streams/WebStreamsMisc.cpp    |  89 +++++
 src/runtime/server/RequestContext.rs               |  21 ++
 src/runtime/webcore/Body.rs                        | 232 ++++++++----
 src/runtime/webcore/ReadableStream.rs              |  46 ++-
 src/runtime/webcore/response.classes.ts            |   2 +
 test/js/web/fetch/body.test.ts                     | 397 +++++++++++++++++++++
 test/js/web/fetch/wpt/textstream-wpt.test.ts       |  32 ++
 test/js/web/fetch/wpt/textstream.any.js            | 177 +++++++++
 20 files changed, 1258 insertions(+), 76 deletions(-)

gate history · 17 passed · 0 rejected · iteration 14

evidence per changed file
file                                                      reads  edits  tests
packages/bun-types/fetch.d.ts                                 2      2      0
src/jsc/bindings/webcore/streams/BunStreamSource.cpp         11     17      0
src/jsc/bindings/webcore/streams/BunStreamSource.h            5      5      0
src/jsc/bindings/webcore/streams/JSReadRequest.cpp            3      5      0
…ings/webcore/streams/JSReadableByteStreamController.cpp      1      2      0
src/jsc/bindings/webcore/streams/JSReadableStream.h           4      4      0
…s/webcore/streams/JSReadableStreamDefaultController.cpp      2      3      0
…c/bindings/webcore/streams/ReadableStreamOperations.cpp     14     22      0
src/jsc/bindings/webcore/streams/StreamQueue.h                2      1      0
src/jsc/bindings/webcore/streams/StreamsForward.h             7     11      0
src/jsc/bindings/webcore/streams/WebStreamsExports.cpp        4      8      0
src/jsc/bindings/webcore/streams/WebStreamsInternals.h        6     11      0
src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp           6     10      0
src/runtime/server/RequestContext.rs                          5      2      0
src/runtime/webcore/Body.rs                                  14     10      0
src/runtime/webcore/ReadableStream.rs                         3      8      0
(+ 4 more files)

@robobun
robobun requested a review from alii as a code owner July 9, 2026 07:22
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:16 PM PT - Jul 18th, 2026

@robobun, your commit 6b1404c has 1 failures in Build #75623 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33825

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

bun-33825 --bun

@github-actions github-actions Bot added the claude label Jul 9, 2026
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/jsc/bindings/webcore/streams/StreamsForward.h Outdated
Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp Outdated
@coderabbitai

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

This PR adds textStream() to Request and Response, returning a UTF-8-decoded ReadableStream<string>. It threads text decoding through stream internals, native source handling, Rust bindings, Body logic, prototype wiring, and test coverage.

Changes

textStream() implementation

Layer / File(s) Summary
Type declarations and decode contracts
packages/bun-types/fetch.d.ts, src/jsc/bindings/webcore/streams/StreamsForward.h, src/jsc/bindings/webcore/streams/StreamQueue.h, src/jsc/bindings/webcore/streams/WebStreamsInternals.h, src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
Adds textStream() declarations, TextDecode stream kinds, inline decode state, and streaming decode helper declarations and implementation used by the new text stream path.
TextDecode dispatch wiring
src/jsc/bindings/webcore/streams/JSReadRequest.cpp, src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp, src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp, src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
Adds TextDecode handling to read requests and stream controller dispatch, and implements the derived readable stream text-decode pipeline.
Native source text mode
src/jsc/bindings/webcore/streams/BunStreamSource.h, src/jsc/bindings/webcore/streams/JSReadableStream.h, src/jsc/bindings/webcore/streams/BunStreamSource.cpp, src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp
Adds native stream text-mode state and updates native enqueue, drain, pull-result, start, close, and buffered fast-path handling for text mode.
C++ FFI exports for text streams
src/jsc/bindings/webcore/streams/WebStreamsExports.cpp, src/jsc/bindings/webcore/streams/WebStreamsInternals.h
Adds FFI entry points for creating native text streams, creating streams from decoded text, and decoding text from an existing stream.
Rust ReadableStream text constructors
src/runtime/webcore/ReadableStream.rs
Adds Rust FFI declarations, public constructors, and helper refactors for creating native text streams and text-decoding streams.
Body text stream entry points
src/runtime/webcore/Body.rs, src/runtime/server/RequestContext.rs
Adds Body::to_text_readable_stream and BodyMixin::get_text_stream, plus request-stream shutdown handling for pending readable bodies.
Prototype wiring and tests
src/runtime/webcore/response.classes.ts, test/js/web/fetch/body.test.ts, test/js/web/fetch/wpt/textstream-wpt.test.ts, test/js/web/fetch/wpt/textstream.any.js
Wires textStream onto Request and Response prototypes and adds tests for decoding, consumption, locking, cancellation, abort handling, and WPT coverage.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: implementing Body.textStream().
Description check ✅ Passed The description covers what changed, the implementation approach, and verification details, matching the template intent.

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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/webcore/streams/StreamsForward.h`:
- Around line 119-120: The comments for SourceKind::TextDecode and the stream
controller setup are stale and describe non-existent types/shapes. Update the
documentation in StreamsForward.h around TextDecode and the controller
algorithmContext comment to match the actual ReadableStreamOperations.cpp
behavior: algorithmContext is the JSReadableStreamDefaultReader and
underlyingObject is the state Uint8Array, with no InternalFieldTuple or
JSStreamTextDecodeContext mentioned. Keep the wording aligned with the existing
WebStreamsInternals.h comment for the same feature.

In `@test/js/web/fetch/body.test.ts`:
- Around line 276-416: The current textStream() coverage in body.test.ts misses
the locked native-request body path that can expose the Body.rs
to_text_readable_stream Locked behavior. Add a test using a
server/request-backed Request or Response body in a pending Locked state, then
call .textStream() and verify subsequent access to .body or a second
.textStream() behaves correctly without corrupting the underlying readable slot.
Use the existing textStream() test group and the fn()/ReadableStream-based
helpers as the reference points when adding the new scenario.
🪄 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: bd5b70fa-7102-4b26-b8cb-042430d8b7c4

📥 Commits

Reviewing files that changed from the base of the PR and between bdd6c1f and 4e0818c.

📒 Files selected for processing (16)
  • packages/bun-types/fetch.d.ts
  • src/jsc/bindings/webcore/streams/BunStreamSource.cpp
  • src/jsc/bindings/webcore/streams/BunStreamSource.h
  • src/jsc/bindings/webcore/streams/JSReadRequest.cpp
  • src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStream.h
  • src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp
  • src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
  • src/jsc/bindings/webcore/streams/StreamsForward.h
  • src/jsc/bindings/webcore/streams/WebStreamsExports.cpp
  • src/jsc/bindings/webcore/streams/WebStreamsInternals.h
  • src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/ReadableStream.rs
  • src/runtime/webcore/response.classes.ts
  • test/js/web/fetch/body.test.ts

Comment thread src/jsc/bindings/webcore/streams/StreamsForward.h Outdated
Comment thread test/js/web/fetch/body.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  • 🔴 src/runtime/webcore/Body.rs:1011-1030 — The Value::Locked arm stores the text-mode stream in locked.readable (tagged Source::Bytes) and never transitions to Value::Used — so on a fetch response or incoming server request body, after res.textStream(): res.body returns the string stream (consumers expecting Uint8Array chunks get strings), res.bodyUsed is still false, and a second res.textStream() doesn't throw but instead wraps the string stream in another TextDecode reader that errors on first read with "chunk that is not an ArrayBufferView". The existing "second textStream() call throws" test only covers fn("hello") (the WTFStringImpl arm, which correctly transitions to Used); this arm should likewise transition to Value::Used and not cache the text stream in the byte-oriented locked.readable slot.

    Extended reasoning...

    What the bug is

    to_text_readable_stream()'s Value::Locked arm (Body.rs:1011–1030) creates the text-mode native stream via reader.to_text_readable_stream(), then stores its JSValue in locked.readable as a ReadableStream { ptr: Source::Bytes(context_ptr), value: <text stream> } and returns — leaving *self as Value::Locked. Every other arm of this function (Empty, InternalBlob/WTFStringImpl, Blob) transitions to Value::Used, and the sibling early-return in get_text_stream() (the already-materialized-stream path) also sets *self.get_body_value() = Value::Used. Only this arm — the one that runs for fetch responses and incoming server request bodies — leaves the body in a state where the byte-oriented locked.readable slot holds a ReadableStream<string>.

    Reachability

    A fetch Response body is created as Value::Locked with on_start_streaming set and locked.readable empty (FetchTasklet.rs), and the JS stream cache slot is unset until .body is accessed. So on a fresh fetch response, get_text_stream() at Body.rs:1959–1966 checks get_body_readable_stream(), which returns None (JS cache empty at line 1801, locked.readable empty at line 1813), and falls through to to_text_readable_stream()'s Value::Locked arm. The same is true for an incoming Bun.serve request body before .body is touched. This is the primary use case for textStream(), not an edge case.

    Consequence 1: res.body returns a string stream

    After res.textStream(), the body is still Value::Locked with locked.readable = the text stream. get_body() (Body.rs:1939–1942) on Value::Locked calls get_body_readable_stream(), which now finds locked.readable at line 1813 and returns readable.value — the text-mode stream. So res.body yields a ReadableStream whose chunks are JS strings, not Uint8Arrays. Any consumer that expects the spec'd ReadableStream<Uint8Array> (e.g. res.body.pipeTo(writableExpectingBytes), or for await (const chunk of res.body) chunk.byteLength) will misbehave.

    Consequence 2: second res.textStream() doesn't throw synchronously

    throw_if_body_unusable()body_stream_check(is_disturbed || is_locked) finds locked.readable = the text stream. That stream is a fresh NativePending stream with no reader attached (m_reader empty, m_lockedWithoutReader = false, m_disturbed = false, nativeHandleDetached() = false), so is_disturbed || is_locked is falseno throw. Then get_body_readable_stream() returns the text stream and text_decode_from() locks a reader on it. On the first pull, textDecodeReadRequestChunkSteps() receives a JS string chunk, dynamicDowncast<JSArrayBufferView> fails, and the output stream errors with "Body.textStream() received a chunk that is not an ArrayBufferView". The spec requires a synchronous TypeError on the second call; instead the user gets a stream that errors asynchronously with a confusing message.

    Consequence 3: res.bodyUsed is false immediately after textStream()

    get_body_used()body_stream_check(is_disturbed) on the not-yet-read text stream → false. Per the spec, textStream() disturbs the body's stream, so bodyUsed must be true immediately after. The "marks body as used" test uses fn("hello") (the WTFStringImpl arm) and passes; the fetch test only checks bodyUsed after Array.fromAsync has consumed the stream, so neither catches this.

    Step-by-step proof

    const res = await fetch(server.url);        // body = Value::Locked, locked.readable empty
    const s1 = res.textStream();
    // → get_text_stream: get_body_readable_stream() = None → to_text_readable_stream()
    // → Value::Locked arm: locked.readable = Strong { Source::Bytes(ctx), <text stream> }
    //   *self stays Value::Locked
    
    res.bodyUsed;        // false  ← should be true
    res.body;            // returns the text stream ← should be a byte stream (or locked/used)
    const s2 = res.textStream();
    // → throw_if_body_unusable: text stream is neither disturbed nor locked → OK
    // → get_body_readable_stream() = text stream → text_decode_from(text stream)
    //   ← should have thrown TypeError synchronously
    for await (const _ of s2) {}
    // → textDecodeReadRequestChunkSteps gets a string chunk → errors:
    //   "Body.textStream() received a chunk that is not an ArrayBufferView"

    Why the byte-mode sibling's caching doesn't apply here

    to_readable_stream()'s Value::Locked arm intentionally caches the byte stream in locked.readable so .body is idempotent — accessing .body twice returns the same ReadableStream<Uint8Array>. That invariant is exactly what the locked.readable slot exists for, and is why get_body() short-circuits through it. textStream() has different semantics: it consumes the body (like .text()), so caching its result in the slot that .body/bodyUsed/throw_if_body_unusable all consult as "the body's byte stream" is a category error.

    Fix

    The Value::Locked arm still needs to hand the ByteStream context to on_readable_stream_available (fetch/server rely on it to start streaming), but it should not store the text-mode JS stream in locked.readable, and it should transition the body to Value::Used before returning — matching every other arm and the early-return path in get_text_stream(). One shape: keep the NewSource<ByteStream> setup, call on_readable_stream_available with a ReadableStream { ptr: Source::Bytes(context_ptr), value: stream_value } constructed on the stack (no Strong::init into locked.readable), then *self = Value::Used and return stream_value. Add a test that calls .textStream() on a fetch response, then asserts res.bodyUsed === true immediately and () => res.textStream() throws TypeError — the current "second textStream() call throws" test only exercises the WTFStringImpl path.

Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp Outdated
Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the three findings from the second review plus the CI exception-check failure in 60f32cb:

  • The StreamingUTF8DecodeState is now a plain 4-byte field on SourceAlgorithmSlots (no JSCell allocation per TextDecode controller).
  • The Value::Locked arm now marks the body Value::Used after creating the text stream, so on a streaming fetch/server body bodyUsed is true immediately and a second textStream() throws synchronously. The "streams a fetch response body" test now asserts this.
  • textDecodeReadRequest{Chunk,Close}Steps guard with canCloseOrEnqueue so concurrent output reads followed by a source close cannot touch cleared algorithm slots (regression test added).
  • Raw ArrayBuffer chunks from a user ReadableStream body are now accepted (BufferSource, not just ArrayBufferView).
  • Added RETURN_IF_EXCEPTION after readableStreamDefaultControllerError for the x64-asan unchecked-exception assertion.

Comment thread src/runtime/webcore/Body.rs
Comment thread src/jsc/bindings/webcore/streams/WebStreamsExports.cpp
Comment thread src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
Comment thread src/runtime/webcore/Body.rs
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the latest review batch:

  • 🔴 "Locked arm not setting Value::Used": this was already fixed in 60f32cb (if text_mode { *self = Value::Used; } at the tail of locked_to_native_stream); the "streams a fetch response body" test asserts bodyUsed === true and a second-call TypeError before reading, and passes. The review was against the pre-fix diff range.
  • 🟡 exceedsStringLimit guard: added in d329cf4 at the top of the decode dispatch in streamingUTF8Decode, throwing a catchable OutOfMemoryError to match the other text consumers.
  • 🟡 text-mode stream tagged as byte source: added the m_nativeTextMode guard to tryUseReadableStreamBufferedFastPath in d329cf4 (with a test). Did not guard ReadableStreamTag__tagged: FetchTasklet re-derives the push-side ByteStream* through that path, so returning JavaScript there breaks streaming fetch bodies (verified empirically). Filing that as a follow-up that needs the tasklet to store the pointer directly.
  • 🟡 string-body BOM: leaving as-is; .text() has the same behavior via the same helper, so it belongs in a shared Any::to_string() change rather than here.

Comment thread src/runtime/webcore/Body.rs
Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp Outdated
@robobun
robobun force-pushed the claude/farm/284405a5/body-textstream branch from c17406c to ba363d3 Compare July 9, 2026 09:31

@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/jsc/bindings/webcore/streams/BunStreamSource.cpp (1)

744-758: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Missing exception check after nativeEnqueueTextChunk in nativeSourceOnDrain.

streamingUTF8Decode can throw a catchable OutOfMemoryError (per the exceedsStringLimit guard added for this feature), and nativeEnqueueTextChunk propagates that via RETURN_IF_EXCEPTION rather than absorbing it. Every other call site of nativeEnqueueTextChunk in this file either checks the exception (with its own scope) or absorbs it via DECLARE_TOP_EXCEPTION_SCOPE + Bun__reportError (see nativeSourceCallClose). nativeSourceOnDrain has no throw scope at all, so a pending exception silently leaks back into the native onDrain callback boundary, which isn't expecting one — the same class of bug the PR's own follow-up fix (adding RETURN_IF_EXCEPTION after readableStreamDefaultControllerError) was addressing elsewhere.

🐛 Proposed fix mirroring `nativeSourceCallClose`'s absorb pattern
     if (adapter->m_textMode) {
         auto& vm = getVM(globalObject);
         if (auto* view = dynamicDowncast<JSC::JSArrayBufferView>(chunk)) {
             std::span<const uint8_t> bytes { static_cast<const uint8_t*>(view->vector()), view->byteLength() };
-            nativeEnqueueTextChunk(vm, globalObject, adapter, controller, bytes, /* flush */ false);
+            auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
+            nativeEnqueueTextChunk(vm, globalObject, adapter, controller, bytes, /* flush */ false);
+            if (catchScope.exception()) [[unlikely]] {
+                JSValue thrown = takeAbruptCompletion(globalObject, catchScope);
+                if (!thrown.isEmpty())
+                    Bun__reportError(globalObject, JSValue::encode(thrown));
+            }
         }
         return;
     }

As per path instructions: "In C++ code that can enter JS, check for exceptions after every call that can throw or run user code before using the result."

🤖 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/BunStreamSource.cpp` around lines 744 - 758,
The nativeSourceOnDrain path in BunStreamSource.cpp calls nativeEnqueueTextChunk
without any exception handling, so a thrown OutOfMemoryError can escape the
onDrain boundary. Update nativeSourceOnDrain to use a throw scope or otherwise
check and handle the pending exception immediately after nativeEnqueueTextChunk,
matching the exception handling pattern used by nativeSourceCallClose and the
other nativeEnqueueTextChunk call sites in this file.

Source: Path instructions

🤖 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/jsc/bindings/webcore/streams/BunStreamSource.cpp`:
- Around line 744-758: The nativeSourceOnDrain path in BunStreamSource.cpp calls
nativeEnqueueTextChunk without any exception handling, so a thrown
OutOfMemoryError can escape the onDrain boundary. Update nativeSourceOnDrain to
use a throw scope or otherwise check and handle the pending exception
immediately after nativeEnqueueTextChunk, matching the exception handling
pattern used by nativeSourceCallClose and the other nativeEnqueueTextChunk call
sites in this file.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c59ba589-2323-4232-a8e9-e5c0e97f204e

📥 Commits

Reviewing files that changed from the base of the PR and between c17406c and ba363d3.

📒 Files selected for processing (21)
  • packages/bun-types/fetch.d.ts
  • src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp
  • src/jsc/bindings/webcore/streams/BunStreamSource.cpp
  • src/jsc/bindings/webcore/streams/BunStreamSource.h
  • src/jsc/bindings/webcore/streams/JSReadRequest.cpp
  • src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStream.h
  • src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp
  • src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
  • src/jsc/bindings/webcore/streams/StreamQueue.h
  • src/jsc/bindings/webcore/streams/StreamsForward.h
  • src/jsc/bindings/webcore/streams/WebStreamsExports.cpp
  • src/jsc/bindings/webcore/streams/WebStreamsInternals.h
  • src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
  • src/runtime/server/RequestContext.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/ReadableStream.rs
  • src/runtime/webcore/response.classes.ts
  • test/js/web/fetch/body.test.ts
  • test/js/web/fetch/wpt/textstream-wpt.test.ts
  • test/js/web/fetch/wpt/textstream.any.js

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the nativeSourceOnDrain exception-handling finding: the host-function trampoline jsWebStreamsHandler_boundOnNativeSourceDrain (BunStreamSource.cpp:1731) already wraps the call in a DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION, so an exception from nativeEnqueueTextChunk (or from the pre-existing byte-mode readableStreamDefaultControllerEnqueue two lines below, which has the identical structure) propagates correctly to the JS caller. nativeSourceCallClose uses the absorb-and-report pattern only because it runs as a microtask body with no caller to propagate to; nativeSourceOnDrain is invoked through a bound JS function, so the trampoline is the right boundary. Leaving as-is to match the existing byte-mode arm.

Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI on d85e3b2 is red on Windows lanes only, all unrelated to this diff:

  • test/cli/update_interactive_install.test.ts (aarch64): install subprocess exited 1 (flaky)
  • test/js/node/tls/node-tls-connect.test.ts (aarch64): ECONNRESET/ECONNREFUSED (flaky)
  • test/js/sql/postgres-*.test.ts (x64-baseline): ERR_POSTGRES_CONNECTION_REFUSED (local postgres not reachable)

None of these touch fetch/body/streams. The textStream tests (76 in body.test.ts, 14 WPT) pass on all lanes that ran. Already used the one re-roll earlier; ready for a maintainer to rerun or merge.

Comment thread src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
Comment thread test/js/web/fetch/body.test.ts Outdated
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

At 6b1404c (rebased onto f5ba155; all review nits addressed). 88 textStream + 14 WPT + 436 body.test.ts + 1175 wpt-streams pass locally; clippy/ts-types green. All review threads resolved.

Build 75623: only hard-fail is test/js/node/test/parallel/test-net-connect-memleak.js on alpine x64-baseline (GC-timing assertion, single lane). Remaining failures are one-retry flakes (install, es-module-lexer, http-agent-keepalive, bun-jsc, node-net, fs-promises, cpu-prof). None touch fetch/body/streams. textStream suite green on every lane. Ready for merge.

Comment thread src/jsc/bindings/webcore/streams/JSReadableStream.h
Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
Comment thread src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp Outdated
Comment thread test/js/web/fetch/body.test.ts
Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues found on the latest revision, but this is a ~1.3k-line native feature touching the streams GC/re-entrancy machinery, Body/RequestContext lifecycle, and a hand-rolled streaming UTF-8 decoder — worth a human pass on the overall design and the deferred ReadableStreamTag__tagged edge case.

Extended reasoning...

Overview

Implements Body.textStream() on Request/Response per the fetch spec. Rather than body.pipeThrough(new TextDecoderStream()), each body backing decodes directly: in-memory bodies enqueue a single decoded string; native byte sources (fetch/server) run in a text-mode JSNativeStreamSourceAdapter; already-materialized byte streams use a new SourceKind::TextDecode whose pull issues ReadRequestKind::TextDecode reads on a locked source reader. Adds a new streamingUTF8Decode helper (BOM stripping, WHATWG-aligned trailing-byte hold-back, U+FFFD replacement), a 4-byte inline decode state on SourceAlgorithmSlots/JSNativeStreamSourceAdapter, three new FFI exports, and ~570 lines of tests including a vendored WPT.

Files touched: 8 C++ streams files (BunStreamSource, ReadableStreamOperations, JSReadRequest, JSReadableStream, controllers, WebStreamsMisc/Exports/Internals, StreamQueue, StreamsForward), 3 Rust files (Body.rs, ReadableStream.rs, RequestContext.rs), response.classes.ts, fetch.d.ts, and tests.

Security risks

No auth/crypto/permissions surface. The main risk class is memory safety in the C++ streams layer — the review history surfaced (and fixed) three separate user-reachable segfault paths from concurrent-read / re-entrant-close / cancel-after-release interleavings, plus a Bun.serve request-body abort hang. All are now guarded (reader && reader->m_stream at every release site, canCloseOrEnqueue early returns, end_request_streaming fallback to request_body_readable_stream_ref) and covered by regression tests. streamingUTF8Decode has an exceedsStringLimit guard and the empty-span memcmp UB is fixed.

Level of scrutiny

High. This is new user-facing API implemented across the JSC-bindings/Rust FFI boundary in the streams subsystem — the most re-entrancy- and GC-sensitive part of the runtime. It adds a new SourceKind/ReadRequestKind variant threaded through every controller switch, mutates the Value::LockedValue::Used body lifecycle, and adds a fallback abort path in RequestContext::end_request_streaming. The direct-decode design (vs. spec's pipeThrough(TextDecoderStream)) is a deliberate architectural choice a maintainer should sign off on.

Other factors

  • The PR went through 6 review iterations; every 🔴/🟡 inline finding is resolved with a corresponding regression test.
  • One edge case was explicitly deferred by the author (ReadableStreamTag__tagged not gated on m_nativeTextMode, so new Response(res.textStream()) / fetch({body: textStream}) on a native-backed text stream re-tags as raw Bytes) because guarding it broke FetchTasklet's ByteStream re-derivation — noted as a follow-up.
  • The string-body BOM-stripping deviation was intentionally left to match existing .text() behavior.
  • Test coverage is thorough (concurrent reads, re-entrant close, cancel-after-close, mid-upload abort, invalid-prefix hold-back, detached buffers, WPT), and the gate confirms fail-without-fix / pass-with-fix on both ASAN debug and release.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase and fix conflicts

@robobun
robobun force-pushed the claude/farm/284405a5/body-textstream branch from 5f1bc1d to a90ccfb Compare July 9, 2026 23:59
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (5d673d9). The only textual conflict was in StreamsForward.h where #33833 added ReadRequestKind::ReadStreamIntoSink/ResumableSinkPump alongside this PR's TextDecode; kept both. Verified post-rebase:

  • test/js/web/fetch/body.test.ts: 436 pass (88 textStream)
  • test/js/web/fetch/wpt/textstream-wpt.test.ts: 14 pass
  • test/js/third_party/wpt-streams/wpt-streams.test.ts: 1175 pass
  • test/js/web/streams/sync-pull-fast-path.test.ts: 7 pass
  • test/js/bun/util/v8-heap-snapshot.test.ts (streams edges): pass

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/BunStreamSource.cpp Outdated
autofix-ci Bot and others added 11 commits July 19, 2026 03:25
Convert an abrupt completion from the TextDecode read (e.g. the
exceedsStringLimit OutOfMemoryError inside streamingUTF8Decode) into a
rejected promise so onRSDefaultControllerPullRejected runs and errors
the output stream, instead of leaving m_pulling stuck and the pending
read unsettled. Matches the nativeSourcePull / SourceKind::JavaScript
catch-and-convert pattern.
…ules

incompleteTrailingUTF8() now rejects never-valid leads (0xC0-0xC1,
0xF5-0xF7) and applies the per-lead second-byte range (0xE0: >=0xA0;
0xED: <=0x9F; 0xF0: >=0x90; 0xF4: <=0x8F), so a surrogate / overlong /
out-of-range prefix at a chunk boundary is replaced immediately instead
of held back. Also await the .rejects assertion in the
locks-previous-body test.
…reader on termination

- nativePtrForJS() returns empty when m_nativeTextMode is set so
  Readable.fromWeb(res.textStream()) falls back to a reader-based path
  and sees decoded strings instead of raw bytes. ReadableStreamTag__tagged
  reads the raw slot so the fetch/server push side is unaffected.
- Release the source reader via readableStreamDefaultReaderRelease() on
  every TextDecode terminal path (closeSteps, cancelAlgorithm, errorSteps,
  non-BufferSource error branch) so source.locked becomes false after the
  text stream terminates, matching pipeTo's Finalize step.
…sts by body type

- Add !joined.empty() before the BOM-prefix memcmp so a {nullptr, 0}
  span (detached first chunk) never passes nullptr to memcmp.
- The four server/fetch textStream() tests that do not reference fn now
  only register under the relevant describe (Response for fetch-response
  tests, Request for server-side-request tests) instead of running twice.
…ader

closeSteps can release the source reader while the output controller
still has a queued flush chunk (ClearAlgorithms only runs when the
queue is empty). A subsequent output cancel() then dereferences the
released reader's null m_stream. Guard on reader && reader->m_stream
and return a fulfilled promise when the source is already terminal.
The closeSteps flush enqueue can tail-call callPullIfNeeded and re-enter
closeSteps (source already Closed), whose inner call releases the source
reader; the outer call's release then dereferences the null m_stream.
Guard every readableStreamDefaultReaderRelease(source_reader) site on
reader && reader->m_stream so a second release is a no-op. Added a
regression test for the re-entrant close path.
…queue helper

nativeEnqueueTextChunk now takes the StreamingUTF8DecodeState by reference
instead of the adapter, so the fully-buffered fast path in
materializeNativeSource can share it. All call sites pass view->span()
(or .span().first(count) for the partial-write case) instead of
hand-building the span from vector()+byteLength().
Keeps the existing byte-mode partial-fill path (the two uint8Subarray
calls) at its original indentation so it no longer appears in this diff.
The text-mode path allocates no subarray objects: it decodes directly
from view->span().first(count) and reuses the whole view for the next
pull.
nativePtrForJS() already returns empty for text-mode streams, so the
explicit check immediately before it is dead.
@robobun
robobun force-pushed the claude/farm/284405a5/body-textstream branch from 457c8a5 to 4045358 Compare July 19, 2026 03:29
Comment thread test/js/web/fetch/body.test.ts Outdated
@Jarred-Sumner
Jarred-Sumner merged commit 2a5855b into main Jul 28, 2026
80 of 81 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/farm/284405a5/body-textstream branch July 28, 2026 01:03
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