Skip to content

ByteStream: hold a pending JS error in a visited slot instead of a Strong - #43410

Open
robobun wants to merge 4 commits into
mainfrom
robobun/4ab0dbcd/bytestream-parked-error-slot
Open

robobun wants to merge 4 commits into
mainfrom
robobun/4ab0dbcd/bytestream-parked-error-slot

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A failed HTMLRewriter Response is never collected when the handler's error references it and nothing reads its body. The Notes repro prints retained 50 of 50 protected Error: 50 on main.
  • Cause: when nothing can take the error, ByteStream::append (src/runtime/webcore/ByteStream.rs:680) keeps it in a jsc::strong::Optional. A Strong is a GC root, and the error reaches it again through the Response.

Fix

  • The Bytes source wrapper gets a visited pendingError slot (streams.classes.ts). A waiting JS error sits there, with no Strong. A native error stays native.
  • Readers take it with take_pending_error(). All reach the ByteStream through its wrapper, so the slot lives as long as a reader can exist.
  • Verified: test/js/workerd/html-rewriter-leak.test.ts, two leak tests fail on main (Received: 60, expected < 15). Also ran HTMLRewriter, streams, fetch, S3 suites.
  • Self-reviewed: 3 concerns raised, 3 addressed.

Background

  • A ByteStream is the native source of a streamed body. A producer (HTMLRewriter, fetch, S3) pushes chunks and one terminal error into it.
  • Its JS wrapper is the object the ReadableStream holds. Its values in streams.classes.ts become visited WriteBarrier fields.
  • Considered a per-producer rule (fetch: fail a native reader of an aborted res.body with signal.reason #43699 has one) and a weak handle. The first drops the real error. The second cannot hold a thrown string.

Downsides

Notes

Repro 1 (.body made, never read):

const { heapStats } = require("bun:jsc");
let fired = 0;
const fr = new FinalizationRegistry(() => fired++);
const N = 50;
const tick = () => new Promise(resolve => setImmediate(resolve));
async function once() {
  const holder = {};
  let controller;
  const res = new HTMLRewriter()
    .on("p", { element() { const error = new Error("boom"); error.response = holder.res; throw error; } })
    .transform(new Response(new ReadableStream({ start: c => void (controller = c) })));
  holder.res = res;
  fr.register(res, 0);
  void res.body;
  controller.enqueue(new TextEncoder().encode("<p>x</p>"));
  controller.close();
  for (let i = 0; i < 3; i++) await tick();
}
for (let i = 0; i < N; i++) await once();
for (let i = 0; i < 6; i++) { Bun.gc(true); await tick(); }
console.log("retained", N - fired, "of", N, "protected Error:", heapStats().protectedObjectTypeCounts.Error ?? 0);
  • main: retained 50 of 50 protected Error: 50. This branch: retained 1 of 50 protected Error: 0.
  • "Nothing can take the error" means: no native sink, no buffer action (text() and the like), no pending pull.
  • Repro 2 replaces void res.body with holder.reader = res.body.getReader() and sends a few <b>chunk</b> chunks first. The first chunk fills the reader's queue, the stream stops pulling, and the error finds no pending pull. Same numbers before and after.
  • Control: with a read outstanding, nothing leaks on main either. The pending pull rejects at once and the JS stream keeps the error in its own visited m_storedError.

Heap snapshot (generateHeapSnapshotForDebugging()), retainers of the waiting Error:

  • main: StrongRootBlock [Internal], which is a root.
  • this branch: BytesInternalReadableStreamSource [Property: pendingError], not a root. That wrapper's retainer is ReadableStream [Property: bunNativePtr].

Tests (html-rewriter-leak.test.ts, block "a handler error that nothing has read yet"):

  • Two leak tests: .body made and unread, and a reader with a backlog. 60 rewrites each, then Response and protected Error counts from heapStats(). With the merge base's src/ they fail (Received: 60, expected < 15).
  • Six guard tests pass on main too (a Strong cannot die). They check that the waiting error survives a full GC plus heap churn and reaches each reader intact: text(), Bun.readableStreamToBytes(), read() after the queued chunk, a second rewriter (wire_native_sink), and an S3 upload (the early check, zero requests made). One of them throws a string: the slot holds any value, where a weak handle holds only objects.

Measurements, merge base 37471e5 against this branch, debug (ASAN) builds:

  • size_of::<ByteStream>() 192 to 208, byte_stream::Source 344 to 360. SysError is boxed in the enum (40 bytes inline otherwise).
  • sizeof(JSBytesInternalReadableStreamSource) 72 to 80 (lldb). A JSC cell occupies a multiple of 16 bytes (MarkedBlock::atomSize), so the cell took 80 bytes before too.
  • size build/debug/bun-debug: text +2640, data -1728, bss +128, total +1040 bytes. No release build was made.
  • Per chunk: nothing new. on_data does not change. on_pull and to_any_blob touch the new field only after the last chunk.

The fetch abort reason (#43699, merged after this PR was opened):

  • fetch: fail a native reader of an aborted res.body with signal.reason #43699 never parks a JS abort reason in the ByteStream, because of this root. A reader that already holds the source and pulls later (an idle Readable.fromWeb(res.body)) gets a generic AbortError. Its notes name this slot as the missing piece.
  • This PR does not change that. It only removes the sentence "A stored reason would be a GC root." from error_native_consumer, which the slot makes false.
  • A draft of this PR kept the reason in the slot. That works for a truthy reason. A falsy reason (abort(null)) thrown from the later pull closes the Readable with no 'error' event, because destroy(falsy) is a clean destroy in node:stream. node:stream: Readable.fromWeb emits an AbortError when the web stream fails with a falsy value #43732 maps a falsy pull error to an AbortError. The change can follow when both PRs are merged.
  • Heap probes, 40 aborted fetches each, with a reason that references its Response: body never made, body made and never read, body made then res.text(), reader parked, late native reader, idle Readable.fromWeb() resumed, idle Readable.fromWeb() dropped. Retained counts are the same on the merge base and on this branch (40, 40, 0 to 1, 1, 40, 0, 0). The three rows with 40 are the body's Value::Error root (Body: do not root the error of a failed body #43521).

Self-review:

  • The readers of the waiting error are on_pull, to_buffered_value, wire_native_sink (ReadableStream.rs) and the S3 upload check (s3/client.rs, it read pending.result directly). Each one and each release site of the old pending.result error (to_any_blob, on_cancel, finalize, the buffer action reject path) behaves as before.
  • Concern 1, struct size: SysError is boxed in the enum.
  • Concern 2, thrown primitives: a string, number, symbol or bigint waits in the slot and reaches the reader unchanged. One guard test throws a string.
  • Concern 3, after the rebase onto fetch: fail a native reader of an aborted res.body with signal.reason #43699: the slot made one sentence in error_native_consumer false. It is gone.

Behaviour that does not change:

Suites run on the debug build: html-rewriter-leak, html-rewriter, html-rewriter-end-error, fetch-abort-stream-body, body-mixin-errors, body-clone, fetch-stream-cancel-leak, fetch-response-finalizer-sweep, fetch-cyclic-reference, fetch-backpressure, streams, native-source-onclose-leak, web-stream-state, node-stream, s3-stream-error-gc, s3-upload-stream-gc, s3-stream-cancel-leak, and three node *from-web* files.

Not covered here:

  • Body::Value::Error(ValueError::JSValue(Strong)) is a second parking spot with the same cycle. A failed rewrite whose .body nobody touched stores the handler's error there (RewriterPipe::fail, the branch without an output stream). Body: do not root the error of a failed body #43521 addresses it. This PR touches neither Body.rs nor html_rewriter.rs.
  • Found in review, on main too: append(Err) clears the buffer and leaves offset as it was, so a later take_buffer can panic (range end index 65536 out of range for slice of length 0). This PR does not change that path.
  • Three older tests in html-rewriter-leak.test.ts run close to the 5 s timeout on a debug (ASAN) build: "transform(new Response(ascii)) releases the input WTFStringImpl", "a worker exiting with an unobserved transform's pull queued frees the pipe" and "element.attributes iterator does not leak names/values". Each takes 2.1 to 3.5 s on an idle machine. With 24 busy loops on 16 cores, the third one timed out in 2 of 3 runs of the file. The tests this PR adds take 0.6 to 0.9 s each and passed in every such run.

[human-review] gate passed · iteration 1 · 4 files touched

fails on main (without fix)
ASAN without fix: 2 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/workerd/html-rewriter-leak.test.ts
bun test v1.4.3 (367d939d9)

test/js/workerd/html-rewriter-leak.test.ts:
(pass) transform(new Response(ascii)) releases the input WTFStringImpl [2636.30ms]
(pass) exceptions thrown from handlers do not leak protected Exception roots [756.78ms]
(pass) onEndTag callbacks are released after the rewrite [491.61ms]
(skip) HTMLRewriter does not leak element/document handler allocations
(pass) async handler reject/cancel on native-stream input releases the pipe [1482.05ms]
(pass) cancelling the output reader from inside a handler releases the pipe [873.47ms]
(pass) suspended rewrites release their parked state once the handler settles [651.82ms]
(pass) never-settling handler promises are abandoned and release their parked state [400.87ms]
(pass) never-settling handler promises on a file-backed input are abandoned [90.87ms]
(pass) a held reader's read() rejects when the handler promise is collected [40.21ms]
(pass) never-settling handler promises with a realized body release their parked state [383.66m
... (truncated)

release without fix: 1 skipped
bun test v1.4.3-canary.1 (8d5449830)

test/js/workerd/html-rewriter-leak.test.ts:
(pass) transform(new Response(ascii)) releases the input WTFStringImpl [67.04ms]
(pass) exceptions thrown from handlers do not leak protected Exception roots [15.13ms]
(pass) onEndTag callbacks are released after the rewrite [7.51ms]
(pass) HTMLRewriter does not leak element/document handler allocations [886.54ms]
(pass) async handler reject/cancel on native-stream input releases the pipe [109.70ms]
(pass) cancelling the output reader from inside a handler releases the pipe [71.59ms]
(pass) suspended rewrites release their parked state once the handler settles [185.17ms]
(pass) never-settling handler promises are abandoned and release their parked state [11.55ms]
(pass) never-settling handler promises on a file-backed input are abandoned [110.15ms]
(pass) a held reader's read() rejects when the handler promise is collected [3.15ms]
(pass) never-settling handler promises with a realized body release their parked state [11.45ms]
(skip) abandoned transforms over a never-closing JS stream survive GC sweep order
(pass) a direct-stream pull parked on flush(true) is released when the handler 
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/workerd/html-rewriter-leak.test.ts
bun test v1.4.3 (367d939d9)

test/js/workerd/html-rewriter-leak.test.ts:
(pass) transform(new Response(ascii)) releases the input WTFStringImpl [2841.30ms]
(pass) exceptions thrown from handlers do not leak protected Exception roots [771.41ms]
(pass) onEndTag callbacks are released after the rewrite [445.04ms]
(skip) HTMLRewriter does not leak element/document handler allocations
(pass) async handler reject/cancel on native-stream input releases the pipe [955.66ms]
(pass) cancelling the output reader from inside a handler releases the pipe [867.07ms]
(pass) suspended rewrites release their parked state once the handler settles [676.08ms]
(pass) never-settling handler promises are abandoned and release their parked state [390.82ms]
(pass) never-settling handler promises on a file-backed input are abandoned [848.62ms]
(pass) a held reader's read() rejects when the handler promise is collected [43.00ms]
(pass) never-settling handler promises with a realized body release their parked state [394.12m
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 6195ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/208] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 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 /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (29 fields)
Found 9 classes from /workspace/bun/src/runtime/api/html_rewriter.classes.ts
  - HTMLRewriter (3 fields)
  - TextChunk (7 fields)
  - DocType (5 field
... (truncated)
diff hotspot
src/runtime/api/streams.classes.ts         |  10 +-
 src/runtime/webcore/ByteStream.rs          |  94 +++++++++++++----
 src/runtime/webcore/s3/client.rs           |  17 +--
 test/js/workerd/html-rewriter-leak.test.ts | 163 +++++++++++++++++++++++++++++
 4 files changed, 246 insertions(+), 38 deletions(-)

gate history · 2 passed · 1 rejected · iteration 1

evidence per changed file
file                                        reads  edits  tests
src/runtime/api/streams.classes.ts              1      2     27
src/runtime/webcore/ByteStream.rs               5     15     30
src/runtime/webcore/s3/client.rs                1      1     27
test/js/workerd/html-rewriter-leak.test.ts      4      3     27

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: d8a9832f-8827-49fe-a686-3824e11557db

📥 Commits

Reviewing files that changed from the base of the PR and between 9cbc99a and 8d54498.

📒 Files selected for processing (1)
  • src/runtime/webcore/ByteStream.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

ByteStream now stores terminal errors separately from its pending read result. JavaScript errors use a cached Bytes wrapper slot. Read, buffering, blob-conversion, and S3 upload paths use the separate error state. Tests cover unread HTMLRewriter output, collection behavior, and later error delivery.

Changes

ByteStream Error Handling

Layer / File(s) Summary
Store terminal errors
src/runtime/api/streams.classes.ts, src/runtime/webcore/ByteStream.rs
The Bytes source gains a pendingError slot. ByteStream stores terminal errors separately from its pending read result and caches JavaScript errors in the wrapper slot.
Retrieve and validate terminal errors
src/runtime/webcore/ByteStream.rs, src/runtime/webcore/s3/client.rs, test/js/workerd/html-rewriter-leak.test.ts
Read and conversion paths retrieve stored errors through take_pending_error(). Blob conversion clears stored errors. S3 upload rejects with the retrieved error. Tests cover collection behavior and later error delivery for unread HTMLRewriter output.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 8d544

The reviewed change has no established merge-blocking behavior. The inspected producer paths create the readable wrapper before an error can be delivered.

🚥 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 clearly and concisely describes the main change: moving pending JavaScript errors from a Strong root into a visited slot.
Description check ✅ Passed The description explains the problem, the fix, known limitations, and verification results. It does not use the exact template headings, but it provides the required information and is substantially c…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced with the script in the PR description:

$ bun repro1.js   # .body made, never read
retained 50 of 50 protected Error: 50
$ bun repro2.js   # reader attached, backlog, never reads
retained 50 of 50 protected Error: 50

With this branch (debug build): retained 1 of 50 protected Error: 0.

Test proof: test/js/workerd/html-rewriter-leak.test.ts, block "a handler error that nothing has read yet".

  • src/ of the merge base (37471e5): the two leak tests fail (Expected: < 15, Received: 60). The six guard tests pass.
  • this branch: all eight pass.

CI (build 122408 on 8d54498): 180 of 181 jobs pass. The one red test is test/js/bun/spawn/spawn.test.ts ("an idle reader stopped at the highwater mark", debian 13 x64-asan). This diff does not reach it: a subprocess stdout is a FileReader source (ReadableStream::from_pipe), and the new slot exists only on the Bytes source. The same test is red in the final builds of the merged PRs #44350 (build 122284) and #44345 (build 122179).

PR: #43410

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the sibling ReadableStreamPtr::File arm in src/runtime/webcore/s3/client.rs (still reads pending.result directly) for the same rooted-cycle class — FileReader only ever parks native StreamError::Error values there, never a JS value, so it is not affected. The other candidate examined (a JS error dropped as PendingError::None when the wrapper is already gone at ByteStream.rs:743) was ruled out because no reader can reach the stream without the wrapper.

Extended reasoning...

Three confirmed findings are already posted inline, so this body only records what else was examined. The FileReader arm of upload_stream was left on the old pending.result replace/reset pattern; that is fine because FileReader.rs stores only StreamError::Error(sys::Error) in pending.result, which is not a GC root and cannot close a cycle through a Response. The wrapper-gone drop in set_pending_error is benign since every consumer (on_pull, to_buffered_value, wire_native_sink, the S3 check) reaches the ByteStream through its wrapper cell.

Comment thread src/runtime/webcore/ByteStream.rs Resolved
Comment thread src/runtime/webcore/ByteStream.rs Resolved
Comment thread src/runtime/webcore/ByteStream.rs Resolved
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:36 AM PT - Oct 1st, 2026

✅ @robobun, your commit ec56c3a85869819428276cc92070e3bbc780b4a1 passed in Build #122474! 🎉


🧪   To try this PR locally:

bunx bun-pr 43410

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

bun-43410 --bun

Jarred-Sumner added a commit that referenced this pull request Sep 21, 2026
…#43699)

### Problem
- A `fetch()` is aborted while its body still arrives. A native reader
of `res.body` does not get `signal.reason`, as it does on 1.3.14 and
Node. One that already reads gets a fresh `AbortError: The operation was
aborted.`. One that starts later gets a clean, empty body: `text()`
gives `""`, `Bun.serve` answers 200.
- The cause is `ReadableStream::error`
(`src/runtime/webcore/ReadableStream.rs:291`), called by
`BodyAbortListener::on_abort` since #35093. It errors the JS stream,
then cancels the `ByteStream`. `ByteStream::on_cancel` ends a native
reader with a generic `UserAbort`, and a later one never looks at the JS
stream.

### Fix
- `ReadableStream::error` fails what reads natively right now (a wired
sink, a buffer action, a parked pull) with the reason. The reason is
never kept natively: a `Strong` roots a reason that references its own
`Response`.
- An errored JS stream hides its native source (`nativePtrForJS()`, the
Rust tag), so a later reader gets the stored error.
- `FileReader` gets the same hook, and its `on_cancel` now fails a sink
that is still wired (3980be6). No caller of `error` holds a File
source.
- Verified: `test/js/web/fetch/fetch-abort-stream-body.test.ts` (68 new
cases, main fails 55). Suites: see Notes.

### Background
- A `ByteStream` is the native source of a streamed body. The fetch
tasklet pushes chunks, or one terminal error, into it.
- A native reader takes the bytes with no JS reader: a buffer action
(`new Response(res.body).text()`), a wired sink (`Bun.write`, a fetch
upload, `HTMLRewriter`, `Bun.serve`), or `Readable.fromWeb()`.
- `BodyAbortListener` is a fetch `Response`'s `AbortSignal` listener. It
errors the body on abort.

<details><summary>Notes</summary>

**Repro (from the report).** A server sends a head and 40 of 100 body
bytes, then stalls. `AbortSignal.timeout(150)` or `ac.abort(new
Error("custom"))` fires.

| reader of `res.body` | main, reader started before the abort | main,
reader starts after the abort | this PR, both |
| --- | --- | --- | --- |
| `res.text()`, reader loop, `pipeTo`, `pipeThrough`, `tee` |
`signal.reason` | `signal.reason` | `signal.reason` |
| `new Response(res.body).text()` / `json` / `bytes` / `blob` /
`arrayBuffer` | fresh `AbortError` | resolves `""` / empty |
`signal.reason` |
| `new Request(url, { body: res.body }).text()` | fresh `AbortError` |
resolves `""` | `signal.reason` |
| `Bun.readableStreamToText` / `Bytes` / `ArrayBuffer` / `JSON` /
`Blob`, `res.body.text()` | fresh `AbortError` | resolves `""` / empty |
`signal.reason` |
| `Bun.write(path, new Response(res.body))` | fresh `AbortError` |
resolves `0` | `signal.reason` |
| `fetch(url, { method: "POST", body: res.body })` | fresh `AbortError`
| uploads an empty body, resolves | `signal.reason` |
| `new HTMLRewriter().transform(new Response(res.body)).text()` | fresh
`AbortError` | resolves `""` | `signal.reason` |
| `Readable.fromWeb(res.body)` | `end`, as if the body were complete |
`end` after 0 bytes | `signal.reason` (see the idle case below) |
| `Bun.serve` handler returns `new Response(res.body)` | reports the
generic `AbortError`, cuts the connection | 200 with an empty body |
reports `signal.reason`, cuts the connection |

**History of this PR.** The first commit handled only a reader that
already reads. The review found the late reader (second column). The
second commit stored the reason in the ByteStream for it. The review
then found that this `Strong` is a GC root: with `abort(new Error("x", {
cause: res }))`, `res.body` touched and `await res.text().catch(() =>
{})`, main collects every `Response` and that head kept 200 of 200. The
last source commit stores nothing and hides the native source of an
errored stream instead. The `ByteStream::to_any_blob` and `Bun.serve`
changes of the second commit are gone.

**The idle `Readable.fromWeb()`.** It already holds the native source,
so the errored JS stream cannot stop it, and it has no pull parked, so
nothing can take the reason when the abort fires. It now fails with a
generic `AbortError` on its next pull. On main it ends cleanly. It
cannot get the reason without a traced slot for it. #43410 adds such a
slot (`pendingError`).

**The `FileReader` commit (3980be6).** It mirrors `ByteStream`:
`error_native_consumer` for a wired sink or a parked pull, and
`on_cancel` ends a sink that is still wired with `UserAbort`, so the
sink does not wait for an end that never comes. `ReadableStream::error`
has two callers (`Response.rs:155`, `Body.rs:1374`) and neither holds a
File source, so the `error` arm is parity only and no public API can
test it.

**Other notes.**
- A `Bun.serve` response of an errored stream is reported on the server
and the connection is cut. That is what `Bun.serve` does for any JS
stream that errors, also before the first byte. This PR does not change
that policy.
- `res.body; await res.text()` is not affected on main: the body mixin
drops `Locked.readable` once it starts the buffer action, so `on_abort`
finds no stream and the tasklet delivers the reason.
- Primitive reasons (`null`, `42`, `"str"`, a symbol) and the default
`abort()` reason reach the reader unchanged. The test covers them with
the abort before and after the reader starts.
- Node v26.3.0 rejects the upload case with `TypeError: fetch failed`
and `cause === signal.reason`. Bun rejects an upload with the body
stream's error itself. This PR keeps that and restores the 1.3.14
result.
- Heap probes (200 aborted fetches each: body never read, read late,
parked reader, and a reason that references its `Response` followed by
`res.text()`) retain the same as main. A reason that references its
`Response` is retained on main already when the body is never read,
through the body's `Value::Error`. #43521 addresses that.
- Not covered here, reported separately: a body that fails with no abort
(a connection reset) before the reader starts. On main a late JS reader
gets a clean `done` (`ByteStream::on_start` ignores the stored error),
`Bun.write(path, new Response(res.body))` resolves `0`, and a
`Bun.serve` handler that returns `new Response(res.body)` answers 200
with an empty body (`ByteStream::to_any_blob` and the `Bun.serve`
ByteStream path ignore it too).
- Suites run on a debug build: `fetch-abort-stream-body`,
`fetch-backpressure`, `body-stream`, `body`, `body-clone`,
`body-mixin-errors`, `fetch-stream-cancel-leak`,
`fetch-cyclic-reference`, `fetch-leak -t abort`, `streams`,
`native-source-onclose-leak`, `sync-pull-fast-path`, `node-stream`,
`html-rewriter`, `html-rewriter-leak`, `proxy-stress-lifecycle`,
`proxy-stress-errors`, `async-iterator-stream`, `serve`, and the node
`test-webstreams-*` / `test-whatwg-readablestream*` /
`test-*readable-from-web*` files. An earlier head of the test file also
passed on a Windows x64 debug build.
- Local failures that also fail without this change: `serve.test.ts`
"root range port" and "/bun:info to loopback clients" (the container
runs as root), `fetch-leak.test.ts` "Sending URLSearchParams does not
leak" (120 s timeout), some `fetch.stream.test.ts` "Content-Length
response works (multiple parts)" cases (5 s timeout).

</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/fetch-abort-stream-body.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
…rapper

A producer can deliver its terminal error while nothing can take it: no
sink, no buffer action, no pending pull. ByteStream kept that error in a
Strong until the next read. A Strong is a GC root, so an error that
references the Response that owns the stream closed a cycle through a
root, and none of it was ever collected.

The JS error now sits in a new visited pendingError slot of the Bytes
source wrapper. A native error stays native. Every reader goes through
take_pending_error().
@robobun
robobun force-pushed the robobun/4ab0dbcd/bytestream-parked-error-slot branch from e457f43 to 9cbc99a Compare October 1, 2026 09:10
Comment thread src/runtime/webcore/ByteStream.rs Resolved
Comment thread src/runtime/webcore/ByteStream.rs Resolved
Comment thread src/runtime/webcore/ByteStream.rs Resolved

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

Code review found no issues

No high-confidence issues detected in this change.

Also drops one sentence in error_native_consumer: a JS reason that
waits in the pendingError slot is not a GC root.

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

Code review found no issues

No high-confidence issues detected in this change.

Jarred-Sumner pushed a commit that referenced this pull request Oct 8, 2026
…with the buffer (#38003)

### Problem

- A native byte stream first read after its producer failed ends clean
after 0 bytes. Sources: `fetch` body, `req.body`, `S3File.stream()`,
`HTMLRewriter` output. `Bun.serve` answers an empty `200`. An S3 copy
stores an empty object.
- If a reader took part of the buffer, the process aborts: `panic: range
end index 262144 out of range for slice of length 0`
(`ByteStream::take_buffer`).
- Cause: `ByteStream::append(Err)` stores the error, drops `buffer` and
keeps `offset`. `on_start`, `to_any_blob` and `Bun.serve` read that as
"ended, empty".

### Fix

- A stored error makes `on_start` return `Start::Ready` and
`to_any_blob` return `None`, so the first pull delivers it. `Bun.serve`
hands it to its sink, and `end_chunk` calls `error()`.
- `buffer` and `offset` become one `Buffered` value. Every method that
empties the bytes resets the index.
- Verified: six test files (Notes). 24 new tests fail without the
change.
- Self-reviewed: 2 concerns raised, 2 addressed.

### Background

- `ByteStream` is the native source of a streamed body.
- A pull copies into a view. Bytes that do not fit stay buffered.
`offset` counts the ones already taken.
- Weighed a store in `append(Err)` and a clamp in `take_buffer`. Six
sites empty the buffer, and a clamp hides a wrong index.

### Downsides

- A handler that returned such a body got a silent `200`. It now gets
`error()` or the default 500.
- One test of `pending.result` per stream start and per `Bun.serve`
attach. Release `text`: +768 bytes.
- Still open: `Bun.write(path, stream)` rejects after it truncated the
file. A started body that did not fail loses its queued bytes (#33461,
#42281).

<details><summary>Notes</summary>

**Origin and reach.** No user reported either symptom. A fuzzer found
the panic, and a differential run against node v26.3.0 found the empty
body. The panic ships since 1.4.0: #38656 gave `take_buffer` its
`drain(..consumed)`. The review thread at
#43410 (comment) named
the stale `offset` on 09-19 and got the answer that the crash would have
its own change with its own test. This is that change, inside this PR so
that `ByteStream.rs` has one pull.

**Three routes reach `take_buffer` with an index left behind an emptied
buffer.**

1. `Bun.serve` renders a `Source::Bytes` body that a consumer started
and let go (`res.body.getReader().releaseLock()`), after the producer
failed. `do_render_with_body` calls `drain()`.
2. `Readable.fromWeb()` on such a stream. `on_start` took the buffer and
kept the index, then `native-readable.ts` calls `drain()`. This one
needs no failure.
3. The stream's own pull loop in text mode (`res.textStream()`). The
adapter keeps its pull view there, so `nativeSourcePullImpl`
(`BunStreamSource.cpp`) calls `handle.drain()` before each pull. A body
that fails after a partial read is enough. No second consumer is
involved.

Sites that emptied `buffer` and kept `offset` on main: `append(Err)`,
the buffer-action error arm of `on_data`, `on_cancel`, the stored-error
arm of `to_buffered_value`, the `OwnedAndDone` arm of `on_start`,
`to_any_blob`. `Buffered` offers `take`, `clear`, `copy_out`, `adopt`
and `extend`, and nothing else writes the two fields.

**Before and after** (release build of main, debug build of this
branch):

| script | main | this PR |
| --- | --- | --- |
| `HTMLRewriter` output, probed with `getReader().releaseLock()`,
400,007 bytes of output, then a handler throws, returned from
`Bun.serve` | panic, exit 134 | `500`, `error()` got `boom` |
| the same with exactly one pull view of output (262,144 bytes) | `200
""` | `500`, `error()` got `boom` |
| the same after only `void res.body` | `200 ""` | `500`, `error()` got
`boom` |
| `const body = res.body`, upstream resets, `for await (const c of
body)` | ends cleanly after 0 of 100,000 bytes | rejects `ECONNRESET`
(node: rejects) |
| `res.textStream()` reader, one view read, upstream resets, next read |
panic, exit 134 | the read rejects `ECONNRESET` |
| `Readable.fromWeb()` on a probed rewriter output, clean end | panic,
exit 134 | the bytes the native source still holds |
| `req.body` read after the uploading client went away | `{ done: true
}` | rejects `AbortError` |
| `new Response(body).arrayBuffer()` / `bytes()` / `blob()` / `json()`
on a body that failed while idle | resolve empty | reject `ECONNRESET` |
| `new Bun.S3Client({}).file(key).stream()` (no credentials), then `for
await`, a reader, `pipeTo`, `tee`, `new Response(s).bytes()` or
`.blob()` | end cleanly after 0 bytes | reject
`ERR_S3_MISSING_CREDENTIALS` |
| `client.file(key).stream()` read after the download failed: a body cut
short, a missing key (404), a denied key (403) | ends cleanly after 0
bytes | rejects `ConnectionClosed`, `NoSuchKey`, `AccessDenied` |
| `client.write(key, new Response(s))` or `Bun.write(s3file, new
Response(s))` with such a stream `s` | resolves 0, a `PUT` of 0 bytes
replaces the object | rejects, no request goes out |
| `fetch(url, { method: "POST", body: s })` | resolves 200, the server
gets a complete POST with `Content-Length: 0` | rejects, the server gets
no complete request |
| `HTMLRewriter` output `.body` taken, a handler throws or the upstream
resets, then the first read | `{ done: true }` | rejects with the
handler's or the upstream's error |

**Four sources, one script.** Each transfer announces 100 bytes, sends
50, and is cut by a FIN 20 ms later. The stream is taken at once and
read 100 ms later: `(await fetch(url)).body`, `req.body` in `Bun.serve`,
`s3.file(key).stream()`, `new HTMLRewriter().transform(await
fetch(url)).body`. A release canary (367d939) and a debug build of
main bd599f5 end all four cleanly with 0 bytes, 3 of 3 runs each. A
debug build of this branch rejects all four, 3 of 3 runs, with the error
the same read gets when it is made at once: `ECONNRESET`, `AbortError`,
`S3Error ConnectionClosed`, `ECONNRESET`.

**What stays open.** For a stream source, `Bun.write(path, source)`
opens the destination with `O_TRUNC` before it reads the stream
(`pipe_readable_stream_to_blob`, `Blob.rs:1448`). With a failed stream,
or a `Response` or `Request` that wraps one, the write now rejects, and
a file that held 777 bytes holds 0. Main does the same for a bare
stream, and resolved 0 for the wrappers. Only `Bun.write(path, res)`
with the fetch `Response` itself keeps the old bytes. An S3 destination
keeps the old object, because no request goes out. This PR does not
change when `Bun.write` truncates.

Two behaviours change only in the state that used to abort: `on_start`
and `resume` hand over the bytes no consumer took, not the whole buffer,
so a prefix a reader already holds is not sent twice. The `to_write ==
0` arm of `on_pull` is gone: it handled a buffer whose bytes were all
taken but not dropped, which cannot exist now.

The `HTMLRewriter` paces its input by the whole buffer, the taken prefix
included (#38656). `Buffered::held_len()` keeps that. Counting only the
untaken bytes made `html-rewriter.test.js` "a locked reader paces the
input by its reads" fail, which is how the difference showed.

**Self-review.** Two concerns survived: that the change may claim
severity but not demand (so this body states that no user reported it
and lists the routes), and that a rename in `ByteStream.rs` races the
other open pulls on the file (it now ships inside this one).

**Since the first revision of this PR.** Merged up to main. The
`Blob.rs` hunk is gone: main rewrote `pipe_readable_stream_to_blob` and
marks the pump's rejected promise handled there.
`s3-write-to-file-sync-close.test.ts` now asserts the rejection instead
of accepting both outcomes. `end_sink_with_pending_error` uses main's
`detach_sink` plus `sink.end`. The fixture variants sit in main's
`nativeBodies` table (#40596). The "failed after attach" control is
dropped: main's `proxied-fetch-after-status` row covers it with #40596's
contract (the connection closes without a complete message). One variant
is new: the probed rewriter output from the first row above.

**Tests.** `bun bd test` on Linux x64:

- New or ported, failing without the change (24):
`serve-stream-body-error.test.ts` (8), `body-mixin-errors.test.ts` (9),
`bun-server.test.ts` (1), `fetch.stream.test.ts` (2),
`s3-stream-error-gc.test.ts` (2), `html-rewriter.test.js` (2). Each was
run against a debug build of main bd599f5, the first file against the
release canary.
- `s3-write-to-file-sync-close.test.ts` passes with and without the
change: main already rejects that write, and the test now pins it.
- After the rebase onto main (bd599f5): `serve-stream-body-error` 34
pass, `body-mixin-errors` 23, `s3-stream-error-gc` 4,
`s3-write-to-file-sync-close` 1, `html-rewriter.test.js` 188,
`bun-server` 81, `fetch.stream` 121.
- This container gives the debug build about two cores. `bun-server` and
`fetch.stream` were run with `--max-concurrency` lowered: with the
default, their concurrent subprocess tests pass the 5 s limit, with and
without the change. The "Content-Length response works (multiple parts)"
rows of `fetch.stream` take 2.1 s to 4.9 s each on both builds, and in
one run two of them passed the limit.
- Earlier, before the rebase: `fetch-backpressure` 127, `body-stream`
9086, `body` 774, `streams` 624, `node-stream` 112. `serve.test.ts`: 325
pass. "root range port" and "/bun:info to loopback" fail on the release
build of main too (the container runs as root).

`bytestream-partial-read-error-fixture.ts` stages the failure with an
`HTMLRewriter` whose handler throws on the second chunk, so every step
runs on the JS thread in order. It waits for the handler to have run,
checks that the first read is exactly one pull view, and requires the
next read to reject with the handler's own error. A text-mode reader is
required: a byte reader's full view retires the adapter's pending view,
and the next pull then skips `drain()`. Against the released bun the
fixture aborts with the panic in its header.

`s3-stream-stored-error-fixture.ts` has two modes. With no credentials
the download fails inside `stream()`, so every reader and sink runs
against a stored failure and no timing is involved. For the failures on
the wire, the stub closes the connection behind each answer. The read
starts after the client has closed its end and one more request has made
the round trip through the HTTP thread. The fixture writes to an
absolute path: `Bun.write("out.bin", new Response(""))` with a relative
path to a file that does not exist rejects with `ENOENT` from
`truncate`, on main too. That is a separate defect.

**Size.** `size` of the release binary: `text` 80,676,945 on main,
80,677,713 with the change. `data` and `bss` are equal. Measured before
the rebase.

**History.** The branch is rebased onto main at bd599f5: one commit for
the change, and one for the tests of the S3 and `HTMLRewriter` sources.

**Related open pulls.** #33461 and #36110 give opposite answers for a
started body that did not fail (send everything, or refuse). #42281
keeps `Readable.fromWeb()` from taking a started native source. None of
them touches `ByteStream.rs`. #43410 rewrites how `append(Err)` stores a
JS error and needs a rebase after this.

</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/workerd/html-rewriter.test.js,
test/js/web/fetch/fetch.stream.test.ts,
test/js/bun/http/bun-server.test.ts

<!-- robobun:evidence:end -->

This branch has not been deployed

No deployments
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.

1 participant