Skip to content

fetch: reject already-used Request stream bodies before connecting - #36499

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/272de517/fetch-reject-used-stream-body-preflight
Jul 31, 2026
Merged

fetch: reject already-used Request stream bodies before connecting#36499
Jarred-Sumner merged 3 commits into
mainfrom
farm/272de517/fetch-reject-used-stream-body-preflight

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

fetch(request) where request has a ReadableStream body that was already consumed opens a TCP connection and writes the full request head (POST /path HTTP/1.1 + Transfer-Encoding: chunked) before rejecting with ERR_STREAM_CANNOT_PIPE. Each retry leaves a half-open, unterminated chunked request at the origin (0 body bytes, no 0\r\n\r\n).

const rs = new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode("x")); c.close(); } });
const req = new Request(url, { method: "POST", body: rs, duplex: "half" });
await fetch(req);              // 200, bodyUsed: true
await fetch(req);              // rejects ERR_STREAM_CANNOT_PIPE, but origin saw a POST head
await fetch(req);              // same
// origin saw 3 connections (1 real + 2 phantom POST heads); node/undici: 1

Node/undici throws TypeError before any network activity. String/Blob-body used Requests already reject pre-flight in Bun; only the stream-body path has this bug.

Cause

The pre-flight already_used check in fetch_impl consults PendingValue::is_disturbed::<Request>, which looks at body_get_cached (only populated once .body is accessed) and Locked.readable. But check_body_stream_ref (run from Request::to_js) migrates the stream out of Locked.readable into the JS stream cache slot, so neither lookup finds it. The disturbed stream isn't checked until ResumableSink::init_exact_refs runs, which is after the HTTP thread has connected and written the request head.

Fix

Check is_disturbed || is_locked on the actual ReadableStream returned by get_body_readable_stream (which consults the stream cache slot) before wrapping it as the request body. This is the same spec unusable check that Request.prototype.clone applies via throw_if_body_unusable, and throws TypeError with ERR_BODY_ALREADY_USED before any I/O.

Verification

# before (system bun)
connections seen by origin: 4   refetch -> Error ERR_STREAM_CANNOT_PIPE

# after
connections seen by origin: 1   refetch -> TypeError ERR_BODY_ALREADY_USED

New tests in test/js/web/fetch/body-mixin-errors.test.ts assert the origin sees exactly the expected connection count (2: first fetch + probe) and that re-fetches reject with TypeError/ERR_BODY_ALREADY_USED. A second test covers the locked-but-not-disturbed case.


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

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/body-mixin-errors.test.ts
bun test v1.4.0 (5739a2efa)

test/js/web/fetch/body-mixin-errors.test.ts:
(pass) body-mixin-errors > should throw TypeError when body already used on Response [14.02ms]
(pass) body-mixin-errors > should throw TypeError when body already used on Request [8.76ms]
(pass) body-mixin-errors > fetch: truncated body read rejects with TypeError and marks body used [506.07ms]
(pass) body-mixin-errors > fetch: body that failed before any reader call is still consumed by the first read [392.05ms]
(pass) body-mixin-errors > fetch: reading .body directly marks body used when the stream errors [388.69ms]
(pass) body-mixin-errors > fetch: truncated body arrayBuffer() marks body used [403.82ms]
(pass) body-mixin-errors > fetch: truncated body bytes() marks body used [452.99ms]
(pass) body-mixin-errors > fetch: truncated body blob() marks body used [170.51ms]
(pass) body-mixin-errors > fetch: truncated body json() marks body used [167.50ms]
172 |         url,
173 |         makeBody,
174 |         expectConnec
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (c4d619167)

test/js/web/fetch/body-mixin-errors.test.ts:
(pass) body-mixin-errors > should throw TypeError when body already used on Response [0.25ms]
(pass) body-mixin-errors > should throw TypeError when body already used on Request [0.28ms]
(pass) body-mixin-errors > fetch: truncated body read rejects with TypeError and marks body used [11.01ms]
(pass) body-mixin-errors > fetch: body that failed before any reader call is still consumed by the first read [9.00ms]
(pass) body-mixin-errors > fetch: reading .body directly marks body used when the stream errors [8.86ms]
(pass) body-mixin-errors > fetch: truncated body arrayBuffer() marks body used [8.77ms]
(pass) body-mixin-errors > fetch: truncated body bytes() marks body used [8.68ms]
(pass) body-mixin-errors > fetch: truncated body blob() marks body used [8.63ms]
(pass) body-mixin-errors > fetch: truncated body json() marks body used [8.59ms]
(pass) body-mixin-errors > fetch: Request with a locked stream body rejects before any network I/O [8.79ms]
(pass) body-mixin-errors > fetch: re-fetching a Request whose stream body was consumed rejects before any network I/O [9.37ms]

 11 pass
 0 fai
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/body-mixin-errors.test.ts
bun test v1.4.0 (5739a2efa)

test/js/web/fetch/body-mixin-errors.test.ts:
(pass) body-mixin-errors > should throw TypeError when body already used on Response [18.13ms]
(pass) body-mixin-errors > should throw TypeError when body already used on Request [15.51ms]
(pass) body-mixin-errors > fetch: truncated body read rejects with TypeError and marks body used [617.46ms]
(pass) body-mixin-errors > fetch: body that failed before any reader call is still consumed by the first read [490.63ms]
(pass) body-mixin-errors > fetch: reading .body directly marks body used when the stream errors [485.67ms]
(pass) body-mixin-errors > fetch: truncated body arrayBuffer() marks body used [498.10ms]
(pass) body-mixin-errors > fetch: truncated body bytes() marks body used [495.77ms]
(pass) body-mixin-errors > fetch: truncated body blob() marks body used [135.92ms]
(pass) body-mixin-errors > fetch: truncated body json() marks body used [131.40ms]
(pass) body-mixin-errors > fetch: Request with a locked stream body 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1521ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output 
... (truncated)
diff hotspot
src/runtime/webcore/fetch.rs                |   8 +++
 test/js/web/fetch/body-mixin-errors.test.ts | 104 ++++++++++++++++++++++++++++
 2 files changed, 112 insertions(+)

gate history · 3 passed · 0 rejected · iteration 1

evidence per changed file
file                                         reads  edits  tests
src/runtime/webcore/fetch.rs                     1      1      0
test/js/web/fetch/body-mixin-errors.test.ts      2      3      0

fetch(request) where request has a ReadableStream body that was already
consumed would open a TCP connection and write the full request head
(POST /path HTTP/1.1 + Transfer-Encoding: chunked) before rejecting with
ERR_STREAM_CANNOT_PIPE. Each retry left a half-open, unterminated chunked
request at the origin.

The pre-flight body check in fetch_impl consulted PendingValue::is_disturbed,
which looks at body_get_cached (only populated once .body is accessed) and
Locked.readable, but check_body_stream_ref had already migrated the stream
out of Locked.readable into the JS stream cache slot. So the disturbed stream
was never found until ResumableSink::init_exact_refs ran, after the head was
on the wire.

Check is_disturbed/is_locked on the actual ReadableStream returned by
get_body_readable_stream (which consults the stream cache slot) before
wrapping it as the request body, so the TypeError is thrown before any I/O.
This matches the spec unusable check used by Request.clone() and node/undici.
@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced and fixed: pre-flight check now finds the stream in the JS stream cache slot and rejects before the HTTP thread is scheduled. Origin sees 1 connection instead of 4; error is TypeError / ERR_BODY_ALREADY_USED matching node.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Fetch now rejects disturbed or locked Request bodies with BODY_ALREADY_USED before network activity. Tests cover consumed and locked streaming bodies, error details, body state, and connection counts.

Changes

Request body validation

Layer / File(s) Summary
Stream state validation
src/runtime/webcore/fetch.rs
Request body extraction checks whether the readable stream is disturbed or locked. It returns BODY_ALREADY_USED before constructing the request body stream.
Pre-network rejection tests
test/js/web/fetch/body-mixin-errors.test.ts
Tests verify rejection of consumed and locked streaming Request bodies with TypeError and ERR_BODY_ALREADY_USED. The tests also verify that no unauthorized network connections are opened.

Possibly related PRs

Suggested reviewers: jarred-sumner

🚥 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 fetch behavior change.
Description check ✅ Passed The description explains the problem, cause, fix, and verification, covering the template requirements despite different headings.

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.

LGTM — narrow pre-flight guard, well-tested. The inline nit on the locked-body test's connections assertion is worth tidying but not blocking.

Extended reasoning...

Overview

Adds an 8-line is_disturbed || is_locked guard in fetch_impl's Request-body extraction path, immediately after get_body_readable_stream returns the stream and immediately before it is wrapped as HTTPRequestBody::ReadableStream. Two new tests in body-mixin-errors.test.ts assert the origin sees no extra connections and the rejection is TypeError/ERR_BODY_ALREADY_USED.

Security risks

None. This is a stricter pre-flight rejection using existing error-code machinery; no new parsing, no new I/O, no auth/crypto surface.

Level of scrutiny

Medium — fetch.rs is hot-path code, but the change is a pure guard clause on a path that would already fail later (in ResumableSink::init_exact_refs, post-connect). The successful path is unchanged: the guard sits exactly where the stream is first obtained, uses the same ErrorCode::BODY_ALREADY_USED / message as the sibling already_used branch just above, and ERR_BODY_ALREADY_USED maps to TypeError in ErrorCode.ts so the tests' instanceof TypeError assertions hold.

Other factors

  • Verified the sibling init.body path (HTTPRequestBody::from_js) already has its own disturbed check and does not go through the stream-cache-slot migration that caused this bug, so the fix correctly targets only the Request-object arm.
  • Tests use port: 0, it.concurrent, socket cleanup in finally, and a probe fetch to synchronize the connection-count assertion in the primary test.
  • One nit was flagged (the locked-body test's connections === 0 check lacks the same probe barrier); it's non-blocking because the TypeError/ERR_BODY_ALREADY_USED assertions already distinguish fixed from unfixed builds — the connection count is defense-in-depth.

Comment thread test/js/web/fetch/body-mixin-errors.test.ts Outdated
Mirrors the sibling test so the 'no network I/O' claim can actually fail
on a regressed build (the server's connection callback can lag the fetch
rejection otherwise).

@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 — thanks for adding the probe barrier to the locked-body test in c4d6191. The remaining inline note about extracting a shared withConnectionCountingServer helper is a style nit, not blocking.

Extended reasoning...

Overview

8-line addition to fetch_impl in src/runtime/webcore/fetch.rs: after get_body_readable_stream returns the Request's stream, check is_disturbed || is_locked and reject with BODY_ALREADY_USED before wrapping it as the outbound body. Two new tests in test/js/web/fetch/body-mixin-errors.test.ts assert the origin sees no phantom connections and the rejection is TypeError / ERR_BODY_ALREADY_USED.

Correctness

The predicate and error shape mirror the existing spec-unusable checks at Body.rs:1018 (body init), Body.rs:1886 (throw_if_body_unusable used by Request.clone/Response.clone), and ResumableSink.rs:160 (the post-connect check this PR pre-empts). is_disturbed/is_locked are the same non-throwing FFI accessors used at every sibling site, so no new exception-scope handling is needed. The check sits inside the existing BodyValue::Locked branch, so string/Blob/empty bodies are unaffected, and it runs before FetchTasklet::queue so no HTTP thread work is scheduled.

Security risks

None. The change makes fetch reject earlier on an already-invalid path, eliminating half-open chunked requests at the origin — strictly less network activity than before.

Level of scrutiny

Low-to-moderate. The runtime change is small, additive, and pattern-matched against three existing call sites; it does not touch memory ownership, threading, or the happy path. The tests are the larger surface and were the focus of both review rounds.

Other factors

  • My prior nit (missing probe barrier on the locked-body connection assertion) was addressed in c4d6191 and the author confirmed it now fails Expected: 1, Received: 2 on a pre-fix build.
  • PR description shows the tests fail on both ASAN-debug and release builds without the fix and pass with it.
  • The one remaining inline finding (duplicated ~30-line server scaffolding across the two new tests) is a code-style nit that doesn't affect correctness; a withConnectionCountingServer helper would be nice but isn't merge-blocking.

Comment thread test/js/web/fetch/body-mixin-errors.test.ts Outdated
Deduplicates the server/probe scaffolding shared by the two stream-body
pre-flight tests, matching the withTruncatedBodyServer pattern already
used in this file.

@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 — both earlier nits (probe barrier, helper extraction) are addressed in c4d6191 and 5739a2e.

What was reviewed:

  • The 8-line guard mirrors the exact is_disturbed || is_locked spec-unusable check already used at Body.rs:1018, Body.rs:1886 (throw_if_body_unusable), and ResumableSink.rs:160; is_disturbed/is_locked are non-throwing FFI slot reads.
  • get_body_readable_stream was already called at this site, so the new check adds no side effects — it just gates on the returned stream before wrapping it.
  • withConnectionCountingServer now owns the probe-fetch barrier and awaited server.close(); both tests share it, are hermetic (port 0, local net.Server), and assert exact error class + code.
Extended reasoning...

Overview

The PR adds an 8-line pre-flight check in fetch_impl (src/runtime/webcore/fetch.rs) that rejects a Request whose ReadableStream body is disturbed or locked with TypeError/ERR_BODY_ALREADY_USED before any network I/O. Two new tests in test/js/web/fetch/body-mixin-errors.test.ts verify the origin sees no phantom connections for both the consumed-stream and locked-stream cases, using a shared withConnectionCountingServer helper with a probe-fetch synchronization barrier.

Security risks

None. The change tightens validation (rejects earlier), does not touch TLS/auth/credential paths, and does not expand accepted input. The tests use a local net.Server on port 0 with no external network access.

Level of scrutiny

Low-to-moderate. This is a small, focused correctness fix in the fetch pre-flight path. The guard is inserted between an existing get_body_readable_stream call and the existing HTTPRequestBody::ReadableStream wrap — no new call, no new state, no exception-handling requirement (both is_disturbed and is_locked return plain bool via FFI slot reads). The identical predicate + error code are already used at three sibling sites in the same subsystem (Body.rs:1018 for body-init streams, Body.rs:1886 for clone(), ResumableSink.rs:160 for the late sink check this PR pre-empts), so the pattern is well-precedented.

Other factors

I left two prior review rounds on this PR — a missing probe barrier in the locked-body test and duplicated ~30-line test scaffolding — and both were addressed (c4d6191, 5739a2e). The extracted helper follows the file's existing withTruncatedBodyServer convention and awaits server.close(). The author confirmed the locked-body test fails with Expected: 1, Received: 2 on a pre-fix build after the barrier was added. The bug hunting system found nothing this run. No outstanding reviewer comments remain.

@Jarred-Sumner
Jarred-Sumner merged commit 21932c3 into main Jul 31, 2026
55 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/272de517/fetch-reject-used-stream-body-preflight branch July 31, 2026 04:57
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