From 53d91ca89e3fbfc2234c2bd3685dff5f4c901ceb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:42:29 +0000 Subject: [PATCH 1/4] wip: formData() reads the body before it rejects a Content-Type that is not form data --- src/jsc/JSGlobalObject.rs | 16 ++ .../webcore/streams/BunStreamConsumers.cpp | 23 ++- .../webcore/streams/JSStreamsRuntime.h | 2 + .../webcore/streams/WebStreamsExports.cpp | 9 + .../webcore/streams/WebStreamsInternals.h | 3 + src/runtime/webcore/Body.rs | 79 ++++---- test/js/web/fetch/body.test.ts | 168 ++++++++++++++++++ 7 files changed, 264 insertions(+), 36 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 11115339f3f0..e33da575c7e6 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1158,6 +1158,17 @@ impl JSGlobalObject { }) } + /// A promise that rejects with `error` once the stream has been read to its end. + pub fn readable_stream_consume_then_reject( + &self, + value: JSValue, + error: JSValue, + ) -> JsResult { + crate::call_zero_is_throw(self, || { + ZigGlobalObject__readableStreamConsumeThenReject(self, value, error) + }) + } + /// Returns a freshly-created `napi_env` owned by this global, for use by /// the FFI module. The concrete `NapiEnv` struct lives in `bun_runtime` /// (which depends on `bun_jsc`), so this returns the raw pointer untyped; @@ -1565,6 +1576,11 @@ unsafe extern "C" { value: JSValue, content_type: JSValue, ) -> JSValue; + safe fn ZigGlobalObject__readableStreamConsumeThenReject( + this: &JSGlobalObject, + value: JSValue, + error: JSValue, + ) -> JSValue; safe fn ZigGlobalObject__readableStreamToBlob(this: &JSGlobalObject, value: JSValue) -> JSValue; diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index f510e1a76142..d415bbde13b0 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -1311,7 +1311,8 @@ JSValue readableStreamToBlob(JSGlobalObject* globalObject, WebCore::JSReadableSt return derived; } -JSValue readableStreamToFormData(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, JSValue contentType) +// Reads `stream` to its end as a Blob; `(runtime->*handler)()(blob, context)` then settles the result. +static JSValue readableStreamToBlobThen(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, JSFunction* (JSStreamsRuntime::*handler)() const, JSValue context) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -1326,10 +1327,20 @@ JSValue readableStreamToFormData(JSGlobalObject* globalObject, WebCore::JSReadab } auto* runtime = JSStreamsRuntime::from(globalObject); auto* derived = JSPromise::create(vm, globalObject->promiseStructure()); - blobPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReadableStreamToFormDataFulfilled(), jsUndefined(), derived, contentType); + blobPromise->performPromiseThenWithContext(vm, globalObject, (runtime->*handler)(), jsUndefined(), derived, context); return derived; } +JSValue readableStreamToFormData(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, JSValue contentType) +{ + return readableStreamToBlobThen(globalObject, stream, &JSStreamsRuntime::onReadableStreamToFormDataFulfilled, contentType); +} + +JSValue readableStreamConsumeThenReject(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, JSValue error) +{ + return readableStreamToBlobThen(globalObject, stream, &JSStreamsRuntime::onReadableStreamConsumedThenReject, error); +} + } // namespace WebStreams } // namespace Bun @@ -1534,6 +1545,14 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadableStreamToFormDataFulfilled RELEASE_AND_RETURN(scope, JSValue::encode(JSC::call(globalObject, fromFunction, callData, constructor, arguments))); } +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadableStreamConsumedThenReject, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + throwException(globalObject, scope, callFrame->argument(1)); + return {}; +} + JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onIntoArrayReadManyFulfilled, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = getVM(globalObject); diff --git a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h index 02c66e874f45..02c08560ab04 100644 --- a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h +++ b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h @@ -203,6 +203,7 @@ namespace WebCore { // onReadableStreamTo*Fulfilled: the generic-path promise chains // (toArrayBuffer/toBytes/toBlob: value = the chunk array; toJSON: value = the text; // toFormData: value = the Blob, context = the contentType JSString). +// onReadableStreamConsumedThenReject: value = the Blob (dropped), context = the error to throw. // onIntoArrayReadMany*: readableStreamIntoArray's readMany() continuation (readMany may // return a Promise); context = an InternalFieldTuple{reader, resultArray}. // onDirectConsumeLoopRead*: the readableStreamTo{Text,Array}Direct read loop; @@ -219,6 +220,7 @@ namespace WebCore { V(onReadableStreamToJSONFulfilled) \ V(onReadableStreamToBlobFulfilled) \ V(onReadableStreamToFormDataFulfilled) \ + V(onReadableStreamConsumedThenReject) \ V(onIntoArrayReadManyFulfilled) /* append value; !done => readMany() again; done => release + resolve */ \ V(onIntoArrayReadManyRejected) /* release the reader, reject the result promise */ \ V(onIntoArrayReadFulfilled) /* persistent-op pump: append the read chunk, keep filling */ \ diff --git a/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp b/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp index 425e35a43e31..9995a57dcf7f 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp +++ b/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp @@ -404,3 +404,12 @@ extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToFormData(Zig::Gl RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, JSValue::encode(readableStreamToFormData(globalObject, stream, JSValue::decode(contentType)))); } + +extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamConsumeThenReject(Zig::GlobalObject* globalObject, JSC::EncodedJSValue streamValue, JSC::EncodedJSValue error) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* stream = toReadableStream(globalObject, scope, streamValue); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, JSValue::encode(readableStreamConsumeThenReject(globalObject, stream, JSValue::decode(error)))); +} diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h index f9d88b171810..68673bc5ed69 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h +++ b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h @@ -652,6 +652,8 @@ JSC::JSValue readableStreamToBytes(JSC::JSGlobalObject*, JSReadableStream*); // JSC::JSValue readableStreamToJSON(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp JSC::JSValue readableStreamToBlob(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp JSC::JSValue readableStreamToFormData(JSC::JSGlobalObject*, JSReadableStream*, JSC::JSValue contentType); // userJS: yes — BunStreamConsumers.cpp +// Body.formData() on a body that is not form data: the stream is read to its end like any body, then `error` rejects the result. +JSC::JSValue readableStreamConsumeThenReject(JSC::JSGlobalObject*, JSReadableStream*, JSC::JSValue error); // userJS: yes — BunStreamConsumers.cpp // The buffered fast path: returns the native handle's own .text()/.arrayBuffer()/... promise, // or the EMPTY JSValue if the fast path does not apply. `method` is the property name to @@ -733,5 +735,6 @@ JSC::EncodedJSValue ZigGlobalObject__readableStreamToText(Zig::GlobalObject*, JS JSC::EncodedJSValue ZigGlobalObject__readableStreamToJSON(Zig::GlobalObject*, JSC::EncodedJSValue stream); // userJS: yes JSC::EncodedJSValue ZigGlobalObject__readableStreamToBlob(Zig::GlobalObject*, JSC::EncodedJSValue stream); // userJS: yes JSC::EncodedJSValue ZigGlobalObject__readableStreamToFormData(Zig::GlobalObject*, JSC::EncodedJSValue stream, JSC::EncodedJSValue contentType); // userJS: yes +JSC::EncodedJSValue ZigGlobalObject__readableStreamConsumeThenReject(Zig::GlobalObject*, JSC::EncodedJSValue stream, JSC::EncodedJSValue error); // userJS: yes } // extern "C" diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 66e2e1315c08..8b782369a297 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -423,18 +423,23 @@ impl PendingValue { Action::GetBytes => global_this.readable_stream_to_bytes(readable.value), Action::GetText => global_this.readable_stream_to_text(readable.value), Action::GetBlob => global_this.readable_stream_to_blob(readable.value), - Action::GetFormData(form_data) => 'brk: { - let fd = form_data.take().unwrap(); - let encoding_js = match &fd.encoding { - bun_core::form_data::Encoding::Multipart(multipart) => { - bun_string_jsc::create_utf8_for_js(global_this, multipart)? - } - bun_core::form_data::Encoding::URLEncoded => JSValue::UNDEFINED, - }; - // fd dropped at end of scope (Box -> Drop) - break 'brk global_this - .readable_stream_to_form_data(readable.value, encoding_js); - } + Action::GetFormData(form_data) => match form_data.take() { + Some(fd) => { + let encoding_js = match &fd.encoding { + bun_core::form_data::Encoding::Multipart(multipart) => { + bun_string_jsc::create_utf8_for_js(global_this, multipart)? + } + bun_core::form_data::Encoding::URLEncoded => JSValue::UNDEFINED, + }; + // fd dropped at end of scope (Box -> Drop) + global_this + .readable_stream_to_form_data(readable.value, encoding_js) + } + None => global_this.readable_stream_consume_then_reject( + readable.value, + form_data_mime_error(global_this), + ), + }, _ => unreachable!(), }; self.readable.deinit(); @@ -475,6 +480,7 @@ pub(crate) enum Action { GetArrayBuffer, GetBytes, GetBlob, + /// `None`: the Content-Type is not form data, so the read rejects once the body has been read. GetFormData(Option>), } @@ -1142,12 +1148,7 @@ impl Value { let mut blob = new.use_as_any_blob(); let Some(async_form_data) = form_data_slot.take() else { // `blob.detach()` below covers the reject error path too. - let r = promise.reject( - cx.global(), - cx.global().create_error_instance(format_args!( - "Internal error: task for FormData must not be null" - )), - ); + let r = promise.reject(cx.global(), form_data_mime_error(cx.global())); blob.detach(); r?; break 'inner; @@ -2081,17 +2082,9 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { } } - let Some(encoder) = self.get_form_data_encoding()? else { - // TODO: catch specific errors from getFormDataEncoding - return Ok(global_object - .err( - jsc::ErrorCode::FORMDATA_PARSE_ERROR, - format_args!( - "Can't decode form data from body because of incorrect MIME type/boundary" - ), - ) - .reject()); - }; + // https://fetch.spec.whatwg.org/#concept-body-consume-body: the body is read first; a + // Content-Type that is not form data rejects the read afterwards. + let encoder = self.get_form_data_encoding()?; let value = self.get_body_value(); if let Value::Locked(_locked) = value { @@ -2101,13 +2094,19 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { let Value::Locked(locked) = value else { unreachable!() }; - return locked.set_promise( - global_object, - Action::GetFormData(Some(encoder)), - owned_readable, - ); + return locked.set_promise(global_object, Action::GetFormData(encoder), owned_readable); } + let Some(encoder) = encoder else { + // Nothing to read in a null body. + if !matches!(value, Value::Null) { + *value = Value::Used; + } + return Ok( + JSPromise::rejected_promise(global_object, form_data_mime_error(global_object)) + .to_js(), + ); + }; let mut blob: AnyBlob = value.use_as_any_blob(); // `encoder.encoding` is `bun_core::form_data::Encoding`; convert // to the `webcore::form_data::Encoding` shape FormData::to_js expects. @@ -2228,6 +2227,18 @@ fn handle_body_already_used(global_object: &JSGlobalObject) -> JSValue { .reject() } +/// What `formData()` rejects with when the Content-Type is not form data. +fn form_data_mime_error(global_object: &JSGlobalObject) -> JSValue { + global_object + .err( + jsc::ErrorCode::FORMDATA_PARSE_ERROR, + format_args!( + "Can't decode form data from body because of incorrect MIME type/boundary" + ), + ) + .to_js() +} + /// : a disturbed or locked stream rejects every reader. fn handle_body_stream_unusable( readable: &ReadableStream, diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts index 0a71d6f50ad0..269d41b65c0a 100644 --- a/test/js/web/fetch/body.test.ts +++ b/test/js/web/fetch/body.test.ts @@ -1584,6 +1584,7 @@ describe("body stream bookkeeping does not depend on the body's source", () => { locked: stream.locked, }); const consumedState = { readable: false, errored: false, disturbed: true, locked: true }; + const notFormData = { "content-type": "application/json" }; for (const [ownerName, make] of owners) { describe(ownerName, () => { @@ -1850,9 +1851,176 @@ describe("body stream bookkeeping does not depend on the body's source", () => { }); } }); + + // https://fetch.spec.whatwg.org/#dom-body-formdata is "consume body" like + // every other reader: the body is read to its end, and only then does a + // Content-Type that is not form data reject the read. A formData() that + // fails this way has used the body up, however the body is held. WPT + // asserts the same in fetch/api/response/response-consume.html + // (checkBodyFormDataError: "bodyUsed turned true"). + describe("formData() reads the body before it rejects a Content-Type that is not form data", () => { + const cases = sources.flatMap(([name, init]) => [ + [name, "", init, false] as const, + [name, " after .body", init, true] as const, + ]); + test.each(cases)("%s%s", async (name, when, init, touch) => { + const owner = make(init(), notFormData); + const stream = touch ? owner.body! : null; + const error = await owner.formData().then( + () => null, + e => e, + ); + expect({ + error: [error?.constructor.name, error?.code], + bodyUsed: owner.bodyUsed, + sameStream: stream === null || owner.body === stream, + locked: owner.body!.locked, + getReader: errorName(() => owner.body!.getReader()), + clone: errorName(() => owner.clone()), + text: await settled(owner.text()), + formDataAgain: await settled(owner.formData()), + }).toEqual({ + error: ["TypeError", "ERR_FORMDATA_PARSE_ERROR"], + bodyUsed: true, + sameStream: true, + locked: true, + getReader: "TypeError", + clone: "TypeError", + text: "TypeError", + formDataAgain: "TypeError", + }); + }); + + test("a body without a Content-Type", async () => { + const owner = make(jsStream("payload")); + expect(await owner.formData().then(String, e => e.code)).toBe("ERR_FORMDATA_PARSE_ERROR"); + expect({ bodyUsed: owner.bodyUsed, text: await settled(owner.text()) }).toEqual({ + bodyUsed: true, + text: "TypeError", + }); + }); + + test("a FormData body under a Content-Type that is not form data", async () => { + const form = new FormData(); + form.append("a", "1"); + const owner = make(form, notFormData); + expect(await owner.formData().then(String, e => e.code)).toBe("ERR_FORMDATA_PARSE_ERROR"); + expect({ bodyUsed: owner.bodyUsed, text: await settled(owner.text()) }).toEqual({ + bodyUsed: true, + text: "TypeError", + }); + }); + + test("a body stream that fails rejects with its own error", async () => { + const boom = new Error("boom"); + const owner = make( + new ReadableStream({ + pull(c) { + c.error(boom); + }, + }), + notFormData, + ); + expect( + await owner.formData().then( + () => null, + e => e, + ), + ).toBe(boom); + expect(owner.bodyUsed).toBe(true); + }); + }); }); } + // There is nothing to read in a null body, so the failed formData() leaves it as it was. + test("formData() on a null body that is not form data rejects and uses nothing up", async () => { + for (const owner of [ + new Response(null, { headers: notFormData }), + new Request("http://a/", { method: "POST", headers: notFormData }), + ]) { + expect(await owner.formData().then(String, e => e.code)).toBe("ERR_FORMDATA_PARSE_ERROR"); + expect({ + body: owner.body, + bodyUsed: owner.bodyUsed, + clone: errorName(() => owner.clone()), + text: await owner.text(), + }).toEqual({ body: null, bodyUsed: false, clone: "ok", text: "" }); + } + }); + + // The same holds for a body that is still on the wire when formData() is + // called: the read waits for the rest of it, then rejects. + describe("formData() on a body that is still arriving and is not form data", () => { + const encoder = new TextEncoder(); + // A JSON body in two parts. The second part is held back until `release()`. + const gated = () => { + const { promise, resolve: release } = Promise.withResolvers(); + const stream = new ReadableStream({ + async pull(c) { + c.enqueue(encoder.encode('{"a":')); + await promise; + c.enqueue(encoder.encode("1}")); + c.close(); + }, + }); + return { stream, release }; + }; + const after = async (owner: Request | Response, touch: boolean, release: () => void) => { + const stream = touch ? owner.body! : null; + const pending = owner.formData().then( + () => null, + e => e, + ); + release(); + const error = await pending; + return { + error: [error?.constructor.name, error?.code], + bodyUsed: owner.bodyUsed, + sameStream: stream === null || owner.body === stream, + clone: errorName(() => owner.clone()), + text: await settled(owner.text()), + }; + }; + const used = { + error: ["TypeError", "ERR_FORMDATA_PARSE_ERROR"], + bodyUsed: true, + sameStream: true, + clone: "TypeError", + text: "TypeError", + }; + + test.each([ + ["", false], + [" after .body", true], + ] as const)("a fetch() response%s", async (when, touch) => { + const { stream, release } = gated(); + await using server = Bun.serve({ + port: 0, + fetch: () => new Response(stream, { headers: notFormData }), + }); + expect(await after(await fetch(server.url), touch, release)).toEqual(used); + }); + + test.each([ + ["", false], + [" after .body", true], + ] as const)("a Bun.serve request%s", async (when, touch) => { + const { stream, release } = gated(); + await using server = Bun.serve({ + port: 0, + fetch: async request => Response.json(await after(request, touch, release)), + }); + const response = await fetch(server.url, { + method: "POST", + body: stream, + headers: notFormData, + duplex: "half", + } as RequestInit); + expect(await response.json()).toEqual(used); + }); + }); + // A null body is not a zero-length body: nothing can use it up. test("Response.redirect() and Response.error() have a null body", async () => { for (const response of [Response.redirect("http://a/"), Response.error()]) { From 3ab14010044525c39a91e0fa563f547581f212a6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:47:51 +0000 Subject: [PATCH 2/4] formData(): cover Bun.write() and a Bun.file() body after the failed read; rustfmt --- src/runtime/webcore/Body.rs | 9 +++++---- test/js/web/fetch/body.test.ts | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 9c507c34f0bc..7fa0a37eacb3 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -2109,10 +2109,11 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { if !matches!(value, Value::Null) { *value = Value::Used; } - return Ok( - JSPromise::rejected_promise(global_object, form_data_mime_error(global_object)) - .to_js(), - ); + return Ok(JSPromise::rejected_promise( + global_object, + form_data_mime_error(global_object), + ) + .to_js()); }; let mut blob: AnyBlob = value.use_as_any_blob(); // `encoder.encoding` is `bun_core::form_data::Encoding`; convert diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts index 7b56d930ffd0..46007d177aa5 100644 --- a/test/js/web/fetch/body.test.ts +++ b/test/js/web/fetch/body.test.ts @@ -1944,6 +1944,43 @@ describe("body stream bookkeeping does not depend on the body's source", () => { } }); + // Bun.write() is one more reader of the body, and a Bun.file() is one more + // kind of body. After the failed formData() every reader gives the answer + // that bodyUsed gives. + test.each([ + ["a string", "", false, false], + ["a string", " after .body", false, true], + ["a Bun.file()", "", true, false], + ["a Bun.file()", " after .body", true, true], + ] as const)( + "a Response over %s is used up by a failed formData()%s, for Bun.write() too", + async (kind, when, onDisk, touch) => { + using dir = tempDir("body-formdata-write", { "a.txt": "every byte of a small file on disk\n" }); + const response = onDisk + ? new Response(Bun.file(join(String(dir), "a.txt"))) + : new Response("payload", { headers: notFormData }); + const stream = touch ? response.body! : null; + expect(await response.formData().then(String, e => e.code)).toBe("ERR_FORMDATA_PARSE_ERROR"); + expect({ + bodyUsed: response.bodyUsed, + sameStream: stream === null || response.body === stream, + locked: response.body!.locked, + getReader: errorName(() => response.body!.getReader()), + clone: errorName(() => response.clone()), + written: await Bun.write(join(String(dir), "out"), response).then(String, e => e.code), + text: await settled(response.text()), + }).toEqual({ + bodyUsed: true, + sameStream: true, + locked: true, + getReader: "TypeError", + clone: "TypeError", + written: "ERR_BODY_ALREADY_USED", + text: "TypeError", + }); + }, + ); + // The same holds for a body that is still on the wire when formData() is // called: the read waits for the rest of it, then rejects. describe("formData() on a body that is still arriving and is not form data", () => { From d0f9290bb812f6090b0bd97e8a5eff79f5ff5aba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:42:06 +0000 Subject: [PATCH 3/4] formData(): put the spec reference on one line --- src/runtime/webcore/Body.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 7fa0a37eacb3..c54ea805d4a3 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -2089,8 +2089,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { } } - // https://fetch.spec.whatwg.org/#concept-body-consume-body: the body is read first; a - // Content-Type that is not form data rejects the read afterwards. + // reads the body first, then a Content-Type that is not form data rejects. let encoder = self.get_form_data_encoding()?; let value = self.get_body_value(); From a02fcc19ca885a7c9e3ae701fb9e2b33c1b306b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:34:16 +0000 Subject: [PATCH 4/4] test: the string-body leak cases do fewer reads on a debug build --- test/js/web/fetch/body.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts index 46007d177aa5..1ab3ba2e418d 100644 --- a/test/js/web/fetch/body.test.ts +++ b/test/js/web/fetch/body.test.ts @@ -1,6 +1,6 @@ import { file, spawn, version, type Socket } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, exampleSite, tempDir } from "harness"; +import { bunEnv, bunExe, exampleSite, isDebug, tempDir } from "harness"; import net from "net"; import { join } from "node:path"; // @ts-expect-error missing in @types/node @@ -1323,8 +1323,10 @@ describe.concurrent("string body consumption does not leak", () => { ctor === "Request" ? `b => new Request("http://example.com/", { method: "POST", body: b })` : `b => new Response(b)`; + // A debug build takes over 5 s for 130 of these reads, so it does fewer. + const [warm, block] = isDebug ? [16, 16] : [50, 40]; const src = ` - const SZ = 2_000_000, WARM = 50, BLOCK = 40; + const SZ = 2_000_000, WARM = ${warm}, BLOCK = ${block}; const rss = process.memoryUsage.rss; const mb = () => (rss() / 1048576) | 0; const body = ${makeBody}; @@ -1346,9 +1348,10 @@ describe.concurrent("string body consumption does not leak", () => { const m = stdout.match(/BLOCK1:(-?\d+) BLOCK2:(-?\d+)/); expect(m).not.toBeNull(); const block2 = Number(m![2]); - // Bounded: BLOCK2 ≈ 0 (plateaued). Leaking: BLOCK2 ≈ 40 × ~3 MB ≈ 100+ MB - // and keeps growing every block. 50 MB cleanly separates the two. - expect(block2).toBeLessThan(50); + // Bounded: BLOCK2 ≈ 0 (plateaued). Leaking: BLOCK2 ≈ BLOCK × ~3 MB (100+ MB + // for 40) and keeps growing every block. BLOCK × 1.25 MB (50 MB for 40) + // cleanly separates the two. + expect(block2).toBeLessThan(block * 1.25); expect(exitCode).toBe(0); }); }