Skip to content

streams: fail both tee branches when the read of the source throws - #43956

Open
robobun wants to merge 1 commit into
mainfrom
robobun/fa32c89b/tee-pull-read-throws
Open

robobun wants to merge 1 commit into
mainfrom
robobun/fa32c89b/tee-pull-read-throws

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A type: "direct" source has a close() hook that throws. After tee(), Response.clone() or Request.clone(), a consumer of the first branch never settles. The error is reported as uncaught.
  • defaultTeePullAlgorithm (ReadableStreamOperations.cpp:1105) lets the throw leave through the branch controller, which already set m_pulling. That branch never pulls again.
  • This is a regression in 1.4.1. No user is known to be affected.

Fix

  • The pull algorithm is now the boundary for that throw, as webstreams: propagate exceptions through helpers, convert only at boundaries #39913 made textDecodePullAlgorithm. It never throws.
  • One read feeds both branches, so both fail with the error. The cancel promise settles.
  • If the source already gave the branches their outcome, the error is reported, as deliverDirectError does.
  • Verified: test/js/web/streams/streams.test.js (32 new tests, 0 pass on main) and 11 more suites. Self-reviewed: 12 concerns raised, 11 addressed, 1 in part.

Background

  • A direct source can have a close(reason) hook. It runs after pull() returns when pull() called close().
  • Considered promiseFromSteps alone: 4 cells hang and the branches disagree in 25 of 110. No design review was completed before the code.

Downsides

  • The hook's error is reported as uncaught in 40 of 110 cells. There the source closed with no final chunk or failed itself.
  • Main gave that error to a late read(). A caller could catch it, but lost a chunk. Now the caller gets the chunk and cannot catch the error.
  • bun text grows by 512 bytes. A pull that does not throw makes no new call. Instructions are not counted: perf and valgrind are missing.
Notes

Repro

const rs = new ReadableStream({
  type: "direct",
  pull(c) { c.write("a"); c.close(); },
  close() { throw new Error("close hook"); },
});
const res = new Response(rs);
res.clone();
await res.text(); // 1.4.0: resolves "a". 1.4.1, 1.4.2, main: never settles, "error: close hook". This PR: rejects "close hook".

Regression window. Release binaries: 1.3.14 and 1.4.0 resolve, 1.4.1 and 1.4.2 never settle. #39913 is in 1.4.1. It made the hook's throw propagate and gave textDecodePullAlgorithm its boundary. The two tee pull algorithms did not get one. I did not bisect commit by commit.

Measurements. Debug builds of main (73df7bb) with one file changed. One probe, 11 source shapes x 12 consumers = 132 cells, 110 of them with a tee. The hook throws in every cell.

Tee cells main promiseFromSteps alone this PR
never settle 13 4 0
read() throws synchronously 3 not measured 0
branches disagree 3 25 0
report uncaught 87 0 40
report an unhandled rejection 0 9 0
  • Cells without a tee that change: 0.
  • Tee cells that change: 51. None is healthy on main. Each one hangs, reports uncaught, throws from read(), or has branches that disagree.
  • promiseFromSteps alone reports 0 uncaught because it drops the error in those cells.
  • Of the 40 cells that report uncaught with this PR, 39 report uncaught on main too. In 1 cell main gave the error to a read().

Where the error goes

State when the read throws Shapes Branches Hook's error
A branch is still open close() or end() inside pull() with a final chunk both reject with it not reported
The source closed the branches close() with no final chunk end with their data reported
The source failed with its own error pull() throws, close(error) reject with the source's error reported

A branch that still holds a queued chunk counts as closed. It keeps the chunk.

Open choice for a maintainer: the error when no branch can take it. The code review on this PR found that this PR takes away a way to catch the error. It is correct. Measured on release builds, with a source whose second pull() closes with no final chunk:

main this PR
Reader waits (a microtask makes the pull) both branches get "a", error reported as uncaught the same
Reader is late (its read() makes the pull) that read() throws close hook synchronously, branch a never gets "a", nothing is reported both branches get "a", error reported as uncaught
Late reader with a try/catch, no process handler catches the error, 0 chunks, exit code 0 no error in the loop, 1 chunk, exit code 1

The same holds when a later pull() throws: on main a late read() throws the hook's error, and with this PR the hook's error is reported and the branches reject with the error of pull().

Option Caller can catch Data Same result in every timing
Report to the process (this PR) no kept yes
Rethrow to whoever pulled (main) only a late reader a late reader loses a chunk no
Close the branches after the read returns, so both reject yes a slow branch loses its queued chunks yes

The third option is not built. It moves the close of the branches after the hook for a hook that does not throw. The self-review asked for the first option. The code review asks for the second or third. I kept the first and will change it if a maintainer prefers another.

Why report and not drop. The review thread of #39913 states the rule: "a hook throwing after the stream already closed is reported rather than dropped, without double-reporting errors that did error the stream" (#39913 (comment)). The tee's pull algorithm runs in the frame of whoever pulled, so a rethrow does not reach the runner reliably. On main a read() on a branch throws the hook's error synchronously and loses a queued chunk. So the deliver step calls Bun__reportUnhandledError.

Sites that have the same shape and are not changed

Site Why it is excluded
byteTeePullAlgorithm A different bug, and older. A BYOB read with a partly filled view makes the tee's own close step throw TypeError: Cannot close a ReadableByteStreamController while a BYOB read request is partially filled. The other branch then stays pending until one more read. 1.4.0 does the same. The throw comes from the tee's close step, not from the source, so it needs its own fix.
Body.textStream() On main it drops the hook's error when the source closes with no final chunk.
Tee, close() with no final chunk Still reported as uncaught. To reject the branches there, the tee must close them after the hook runs. That changes the order of close and hook for a hook that does not throw.
bytes() and arrayBuffer() with no tee Covered by #41472. This PR does not touch its files.

Self-review. It raised 12 concerns and changed the fix twice.

  • The first version gave a branch the hook's error when pull() had thrown too. The source's own error was then reported nowhere. Now the branches keep the source's error.
  • The first version rethrew when no branch was open. read() on a branch then threw synchronously. Now the error goes to the runner.
  • It disproved my claim that no input makes the byte tee's read throw. The table above has the true reason.
  • Addressed in part: it asked for one tracking issue for the excluded sites. They are listed in the table above and no issue is open.

Tests

  • 30 rows: 5 source shapes x 6 consumers. Every branch rejects with close hook.
  • 1 test: cancel() of the other branch settles.
  • 1 pin of today's outcome for the shapes where the error is reported. It is not a contract.
  • With src/ at main: 0 of 32 pass (debug build). On the 1.4.2 release: 0 of 32 pass.
  • Each clause of the fix, removed alone, breaks a test (6 mutations).
  • BUN_JSC_validateExceptionChecks=1: 0 reports on the new tests, the probe, and the 63 tee tests of the file. A deliberate violation makes the validator abort, so it is live.

Suites run on the fix (debug build). streams.test.js 656, body-stream 9086, body 774, body-clone 85, compression 73, response 23, body-mixin-errors 13, sync-pull-fast-path 7, body-async-iterator 6, native-source-onclose-leak 4, readable-stream-terminal-barrier-release 2, pipeTo-shutdown-gc 1, and 7 node compat stream files. All pass.

Size. Two LTO release builds that differ in one file. bun: text 80,676,462 to 80,676,974 (+512), data and bss +0, file size 80,844,320 in both. defaultTeePullAlgorithm: 469 to 553 bytes. Its one new call is the deliver step, out of line, on the throw path only.

Found on the way, not changed here

  • response.test.ts "handle stack overflow" takes 4.4 to 4.5 s of its 5 s limit on a debug build, on main and on this PR alike. It timed out once under host load.
  • new Response(rs).text() stays pending after one sync pull() that writes and flushes with no close(). This happens with no hook and no tee. It can be intended.
  • With no tee, close(new Error("reason")) inside pull() with a hook that throws: the consumer rejects with the hook's error and reason is reported as an unhandled rejection.

Rebase. #41472 adds tests to the same describe block of streams.test.js. The PR that lands second needs a manual rebase of that file.


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/streams/streams.test.js

The pull algorithm of a default tee let a throw from its read of the
source leave through the branch controller. The controller had set
[[pulling]] already, so that branch never pulled again and its consumer
never settled. The error went to whichever frame made the pull: the
runner reported it as uncaught, or a read() on a branch threw it
synchronously.

A type: "direct" source can throw from that read. Its close() hook runs
after pull() returns when close() or end() was called inside pull().

The pull algorithm is now the boundary for that throw and never throws.
One read feeds both branches, so both branches fail with the error and
the cancel promise settles. If the source already gave the branches
their outcome (it closed them, or it failed with its own error), no
branch is left to take the error, and it is reported to the runner.
@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status for 7d348f5.

How it was reproduced: the script below prints PENDING on the 1.4.1 and 1.4.2 releases and on a debug build of main, and the hook's error is reported as uncaught. The 1.3.14 and 1.4.0 releases print resolved:a. This build prints rejected:close hook and reports nothing.

process.on("uncaughtException", e => console.log("uncaught:", e.message));
const rs = new ReadableStream({
  type: "direct",
  pull(c) {
    c.write("a");
    c.close();
  },
  close() {
    throw new Error("close hook");
  },
});
const res = new Response(rs);
res.clone();
const pending = new Promise(r => setTimeout(() => r("PENDING"), 500));
console.log(await Promise.race([res.text().then(v => "resolved:" + v, e => "rejected:" + e.message), pending]));
process.exit(0);

With src/ at main, 0 of the 32 new tests in test/js/web/streams/streams.test.js pass. With this change, 32 of 32 pass.

PR: #43956

@coderabbitai

coderabbitai Bot commented Sep 25, 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: 031f62d3-def7-4fb4-b984-760df3bd8939

📥 Commits

Reviewing files that changed from the base of the PR and between 29d9638 and 7d348f5.

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

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

Default stream teeing now routes synchronous source-read errors through shared branch-erroring logic. Tests cover throwing direct-source close hooks across tees, clones, nested tees, cancellation, and cases where branches already received the source outcome.

Changes

Default stream tee error delivery

Layer / File(s) Summary
Route default tee read errors
src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
A shared helper errors open branches and resolves the tee cancellation promise when a branch has not been canceled. Synchronous source-read errors use this helper. Reader closed-promise rejections also delegate to it.
Test throwing close hooks
test/js/web/streams/streams.test.js
Tests cover throwing close hooks with direct tees, response and request clones, nested tees, branch cancellation, and cases where branches already received the source outcome.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 7d348

The change appears mergeable after normal checks; no remaining issue is established that would leave tee consumers pending.

🚥 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 summarizes the main change: both tee branches now fail when a source read throws.
Description check ✅ Passed The description explains the problem, fix, scope, trade-offs, regression behavior, and verification results. It does not use the template headings exactly, but it provides the required content and is …

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

@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 finding, I also diffed the extracted defaultTeeErrorBranches against the old inline body of defaultTeeReaderClosedRejected — same error-then-resolve sequence and cancel polarity, so that refactor is behavior-preserving; and the atStreamsBoundary deliver runs no user JS before deciding, so a throwing deliver cannot double-report.

Extended reasoning...

The change touches only the default-tee pull path in ReadableStreamOperations.cpp and its tests, no security-sensitive surface. One confirmed finding (behavior change for programs that caught the close hook's error on the base branch) is posted inline, so a human look is already signalled.

Comment on lines +1129 to +1131
bool open = (controller1 && readableStreamDefaultControllerCanCloseOrEnqueue(controller1)) || (controller2 && readableStreamDefaultControllerCanCloseOrEnqueue(controller2));
if (!open || teeState->stream()->m_state == ReadableStreamState::Errored)
RELEASE_AND_RETURN(scope, Bun__reportUnhandledError(globalObject, JSValue::encode(error)));

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.

🟡 (optional) Programs that caught the close hook's error from a branch read on the base branch now get an uncaught exception and exit code 1 instead. ReadableStreamOperations.cpp:1131 routes the throw to Bun__reportUnhandledError whenever the branches are already closed, e.g. the source closes with no final chunk on a later pull. On base that throw left the pull through read()'s scope at JSReadableStreamDefaultReader.cpp:725/737, so await reader.read() rejected and a try/catch handled it. Fix: give the error to the caller that made the read (reject that branch's read, or error the branches) instead of the process-level reporter; keep the reporter only when no consumer exists at all.

Why this was flagged

Trigger: a direct source teed (stream.tee(), Response.clone(), Request.clone()) whose close() hook throws after the source closed without a final chunk, e.g. the PR's own secondPullClosesOnly shape (test/js/web/streams/streams.test.js:4376): pull 1 writes "a", pull 2 only calls close(). The second pull is made from the branch's reader.read() (JSReadableStreamDefaultReader.cpp:724-742), which on base returned the hook's error as a synchronous throw or rejection of read() that user code could catch. The new deliver at ReadableStreamOperations.cpp:1129-1131 computes open=false because closeSteps (JSReadRequest.cpp:142-153) already closed both branches, and calls Bun__reportUnhandledError. uncaught_exception (VirtualMachine.rs:2143-2162) with no process handler prints the error and sets exit_handler.exit_code = 1; read() resolves normally. The dismissal took the PR's table (87 cells report on main) as proof the population is unchanged, but the PR itself lists 3 cells where main threw from read(); those move from catchable to uncatchable with a nonzero exit. Remedy: reject the read that…

Verification: nit — acknowledged in diff: the description's table admits "read() throws synchronously: main 3 → this PR 0" and the added subprocess test pins secondPullClosesOnly: { a: { ok: "a" }, b: { ok: "a" }, uncaught: ["close hook"] } (test/js/web/streams/streams.test.js, "an error that no branch can take is reported…"), but the Downsides claim that these cells are "reported as uncaught, as…

@robobun robobun Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This finding is correct. I measured it on release builds of main and of this PR.

Source: the first pull() writes "a" and flushes, the second pull() only calls close(), and the hook throws.

main this PR
Reader waits (a microtask makes the pull) both branches get "a", the error is reported as uncaught the same
Reader is late (its read() makes the pull) that read() throws close hook synchronously, branch a never gets "a", nothing is reported both branches get "a", the error is reported as uncaught
Late reader with a try/catch, no process handler catches the error, 0 chunks, exit code 0 no error in the loop, 1 chunk, exit code 1

Two corrections to the numbers:

  • Of the 3 measured cells where main threw from read(), 1 moves to the reporter. In the other 2 the closing pull() writes a final chunk. Both branches now reject with the hook's error, so both consumers can catch it.
  • The Downsides section said "as on main" for all 40 cells. That was wrong for the late reader. The PR body now states the change.

Why the code reports and does not rethrow: on main a caller can catch the error on one path only. On that path read() throws synchronously and the branch loses the chunk that it dequeued. The frame that gets the error depends on timing.

I did not change the code. The PR body lists three options under "Open choice for a maintainer". I will build the one that a maintainer prefers.

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