webcore(Blob): share Blob-typed parts via a rope store instead of eager memcpy
What
new Blob([blobA, blobB, ...]) now shares each Blob-typed part's backing store instead of memcpy'ing every part into one fresh contiguous buffer at construction time.
Why
The constructor walked every part through a StringJoiner and joined them into a single Vec<u8> (Blob.rs from_js_without_defer_gc → joiner.done() → out.extend_from_slice(node.slice())). Composing N × 100 MB blobs cost N × 100 MB of RSS even though each part's bytes are already immutable and refcounted behind a Store.
The existing storage model forced this: Blob = { store: Option<StoreRef>, offset, size } is one window into one Store, and Store::Data = Bytes | File | S3 has no multi-part variant, so composition had to materialise contiguous bytes.
How
Add Store::Data::Rope: a Vec<{ StoreRef, offset, len }> of windows into other in-memory stores.
- The constructor pushes Blob-typed parts as rope segments (cloning the
StoreRef, zero copy) and keeps snapshotting string / typed-array /ArrayBuffer/DataViewparts into owned runs, so a caller mutating a source typed array after construction still cannot alias the blob. - Consecutive non-Blob parts are coalesced into one
Bytessegment per run; a rope-backed source splices its segments into the outer rope so depth stays at one. StoreRef::flatten_if_ropecollapses a rope into a singleBytesin place on the first consumer that needs a contiguous view (shared_view/shared_view_raw/Bun.write/ S3 upload).ByteBlobLoaderreads through the flattened store so.stream()/.arrayBuffer()/.text()all see the same bytes.- Every
Datamatch site gains aRopearm that either reuses theBytesbehaviour or flattens first. Theis_memory_backed()predicate is factored out soexistsSync, write-target validation, structured clone, wasm streaming, and console inspection all treatRopelikeBytes.
Follow-up (not in this PR): teach ByteBlobLoader / Bun.write / the HTTP body writer to walk rope segments directly so .stream() and Bun.serve never flatten at all.
Measurements
RSS delta around construction, isolated child process:
| Case | before | after |
|---|---|---|
new Blob(8 × [100 MB blob]) | +1000 MB | +2 MB |
new Blob([hdr, 100 MB blob, ftr]) | +300 MB | +2 MB |
new Blob([blob]) (single-part fast path) | +0.7 MB | unchanged |
Node passes all five cases of the same verify harness; Bun went from 3/5 to 5/5.
Verification
test/js/web/fetch/blob-rope-store.test.ts (6 tests) covers the two RSS deltas plus byte-exact arrayBuffer/stream/slice/text round-trips, the typed-array-snapshot aliasing guarantee, nested-rope splicing, and structuredClone of a rope-backed File. The two RSS tests fail on current main (+576 MB / +128 MB) and pass with this change. Existing blob.test.ts, blob-array-fast-path.test.ts, blob-cow.test.ts, blob-oom.test.ts, the deno blob suite, FormData.test.ts, body.test.ts, structured-clone-blob-file.test.ts, worker_blob.test.ts, and archive.test.ts all pass unchanged.
[review] gate passed · iteration 5 · 10 files touched
fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/blob-rope-store.test.ts
bun test v1.4.0 (5af3a3818)
test/js/web/fetch/blob-rope-store.test.ts:
48 | const after = rssMB();
49 | process.stdout.write(JSON.stringify({ delta: after - before, size: framed.size }));
50 | `);
51 | expect(result.size).toBe(64 * 1024 * 1024 + 32);
52 | // Pre-rope: one 64 MB memcpy into the joined buffer.
53 | expect(result.delta).toBeLessThan(isASAN || isDebug ? 32 : 16);
^
error: expect(received).toBeLessThan(expected)
Expected: < 32
Received: 128
at <anonymous> (/workspace/bun/test/js/web/fetch/blob-rope-store.test.ts:53:24)
(fail) new Blob([header, big, footer]) shares the Blob part and snapshots the scalar parts [1204.88ms]
32 | process.stdout.write(JSON.stringify({ delta: after - before, size: composite.size }));
33 | `);
34 | expect(result.size).toBe(8 * 64 * 1024 * 1024);
35 | // Pre-rope: copies 8 * 64 MB = 512 MB into a fresh buffer (+512 MB RSS).
36 | // Rope: 8 StoreRef clones + one small Vec<RopeSegment>.
37 | ex
... (truncated)
release without fix: all passed
bun test v1.4.0-canary.1 (5c1a54c1e)
test/js/web/fetch/blob-rope-store.test.ts:
(pass) new Blob([header, big, footer]) shares the Blob part and snapshots the scalar parts [61.54ms]
(pass) new Blob([big, big, ...]) shares the source Blob's store instead of copying [63.27ms]
(pass) rope-backed Blob is byte-exact across arrayBuffer/stream/slice/text [1.20ms]
(pass) typed-array parts are snapshotted at construction time [0.10ms]
(pass) a Blob part that is itself rope-backed splices into the outer rope [0.09ms]
(pass) structuredClone of a rope-backed File preserves bytes and name [0.25ms]
(pass) FormData.append filename sticks for a rope-backed Blob [0.13ms]
(pass) Bun.Archive accepts a rope-backed Blob [1.42ms]
(pass) Bun.write rejects a rope-backed Blob destination like any other in-memory Blob [0.13ms]
(pass) a multi-part File whose non-Blob parts are empty does not alias the source Blob's stored_name [0.13ms]
(pass) URL.createObjectURL flattens a rope before it can be reached from another JS thread [3.29ms]
11 pass
0 fail
31 expect() calls
Ran 11 tests across 1 file. [222.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/blob-rope-store.test.ts
bun test v1.4.0 (5af3a3818)
test/js/web/fetch/blob-rope-store.test.ts:
(pass) new Blob([big, big, ...]) shares the source Blob's store instead of copying [1217.83ms]
(pass) new Blob([header, big, footer]) shares the Blob part and snapshots the scalar parts [1181.72ms]
(pass) rope-backed Blob is byte-exact across arrayBuffer/stream/slice/text [41.21ms]
(pass) typed-array parts are snapshotted at construction time [5.70ms]
(pass) a Blob part that is itself rope-backed splices into the outer rope [6.71ms]
(pass) structuredClone of a rope-backed File preserves bytes and name [13.54ms]
(pass) FormData.append filename sticks for a rope-backed Blob [7.48ms]
(pass) Bun.Archive accepts a rope-backed Blob [188.67ms]
(pass) Bun.write rejects a rope-backed Blob destination like any other in-memory Blob [6.47ms]
(pass) a multi-part File whose non-Blob parts are empty does not alias the source Blob's stored_name [9.60ms]
(pass) URL.createObjectURL flattens a rope before it can be reached from another JS thr
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 751ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[1/6] 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)
[1m[92m Compiling[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
[1m[92m Compiling[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
[1m[92m Compiling[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
[1m[92m Compiling[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
[1m[92m Compiling[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
[1m[92m Compiling[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
[1m[92m Compiling[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
[1m[92m Compiling[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
[1m[92m Compiling[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
[1m[92m Compiling[0m bun_output v
... (truncated)
diff hotspot
src/jsc/webcore_types.rs | 146 ++++++++++++++++++++++++-
src/runtime/api/Archive.rs | 2 +
src/runtime/webcore/Blob.rs | 126 +++++++++++++++-------
src/runtime/webcore/ByteBlobLoader.rs | 1 +
src/runtime/webcore/FileReader.rs | 4 +-
src/runtime/webcore/ObjectURLRegistry.rs | 3 +
src/runtime/webcore/ReadableStream.rs | 2 +-
src/runtime/webcore/blob/Store.rs | 11 +-
src/runtime/webcore/wasm_streaming.rs | 7 +-
test/js/web/fetch/blob-rope-store.test.ts | 174 ++++++++++++++++++++++++++++++
10 files changed, 431 insertions(+), 45 deletions(-)
gate history · 6 passed · 1 rejected · iteration 5
evidence per changed file
file reads edits tests
src/jsc/webcore_types.rs 9 26 0
src/runtime/api/Archive.rs 2 2 0
src/runtime/webcore/Blob.rs 8 28 0
src/runtime/webcore/ByteBlobLoader.rs 2 1 0
src/runtime/webcore/FileReader.rs 1 1 0
src/runtime/webcore/ObjectURLRegistry.rs 1 1 0
src/runtime/webcore/ReadableStream.rs 1 1 0
src/runtime/webcore/blob/Store.rs 3 4 0
src/runtime/webcore/wasm_streaming.rs 3 3 0
test/js/web/fetch/blob-rope-store.test.ts 3 9 0