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

Closed
RO
1mo ago
+115-34

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)

   Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)
   Compiling bun_errno v0.0.0 (/workspace/bun/src/errno)
   Compiling bun_ptr v0.0.0 (/workspace/bun/src/ptr)
   Compiling bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
   Compiling bun_safety v0.0.0 (/workspace/bun/src/safety)
   Compiling bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
   Compiling bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
   Compiling bun_zstd v0.0.0 (/workspace/bun/src/zstd)
   Compiling bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
  
... (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