Skip to content

webcore: consume body when formData() rejects on MIME type - #35847

Closed
robobun wants to merge 5 commits into
mainfrom
claude/c9d315d8/formdata-mime-reject-consumes-body
Closed

robobun wants to merge 5 commits into
mainfrom
claude/c9d315d8/formdata-mime-reject-consumes-body

Conversation

@robobun

@robobun robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Body.formData() rejected early on an unrecognized Content-Type without touching the body. After the TypeError, bodyUsed stayed false and a subsequent text()/json()/blob() re-read the full payload. Code like try { await req.formData() } catch { await req.json() } silently worked in Bun and threw everywhere else.

The spec's consume body fully reads the body before packageData runs (which performs the MIME check and may throw), so a failed MIME check must still consume the body. Node/undici match the spec: bodyUsed is true after the rejection and further reads throw TypeError.

// before: {formData:"REJECTED TypeError", bodyUsedAfterReject:false, thenText:'RESOLVED "hello world"'}
// after:  {formData:"REJECTED TypeError", bodyUsedAfterReject:true,  thenText:'REJECTED TypeError'}
// node:   {formData:"REJECTED TypeError", bodyUsedAfterReject:true,  thenText:'REJECTED TypeError'}
const r = new Response("hello world");
let fd; try { await r.formData(); fd = "RESOLVED"; } catch (e) { fd = "REJECTED " + e.constructor.name; }
const usedAfterReject = r.bodyUsed;
let t; try { t = "RESOLVED " + JSON.stringify(await r.text()); } catch (e) { t = "REJECTED " + e.constructor.name; }
console.log({ formData: fd, bodyUsedAfterReject: usedAfterReject, thenText: t });

How did you verify your code works?

New tests in test/js/web/fetch/body.test.ts cover string / Uint8Array / Blob / ReadableStream / JSON bodies on both Request and Response, a null body (stays unconsumed, matching Node), and an incoming Bun.serve request body. All fail on the previous build and pass with the fix.

Fix

  • Synchronous body: call use_as_any_blob() before the encoding check so the body is marked Used first.
  • Locked body: pass the Option<encoder> through Action::GetFormData. With None, the ReadableStream is still drained (via a false contentType sentinel that the fulfillment handler turns into the ERR_FORMDATA_PARSE_ERROR rejection), and a pending HTTP body is still buffered before resolve() rejects.
  • Null body: unchanged (use_as_any_blob() leaves Null as Null), matching Node.

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

fails on main (without fix)
ASAN without fix: 11 failed, 4 skipped
$ 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.test.ts
bun test v1.4.0 (02ea67da4)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [5.42ms]
(pass) Request > constructor > null [3.80ms]
(pass) Request > constructor > string > "" [6.92ms]
(pass) Request > constructor > string > "Hello world" [1.70ms]
(pass) Request > constructor > string > "🫠" [1.54ms]
(pass) Request > constructor > string > "⁉️" [1.16ms]
(pass) Request > constructor > ArrayBuffer > empty buffer [10.60ms]
(pass) Request > constructor > ArrayBuffer > small buffer [5.63ms]
(pass) Request > constructor > ArrayBuffer > large buffer [5.09ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [2.21ms]
(pass) Request > constructor > SharedArrayBuffer > small buffer [2.09ms]
(pass) Request > constructor > SharedArrayBuffer > large buffer [4.18ms]
(pass) Request > constructor > Buffer > empty buffer [1.89ms]
(pass) Request > constructor > Buffer > small buffer [1.78ms]
(pass) Request > constructor > Buffer > large buffer [4.19ms]
(pass) Request > constructor
... (truncated)

release without fix: 4 skipped
bun test v1.4.0-canary.1 (1f5cfdc95)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [5.17ms]
(pass) Request > constructor > null [0.12ms]
(pass) Request > constructor > string > "" [0.13ms]
(pass) Request > constructor > string > "Hello world" [0.02ms]
(pass) Request > constructor > string > "🫠" [0.02ms]
(pass) Request > constructor > string > "⁉️"
(pass) Request > constructor > ArrayBuffer > empty buffer [0.14ms]
(pass) Request > constructor > ArrayBuffer > small buffer [3.39ms]
(pass) Request > constructor > ArrayBuffer > large buffer [1.64ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [0.04ms]
(pass) Request > constructor > SharedArrayBuffer > small buffer [0.03ms]
(pass) Request > constructor > SharedArrayBuffer > large buffer [2.11ms]
(pass) Request > constructor > Buffer > empty buffer [0.02ms]
(pass) Request > constructor > Buffer > small buffer [0.02ms]
(pass) Request > constructor > Buffer > large buffer [1.70ms]
(pass) Request > constructor > Uint8Array > empty buffer [0.01ms]
(pass) Request > constructor > Uint8Array > small buffer [0.04ms]
(pass) Request > constructor > Uint8Array > large buffer [1.5
... (truncated)
passes on PR (with fix)
ASAN with fix: 4 skipped
$ 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.test.ts
bun test v1.4.0 (02ea67da4)

test/js/web/fetch/body.test.ts:
(pass) Request > constructor > undefined [4.98ms]
(pass) Request > constructor > null [3.08ms]
(pass) Request > constructor > string > "" [6.27ms]
(pass) Request > constructor > string > "Hello world" [1.56ms]
(pass) Request > constructor > string > "🫠" [1.44ms]
(pass) Request > constructor > string > "⁉️" [1.10ms]
(pass) Request > constructor > ArrayBuffer > empty buffer [9.92ms]
(pass) Request > constructor > ArrayBuffer > small buffer [5.21ms]
(pass) Request > constructor > ArrayBuffer > large buffer [5.39ms]
(pass) Request > constructor > SharedArrayBuffer > empty buffer [2.36ms]
(pass) Request > constructor > SharedArrayBuffer > small buffer [2.01ms]
(pass) Request > constructor > SharedArrayBuffer > large buffer [4.04ms]
(pass) Request > constructor > Buffer > empty buffer [1.61ms]
(pass) Request > constructor > Buffer > small buffer [1.53ms]
(pass) Request > constructor > Buffer > large buffer [3.60ms]
(pass) Request > constructor 
... (truncated)

release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 909ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] gen cpp.rs (cppbind)
[2/8] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[2/8] 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  
... (truncated)
diff hotspot
.../webcore/streams/BunStreamConsumers.cpp         |  5 ++
 src/runtime/webcore/Body.rs                        | 68 +++++++++----------
 test/js/web/fetch/body.test.ts                     | 76 ++++++++++++++++++++++
 3 files changed, 115 insertions(+), 34 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                                     reads  edits  tests
src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp      4      4      0
src/runtime/webcore/Body.rs                                  8     10      0
test/js/web/fetch/body.test.ts                               2      1      0

Body.formData() rejected early on an unrecognized Content-Type without
touching the body, leaving bodyUsed false and allowing a subsequent
text()/json() to read the full payload. The spec's "consume body"
algorithm fully reads the body before packageData runs (and may throw),
so a rejected MIME check must still consume it. Node/undici agree:
bodyUsed is true after the rejection and further reads throw TypeError.

For synchronous bodies the fix is to call use_as_any_blob() before the
encoding check. For Locked bodies the encoder Option is passed through
Action::GetFormData: a None encoder still drains the ReadableStream (via
a contentType sentinel the fulfillment handler rejects on) or still
buffers the pending HTTP body before resolve() rejects. Null bodies stay
unconsumed, matching Node.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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

How do review 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 refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bbf0a769-a8dd-4ad3-b166-9a175a03d926

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 02ea67d.

📒 Files selected for processing (3)
  • src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp
  • src/runtime/webcore/Body.rs
  • test/js/web/fetch/body.test.ts

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

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:16 PM PT - Jul 25th, 2026

❌ @robobun, your commit 02ea67d has 2 failures in Build #81977 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35847

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

bun-35847 --bun

Comment thread src/runtime/webcore/Body.rs Resolved
Comment thread src/runtime/webcore/Body.rs Resolved
Comment thread src/runtime/webcore/Body.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.

I didn't find any bugs, but this changes user-visible Body.formData() semantics (previously-working try { formData() } catch { json() } now throws) and threads a false sentinel through the promise-reaction context as a workaround for null/undefined being dropped — worth a maintainer's eyes on the approach.

Checked: the contentType.isFalse() branch in the shared onReadableStreamToFormDataFulfilled handler is also reachable from public Bun.readableStreamToFormData(stream, false) — that call now rejects with ERR_FORMDATA_PARSE_ERROR instead of falling through to FormData.from(blob, false), which was already invalid usage. The removed ZigString/ZigStringJsc imports have no remaining uses in Body.rs. Null-body path verified: use_as_any_blob() leaves Value::Null as Null, so bodyUsed stays false per the test.

Extended reasoning...

Overview

The PR aligns Body.formData() with the fetch spec's consume body algorithm: the body must be fully read before packageData runs the MIME check, so a MIME-type rejection must still mark the body as used. Three code paths in src/runtime/webcore/Body.rs are updated (synchronous body via use_as_any_blob() before the encoder check; Value::Locked with a readable stream via Action::GetFormData(None) → JSValue::FALSE sentinel → drain then reject; Value::Locked pending resolve via the form_data_slot.take() None arm), plus a 5-line addition to jsWebStreamsHandler_onReadableStreamToFormDataFulfilled in BunStreamConsumers.cpp to convert the sentinel into ERR_FORMDATA_PARSE_ERROR. A shared form_data_mime_error helper deduplicates the error construction. Tests in test/js/web/fetch/body.test.ts cover string/Uint8Array/Blob/ReadableStream/JSON bodies × Request/Response, the null-body carve-out, and an incoming Bun.serve request.

Security risks

None identified. This tightens (not loosens) body-reuse semantics; no new parsing of untrusted input, no auth/crypto/permission surface.

Level of scrutiny

Moderate-to-high. This is a deliberate, user-visible behavioral change to a core Web API path (Request/Response body mixin) — code that previously relied on Bun's non-spec fallback (try { formData() } catch { json() }) will now throw. The change is spec-correct and matches Node/undici, but it warrants a maintainer confirming the compat trade-off is acceptable. Separately, the JSValue::FALSE sentinel plumbed through performPromiseThenWithContext is an implementation choice the author explicitly justified (null/undefined are dropped by the reaction-context layer) after comment-cop flagged it — a maintainer should confirm this is the preferred shape vs. e.g. a distinct action variant or a dedicated drain-then-reject path.

Other factors

  • The previously-unreachable form_data_slot.take() == None arm (which used to reject with "Internal error: task for FormData must not be null") is now a legitimate path; the message change from internal-error to ERR_FORMDATA_PARSE_ERROR is correct.
  • .err(...).reject() and JSPromise::rejected_promise(global, form_data_mime_error(global)).to_js() are equivalent (ErrorCode::reject calls rejected_promise internally).
  • All comment-cop threads on the PR are resolved (comments trimmed in a1efe80 / 1f5cfdc).
  • Test coverage is good and follows harness conventions (port: 0, await using server, test.each, awaited .rejects).

Deferring for a human to sign off on the behavioral change and the sentinel design.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

On the sentinel design: the alternative I considered was a distinct Action variant (e.g. Action::DrainThenRejectFormData), but that still has to drain a ReadableStream and settle the derived promise, which is exactly what readableStreamToFormData already does (drain to blob, then run a fulfillment handler). Threading Option<encoder> through the existing path and letting the fulfillment handler reject keeps the drain machinery in one place. The false value is the context sentinel only because performPromiseThenWithContext drops null/undefined before calling the handler (JSMicrotask.cpp, PromiseReactionJob).

The Bun.readableStreamToFormData(stream, false) collision was checked: it already rejected (via FormData.from's "boundary must be a string or ArrayBufferView" TypeError) and still rejects, just with ERR_FORMDATA_PARSE_ERROR now. Happy to switch to a dedicated drain-then-reject path if preferred.

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

I didn't find any bugs, but this changes user-visible formData() behavior (spec-correct, but code doing try { formData() } catch { text() } will now break) and the author left the false-sentinel-vs-dedicated-drain-path design open for maintainer preference — worth a human look.

What was reviewed:

  • Traced the three consume paths (sync use_as_any_blob, Locked→ReadableStream drain, Locked→on_start_buffering→resolve()) — all mark the body used before rejecting; blob.detach() is called on every reject arm.
  • Value::Null stays Null through use_as_any_blob(), so the null-body test's bodyUsed === false holds.
  • Checked the contentType.isFalse() branch in jsWebStreamsHandler_onReadableStreamToFormDataFulfilled: only reachable via Bun.readableStreamToFormData(stream, false), which already rejected (author confirmed; only the error code changes).
Extended reasoning...

Overview

Aligns Body.formData() with the fetch spec's consume body algorithm: when the Content-Type is not multipart/urlencoded, the body is now fully read and marked used before the ERR_FORMDATA_PARSE_ERROR rejection. Touches src/runtime/webcore/Body.rs (the BodyMixin::get_form_data sync path, the PendingValue::set_promise ReadableStream path, and the Value::resolve buffered-HTTP path), plus a 5-line sentinel check in BunStreamConsumers.cpp. New tests in body.test.ts cover Request/Response × {string, Uint8Array, Blob, ReadableStream, JSON content-type}, null body, and a Bun.serve incoming request.

Security risks

None identified. No new parsing of untrusted input; the change only reorders when consumption happens relative to an existing MIME-type rejection. The false sentinel is compared via JSValue::isFalse() (exact identity), not truthiness.

Level of scrutiny

Medium-high. Body.rs is on the hot path for every incoming HTTP request body and every Response consumer. The change is small (~35 lines of logic) and well-localized, but it is a deliberate observable behavior change to a Web API — code that relied on Bun's previous permissive fallback (try { formData() } catch { json() }) will now throw. That is spec-correct and matches Node/undici, but it is the kind of compat trade-off a maintainer should sign off on rather than a bot.

Other factors

  • The JSValue::FALSE sentinel threaded through performPromiseThenWithContext is a workaround for the reaction-context layer dropping null/undefined. The author explicitly offered to replace it with a dedicated drain-then-reject path if preferred — that's an open design question.
  • The public Bun.readableStreamToFormData(stream, false) API now rejects with ERR_FORMDATA_PARSE_ERROR instead of the previous "boundary must be a string or ArrayBufferView" TypeError. Minor, but a user-visible message change on a Bun-namespaced API.
  • The Action::GetFormData(None) arm in Value::resolve previously represented an internal-error state ("task for FormData must not be null") and now represents the intended MIME-failure path. I verified there are no other producers of Action::GetFormData(None) besides this PR's set_promise call site, so the repurposing is safe, but a maintainer may want to confirm no other in-flight work relies on that being an internal error.
  • Test coverage is thorough and the PR includes evidence that the tests fail on main and pass with the fix under both ASAN debug and release.

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green. Gate (fail-before/fail-after on ASAN and release) passed. The red on builds #81875 and #81977 is the *-build-cpp sibling jobs erroring/timing out so the dependent *-build-bun jobs have nothing to link ("Sibling step ...-build-cpp errored"). On 81875 this was linux-aarch64-musl; on 81977 it hit six platforms at once (darwin-x64, linux-x64, linux-x64-musl, freebsd-aarch64, windows-x64, windows-aarch64) and is marked pre-existing on main. The Rust build itself (which contains Body.rs) finished cleanly on every lane, and the C++ change built and passed on every lane that completed in 81875 (darwin-aarch64/x64, debian-x64-asan, alpine-x64, android, freebsd). The handful of test-lane failures (require-cache, webview-chrome, 20144, jsc-stress, fastutf8stream-reopen) all passed on retry and are unrelated to the body mixin. Ready for review.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-26, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Jarred-Sumner pushed a commit that referenced this pull request Sep 14, 2026
…esponse (#42573)

### Problem

- With the built-in `node-fetch` and `undici` shims, `res.body` emits
`'error'` after a body method: `Invalid state: ReadableStream is locked`
from `resume()`, `pipe()`, `for await`, and `Cannot cancel a locked
ReadableStream` from `destroy()` (for example `finally { body.destroy()
}`). Bun 1.4.2 and Node emit `'end'`, `'close'`. Regression from #42116
(not released). No user reported it.
- Cause: `ReadableFromWeb` (`src/js/internal/webstreams_adapters.ts`)
calls `stream.getReader()` in the first `_read()` and `stream.cancel()`
in `_destroy()`. Since #42116 a body method keeps the web stream locked,
so both throw.

### Fix

- `ReadableFromWeb` takes a `responseBody` option. Both shims set it,
`Readable.fromWeb()` does not. If the wrapper has not opened the web
stream and the stream is locked, the Response took the body: `_read()`
pushes `null`, `_destroy()` skips `cancel()`.
- The check reads `stream.locked`, not `bodyUsed`: after an aborted
`text()`, `bodyUsed` is `false` and the stream is locked.
- `node-fetch`: the body methods no longer read `this.body` first, so
the five overrides are deleted. `clone()` drops the cached node stream,
because the body moves to a new web stream.
- Verified: `test/js/node/http/node-fetch.test.js`,
`test/js/first_party/undici/undici.test.ts` (27 new tests, 25 fail with
main's `src/`). Self-reviewed: 3 concerns raised, 3 addressed.

### Background

- Both shims hand out the body as a node `Readable` (`ReadableFromWeb`)
around the web `ReadableStream` of a Bun `Response`. `text()`, `json()`,
... are the native methods and read the web stream directly.
- `ReadableFromWeb` opens the web stream lazily, so a body method can
take it while the wrapper has no reader.
- Real `node-fetch` and `undici` read that node stream in `text()`, so
it is ended afterwards.

<details><summary>Notes</summary>

**Origin.** Found during the review of #42516. That PR lists this
symptom as tracked separately.

**Reproduction.**

```js
import nodeFetch from "node-fetch";
await using server = Bun.serve({ port: 0, fetch: () => new Response("hello") });
const res = await nodeFetch(server.url);
const body = res.body;
await res.arrayBuffer();
body.on("error", e => console.log("error:", e.message)).on("end", () => console.log("end"));
body.resume();
// 1.4.2: end
// main:  error: Invalid state: ReadableStream is locked
```

**Behaviour per case**, both shims, checked against a 1.4.2 binary.
"clean" means `end`, `close` for `resume()`, `close` for `destroy()`,
and no chunks for `for await`.

| case | 1.4.2 | main | this PR |
| --- | --- | --- | --- |
| `body` taken, then `text()` / `json()` / `arrayBuffer()` / `blob()` /
`formData()` / `buffer()` / `bytes()`, then `resume()`, `destroy()` or
`for await` | clean | `error` | clean |
| body method first, then `res.body` for the first time | clean
(`bytes()` throws) | `error` | clean |
| `text()` aborted by the signal (or `AbortSignal.timeout()`), then
`resume()` / `destroy()` | clean | `error` | clean |
| `formData()` rejects the content type, then `resume()` / `destroy()` |
clean | `error` | clean |
| `json()` rejects with a `SyntaxError`, then `resume()` / `destroy()` |
clean | `error` | clean |
| `text()` still pending, then `resume()` / `destroy()` | `error`
(locked) | `error` | clean, and `text()` resolves with the full body |
| `body` taken, then `res.clone()`, then read `res.body` | throws
(locked by `tee()`) | throws | `res.body` is a new node stream and
delivers the body. The old node stream ends. |
| `res.body` streamed with `for await`, `on("data")`, `pipe()` | works |
works | works |
| `destroy()` on a body that nothing read | cancels the fetch | same |
same |

**Why `stream.locked`.** The first version of this change asked the
Response for `bodyUsed`. That misses every body method that rejects
after it took the stream: on main `bodyUsed` stays `false` there while
the stream stays locked (node reports `true` and locked). The lock of
the stream itself is what makes `getReader()` and `cancel()` throw, so
the wrapper checks that.

**Why the overrides are deleted.** #6983 (for #6200) made `text()`,
`json()`, `arrayBuffer()`, `blob()`, `formData()` read `this.body`
first, because a wrapper made after the consume crashed in the stream
code of that time. Such a wrapper now ends without an error, and the
test for each body method reads `res.body` for the first time after the
method. Without the preload a plain `await res.json()` does not create a
web stream and a node stream that nothing reads.

**`clone()`.** `super.clone()` moves the body of the original to a new
web stream (a `tee()` branch, or a fresh stream over the same bytes).
The cached wrapper held the old stream, so `res.body` taken before
`clone()` threw `ReadableStream is locked` on read after it, also on
1.4.2. With the lock check it would have ended without data instead.
`clone()` now clears the cache. Real `node-fetch` also replaces `body`
with a new `PassThrough` in `clone()`.

**A body method that fails** (abort, network error, bad JSON) leaves the
node stream ended without an error. The rejected promise of the body
method carries the error.

**Not in this PR.** On main `bodyUsed` is `false` after a body method
that rejects when `.body` was read before it. That is native
(`src/runtime/webcore/Body.rs`) and #35847 covers the `formData()` part.

**Suites run** on the debug build: `node-fetch.test.js`,
`undici.test.ts`, `node-fetch-cjs.test.js`,
`node-fetch-primordials.test.ts`, `undici-primordials.test.ts`,
`node-stream.test.js`, `node-http-agent-free-socket.test.ts`, regression
`26225`, `014865`, `04947`, and the Node
`test-stream-readable-from-web-termination.js`,
`test-readable-from-web-enqueue-then-close.js`,
`test-whatwg-webstreams-adapters-to-*.js` files.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 2 · 5 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 25 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/first_party/undici/undici.test.ts test/js/node/http/node-fetch.test.js
bun test v1.4.3 (6a92015)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [68.88ms]
(pass) undici > request > should error when body has already been consumed [13.68ms]
 97 |           const url = new URL(method, server.url).href;
 98 | 
 99 |           for (const [action, expected] of bodyActions) {
100 |             const { body } = await request(url);
101 |             await body[method]();
102 |             expect(await eventsUntilClose(body, () => body[action]())).toEqual([...expected]);
                                                                             ^
error: expect(received).toEqual(expected)

  [
-   "end",
+   [TypeError: Invalid state: ReadableStream is locked],
    "close",
  ]

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/first_party/undici/undici.test.ts:102:72)
(fail) undici > request > body stream when a body method took the r
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (7b5b9c1)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [2.61ms]
(pass) undici > request > should error when body has already been consumed [0.37ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after arrayBuffer() consumed it [5.40ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after blob() consumed it [1.87ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after formData() consumed it [2.11ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after json() consumed it [2.05ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after text() consumed it [1.75ms]
(pass) undici > request > body stream when a body method took the response > resume() leaves a body method that is still reading alone [2.04ms]
(pass) undici > request > body stream when a body method took the response > destroy() leaves a body method that 
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/first_party/undici/undici.test.ts test/js/node/http/node-fetch.test.js
bun test v1.4.3 (6a92015)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [78.45ms]
(pass) undici > request > should error when body has already been consumed [15.95ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after arrayBuffer() consumed it [258.02ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after blob() consumed it [57.58ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after formData() consumed it [50.54ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after json() consumed it [48.81ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after text() consumed it [48.04ms]
(pass) undici > request > body stream when a bo
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 639ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (7038ms)
Bundle modules (48ms)
Postprocesss modules (24ms)
Bundle Functions (489ms)
Generate Code (42ms)

[7.65s] Bundled "src/js" for production
  2600 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/6] cargo bun_runtime → libbun_runtime.a
�[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_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compili
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/internal/webstreams_adapters.ts    |  30 ++++--
 src/js/thirdparty/node-fetch.ts           |  36 +------
 src/js/thirdparty/undici.js               |   2 +-
 test/js/first_party/undici/undici.test.ts | 127 +++++++++++++++++++++++++
 test/js/node/http/node-fetch.test.js      | 150 ++++++++++++++++++++++++++++++
 5 files changed, 304 insertions(+), 41 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 2

<details><summary>evidence per changed file</summary>

```
file                                       reads  edits  tests
src/js/internal/webstreams_adapters.ts         5     11     17
src/js/thirdparty/node-fetch.ts                3      3     17
src/js/thirdparty/undici.js                    2      3     17
test/js/first_party/undici/undici.test.ts      1      1     12
test/js/node/http/node-fetch.test.js           2      3     17
```

</details>

<!-- robobun:evidence:end -->
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…esponse (oven-sh#42573)

### Problem

- With the built-in `node-fetch` and `undici` shims, `res.body` emits
`'error'` after a body method: `Invalid state: ReadableStream is locked`
from `resume()`, `pipe()`, `for await`, and `Cannot cancel a locked
ReadableStream` from `destroy()` (for example `finally { body.destroy()
}`). Bun 1.4.2 and Node emit `'end'`, `'close'`. Regression from oven-sh#42116
(not released). No user reported it.
- Cause: `ReadableFromWeb` (`src/js/internal/webstreams_adapters.ts`)
calls `stream.getReader()` in the first `_read()` and `stream.cancel()`
in `_destroy()`. Since oven-sh#42116 a body method keeps the web stream locked,
so both throw.

### Fix

- `ReadableFromWeb` takes a `responseBody` option. Both shims set it,
`Readable.fromWeb()` does not. If the wrapper has not opened the web
stream and the stream is locked, the Response took the body: `_read()`
pushes `null`, `_destroy()` skips `cancel()`.
- The check reads `stream.locked`, not `bodyUsed`: after an aborted
`text()`, `bodyUsed` is `false` and the stream is locked.
- `node-fetch`: the body methods no longer read `this.body` first, so
the five overrides are deleted. `clone()` drops the cached node stream,
because the body moves to a new web stream.
- Verified: `test/js/node/http/node-fetch.test.js`,
`test/js/first_party/undici/undici.test.ts` (27 new tests, 25 fail with
main's `src/`). Self-reviewed: 3 concerns raised, 3 addressed.

### Background

- Both shims hand out the body as a node `Readable` (`ReadableFromWeb`)
around the web `ReadableStream` of a Bun `Response`. `text()`, `json()`,
... are the native methods and read the web stream directly.
- `ReadableFromWeb` opens the web stream lazily, so a body method can
take it while the wrapper has no reader.
- Real `node-fetch` and `undici` read that node stream in `text()`, so
it is ended afterwards.

<details><summary>Notes</summary>

**Origin.** Found during the review of oven-sh#42516. That PR lists this
symptom as tracked separately.

**Reproduction.**

```js
import nodeFetch from "node-fetch";
await using server = Bun.serve({ port: 0, fetch: () => new Response("hello") });
const res = await nodeFetch(server.url);
const body = res.body;
await res.arrayBuffer();
body.on("error", e => console.log("error:", e.message)).on("end", () => console.log("end"));
body.resume();
// 1.4.2: end
// main:  error: Invalid state: ReadableStream is locked
```

**Behaviour per case**, both shims, checked against a 1.4.2 binary.
"clean" means `end`, `close` for `resume()`, `close` for `destroy()`,
and no chunks for `for await`.

| case | 1.4.2 | main | this PR |
| --- | --- | --- | --- |
| `body` taken, then `text()` / `json()` / `arrayBuffer()` / `blob()` /
`formData()` / `buffer()` / `bytes()`, then `resume()`, `destroy()` or
`for await` | clean | `error` | clean |
| body method first, then `res.body` for the first time | clean
(`bytes()` throws) | `error` | clean |
| `text()` aborted by the signal (or `AbortSignal.timeout()`), then
`resume()` / `destroy()` | clean | `error` | clean |
| `formData()` rejects the content type, then `resume()` / `destroy()` |
clean | `error` | clean |
| `json()` rejects with a `SyntaxError`, then `resume()` / `destroy()` |
clean | `error` | clean |
| `text()` still pending, then `resume()` / `destroy()` | `error`
(locked) | `error` | clean, and `text()` resolves with the full body |
| `body` taken, then `res.clone()`, then read `res.body` | throws
(locked by `tee()`) | throws | `res.body` is a new node stream and
delivers the body. The old node stream ends. |
| `res.body` streamed with `for await`, `on("data")`, `pipe()` | works |
works | works |
| `destroy()` on a body that nothing read | cancels the fetch | same |
same |

**Why `stream.locked`.** The first version of this change asked the
Response for `bodyUsed`. That misses every body method that rejects
after it took the stream: on main `bodyUsed` stays `false` there while
the stream stays locked (node reports `true` and locked). The lock of
the stream itself is what makes `getReader()` and `cancel()` throw, so
the wrapper checks that.

**Why the overrides are deleted.** oven-sh#6983 (for oven-sh#6200) made `text()`,
`json()`, `arrayBuffer()`, `blob()`, `formData()` read `this.body`
first, because a wrapper made after the consume crashed in the stream
code of that time. Such a wrapper now ends without an error, and the
test for each body method reads `res.body` for the first time after the
method. Without the preload a plain `await res.json()` does not create a
web stream and a node stream that nothing reads.

**`clone()`.** `super.clone()` moves the body of the original to a new
web stream (a `tee()` branch, or a fresh stream over the same bytes).
The cached wrapper held the old stream, so `res.body` taken before
`clone()` threw `ReadableStream is locked` on read after it, also on
1.4.2. With the lock check it would have ended without data instead.
`clone()` now clears the cache. Real `node-fetch` also replaces `body`
with a new `PassThrough` in `clone()`.

**A body method that fails** (abort, network error, bad JSON) leaves the
node stream ended without an error. The rejected promise of the body
method carries the error.

**Not in this PR.** On main `bodyUsed` is `false` after a body method
that rejects when `.body` was read before it. That is native
(`src/runtime/webcore/Body.rs`) and oven-sh#35847 covers the `formData()` part.

**Suites run** on the debug build: `node-fetch.test.js`,
`undici.test.ts`, `node-fetch-cjs.test.js`,
`node-fetch-primordials.test.ts`, `undici-primordials.test.ts`,
`node-stream.test.js`, `node-http-agent-free-socket.test.ts`, regression
`26225`, `014865`, `04947`, and the Node
`test-stream-readable-from-web-termination.js`,
`test-readable-from-web-enqueue-then-close.js`,
`test-whatwg-webstreams-adapters-to-*.js` files.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 2 · 5 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 25 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/first_party/undici/undici.test.ts test/js/node/http/node-fetch.test.js
bun test v1.4.3 (6a92015)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [68.88ms]
(pass) undici > request > should error when body has already been consumed [13.68ms]
 97 |           const url = new URL(method, server.url).href;
 98 | 
 99 |           for (const [action, expected] of bodyActions) {
100 |             const { body } = await request(url);
101 |             await body[method]();
102 |             expect(await eventsUntilClose(body, () => body[action]())).toEqual([...expected]);
                                                                             ^
error: expect(received).toEqual(expected)

  [
-   "end",
+   [TypeError: Invalid state: ReadableStream is locked],
    "close",
  ]

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/first_party/undici/undici.test.ts:102:72)
(fail) undici > request > body stream when a body method took the r
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (7b5b9c1)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [2.61ms]
(pass) undici > request > should error when body has already been consumed [0.37ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after arrayBuffer() consumed it [5.40ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after blob() consumed it [1.87ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after formData() consumed it [2.11ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after json() consumed it [2.05ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after text() consumed it [1.75ms]
(pass) undici > request > body stream when a body method took the response > resume() leaves a body method that is still reading alone [2.04ms]
(pass) undici > request > body stream when a body method took the response > destroy() leaves a body method that 
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/first_party/undici/undici.test.ts test/js/node/http/node-fetch.test.js
bun test v1.4.3 (6a92015)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [78.45ms]
(pass) undici > request > should error when body has already been consumed [15.95ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after arrayBuffer() consumed it [258.02ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after blob() consumed it [57.58ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after formData() consumed it [50.54ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after json() consumed it [48.81ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after text() consumed it [48.04ms]
(pass) undici > request > body stream when a bo
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 639ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (7038ms)
Bundle modules (48ms)
Postprocesss modules (24ms)
Bundle Functions (489ms)
Generate Code (42ms)

[7.65s] Bundled "src/js" for production
  2600 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/6] cargo bun_runtime → libbun_runtime.a
�[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_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compili
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/internal/webstreams_adapters.ts    |  30 ++++--
 src/js/thirdparty/node-fetch.ts           |  36 +------
 src/js/thirdparty/undici.js               |   2 +-
 test/js/first_party/undici/undici.test.ts | 127 +++++++++++++++++++++++++
 test/js/node/http/node-fetch.test.js      | 150 ++++++++++++++++++++++++++++++
 5 files changed, 304 insertions(+), 41 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 2

<details><summary>evidence per changed file</summary>

```
file                                       reads  edits  tests
src/js/internal/webstreams_adapters.ts         5     11     17
src/js/thirdparty/node-fetch.ts                3      3     17
src/js/thirdparty/undici.js                    2      3     17
test/js/first_party/undici/undici.test.ts      1      1     12
test/js/node/http/node-fetch.test.js           2      3     17
```

</details>

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

robobun commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

An update for anyone who lands here. On 1.4.3 a failed formData() after .body was read leaves the stream locked while bodyUsed stays false.

#44999 fixes that with no behaviour change: the type is checked before the payload is lifted out of the stream, so a failed formData() leaves the body as it was.

#44969 rebuilds the change this PR proposed on current main: the body is read before the MIME check, so a failed formData() uses the body up, as Fetch, WPT response-consume.html and node do. It breaks try { await r.formData() } catch { await r.json() }, which works on every Bun release, so it is a draft that needs a maintainer's yes.

#44930 fixes the same locked stream for a Response that is used as a Bun.serve route.

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