Skip to content

Blob: share a body part's store instead of moving it out of the caller's Blob - #46732

Draft
robobun wants to merge 1 commit into
robobun/902a48fe/blob-ascii-cache-whole-storefrom
robobun/902a48fe/blob-array-body-shares-store
Draft

robobun wants to merge 1 commit into
robobun/902a48fe/blob-ascii-cache-whole-storefrom
robobun/902a48fe/blob-array-body-shares-store

Conversation

@robobun

@robobun robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

@Jarred-Sumner: this drops the no-copy arrayBuffer() of a [blob] body. Accept one copy per read, as new 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]}) and server.fetch(url, {body: blob}) empty the caller's Blob: blob.text() is "", blob.size stays.
  • A Bun.serve handler that returns new Response([blob]) for a Blob that it keeps answers later requests with an empty 200.
  • Cause: both callers ask Blob::get to 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::get loses MOVE. Its arm for one Blob part returns blob.dupe(): one more store reference and no copy, as in new Response(blob).
  • The unreachable BuildArtifact arm is deleted.
  • Verified: 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

Downsides

  • arrayBuffer() and bytes() of such a body copy, as new 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.
  • A file-backed part shares its store with the body: 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

const blob = new Blob(["hello world"]);
new Response([blob]);
await blob.text(); // main and 1.4.2: "", and blob.size is still 11. This PR: "hello world"

The same happens with new Request(url, { method: "POST", body: [blob] }), fetch(url, { method: "POST", body: [blob] }) and server.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, a Bun.file() (its size becomes 0), an S3 file (stat() then rejects with ERR_INVALID_THIS), a Bun.build() output, and the per-realm objects Bun.stdin and Bun.stdout. After new Response([Bun.stdout]), console.write() and Bun.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 and fetch: () => new Response([blob]), the first response is 200, content-length: 11, hello world. Each later response is 200, content-length: 0 and empty. The same holds for a File and a Bun.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.BodyInit lists no array, and server.fetch is typed as fetch(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 by arrayBuffer() and bytes() while the store is shared. It is the copy that new 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):

Read, with the Blob kept by the caller parent this PR
new Response([b]).arrayBuffer(), 2048 x 64 KiB 2048 adopt, 0 Blobs intact 2048 copy, all intact
the same, 128 x 1 MiB and 32 x 4 MiB all adopt all copy
the same, 8 x 16 MiB made from one typed array (a memfd on Linux) 8 adopt 8 copy-on-write maps, 0 copies
the same, 8 x 16 MiB made from two parts 8 adopt 8 copy
new Response([b]).bytes(), 128 x 1 MiB 128 adopt 128 copy
server.fetch, then req.arrayBuffer() or req.bytes(), 128 x 1 MiB 128 adopt 128 copy
new Response(b).arrayBuffer(), 128 x 1 MiB 128 copy 128 copy
text(), json(), blob() of a [b] body, 32 x 1 MiB 0 0
new Response([b]), then the Blob is dropped and Bun.gc(true) runs, then arrayBuffer(), 128 x 1 MiB 128 adopt 128 adopt
await new Response([new Blob([bytes])]).arrayBuffer() with no forced collection, 5000 x 64 KiB and 2000 x 1 MiB all adopt all copy

Time of the read alone (the Blob and the body exist before the clock starts; fastest of 400 reads, median of 5 interleaved runs):

Read of a [blob] body parent this PR parent again new Response(blob), both builds
arrayBuffer(), 64 KiB 310 ns 2.7 µs 310 ns 2.6 to 3.0 µs
arrayBuffer(), 1 MiB 610 ns 35.7 µs 530 ns 40 to 48 µs
arrayBuffer(), 4 MiB 1.6 µs 318 µs 1.5 µs 317 to 332 µs
bytes(), 1 MiB 490 ns 39.6 µs 510 ns 45 to 54 µs

The 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):

Workload parent this PR
[b] body, arrayBuffer(), 32 x 4 MiB RSS +0 MiB, 30 page faults +128.8 MiB, 32,828
the same, 128 x 1 MiB +0.8 MiB, 121 +130.5 MiB, 32,964
the same, 2048 x 64 KiB +3.4 MiB, 602 +106.4 MiB, 26,860
the same, 8 x 16 MiB from one typed array (memfd) +0 MiB, 16 +0 MiB, 19
the same, 8 x 16 MiB from two parts +0 MiB, 16 +128.7 MiB, 32,803
[b] body, bytes(), 32 x 4 MiB +0 MiB, 29 +130.6 MiB, 32,972
server.fetch, then req.arrayBuffer(), 128 x 1 MiB +4.3 MiB, 195 +132.9 MiB, 33,076
new Response(b).arrayBuffer(), 32 x 4 MiB +130.5 MiB, 32,966 +129.8 MiB, 32,922
[b] body, text(), 32 x 4 MiB +0 MiB, 25 +0 MiB, 28

bench/snippets/response-arrayBuffer.mjs has three new rows per size. One iteration of a row also makes the Blob, which copies the bytes once on both builds:

Row (median of 5 interleaved runs) parent this PR parent again
new Response([blob]).arrayBuffer(), 1 MiB 90.8 µs 194.0 µs 91.4 µs
new Response([blob]).bytes(), 1 MiB 92.1 µs 194.3 µs 92.2 µs
new Response(blob).arrayBuffer(), 1 MiB 192.5 µs 190.7 µs 193.4 µs
new Response([blob]).arrayBuffer(), 4 MiB 379 µs 806 µs 383 µs
new Response(blob).arrayBuffer(), 4 MiB 840 µs 817 µs 838 µs

The 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 in Blob::get with its callees, not 316 (gdb stepi). The body holds one more reference on the store: an atomic increment, and a decrement when the body goes away. Release .text goes from 82,236,553 to 82,231,433 B, 5,120 B less.

text(), json() and blob() 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, and new Request(url, { body: blob }) already shares. It can keep the Blob with one token in server_body.rs (Blob::get::<false, false> exists on main and returns blob.dupe()). The deletion of the unreachable BuildArtifact arm and the bench rows need no decision.

Not fixed, the same as new Response(x) on main

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 the Lifetime::Clone arm of to_array_buffer_view_with_bytes). It is a different producer of the same symptom and gets its own PR.
  • Blob::detach and Blob::take_store still take &self. The callers that are left are the Transfer arms, which run on a Blob that the body owns, and the Clone arm above.
  • The guards of Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 for an S3 file with no store (s3_store(), S3BlobStatTask::start, the by-value store of S3Ext::unlink) stay. No JavaScript reaches them through an array body after this PR.

Related PRs and order

Tests

body.test.ts, 40 new cases. For Request and Response: the Blob still reads its bytes, one Blob backs several bodies, a File, a Bun.file() (name, size and bytes), a Bun.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 for blob, and arrayBuffer() of a Blob of 8 MiB gives a copy. Then server.fetch (3), fetch with [blob], three calls that throw after they took the body, and a served new Response([part]) for a Blob, a File and a Bun.file().

On a debug build of the parent, 32 of the 40 fail. The 8 that pass are the blob rows 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 two json cases of string body consumption does not leak need 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.js has 106 pass, and Bun.file(0) survives GC exceeds 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.fetch are 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.

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

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:37 AM PT - Oct 24th, 2026

✅ @robobun, your commit 44c607adb8bd6f41c6876245cbbf7a361e45991b passed in Build #131957! 🎉


🧪   To try this PR locally:

bunx bun-pr 46732

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

bun-46732 --bun

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

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 new Response([blob]), await blob.text() is "" while blob.size is still 11. A Bun.serve handler that returns new Response([blob]) for a Blob that it keeps sends content-length: 0 from the second request on. With this branch the Blob keeps its bytes and each response has them.

Tests: on a debug build of the parent (#46719), 32 of the 40 new cases in test/js/web/fetch/body.test.ts and the 3 cases of test/js/bun/s3/s3-moved-store.test.ts fail. This branch passes them.

CI: build 131957 has no failed job so far. 179 of 181 jobs passed. The other 2 are the shards of darwin any x64 - test-bun, which wait for an agent. Each test in the failure report of the build passed on a retry. This PR adds or changes none of them.

This branch has not been deployed

No deployments
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