Skip to content

webcore: make Request/Response clone() throw on a disturbed or locked body - #33129

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/b579d322/clone-usability-check
Jun 30, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/b579d322/clone-usability-check

Conversation

@robobun

@robobun robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator

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

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 webcore: propagate exceptions from readableStreamTee instead of reporting them as uncaught #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.

… body

Fetch spec step 1 of both clone() algorithms requires a TypeError when
the receiver is unusable (body non-null and its stream disturbed or
locked). Bun never ran this check, so cloning a consumed body succeeded
and, for non-stream bodies, returned a clone whose body silently reads
as empty. Node (undici), Deno, and browsers all throw.

Add BodyMixin::throw_if_body_unusable and call it at the top of both
do_clone entry points. The unusable predicate is the existing bodyUsed
walk plus ReadableStream::is_locked, so get_body_used is refactored to
share it (body_stream_check) rather than duplicating the match.

The error is ERR_BODY_ALREADY_USED (a TypeError) with the message
"Body is disturbed or locked".
@coderabbitai

coderabbitai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

An error occurred during the review process. Please try again later.

Walkthrough

Introduces a shared body_stream_check helper on BodyMixin that centralizes disturbed/locked stream detection. get_body_used and throw_if_body_unusable are updated to use it. Request::ffi_clone, Request::do_clone, and Response::do_clone now call throw_if_body_unusable early. Tests are expanded with subprocess and describe-block coverage for clone error scenarios.

Body Clone Usability Enforcement

Layer / File(s) Summary
BodyMixin shared stream-check helper
src/runtime/webcore/Body.rs
Adds body_stream_check method centralizing Value::Used, Value::Locked-with-action, JS-cache, and native-ref checks. get_body_used and throw_if_body_unusable are refactored to delegate to it.
Clone guards in Request and Response
src/runtime/webcore/Request.rs, src/runtime/webcore/Response.rs
ffi_clone, do_clone (Request), and do_clone (Response) each call throw_if_body_unusable before cloning, returning early on failure.
Test coverage
test/js/web/fetch/body-clone.test.ts
Subprocess assertions updated for locked-stream clone; new subprocess test for new Request(request) with locked body; comprehensive describe suite added for disturbed/locked clone error and success scenarios.

Possibly related PRs

  • oven-sh/bun#31884: Fixes ReadableStream::isLocked() detection, directly coupled to the locked-stream state evaluation used by the new body_stream_check helper.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main clone() usability fix.
Description check ✅ Passed The description is mostly complete, with problem, fix, and tests covered, but it doesn't use the template's exact section headings.
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.

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

@robobun

robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:50 AM PT - Jun 30th, 2026

❌ @robobun, your commit f86f607 has 3 failures in Build #67161 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33129

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

bun-33129 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Reusing an already consumed ReadableStream should always cause a ReadableStream is locked error #6860 - This issue reports that reusing an already-consumed ReadableStream should throw a "ReadableStream is locked" error but sometimes silently succeeds or gives wrong errors. This PR enforces the spec-compliant TypeError on clone() of a disturbed or locked body, directly addressing that missing validation.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #6860

🤖 Generated with Claude Code

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

#6860 is a different code path and is not fixed by this PR, so it should not be linked with a Fixes line.

That issue is about reusing a standalone ReadableStream: passing an already-consumed stream to new Response(stream) a second time, and calling Bun.readableStreamToArrayBuffer(stream) a second time. Those go through the Response constructor's body-extraction step and Bun's stream helpers, not through clone().

I ran #6860's repro against this branch and against the released 1.4.0; the output is identical:

2nd new Response(stream).arrayBuffer(): Error: ReadableStream has already been used
2nd Bun.readableStreamToArrayBuffer(stream): NO-THROW (0 bytes)
2nd new Response(stream).text(): Error: ReadableStream has already been used

The Bun.readableStreamToArrayBuffer case from #6860 still silently resolves to an empty buffer, and the new Response(stream) case still throws a plain Error instead of the TypeError the spec requires, with or without this change. This PR only covers step 1 of the two clone() algorithms.

Comment thread src/runtime/webcore/Request.rs
BunRequest.prototype.clone (the subclass that Bun.serve routes:
handlers receive) dispatches through JSBunRequest::clone ->
Request__clone -> Request::ffi_clone rather than Request::do_clone,
so it skipped the new throw_if_body_unusable check and still returned
a clone with a silently empty body after the request was read.

Add the check at the top of ffi_clone. Returning None with the
exception pending matches the existing contract: JSBunRequest::clone
asserts raw-null iff exception and RETURN_IF_EXCEPTIONs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@test/js/web/fetch/body-clone.test.ts`:
- Around line 558-561: The subprocess assertions in the body-clone tests are too
weak because they only exclude one substring from stderr, allowing unexpected
diagnostics or stack traces to slip through. Update the affected checks in the
body-clone test cases to assert an exact empty stderr result (or otherwise
include stderr in the expected subprocess result) for the bunEnv-backed probes,
using the existing subprocess result assertions around the caught TypeError
cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 16b19ec4-75a4-4e3e-800b-1addba2c3aa1

📥 Commits

Reviewing files that changed from the base of the PR and between a5d122d and 328e7a2.

📒 Files selected for processing (4)
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/Request.rs
  • src/runtime/webcore/Response.rs
  • test/js/web/fetch/body-clone.test.ts

Comment thread test/js/web/fetch/body-clone.test.ts Outdated
Fold stderr into the combined {stdout, stderr, exitCode} toEqual so
any unexpected diagnostic or double-reported error fails the test,
instead of only excluding one substring.

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

My earlier feedback on the BunRequest FFI path is addressed and I found nothing new on re-review; deferring to a human only because this intentionally changes clone() from silently succeeding to throwing on consumed/locked bodies, which is the kind of user-visible compat shift a maintainer should sign off on.

Extended reasoning...

Overview

Adds the fetch-spec step-1 "body unusable" check to Request.prototype.clone(), Response.prototype.clone(), and BunRequest.prototype.clone (the Bun.serve routes: subclass). Implementation refactors BodyMixin::get_body_used into a shared body_stream_check(check_fn) helper, then throw_if_body_unusable calls it with is_disturbed || is_locked and throws ERR_BODY_ALREADY_USED (a TypeError). Three one-line guards are added at the top of Request::do_clone, Request::ffi_clone, and Response::do_clone. ~230 lines of new tests cover every body representation, both handler kinds in Bun.serve, negative cases (null body, materialized-but-unread stream), and preserve coverage for the readableStreamTee C++ bridge via new Request(lockedRequest).

Security risks

None. No auth, crypto, permissions, or untrusted-input parsing is touched. The change adds an early throw on an existing state predicate; it does not relax any check or expose new data.

Level of scrutiny

Moderate-to-high. The Rust diff is small and the refactor of get_body_used is behavior-preserving (same match arms, same is_disturbed predicate), but Request/Response clone() is a core, widely-used web API. The key judgment call is the user-visible behavior change: code that previously called clone() after consuming a body got a silently-empty clone; it will now throw. This is spec-correct and matches Node/Deno/browsers, and the PR description argues convincingly that any such caller was already broken — but it is still the kind of intentional compat shift that benefits from a maintainer's explicit ack rather than a bot approval.

Other factors

  • My one prior finding (the BunRequest ffi_clone path bypassing the check) was fixed exactly as suggested in 328e7a2, with dedicated routes: tests for both parameterized and static routes plus the unread negative case.
  • The CodeRabbit nit (assert empty stderr in the subprocess probes) was adopted in 29e8a85.
  • All inline review threads are resolved; no outstanding human reviewer comments.
  • The robobun build-status comment shows failures on three lanes against 328e7a2, but they are scripts/build/ci.ts build-step failures, not test failures, and predate the latest commit — likely infra, but worth confirming green before merge.
  • No CODEOWNERS cover the touched paths.
  • The PR explicitly scopes out the analogous new Request(usedRequest) constructor check (spec step 36.1), which is a reasonable boundary.

The only failure on build 67145 was
test/js/node/test/parallel/test-net-connect-memleak.js on the two
alpine x64 test lanes. That test's gc() assertion exercises
net.connect and a FinalizationRegistry; nothing in this diff touches
either. It also fails 100 out of 100 runs under the unmodified
bun v1.4.0-canary.1+a1c39ded9 release binary on linux-x64, and
a1c39de is an ancestor of this branch's base, so the failure is
independent of this change.

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

Thanks for the quick fix on the BunRequest path — that and the test additions look right, and I found no further issues. Deferring to a human since this is a user-visible behavior change to Request/Response.prototype.clone() (previously-silent calls will now throw), which is worth a maintainer's sign-off even though it's spec-correct.

Extended reasoning...

Overview

This PR implements step 1 of the fetch spec's clone() algorithms for Request and Response: throw a TypeError when the body is non-null and its stream is disturbed or locked. Previously Bun allowed clone() after the body was consumed, yielding a clone with a silently empty body (for string/Blob/FormData) or a deferred error (for streams).

Changes:

  • src/runtime/webcore/Body.rs (~30 lines): extracts a parameterized body_stream_check helper from the existing get_body_used match, and adds throw_if_body_unusable that calls it with is_disturbed || is_locked and throws ERR_BODY_ALREADY_USED (a TypeError).
  • src/runtime/webcore/Request.rs (2 one-liners): guards do_clone and ffi_clone (the BunRequest.prototype.clone FFI entry).
  • src/runtime/webcore/Response.rs (1 one-liner): guards do_clone.
  • test/js/web/fetch/body-clone.test.ts (~230 lines added): a new describe block covering consumed string/stream/Blob/FormData bodies, locked bodies, in-flight reads, fetch() responses, Bun.serve catch-all and routes: handlers (both parameterized and static), plus negative cases (null body, unread body, body-getter-only). Also updates the existing locked-stream subprocess test for the new message and adds a sibling new Request(lockedRequest) subprocess test to keep the readableStreamTee exception-propagation path covered.

My earlier review flagged that BunRequest.prototype.clone bypassed do_clone via ffi_clone; that was fixed in 328e7a2 with the guard and matching routes: tests. CodeRabbit's stderr-assertion nit was addressed in 29e8a85.

Security risks

None. No auth, crypto, permissions, or untrusted-input parsing is touched. The change adds an early-throw guard; it does not relax any check or expose new data.

Level of scrutiny

Moderate. The Rust diff is small (~35 net lines) and mechanically straightforward — a refactored helper plus three guard calls at the top of existing clone entry points, all returning early via ?/.ok()? on the existing JsResult/Option paths. The refactor of get_body_used into body_stream_check is behavior-preserving (same match arms, same stream lookup order).

What elevates this above auto-approval is that it's a user-visible behavior change to a fundamental Web API: code that previously called req.clone() after consuming the body and got an empty clone will now get a synchronous TypeError. That is spec-correct and matches Node (undici), Deno, and browsers — and the silent-empty-body behavior it replaces is almost always a bug in the caller — but it can still break existing (buggy) middleware in the wild. The PR description explicitly scopes out the related new Request(usedRequest) constructor check for blast-radius reasons, which is a reasonable call but underscores that this class of change merits a maintainer's eye.

Other factors

  • Test coverage: very thorough — positive and negative cases, all three JS entry points (Request.prototype.clone, Response.prototype.clone, BunRequest.prototype.clone), and preservation of the tee-exception regression coverage. The author reports 44/44 passing and surrounding suites unchanged.
  • CI: the one failure (test/js/node/test/parallel/test-net-connect-memleak.js on Linux x64) is unrelated to fetch/body/clone.
  • Prior review: my one finding was addressed with code + tests; CodeRabbit's test-tightening nit was also addressed. No outstanding reviewer comments remain.
  • CODEOWNERS: none cover the touched paths.

@robobun

robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status for a reviewer: the change itself is done and its tests are green. The red CI lanes across all three builds on this branch are unrelated to the diff.

  • Build 67135: one darwin 26 aarch64 - test-bun shard failed with buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. Zero tests ran. The build artifact existed (the log shows Found 2 artifacts); the download from the artifact store timed out on the macOS agent.
  • Build 67145: 280 jobs passed; the two alpine 3.23 x64 test lanes failed test/js/node/test/parallel/test-net-connect-memleak.js, a net.createConnection plus gc() plus FinalizationRegistry assertion. Nothing in this diff executes in that test. The test is also not sound independently of this PR: it fails 100 out of 100 runs under the unmodified bun v1.4.0-canary.1+a1c39ded9 linux-x64 release binary on the same machine, and a1c39ded9 is an ancestor of this branch's base, while bun's own main build 67114 at that exact base passed it on alpine-x64. Its outcome flips with build target and binary layout, not with code. The debug build of this branch passes it 30 out of 30. The only other failure in that build, napi_wrap has the right lifetime on Windows, was classified flaky and auto-retried by CI itself.
  • Build 67161, the one empty ci: retrigger re-roll, is now complete: 281 jobs passed, 5 failed. None of the 5 involve this change:
    • darwin 26 aarch64 - test-bun: the same buildkite-agent artifact download timed out after 120s as build 67135, before any test ran. Second occurrence on that agent pool.
    • darwin 14 x64 - test-bun: test/js/bun/terminal/terminal.test.ts ("creates subprocess with terminal attached") hit its 90 second timeout. A Bun.spawn PTY test.
    • ubuntu 25.04 x64 - test-bun: test/js/bun/util/v8-heap-snapshot.test.ts; the test runner process itself was killed by SIGKILL ("no core file found"), so no assertion failed.
    • alpine 3.23 x64 and alpine 3.23 x64-baseline - test-bun: test-net-connect-memleak.js again, the identical assertion. Reproducing on the retrigger is consistent with the analysis above: the test's outcome on alpine-x64 is determined by the release binary's layout, and an empty commit does not change the source tree.

test/js/web/fetch/body-clone.test.ts passed on every lane in every build. Across the two complete builds (67145 and 67161) the only failure that repeated is test-net-connect-memleak.js on alpine-x64; every other red was a different one-off. I pushed the single empty retrigger I am willing to push; repeating it would only burn CI. What this needs now is a maintainer, either retrying the failed BuildKite jobs or merging over the unrelated noise. It may also be worth looking at test-net-connect-memleak.js on alpine-x64 independently of this PR, given it reproduces against the unmodified main canary.

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