Skip to content

Blob: leave the Blob attached when bytes() rejects it for its size - #46835

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/62012a99/blob-bytes-reject-keeps-blob
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/62012a99/blob-bytes-reject-keeps-blob

Conversation

@robobun

@robobun robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Draft until #46528 is merged, then cut again on top of it (decided there). See "Landing order" in Notes.

Problem

  • bytes() on an in-memory Blob of 2^32 bytes or more rejects with RangeError: Out of memory and empties the Blob. Then Bun.write(path, blob) writes 0 bytes, new Response(blob) serves content-length: 0, and Bun.inspect(blob) prints [Blob detached]. size stays.
  • Cause: self.detach() in the Lifetime::Clone arm of to_array_buffer_view_with_bytes (src/runtime/webcore/Blob.rs:2696). Blob.prototype.bytes() runs that arm on the caller's own Blob.

Fix

  • Delete it. A rejected bytes() leaves the Blob, File or slice as it was.
  • Correct because each caller that passes a private copy releases it: ByteBlobLoader.rs:125, Blob.rs:2774, Drop for S3BlobDownloadTask.
  • Verified: test/js/web/fetch/blob-oom.test.ts, 8 new cases, 6 fail on main. Also blob.test.ts, body.test.ts, streams.test.js.
  • Self-reviewed: 7 concerns raised, 5 addressed. Left open: the same deletion in Blob: count the copy-on-write copies of a memfd store, so two reads of one large body do not share memory #45947, and an issue for the refusal at exactly 2^32 bytes.

Background

Downsides

  • A refused Blob keeps its store until it is collected. Main frees it at the throw: RSS 4129 MiB against 33 MiB for 2^32 + 3 bytes.
  • None other found. A successful bytes() runs the same 691 instructions. .text stays 82,164,617 B.

The trade, for the reviewer to decide. #13142 added the detach to the .clone and .transfer arms together and gives no reason for the .clone one. If it was there to free the store at the throw, this PR gives that up. The rows are release builds with no flags. bytes() has rejected an in-memory Blob of 2^32 + 3 bytes.

main this PR
Bun.write(path, blob) returns 0, the file has 0 bytes returns 4294967299
new Response(blob) from Bun.serve 200, content-length: 0 200, content-length: 4294967299
Bun.inspect(blob) [Blob detached] Blob (4.29 GB)
a second bytes() resolves with 0 bytes RangeError: Out of memory
RSS after the refusal 33 MiB 4129 MiB, until the Blob is collected
Notes

Line numbers are those of main 179664e. "Release" means --profile=release builds of main and of this branch. "Debug" means the ASAN debug builds of the same two trees.

Landing order

match LIFETIME {
    Lifetime::Transfer => self.detach(),
    Lifetime::Clone | Lifetime::Share => {}
    // SAFETY: `Temporary` ⇒ `buf` is a leaked default-allocator `Box<[u8]>`.
    Lifetime::Temporary => unsafe { drop(bun_core::heap::take(buf)) },
}

Reproduce

No flags. It needs about 5 GiB of RAM and 4 GiB of disk.

const part = new Uint8Array(64 * 1024 * 1024).fill(65);
const blob = new Blob([...Array(64).fill(part), "XYZ"]); // 2^32 + 3 bytes
const first = await blob.bytes().then(b => "resolved with " + b.byteLength + " bytes", e => e.name + ": " + e.message);
console.log(first, blob.size, Bun.inspect(blob).split("\n")[0], JSON.stringify(await blob.slice(-3).text()));
console.log("Bun.write:", await Bun.write("/tmp/out.bin", blob));
After the rejected bytes() main (release) this branch (release)
blob.size 4294967299 4294967299
Bun.inspect(blob) [Blob detached] Blob (4.29 GB)
blob.slice(-3).text() "" "XYZ"
blob.stream(), bytes read 0 4294967299
Bun.write(path, blob) over a file that exists returns 0, the file has 0 bytes returns 4294967299, the file has 4294967299 bytes
new Response(blob) from Bun.serve 200, content-length: 0 200, content-length: 4294967299, all received
a second blob.bytes() resolves with 0 bytes RangeError: Out of memory
blob.arrayBuffer() resolves with 0 bytes RangeError: Out of memory, as for a new Blob of this size

The Blob does not have to come from new Blob(). A fetch() response of 2^32 + 1 bytes (4,176,021 bytes of gzip on the wire) read with .blob() gives the same: after the rejected bytes(), Bun.write(path, blob) writes 0 bytes on main and 4294967297 bytes here.

A cheap form of the same path: a 500,000 byte Blob with BUN_GARBAGE_COLLECTOR_LEVEL=0 BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT=100000. Bun reads the second variable only when the first is set.

Which reads empty the Blob on main

Measured on main with a new Blob per row, above the limit.

Read that rejects Caller's Blob afterwards
blob.bytes(), also on a File and on a slice empty (this bug)
blob.text(), blob.json() intact
new Response(blob).bytes() intact
blob.stream().bytes(), Bun.readableStreamToBytes(blob.stream()) intact

The callers of the Clone arm

Four routes reach the refusal in the Clone arm. Only the first passes a Blob that a script holds.

  • Blob.prototype.bytes() (Blob.rs:1173): the caller's Blob. This is the bug.
  • Any::to_action_value for a stream read (Blob.rs:6035): ByteBlobLoader::to_buffered_value builds a private Blob from the stream's store reference and calls blob.detach() after the read (ByteBlobLoader.rs:125).
  • The fallback of the Transfer arm for a store with more than one reference: it calls self.detach() after the Clone arm returns (Blob.rs:2774), and the body callers detach again (Body.rs:1120, Body.rs:2014).
  • The S3 read handler: the Blob is the private Blob::dupe of S3BlobDownloadTask, and Drop releases it.

RefPtr<Store> also releases its reference on Drop, so a private copy cannot leak the store.

History

Tests

test/js/web/fetch/blob-oom.test.ts, describe("Blob after .bytes() rejects"), in-process under the 4 MiB limit of the file:

  • A Blob of one part (a memfd store on Linux), a Blob of two parts (a heap store), a File, and a slice that holds the last reference to a larger store. After two bytes() calls, one toEqual covers both rejections, size, arrayBuffer() (length and content), the first and last 3 bytes, and new Response(blob).arrayBuffer(). 4 cases, all fail on main.
  • bytes() resolves at the limit and rejects one byte above it, and the Blob above the limit keeps its bytes. Fails on main.
  • blob.stream().bytes() and Bun.readableStreamToBytes(blob.stream()) reject and leave the Blob intact. 2 cases. They pass on main too. They hold the stream route, which shares the Clone arm.
  • A Blob that was refused is collected after its last reference goes (heapStats().objectTypeCounts.Blob). Fails on main at the arrayBuffer() check.

Measurements

  • After a refused bytes(): size 9437184, arrayBuffer().byteLength 9437184 (main: 9437184, 0). Blob cells before / alive / after drop and Bun.gc(true): 0 / 1 / 0 in 100 of 100 runs, the same as main (heapStats().objectTypeCounts.Blob, the Blob held only as a property, debug).
  • memfd stores open before / after new Blob / after the refused bytes() with the Blob held / after drop and gc: 0,1,1,0 in 100 of 100 runs (main: 0,1,0,0). Debug.
  • Blob::detach calls from the Clone arm per refused bytes(): 0 (main: 1). Store destroyed inside the bytes() call: 0 (main: 1). Store destroyed by the finalizer of the Blob after drop and gc: 1 (main: 0). gdb breakpoints, debug.
  • to_array_buffer_view_with_bytes::<Clone, Uint8Array>: 390 B -> 365 B. The success path has the same instructions: the load of the store moves below the limit compare and uses another register. The ArrayBuffer instance stays 326 B. .text: 82,164,617 B -> 82,164,617 B (llvm-nm -S, llvm-objdump, llvm-size, release).
  • Instructions per successful blob.bytes() on a 12-byte Blob, host function entry to return: 691 -> 691 on each of 16 warm calls. The four calls before them are 1252, 1252, 1252, 2579 on both builds (gdb stepi, JIT off, release).
  • Memory that a refused Blob holds, release. A heap store of 2^32 + 3 bytes: RSS 4129 MiB after the refusal (main: 33 MiB), 33 MiB after drop and gc. A memfd store of 2^32 bytes: 4096 MiB in the memfd after the refusal (main: 0), 0 after drop and gc.
  • LeakSanitizer reports on blob-oom.test.ts with the settings of the ASAN lane: 0 (main: 0). After a refused new Response(blob).bytes() / blob.stream().bytes() / Bun.readableStreamToBytes(blob.stream()): the caller's Blob has 9437184 bytes each, memfd stores after drop and gc 0 each (main: the same).
  • blob-oom.test.ts: 8 new in-process cases. 6 fail with the 1.4.3 canary and with a release build of main, 0 fail here. 800 of 800 pass with --rerun-each 100, debug and release.

Suites run on the debug build of this branch

blob-oom.test.ts (30 pass, 1 skip), blob.test.ts (149), blob-cow.test.ts (5), body.test.ts (776 pass, 2 skip), body-clone.test.ts (100), blob-write.test.ts (15), response.test.ts (23), streams.test.js (727 pass, 2 skip), readable-stream-blob-consumed.test.ts (1), streams-string-limit.test.ts (9).

Not in this PR

The Lifetime::Clone arm of to_array_buffer_view_with_bytes detached the
Blob before it threw the out of memory error. Blob.prototype.bytes()
calls that arm on the caller's own Blob, so a bytes() that was refused
left that Blob empty while its size stayed. The callers that pass a
private copy to the arm release it themselves.
@robobun

robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft until #46528 is merged, then cut again on top of it (decided there). The change is complete.

Reproduced on a release build of main 179664e with no flags: bytes() of an in-memory Blob of 2^32 + 3 bytes rejects with RangeError: Out of memory. After that, Bun.write(path, blob) writes 0 bytes and Bun.inspect(blob) prints [Blob detached]. With this branch the Blob keeps every byte.

Tests: 6 of the 8 new cases in test/js/web/fetch/blob-oom.test.ts fail on a release build of main and on the 1.4.3 canary. This branch passes them on a debug build and on a release build.

CI: build 132359 finished with 180 of 181 jobs passed. The red job is one of the 2 shards of darwin aarch64. It hit its time limit after bun install and serve.test.ts timed out on that agent. blob-oom.test.ts ran in the other darwin aarch64 shard and passed. No lane reports a failure or a retry of it.

@robobun

robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:46 PM PT - Oct 24th, 2026

❌ @robobun, your commit f26df40 has 2 failures in Build #132359 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46835

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

bun-46835 --bun

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.

1 participant