Repository navigation
Conversation
`new Response([blob])`, `new Request(url, { body: [blob] })`, `fetch(url, { body: [blob] })`
and `server.fetch(url, { body: blob })` took the store out of the caller's
Blob: the Blob then read as empty while its size stayed.
Blob::get loses its MOVE parameter. The arm for one Blob part returns
blob.dupe(), as `new Response(blob)` and `new Blob([blob])` do. The arm for
a BuildArtifact goes away: the Blob downcast already matches an artifact.
|
Updated 7:37 AM PT - Oct 24th, 2026
✅ @robobun, your commit 44c607adb8bd6f41c6876245cbbf7a361e45991b passed in 🧪 To try this PR locally: bunx bun-pr 46732That installs a local version of the PR into your bun-46732 --bun |
|
Status: draft. It waits for #46719 to merge and for a decision on the copy (first line of the description). Reproduced on Bun 1.4.2 and on a release build of main: after Tests: on a debug build of the parent (#46719), 32 of the 40 new cases in CI: build 131957 has no failed job so far. 179 of 181 jobs passed. The other 2 are the shards of |
@Jarred-Sumner: this drops the no-copy
arrayBuffer()of a[blob]body. Accept one copy per read, asnew Response(blob)pays, or keep the move?Draft on #46719, which must merge first.
Problem
new Response([blob]),new Request(url, {body: [blob]}),fetch(url, {body: [blob]})andserver.fetch(url, {body: blob})empty the caller's Blob:blob.text()is"",blob.sizestays.Bun.servehandler that returnsnew Response([blob])for a Blob that it keeps answers later requests with an empty 200.Blob::getto move (src/runtime/webcore/Body.rs:1032,src/runtime/server/server_body.rs:2057), so its arm for one Blob part takes that Blob's store.Fix
Blob::getlosesMOVE. Its arm for one Blob part returnsblob.dupe(): one more store reference and no copy, as innew Response(blob).BuildArtifactarm is deleted.test/js/web/fetch/body.test.ts(32 of 40 new cases fail without the fix),test/js/bun/s3/s3-moved-store.test.ts(3 rewritten). Self-reviewed: 15 concerns raised, 15 addressed.Background
arrayBuffer()andbytes()of a body hand the store's memory to JS only when the body holds the last reference (Hardening round 11: input validation, bounds checks, lifetimes #33072: "transfer only when sole owner").BodyInitin the Fetch standard. Bun reads it as blob parts (Response([1,2,3]).text()produces incorrect output ("123" instead of "1,2,3") #29074), and this PR keeps that.Downsides
arrayBuffer()andbytes()of such a body copy, asnew Response(blob)does: 1 MiB takes 36 µs, not 0.6 µs. 32 Blobs of 4 MiB: RSS +129 MiB. The same for a Blob that nobody keeps, until it is swept.Bun.write(file, new Response([file]))now empties the file (Bun.write on itself causes content to be empty #20462). It threw before.Notes
Repro
The same happens with
new Request(url, { method: "POST", body: [blob] }),fetch(url, { method: "POST", body: [blob] })andserver.fetch(url, { method: "POST", body: blob }), also when the call throws after it took the body (a URL or a header name that is not valid).What is emptied, on main: a Blob, a slice, a
File, aBun.file()(itssizebecomes 0), an S3 file (stat()then rejects withERR_INVALID_THIS), aBun.build()output, and the per-realm objectsBun.stdinandBun.stdout. Afternew Response([Bun.stdout]),console.write()andBun.write(Bun.stdout, x)throw "Cannot write to a detached Blob".A server shows it to its clients. With
const blob = new Blob(["hello world"])outside the handler andfetch: () => new Response([blob]), the first response is200,content-length: 11,hello world. Each later response is200,content-length: 0and empty. The same holds for aFileand aBun.file().Origin and reach
No user reported this. A fuzz run found it. The move is from 2022 (4ab4ae7). On main,
s3-moved-store.test.ts(#46021) is the one test that passes an array with one Blob as a body. All four calls are outside the published types:Bun.BodyInitlists no array, andserver.fetchis typed asfetch(request: Request | string). Node converts an array body with ToString:await new Response([blob]).text()is"[object Blob]", and the Blob stays intact.Every typed spelling already shares the store and leaves the Blob intact:
new Response(blob),new Request(url, { body: blob }),new Response([blob, "!"]),new Blob([blob]).The copy
Nothing is copied when the body is made:
dupe()is one more reference on the store. The copy is made only byarrayBuffer()andbytes()while the store is shared. It is the copy thatnew Response(blob).arrayBuffer()makes on main. The no-copy read existed only because the body had taken the caller's reference, so it cannot stay without the empty Blob.Measured on release builds of the parent (#46719) and of this branch.
Which path a read takes (gdb breakpoints on the copy, adopt and memfd entry points, JIT off; the caller keeps every Blob):
new Response([b]).arrayBuffer(), 2048 x 64 KiBnew Response([b]).bytes(), 128 x 1 MiBserver.fetch, thenreq.arrayBuffer()orreq.bytes(), 128 x 1 MiBnew Response(b).arrayBuffer(), 128 x 1 MiBtext(),json(),blob()of a[b]body, 32 x 1 MiBnew Response([b]), then the Blob is dropped andBun.gc(true)runs, thenarrayBuffer(), 128 x 1 MiBawait new Response([new Blob([bytes])]).arrayBuffer()with no forced collection, 5000 x 64 KiB and 2000 x 1 MiBTime of the read alone (the Blob and the body exist before the clock starts; fastest of 400 reads, median of 5 interleaved runs):
[blob]bodynew Response(blob), both buildsarrayBuffer(), 64 KiBarrayBuffer(), 1 MiBarrayBuffer(), 4 MiBbytes(), 1 MiBThe medians of the same reads: 3.7 µs to 97 µs at 1 MiB and 8.7 µs to 411 µs at 4 MiB.
new Response(blob)has 101 to 103 µs and 417 to 422 µs on both builds.Memory (median of 5 interleaved runs; the caller keeps every Blob and every result):
[b]body,arrayBuffer(), 32 x 4 MiB[b]body,bytes(), 32 x 4 MiBserver.fetch, thenreq.arrayBuffer(), 128 x 1 MiBnew Response(b).arrayBuffer(), 32 x 4 MiB[b]body,text(), 32 x 4 MiBbench/snippets/response-arrayBuffer.mjshas three new rows per size. One iteration of a row also makes the Blob, which copies the bytes once on both builds:new Response([blob]).arrayBuffer(), 1 MiBnew Response([blob]).bytes(), 1 MiBnew Response(blob).arrayBuffer(), 1 MiBnew Response([blob]).arrayBuffer(), 4 MiBnew Response(blob).arrayBuffer(), 4 MiBThe 64 KiB rows are inside the noise of the runner: 19 to 34 µs, with 6 µs between two runs of the parent.
Making the body from
[blob]takes 341 instructions inBlob::getwith its callees, not 316 (gdbstepi). The body holds one more reference on the store: an atomic increment, and a decrement when the body goes away. Release.textgoes from 82,236,553 to 82,231,433 B, 5,120 B less.text(),json()andblob()of such a body copy nothing on both builds.If the copy is refused
server.fetch(url, { body: blob })is the one call here that takes a bare Blob, andnew Request(url, { body: blob })already shares. It can keep the Blob with one token inserver_body.rs(Blob::get::<false, false>exists on main and returnsblob.dupe()). The deletion of the unreachableBuildArtifactarm and the bench rows need no decision.Not fixed, the same as
new Response(x)on mainBun.write(file, new Response([file]))empties the file. Before, the handle had lost its store and the call threw "Cannot write to a detached Blob". Bun.write on itself causes content to be empty #20462 is the open issue and Bun.write: compare the destination with the source before the truncating open #43388 the open fix. The same goes for the other calls that write through the handle after it was a body part: they ran into the missing store, and they run now.statcached on the shared file store bounds what the body reads after the file grows (CallingBunFile.existsmakesBun.writewrite nothing #4930, BunFile.text()does not return correct content after BunFile.write()#23902, draft Bun.file: do not use a cached stat size as the byte budget of a read or a copy #43910).Bun.file(fd)part shares the position of its descriptor with the body (Bun.serve: read a Bun.file(fd) body at its offset, not at the descriptor's position #45266).arrayBuffer()hands out the shared mapping (Blob: count the copy-on-write copies of a memfd store, so two reads of one large body do not share memory #45947).text()of a Blob and as UTF-8 by theInternalBlobdecoders. Which one a stream read of a body gets depends on the store's reference count. SoBun.readableStreamToText(new Response([b]).body)of the bytes FF FE 68 00 69 00 is"hi"with this PR, as it is fornew Response(b). On the parent it is U+FFFD U+FFFDh\0i\0. The closed Blob/Response .text(): stop sniffing UTF-16LE BOM on in-memory bytes #35190 was about that split.Not in this PR
blob.bytes()of an in-memory Blob above the typed array limit rejects and leaves that Blob empty (self.detach()in theLifetime::Clonearm ofto_array_buffer_view_with_bytes). It is a different producer of the same symptom and gets its own PR.Blob::detachandBlob::take_storestill take&self. The callers that are left are theTransferarms, which run on a Blob that the body owns, and theClonearm above.s3_store(),S3BlobStatTask::start, the by-value store ofS3Ext::unlink) stay. No JavaScript reaches them through an array body after this PR.Related PRs and order
image.test.tsthat usesnew Response([file])to empty a file. Git reports no conflict, and the row fails on the merged tree. The one that lands second changes the row.Fileis. The arm here is now onedupe(), so the two changes meet there.Tests
body.test.ts, 40 new cases. ForRequestandResponse: the Blob still reads its bytes, one Blob backs several bodies, aFile, aBun.file()(name, size and bytes), aBun.build()output, the body decodes right after a slice of the Blob was read, four readers of the body bytes give a copy (arrayBuffer(),bytes(),Bun.readableStreamToBytes,new Response(body).bytes()) for[blob]and forblob, andarrayBuffer()of a Blob of 8 MiB gives a copy. Thenserver.fetch(3),fetchwith[blob], three calls that throw after they took the body, and a servednew Response([part])for a Blob, aFileand aBun.file().On a debug build of the parent, 32 of the 40 fail. The 8 that pass are the
blobrows of the copy cases, which hold the rule that the array form now follows.s3-moved-store.test.ts: the 3 cases now check that the S3 file keeps its store, and all 3 fail on the parent.Suites run on the debug build of this branch:
body.test.ts(814 pass, 2 skip; the twojsoncases ofstring body consumption does not leakneed 4.2 to 4.9 s on a debug build and sometimes exceed the 5 s default timeout, with or without this change),s3-moved-store.test.ts(3),blob.test.ts(168). Also, all pass:blob-cow.test.ts(5),blob-oom.test.ts(22, 1 skip),bun-serve-static.test.ts(47),response.test.ts(23),body-clone.test.ts(100),utf8-bom.test.ts(21),worker_blob.test.ts(7),FormData.test.ts(151),structured-clone-blob-file.test.ts(44),blob-write.test.ts(15),body-mixin-errors.test.ts(23),blob-array-fast-path.test.ts(11),image.test.ts(104, 5 skip).bun-write.test.jshas 106 pass, andBun.file(0) survives GCexceeds the 5 s default timeout on a debug build when the whole file runs.Self-review
Fifteen concerns, all addressed: the PR is a draft on top of #46719; the first line asks the question; the array body and
server.fetchare told apart; origin and reach are stated; the served response and the per-realm objects are in the text and in a test; the other producer of an empty Blob is named; the branch is rebased and measured again; the copy is not tied to a live Blob any more; the time of the read is separated from the bench row; the file-backed and memfd cases are listed with their trackers; the copy cases write through the value that the reader returned and cover the stream readers; a wrong comment in a test is gone.