Skip to content

webcore: propagate exceptions from readableStreamTee instead of reporting them as uncaught - #32786

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/d3b59632/readablestream-tee-exception
Jun 26, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/d3b59632/readablestream-tee-exception

Conversation

@robobun

@robobun robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

Calling .clone() on a Request or Response whose body stream is locked is supposed to throw a single, catchable TypeError. In ASSERT_ENABLED builds (debug, and the asan release lane) it instead did three wrong things at once:

const q = new Request("http://example.com/", {
  method: "POST",
  body: new ReadableStream({ start() {} }),
  duplex: "half",
});
q.body.getReader(); // lock the body stream
try {
  q.clone();
} catch (e) {
  console.log("caught:", e.message);
}
TypeError: ReadableStream is locked   <- reported as UNCAUGHT even though it was caught
      at repro.ts:8:5
caught: Value is not a sequence       <- the catch block gets the wrong error
(exit code 1)                         <- process fails despite the try/catch

Release builds behaved correctly (caught: ReadableStream is locked, exit 0). Response.clone() hits the same path.

Cause

ReadableStream__tee invokes the readableStreamTee builtin through a local helper in src/jsc/bindings/webcore/ReadableStream.cpp. That builtin throws a TypeError("ReadableStream is locked") synchronously when the stream is already locked, which is exactly what Request.clone() relies on.

The helper contained this after the call:

#if ASSERT_ENABLED
        if (scope.exception()) [[unlikely]] {
            Bun__reportError(lexicalGlobalObject, JSC::JSValue::encode(scope.exception()));
        }
#endif
        EXCEPTION_ASSERT(!scope.exception() || vm.hasPendingTerminationException());
        RETURN_IF_EXCEPTION(scope, {});

Bun__reportError routes through VirtualMachine::uncaught_exception, which prints the error, sets the process exit code to 1, and clears the pending exception. With the exception gone, RETURN_IF_EXCEPTION no longer fired, so control reached convert<IDLSequence<...>> on the empty JSValue returned by the failed JSC::call, which is where the bogus "Value is not a sequence" came from.

The EXCEPTION_ASSERT on the next line encodes the same false invariant (that readableStreamTee can never throw a non-termination exception); without the Bun__reportError clearing the exception it would have aborted instead.

Fix

Drop both lines and let RETURN_IF_EXCEPTION propagate the exception. ReadableStream__tee already returns false with the exception pending, and the Rust caller (from_js_host_call_generic in ReadableStream::tee) already converts that into an Err that reaches the user's catch. This makes debug and asan builds match what release builds already did.

ReadableStream.prototype.tee() was never affected: it goes through the JS builtin directly and does not use this C++ wrapper.

Verification

New test.each(["Request", "Response"]) cases in test/js/web/fetch/body-clone.test.ts spawn a child, lock the body, clone(), and assert the exact caught message and exit code 0.

Before (debug build):

  {
-   "exitCode": 0,
+   "exitCode": 1,
    "stdout": [
-     "caught TypeError: ReadableStream is locked",
+     "caught TypeError: Value is not a sequence",
      "done",
    ],
  }

After: both pass, and the existing body-clone.test.ts (27), body.test.ts / body-stream.test.ts / body-mixin-errors.test.ts (9434), and streams.test.js suites still pass.

…ting them as uncaught

ReadableStream__tee invoked the readableStreamTee builtin through a helper
that, under ASSERT_ENABLED, passed any pending exception to Bun__reportError.
readableStreamTee legitimately throws a TypeError when the stream is already
locked, which is reachable from Request.clone() and Response.clone() on a
body whose stream the user has called getReader() on.

Routing that exception through the uncaught-exception handler had three bad
effects in debug and asan builds:

- the error was printed as if uncaught even though the caller catches it
- the process exit code was set to 1
- the pending exception was cleared, so control fell through to the
  IDLSequence conversion on an empty JSValue, and clone() surfaced a bogus
  "Value is not a sequence" TypeError instead of the real one

Also drop the EXCEPTION_ASSERT on the same line, which asserted the builtin
can never throw a non-termination exception. Release builds were unaffected
because the whole block was compiled out.
@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on a debug build of main: Request.clone() (and Response.clone()) on a body with a locked stream caught "Value is not a sequence" instead of "ReadableStream is locked", printed a second uncaught TypeError: ReadableStream is locked, and exited 1 despite the user's try/catch. Release builds were unaffected (the removed block is #if ASSERT_ENABLED). The new tests in test/js/web/fetch/body-clone.test.ts fail on the unfixed debug build and pass after the fix.

Status: the diff is green. None of the CI red on build 65138 is caused by this PR. No lane reported a failure in body-clone.test.ts. The 18 failed test-bun shards break down as:

  • 16 lanes (every OS, arch, release and ASAN alike): test/js/node/tls/node-tls-connect.test.ts, always the same assertion: expect(infoAccess["OCSP - URI"]).toBeDefined() in tls.connect > should have peer certificate receives undefined. That test connects to the live bun.sh:443 and checks the server's certificate for an OCSP URI, which is a property of the certificate bun.sh is currently serving, not of any code in this repo. The comment on the line above the assertion even says "this can change over time".

    This PR cannot be the cause: the only src/ change removes lines inside #if ASSERT_ENABLED plus an EXCEPTION_ASSERT (a no-op outside assertion builds), so the 15 release lanes were compiled from code identical to main's and they fail on the same line anyway. main's last completed build (64901, 08:30 UTC today) passed this test, so the certificate changed underneath it sometime after that. It will fail on every branch until the assertion is relaxed.

  • 1 lane (darwin 26 aarch64): buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun', before any tests ran. The previous build (65126) hit the same artifact timeout on the Windows 11 aarch64 lane.

  • 1 lane (darwin 14 x64, in addition to the tls failure): test/js/bun/terminal/terminal.test.ts > Bun.spawn with terminal option > creates subprocess with terminal attached timed out at 90s on that one macOS runner.

Ready for review.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

More reviews will be available in 21 minutes and 18 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

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

🚦 How do rate 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 see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d9d02957-0a3f-4c8e-a431-c47680d60c92

📥 Commits

Reviewing files that changed from the base of the PR and between ba72810 and 4fd5c34.

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

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

Comment thread test/js/web/fetch/body-clone.test.ts Outdated
robobun added 2 commits June 26, 2026 20:31
The only hard failure on build 65126 was a buildkite-agent artifact download
timeout on the windows-aarch64-build-bun step before any tests ran. The
remaining red is auto-retried flake in bun-install-registry.test.ts and
dev-server-ssr-100.test.ts, neither of which this diff touches.

@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 — the comment nit was addressed in 7d400b0, and the fix itself is a clean removal of an incorrect debug-only invariant.

Extended reasoning...

Overview

This PR removes 6 lines from ReadableStream__tee in src/jsc/bindings/webcore/ReadableStream.cpp: an #if ASSERT_ENABLED block that called Bun__reportError on any pending exception, plus the EXCEPTION_ASSERT that encoded the same false invariant (that readableStreamTee never throws non-termination exceptions). It replaces them with a 3-line comment and lets the existing RETURN_IF_EXCEPTION(scope, {}) propagate the exception. A new test.each(["Request", "Response"]) regression test in body-clone.test.ts spawns a subprocess to assert the caught error message and exit code.

Security risks

None. The change is purely subtractive in debug/asan builds and a no-op in release builds (the removed block was #if ASSERT_ENABLED, and EXCEPTION_ASSERT compiles away in release). No new code paths, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-medium. The C++ change only deletes diagnostic code that was actively wrong — readableStreamTee legitimately throws TypeError("ReadableStream is locked") when called via Request/Response.clone() on a locked body, so reporting it as uncaught (which clears the exception and sets exit code 1) was incorrect. Release builds already behaved correctly; this aligns debug/asan with release. The outer RETURN_IF_EXCEPTION(scope, false) in ReadableStream__tee already handles the propagated exception.

Other factors

  • My prior inline nit (5-line comment → 3 lines per CLAUDE.md) was addressed in 7d400b0 and the thread is resolved.
  • The new test follows the existing subprocess pattern already used in the adjacent test in the same file.
  • CI failures (scripts/build/ci.ts musl build, node-tls-connect.test.ts) are unrelated to a debug-only assertion removal in the ReadableStream tee path.
  • No CODEOWNERS entries for the affected paths.
  • Bug-hunting system found no issues.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

A follow-up fuzz report surfaced a second symptom of the same two lines, recording it here since the PR body only covers the wrong-error one.

If the script has installed a process.on("uncaughtException") handler, Bun__reportError dispatches to it through process.emit while the TypeError from readableStreamTee is still pending on the VM, so ASSERT_ENABLED builds abort at the next JS entry:

ASSERTION FAILED: Unexpected exception observed on thread ("Error Exception: ReadableStream is locked")
ExceptionScope.h:61 JSC::ExceptionScope::assertNoException
  <- JSC::Interpreter::executeCallImpl
process.on("uncaughtException", () => {});
const w = new Request("http://h.invalid/x", {
  method: "POST",
  duplex: "half",
  body: new ReadableStream({ start(c) { c.enqueue(new Uint8Array(8)); c.close(); } }),
});
w.body.getReader();
try { w.clone(); } catch {}

Re-verified on main (163e6bc): removing the Bun__reportError block and the EXCEPTION_ASSERT fixes both symptoms. Both clone attempts then throw a catchable TypeError: ReadableStream is locked, the process exits 0, and BUN_JSC_validateExceptionChecks=1 reports nothing on this path. The red lanes on build 65138 (terminal.test.ts timeout on macOS 14 x64, node-tls-connect on x64-asan, next-pages dev-server-ssr-100 on Windows) do not touch this code.

An equivalent variant that also folds the duplicated lambda into the file-level invokeReadableStreamFunction helper (which carries the same false EXCEPTION_ASSERT) is on the farm/5176aa3d/readablestream-tee-shared-helper branch. Either shape resolves the bug.

@Jarred-Sumner
Jarred-Sumner merged commit a11e72a into main Jun 26, 2026
67 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/d3b59632/readablestream-tee-exception branch June 26, 2026 22:43
Jarred-Sumner pushed a commit that referenced this pull request Jun 30, 2026
… body (#33129)

### Problem

`Request.prototype.clone()` and `Response.prototype.clone()` never
perform step 1 of the fetch spec's clone algorithms: "If this is
unusable, then throw a `TypeError`", where unusable means the body is
non-null and its stream is disturbed or locked
(https://fetch.spec.whatwg.org/#body-unusable).

```js
const q = new Request("http://x/", { method: "POST", body: "hello world" });
await q.text();       // body consumed
const c = q.clone();  // node, Deno, browsers: TypeError. Bun: succeeds
await c.text();       // "" (silent empty body)
```

What Bun does instead depends on the body's internal representation:

- Consumed string, Blob, and FormData bodies: `clone()` succeeds and the
clone resolves to an empty body.
- Consumed or locked stream bodies: `clone()` succeeds and an error
surfaces later, from the clone's own read or from the internal tee,
never from `clone()` itself.

The check exists so that clone-after-read, which is always a bug in the
caller, fails loudly in development. Without it, proxy and retry
middleware that does `clone()` then forwards the request silently
forwards an empty body whenever the order of operations is wrong.

Node (undici) throws a `TypeError` synchronously from `clone()` in each
case:

```
$ node repro.mjs
1 req-consumed-string:   clone() THREW TypeError: unusable
2 req-consumed-stream:   clone() THREW TypeError: unusable
3 req-locked(unread):    clone() THREW TypeError: unusable
4 res-consumed-string:   clone() THREW TypeError: Response.clone: Body has already been consumed.
5 res-locked(unread):    clone() THREW TypeError: Response.clone: Body has already been consumed.
6 res-consumed-blob:     clone() THREW TypeError: Response.clone: Body has already been consumed.
7 req-consumed-formdata: clone() THREW TypeError: unusable

$ bun repro.mjs   # 1.4.0 and main
1 req-consumed-string:   clone() OK, clone.text() -> ""
2 req-consumed-stream:   clone() OK, clone.text() THREW TypeError: Body already used
3 req-locked(unread):    clone() OK, clone.text() THREW TypeError: Body already used
4 res-consumed-string:   clone() OK, clone.text() -> ""
5 res-locked(unread):    clone() OK, clone.text() THREW TypeError: Body already used
6 res-consumed-blob:     clone() OK, clone.text() -> ""
7 req-consumed-formdata: clone() OK, clone.text() -> ""
```

### Fix

Add `BodyMixin::throw_if_body_unusable` and call it at the top of both
`do_clone` entry points (`src/runtime/webcore/Request.rs`,
`src/runtime/webcore/Response.rs`).

Request has a third JS entry point: `BunRequest.prototype.clone`, the
subclass that `Bun.serve` `routes:` handlers receive. It dispatches
through `JSBunRequest::clone` -> `Request__clone` ->
`Request::ffi_clone` rather than `do_clone`, so it gets the same check
at the top of `ffi_clone`. Response has no such subclass.
`Request::clone` and `Response::clone` have no other callers, so every
JS-visible `clone()` is covered and no internal clone path changes.

The unusable predicate is the existing `bodyUsed` walk with
`ReadableStream::is_locked` OR'd in, so `get_body_used` is refactored to
share a parameterized helper (`body_stream_check`) instead of
duplicating the match.

The error is `ERR_BODY_ALREADY_USED`, an instance of `TypeError` like
node and browsers, with the message `Body is disturbed or locked`
(WebKit's wording, which covers both halves of the predicate).

Only the two `clone()` entry points are guarded. `new
Request(usedRequest)` has the same spec check (Request constructor step
36.1) and is also missing in Bun, but it is a separate algorithm with a
much wider blast radius in `Bun.serve` middleware, so it is
intentionally not part of this PR. `fetch(usedRequest)` already throws.

### Tests

`test/js/web/fetch/body-clone.test.ts`:

- New `describe("clone() throws when the body is disturbed or locked")`:
consumed string / user-stream / Blob / FormData bodies, locked bodies,
an in-flight read, a `fetch()` response disturbed by a reader, a
consumed `Bun.serve` incoming request (catch-all `fetch` handler), and a
consumed `BunRequest` from a `routes:` handler (both a `/:param` route
and a static one). All fail on `main`.
- Negative coverage: null bodies, an unread `Bun.serve` request (both
handler kinds), and a body materialized by the `body` getter still
clone.
- The existing "`clone()` on a locked stream body throws a catchable
TypeError" subprocess test now asserts the new message. Since the
usability check fires before the stream is teed, that test no longer
reaches the `readableStreamTee` exception-propagation path it was added
for in #32786, so a sibling test drives the same path through `new
Request(lockedRequest)`, which still tees, and keeps that coverage.

With the fix, `bun bd test test/js/web/fetch/body-clone.test.ts` passes
44/44. The surrounding `body.test.ts`, `response.test.ts`,
`body-stream.test.ts`, `blob.test.ts`, and `test/js/web/request/` suites
are unchanged.
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