Skip to content

Blob: append onto the first part's store instead of copying it - #38626

Open
robobun wants to merge 1 commit into
mainfrom
farm/f6a7aa58/blob-append-buffer
Open

Blob: append onto the first part's store instead of copying it#38626
robobun wants to merge 1 commit into
mainfrom
farm/f6a7aa58/blob-append-buffer

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • b = new Blob([b, chunk]) (the usual way to collect chunks into a Blob: stream-to-Blob collectors, upload assemblers, MediaRecorder-style buffers) is O(total bytes²) in Bun and O(n) in Node: every step copies the whole accumulated prefix again. Same box, wall clock, 200 / 400 / 800 chunks of 64 KiB: Bun 269 / 1034 / 3678 ms, Node 8 / 12 / 22 ms; collecting 100 MiB this way pegs a core for about 30 s.
  • Cause: the multi-part constructor (from_js_without_defer_gc, src/runtime/webcore/Blob.rs) pushes every part, Blob parts included, into a StringJoiner, and done() materializes one fresh flat buffer. A Blob has exactly one flat store, so a Blob part's bytes are copied once per construction.
  • This came out of a Node parity perf survey, not a user report. I did not find a server-side occurrence of the idiom in the repo or in test/node_modules; it is common in browser-oriented code, and the cost when it is hit is quadratic CPU on peer-supplied input.

Fix

  • When the first part of new Blob(parts) is a Blob viewing a whole in-memory store, the result is built on an AppendBuffer (new, src/runtime/webcore/blob/AppendBuffer.rs): one allocation with spare capacity, shared by every store that successive appends produce. Only the remaining parts are written; the prefix is not copied.
  • Every store built on the buffer is still an ordinary immutable Data::Bytes, viewing the prefix [0, len). An append onto the store that views the longest published prefix claims [len, len + n) with a CAS on the buffer's committed length, fills it, and publishes a new store of length len + n. Bytes an existing store can see are never written again and the allocation never moves, so the existing readers of store bytes (Bun.write on the thread pool, Archive tasks, blob: URLs handed to Workers, structured clone, text() external strings) are unaffected and unchanged. The CAS is what makes two appends onto the same Blob, or onto the same store from two threads through a blob: URL, safe: the loser copies.
  • When the buffer is full or the tail is already taken, a new buffer is allocated with 50% headroom, so the bytes copied stay linear. The first append onto a plain store allocates exactly, so a one-off new Blob([blob, x]) costs what it did before; headroom only appears once the same data has been appended onto twice.
  • The buffer travels in Bytes::allocator exactly like LinuxMemFdAllocator: each store's Bytes owns one reference and the free-only vtable's free drops it, so the allocation goes away with the last store built on it.
  • Two consumers reason about exclusive ownership of a store's bytes and are taught about shared buffers:
    • Lifetime::Transfer in to_array_buffer_view_with_bytes hands a body's bytes to JS as a writable ArrayBuffer when the store has one reference. It now also requires that no other store is built on the same buffer (AppendBuffer::shares_allocation), otherwise it copies. Without that, writing into await new Response(middle).arrayBuffer() changed a longer Blob built from middle; the new test shows this and fails with the check removed.
    • Bytes::to_internal_blob (the sole-reference fast path behind stream consumption) takes the allocation over as a Vec when the buffer has a single store (AppendBuffer::take_unique_storage) and copies otherwise, the same as it already does for memfd-backed stores. So the one-off new Blob([blob, x]) result keeps its zero-copy consumption.
  • The Bytes doc and Send/Sync justification in src/jsc/webcore_types.rs previously said a Bytes is the sole alias of its allocation; updated to the invariant that actually holds (nothing ever writes bytes a Bytes can see; writers need exclusivity, see as_array_list_leak).
  • After, same box and script: 48 / 72 / 77 ms wall for 200 / 400 / 800 chunks of 64 KiB (Node 8 / 12 / 22). With 64 byte chunks, 4k / 8k / 16k: 3 / 6 / 28 ms in a warmed-up heap versus 118 / 312 / 1516 ms before (Node 15 / 22 / 43). In a fresh process the tiny-chunk case is still dominated by collections triggered by each intermediate Blob reporting its full size as newly allocated; that is Blob: stop reporting a shared store's bytes to the GC as newly allocated for every view #38562's problem and the other half of this item, see below. bench/snippets/blob-append.mjs added.
  • Verified by test/js/web/fetch/blob.test.ts, describe "new Blob([blob, ...]) appends onto the first part's store" (11 tests): a linear-time check in a child process calibrated against building the same Blob once plus n single-chunk Blobs (released binary: about 140x over the baseline and failing; this branch: 4x to 5x in debug/ASAN builds against a 20x bound), every intermediate of a 48-step chain keeping its bytes across regrowths, two appends onto the same Blob, a Blob appended onto itself, empty and mixed parts around the prefix, new File([file, ...]) naming, text() along ASCII and UTF-8 chains, slice()/stream()/structuredClone()/Bun.write() of two Blobs sharing one buffer, the transfer path above, stream consumption that takes the allocation over (checked that it reaches take_unique_storage), and a Worker appending onto a store it received through a blob: URL while the main thread appends onto it too.
  • Existing blob*, body*, FormData*, structured-clone, worker_blob, websocket-blob, readable-stream-blob, deno blob and archive suites pass; cargo clippy -p bun_runtime -p bun_jsc and cargo fmt --check are clean.

Relationship to the other open Blob-part PRs

These all touch the same Blob-part arm of the constructor, so they need ordering rather than being read as independent:

Background

  • A Blob (src/jsc/webcore_types.rs) is a view (offset, size) onto a refcounted Store. A store's data is Bytes (ptr, len, cap, plus the StdAllocator that frees it), a file, or an S3 object. blob.slice() is another view of the same store, which is why every reader already clamps to its Blob's window. Bytes are immutable once created; readers on other threads rely on that, and this change keeps it.
  • StdAllocator is a (context pointer, vtable) pair. LinuxMemFdAllocator already attaches a refcounted object to a Bytes through the context pointer and releases it from a free-only vtable when the Bytes is dropped, using the vtable's address as the type tag. AppendBuffer reuses that pattern as is.
  • StringJoiner (src/bun_core/string/StringJoiner.rs) is the list of borrowed or owned slices the constructor collects parts into; done() concatenates them into a fresh buffer. Here the remaining parts stay in the joiner and node_slices() writes them straight into the buffer.
  • ObjectURLRegistry (URL.createObjectURL) is process global, so a Worker fetching a blob: URL gets a Blob sharing the main thread's store. That is the only way two threads hold views of one buffer, and the reason the tail is claimed with a CAS rather than a plain length bump.
  • Store::has_one_ref() is how a few paths decide nobody else can observe a store's bytes. With shared buffers that is true of the store but not necessarily of the memory, which is what the two consumer changes above account for.

new Blob([blob, ...parts]) copied the leading Blob's bytes into a fresh
store, so the accumulation idiom b = new Blob([b, chunk]) re-copied the
whole prefix on every step and cost O(total^2).

Blobs built this way now share one AppendBuffer: an allocation with
spare capacity that every store produced by successive appends points
into. Each store is still an ordinary immutable Bytes viewing a prefix
of the buffer; an append claims the tail past the longest published
prefix with a CAS, writes only the new parts there and publishes a new
store. When the buffer is full the next append allocates a new one with
50% headroom, so the bytes copied stay linear in the final size. The
buffer travels in Bytes.allocator like LinuxMemFdAllocator does and is
released when the last store built on it is dropped.

Because several stores can now view one allocation, the zero-copy
transfer of a store's bytes into a writable ArrayBuffer additionally
requires that no other store is built on the same buffer, and
to_internal_blob takes the allocation over only when the buffer has a
single store (copying otherwise, as it does for memfd-backed stores).
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f06bc5e3-beda-464c-9289-416b2ea731a7

📥 Commits

Reviewing files that changed from the base of the PR and between 2f5c180 and 02d0c07.

📒 Files selected for processing (6)
  • bench/snippets/blob-append.mjs
  • src/jsc/webcore_types.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/blob/AppendBuffer.rs
  • src/runtime/webcore/blob/Store.rs
  • test/js/web/fetch/blob.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. webcore(Blob): share Blob-typed parts via a rope store instead of eager memcpy #36001 - Targets the same optimization at the same dispatch point in from_js_without_defer_gc — eliminating the eager memcpy of Blob-typed parts in the multi-part new Blob([...]) constructor — using a rope store instead of an append buffer, so the two designs collide and need an ordering rule.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #36001, although the two do meet at the same dispatch point, which the PR description covers under "Relationship to the other open Blob-part PRs".

So they optimize different shapes and can coexist; whichever lands second needs to order the two checks in the Blob-part arm, and the description proposes the rule for that.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the automated bug-hunting pass found no issues. Given the scope — a new shared-allocation abstraction with atomic tail-claiming, changes to the Bytes aliasing invariants, and edits to the Lifetime::Transfer / to_internal_blob ownership-transfer paths — a human look is still warranted.

What was reviewed:

  • AppendBuffer's CAS-based tail claim and refcount lifecycle (vtable free, take_unique_storage, Drop) — balanced on all paths I traced.
  • The shares_allocation guard on the transfer path and the prefix_store gating (offset==0, full view, non-empty) — the sliced/empty/self-append cases are handled.
  • Checked that the StoreRef clone of the prefix keeps it live across later parts running user JS, and that write_suffix's non-overlap holds for the self-append case (second occurrence goes into the joiner as an owned copy).
Extended reasoning...

Overview

This PR adds src/runtime/webcore/blob/AppendBuffer.rs (~285 lines, heavy unsafe), a refcounted growable buffer that lets new Blob([blob, chunk]) append in place instead of re-copying the prefix, turning an O(n²) idiom into O(n). It threads the buffer through Bytes::allocator via a free-only vtable (same pattern as LinuxMemFdAllocator), updates the documented aliasing invariant on Bytes in src/jsc/webcore_types.rs, and teaches the two exclusive-ownership consumers (Lifetime::Transfer in to_array_buffer_view_with_bytes, and Bytes::to_internal_blob) to check for buffer sharing before handing memory out writable or reconstituting it as a Vec. 11 new tests plus a bench snippet.

Security risks

No auth/crypto/permission surface. The risk profile is memory safety: multiple Bytes now view prefixes of one allocation, an atomic committed length arbitrates concurrent tail claims (Workers via blob: URLs), and two paths that previously assumed sole ownership of a store's bytes are updated. A missed writer would be a cross-Blob data race or UAF. I traced the paths named in the description and the guards look correctly placed, but this is exactly the class of change (shared mutable-tail allocation, weakened uniqueness invariant, raw-pointer Vec::from_raw_parts handoff) that REVIEW.md flags as the most-blocked category.

Level of scrutiny

High. This is not a mechanical change: it introduces a new memory-sharing model for Store::Bytes, redefines what has_one_ref() implies about the underlying allocation, and relies on an atomic CAS for cross-thread correctness. The unsafe blocks each carry SAFETY comments, but verifying them requires a reviewer who knows every consumer of Bytes::ptr / as_array_list_leak / allocated_slice across the codebase (thread-pool writers, external strings, structured clone, Archive tasks). The PR description enumerates these, and the tests exercise several, but confirming the enumeration is complete is a human call.

Other factors

The PR also explicitly needs ordering against three other open PRs (#36001 rope stores, #33600 file-backed parts, #38562 GC reporting) touching the same constructor arm, with a proposed merge rule in the description — that coordination decision belongs to a maintainer. Test coverage is thorough (chain intermediates, forked appends, self-append, transfer-path aliasing, Worker cross-thread), and the linear-time test is calibrated against an in-process baseline rather than a wall-clock threshold, which is good. No prior human reviews on the PR yet.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the change is complete and CI is green on every lane that ran.

  • Reproduced with the accumulation loop from the description (b = new Blob([b, chunk]), 200 / 400 / 800 chunks of 64 KiB: 269 / 1034 / 3678 ms before, 48 / 72 / 77 ms after on the same machine); the new linear-time test in test/js/web/fetch/blob.test.ts fails on the released binary (about 4.3 s against a 0.7 s bound) and passes on this branch.
  • Build 96363: 177 of 179 jobs passed. The only entries it reports are retries that then passed (concurrent-test-glob, inspect-error-leak, watch-mode kill signal, test-error-code-done-callback, Windows terminal CRLF, next-pages SSR on Windows aarch64, napi hello world, resolve, cluster-shared-leak), none of which involve Blob.
  • The two remaining jobs, both "darwin 14 aarch64 - test-bun", never started and expired after three hours in the queue. That lane is currently backed up for every build (the handful of agents serving it are busy and several hundred newer builds have the same job waiting), so re-triggering now would expire the same way; the other macOS lanes ran and passed, and nothing in the change is platform-specific. I will re-run it once that queue has drained, or on request.

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