Skip to content

fetch: keep textStream() pulling when a native chunk decodes to nothing - #36180

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/d17facf7/textstream-native-repull
Jul 28, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
farm/d17facf7/textstream-native-repull

Conversation

@robobun

@robobun robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator

Body.textStream() over a native ByteStream (a fetch response body) stalls forever when a multi-byte UTF-8 character is split across three or more delivered chunks, i.e. whenever two consecutive native pulls each decode to the empty string.

Reproduction

const parts = [[0x41], [0xf0], [0x9f], [0xab, 0xa0], [0x42]]; // "A" + "🫠" + "B"
const server = Bun.serve({ port: 0, fetch: () => new Response(new ReadableStream({
  async start(c) { for (const p of parts) { c.enqueue(Uint8Array.from(p)); await new Promise(r => setTimeout(r, 20)); } c.close(); },
})) });
const res = await fetch(server.url);
let out = "";
for await (const ch of res.textStream()) out += ch;  // never resolves; only "A" is delivered

The same origin reads correctly via res.text(), res.body.pipeThrough(new TextDecoderStream()), and textStream() over a user-provided ReadableStream body.

Cause

nativeEnqueueTextChunk holds back an incomplete UTF-8 tail and returns without enqueueing. In byte mode every pull that resolves with data enqueues something, which tail-calls callPullIfNeeded and arms m_pullAgain; in text mode an all-held-back chunk skips that, so once the one buffered re-pull (set by the preceding enqueue's callPullIfNeeded) is consumed, the spec pull-fulfilled handler sees pullAgain == false and stops. The outstanding read request is never fulfilled and handle.pull() is never called again.

The SourceKind::TextDecode path (textDecodeReadRequestChunkSteps, used when the body is already a materialized byte ReadableStream) already handles the empty-decode case by calling readableStreamDefaultControllerCallPullIfNeeded; the native adapter path did not.

Fix

When a non-flush decode produces no output, call readableStreamDefaultControllerCallPullIfNeeded(controller). Inside a pull this sets m_pullAgain so the loop continues; at other call sites (nativeSourceStart with m_started == false, the push-driven onDrain) it is either a no-op or correctly issues the next pull.

Verification

$ bun bd test test/js/web/fetch/body.test.ts
440 pass, 0 fail, 4 skip

New test.each cases in body.test.ts drive a raw net.Server that delivers each body byte as its own HTTP chunk with an event-loop yield between writes, covering: 4-byte char split as [lead][cont][cont cont], 4-byte char byte-at-a-time, 3-byte char byte-at-a-time, and a byte-at-a-time BOM. All four time out on main and pass with the fix.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 5 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
bun test v1.4.0 (adef51e8b)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [4.52ms]
(pass) Request > constructor > null [3.17ms]
(pass) Request > constructor > string > "" [5.94ms]
(pass) Request > constructor > string > "Hello world" [1.47ms]
(pass) Request > constructor > string > "🫠" [1.36ms]
(pass) Request > constructor > string > "⁉️" [1.10ms]
(pass) Request > constructor > ArrayBuffer > empty buffer [8.35ms]
(pass) Request > constructor > ArrayBuffer > small buffer [4.72ms]
(pass) Request > constructor > ArrayBuffer > large buffer [4.21ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [1.66ms]
(pass) Request > constructor > SharedArrayBuffer > small buffer [1.81ms]
(pass) Request > constructor > SharedArrayBuffer > large buffer [3.89ms]
(pass) Request > constructor > Buffer > empty buffer [1.58ms]
(pass) Request > constructor > Buffer > small buffer [1.51ms]
(pass) Request > constructor > Buffer > large buffer [3.20ms]
(pass) Request > constructor 
... (truncated)

release without fix: 93 failed, 4 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [0.12ms]
(pass) Request > constructor > null [0.04ms]
(pass) Request > constructor > string > "" [0.11ms]
(pass) Request > constructor > string > "Hello world" [0.02ms]
(pass) Request > constructor > string > "🫠" [0.02ms]
(pass) Request > constructor > string > "⁉️"
(pass) Request > constructor > ArrayBuffer > empty buffer [0.14ms]
(pass) Request > constructor > ArrayBuffer > small buffer [0.15ms]
(pass) Request > constructor > ArrayBuffer > large buffer [3.00ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [0.05ms]
(pass) Request > constructor > SharedArrayBuffer > small buffer [0.04ms]
(pass) Request > constructor > SharedArrayBuffer > large buffer [3.40ms]
(pass) Request > constructor > Buffer > empty buffer [0.06ms]
(pass) Request > constructor > Buffer > small buffer [0.03ms]
(pass) Request > constructor > Buffer > large buffer [2.97ms]
(pass) Request > constructor > Uint8Array > empty buffer [0.02ms]
(pass) Request > constructor > Uint8Array > small buffer [0.04ms]
(pass) Request > constructor > Uint8Array > large buffer [3.0
... (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
bun test v1.4.0 (adef51e8b)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [4.43ms]
(pass) Request > constructor > null [3.24ms]
(pass) Request > constructor > string > "" [5.86ms]
(pass) Request > constructor > string > "Hello world" [1.51ms]
(pass) Request > constructor > string > "🫠" [1.34ms]
(pass) Request > constructor > string > "⁉️" [1.10ms]
(pass) Request > constructor > ArrayBuffer > empty buffer [8.26ms]
(pass) Request > constructor > ArrayBuffer > small buffer [4.75ms]
(pass) Request > constructor > ArrayBuffer > large buffer [4.25ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [1.63ms]
(pass) Request > constructor > SharedArrayBuffer > small buffer [1.87ms]
(pass) Request > constructor > SharedArrayBuffer > large buffer [3.82ms]
(pass) Request > constructor > Buffer > empty buffer [1.50ms]
(pass) Request > constructor > Buffer > small buffer [1.54ms]
(pass) Request > constructor > Buffer > large buffer [3.32ms]
(pass) Request > constructor 
... (truncated)

release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     adef51e8b1
  features     baseline

22 deps, 108 codegen, 1171 objects in 825ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [22.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1234] gen ErrorCode+*.h
[4/1234] gen bindgenv2
[5/1234] fetch picohttpparser
[picohttpparser] up to date
[6/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [7.00ms]
[7/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1234] fetch tinycc
[tinycc] up to date
[9/1234] fetch zlib
[zlib] up to date
[10/1234] gen .bind.ts → GeneratedBindings.cpp
[11/1234] fetch nodejs (prebuilt)
[nodejs] up to date
[12/1234] subst deps/zlib/zlib.h
[13/1234] fetch zstd
[zstd] up to date
[1
... (truncated)
diff hotspot
.../bindings/webcore/streams/BunStreamSource.cpp   |  9 +++--
 test/js/web/fetch/body.test.ts                     | 42 ++++++++++++++++++++++
 2 files changed, 49 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                  reads  edits  tests
src/jsc/bindings/webcore/streams/BunStreamSource.cpp      2      3      0
test/js/web/fetch/body.test.ts                            3      3      0

Body.textStream() over a native ByteStream (fetch response) stalls forever
when two consecutive network reads each decode to the empty string, which
happens whenever a multi-byte UTF-8 character is split across three or more
delivered chunks (e.g. an emoji trickled byte-at-a-time). The native
text-mode adapter's nativeEnqueueTextChunk holds back the incomplete tail
and returns without enqueueing, so nothing arms m_pullAgain, and once the
one buffered re-pull (from the preceding enqueue) is spent the pull loop
stops with a read request still pending and more data still to come.

The SourceKind::TextDecode path (textStream() over a user ReadableStream)
already handles this by calling callPullIfNeeded on an empty decode; apply
the same contract to the native adapter.
@robobun

robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, waiting on CI.

Reproduced with: bun bd test test/js/web/fetch/body.test.ts -t "consecutive chunks decode to nothing" (5/7 time out on main, 7/7 pass on this branch; the two leading 2-way-split cases pass on main too and are kept as regression guards).

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 50b1b525-44ad-4d9b-bf36-6d865759bebc

📥 Commits

Reviewing files that changed from the base of the PR and between b22e0e6 and adef51e.

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

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

@robobun

robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:49 PM PT - Jul 27th, 2026

@robobun, your commit adef51e is building: #83810

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — minimal, well-targeted hang fix that mirrors the existing TextDecode sibling.

What was reviewed:

  • Confirmed the fix mirrors textDecodeReadRequestChunkSteps (ReadableStreamOperations.cpp:1085-1088), which already handles the same empty-decode case identically.
  • Traced callPullIfNeeded behavior at each of the 6 call sites: inside a pull it sets m_pullAgain; from nativeSourceStart it's a no-op (m_started gate); from onDrain it correctly issues/arms the next pull; the flush=true sites skip it.
  • Checked the written == 0 numeric-result path in nativeDecodePullResult — it doesn't call nativeEnqueueTextChunk, so behaves identically to byte mode there (pre-existing, out of scope).
Extended reasoning...

Overview

Fixes a hang in Body.textStream() over a native fetch body when two consecutive native pulls decode to the empty string (multi-byte UTF-8 char split across ≥3 chunks, or a byte-at-a-time BOM). The 5-line C++ change in nativeEnqueueTextChunk calls readableStreamDefaultControllerCallPullIfNeeded when a non-flush decode yields nothing, re-arming m_pullAgain so the spec pull loop continues. A 7-case test.each in body.test.ts drives a raw chunked net.Server to reproduce the stall.

Security risks

None. No user-controlled input reaches new parsing/allocation; the change only adds a re-pull call on an existing controller. No lifetime, ownership, or exception-scope changes beyond wrapping the tail call in RELEASE_AND_RETURN.

Level of scrutiny

Moderate — C++ JSC streams bindings — but the change is a direct mirror of textDecodeReadRequestChunkSteps at ReadableStreamOperations.cpp:1088, which already handles the identical empty-decode case for the non-native SourceKind::TextDecode path. I verified callPullIfNeeded's behavior at every nativeEnqueueTextChunk call site: during a pull it sets m_pullAgain (JSReadableStreamDefaultController.cpp:534-536); from nativeSourceStart it's gated off by !m_started in shouldCallPull; from onDrain it either issues a pull or arms one; the two flush=true sites (close, buffered fast path) correctly skip it.

Other factors

  • Full body.test.ts suite passed (440 pass, 0 fail); PR notes 5/7 new cases time out on main.
  • The comment-cop bot's inline note was addressed (comment trimmed in adef51e; thread resolved).
  • Tests are hermetic (net.createServer on port: 0, try/finally cleanup, no external hosts). The setImmediate yield between socket writes is used to separate chunks on the wire, not to wait for a condition — coalescing would only weaken (not flake) the test.
  • Ruled out the written == 0 numeric branch in nativeDecodePullResult as a related gap: it never calls nativeEnqueueTextChunk and matches byte-mode behavior, so it's pre-existing and orthogonal.

@Jarred-Sumner
Jarred-Sumner merged commit d549845 into main Jul 28, 2026
49 of 53 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/d17facf7/textstream-native-repull branch July 28, 2026 05:21
Jarred-Sumner pushed a commit that referenced this pull request Jul 28, 2026
Follow-up to #36180. Test-only.

Adds regression coverage for the additional faces of the native-adapter
empty-decode stall, all verified to hang on b22e0e6 (the commit
before #36180) and pass on d549845:

- **close-after-empty**: a body that ends in (or is only) an incomplete
UTF-8 tail reaches the flush decode and closes (`"\ufffd"`) instead of
stalling on the final pull.
- **BOM-carry then close**: `[EF][BB]` then FIN covers the other
null-returning branch of `streamingUTF8Decode` (the possible-BOM
hold-back).
- **error-after-empty**: a socket drop while the last pull decoded to
nothing surfaces as a rejection instead of an idle hang.
- **`req.textStream()` server-side**: a chunked upload that splits a
code point across chunks completes (same `ByteStream` adapter as the
fetch-response path).

Also refactors the raw-socket chunked origin into a `rawChunkedServer`
helper with `Symbol.asyncDispose` so each case is a three-line `await
using`.

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

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

---

**[stamp-90s]** gate passed · iteration 0 · 1 files touched

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

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/web/fetch/body.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/web/fetch/body.test.ts
bun test v1.4.0 (0cdbb3a)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [5.03ms]
(pass) Request > constructor > null [3.19ms]
(pass) Request > constructor > string > "" [6.13ms]
(pass) Request > constructor > string > "Hello world" [1.53ms]
(pass) Request > constructor > string > "🫠" [1.42ms]
(pass) Request > constructor > string > "⁉️" [1.37ms]
(pass) Request > constructor > ArrayBuffer > empty buffer [8.69ms]
(pass) Request > constructor > ArrayBuffer > small buffer [5.06ms]
(pass) Request > constructor > ArrayBuffer > large buffer [4.85ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [1.88ms]
(pass) Request > constructor > SharedArrayBuffer > small buffer [1.76ms]
(pass) Request > constructor > SharedArrayBuffer > large buffer [4.13ms]
(pass) Request > constructor > Buffer > empty buffer [1.53ms]
(pass) Request > constructor > Buffer > small buffer [1.52ms]
(pass) Request > constructor > Buffer > large buffer [4.65ms]
(pass) Request > constructor > Uint8Array > empty buffer [1.26ms]
(pass) Request > constructor > Uint8Array > small buffer [1.78ms]
(pass) Request > constructor > Uint8Array > large buffer [3.91ms]
(pass) Request > constructor > Uint8ClampedArray > empty buffer [1.63ms]
(pass) Request > constructor > Uint8ClampedArray > small buffer [1.55ms]
(pass) Request > constructor > Uint8ClampedArray > large buffer [3.41ms]
(pass) Request > constructor > Uint16Array > empty buffer [1.67ms]
(pass) Request > constructor > Uint16Array > small buffer [1.95ms]
(pass) Request > constructor > Uint16Array > large buffer [6.16ms]
(pass) Request > constructor > Uint32Array > empty buffer [19.54ms]
(pass) Request > constructor > Uint32Array > small buffer [2.43ms]
(pass) Request > constructor > Uint32Array > large buffer [10.54ms]
(pass) Request > constructor > Int8Array > empty buffer [1.41ms]
(
... (truncated)
Exit: 0
```

</details>

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

```
test/js/web/fetch/body.test.ts | 96 ++++++++++++++++++++++++++++++++----------
 1 file changed, 73 insertions(+), 23 deletions(-)
```

</details>

**gate history** · 3 passed · 0 rejected · iteration 0

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

```
file                            reads  edits  tests
test/js/web/fetch/body.test.ts      7     10      0
```

</details>

<!-- robobun:evidence:end -->
Jarred-Sumner pushed a commit that referenced this pull request Aug 30, 2026
…40947)

### Problem
- `test/js/web/fetch/body.test.ts` > "rejects a fetch textStream() when
the connection drops after an empty decode" fails in the parallel CI
batch with `received: ""` instead of `"A"`. The error code is the
expected `ECONNRESET`. It passes when it runs alone.
- The test server writes `"A"`, `0xF0`, `0x9F` (one `setImmediate`
apart) and then destroys the socket. Under load, the HTTP thread sees
all of that before the JS thread has read anything.
`FetchTasklet::callback` coalesces the updates into one progress update
with `fail` set, and `to_body_value`
(`src/runtime/webcore/fetch/FetchTasklet.rs:1707`) or `on_body_received`
(`:740`) turns the body into an errored stream. The buffered `"A"` is
discarded by design.

### Fix
- The server now waits until the client has consumed the first chunk
before it writes the incomplete UTF-8 tail and drops the socket. A
`Promise.withResolvers` resolved from the `for await` body is the
handshake. A `finally` resolves it too, so the server still closes the
socket if the stream ends early.
- The drop still lands after the empty decodes (`0xF0`, `0x9F` decode to
nothing), which is what the test guards: the stall fixed in #36180
surfaced as a hang, not a rejection.
- Verified: 24 parallel loops of the old test body failed 1400 of 3600
iterations. The new body: 0 of 3600. With `bun test` under the same
load: old 32 of 192 failures, new 0 of 192 (release) and 0 of 60 (debug
build). The whole file passes with `bun bd test`.

### Background
- `res.textStream()` on a fetch response is a native `ByteStream` source
in text mode. The HTTP thread delivers body bytes and the terminal
result to the JS thread through `FetchTasklet`.
- `FetchTasklet::callback` posts at most one task at a time
(`has_schedule_callback`). Results that arrive before the JS thread runs
that task are merged: body bytes are appended to
`scheduled_response_buffer`, and the latest `fail` or `has_more` wins.
- When the merged update carries the response head and a failure, the
body is `BodyValue::Error` and the bytes are dropped. When it carries a
failure after the head was already delivered, `ByteStream::on_data(Err)`
rejects the pending pull or, with no pull pending, `append(Err)` clears
the buffer (#32662). Whether the consumer sees the bytes before the
error depends on timing.

<details><summary>Notes</summary>

- Repro: `/tmp/repro-textstream.ts` style loop (the test body, 150
iterations) run 24 times in parallel on a 16-core box. Failure rate 24%
to 59% per process, all with `received: ""` and `code: "ECONNRESET"`.
The same loop with the handshake: 0 failures in 3600 iterations.
- The single-process loop (no load) passes 300 of 300 with the old body,
which is why the test passes alone.
- The other cases in the same `test.each` table end with a clean
`0\r\n\r\n`. A coalesced successful end delivers the bytes
(`TemporaryAndDone`), so they are not affected.
- The data-then-error order is timing dependent in bun for any streaming
fetch that fails mid-body. Browsers deliver the bytes received before
the error first. This PR does not change that behavior. It only removes
the dependence from the test.
</details>

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

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/web/fetch/body.test.ts

<!-- robobun:evidence:end -->
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