Blob: append onto the first part's store instead of copying it - #38626
Blob: append onto the first part's store instead of copying it#38626robobun wants to merge 1 commit into
Conversation
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).
|
Warning Review limit reached
Next review available in: 4 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
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. |
There was a problem hiding this comment.
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 (vtablefree,take_unique_storage,Drop) — balanced on all paths I traced.- The
shares_allocationguard on the transfer path and theprefix_storegating (offset==0, full view, non-empty) — the sliced/empty/self-append cases are handled. - Checked that the
StoreRefclone of the prefix keeps it live across later parts running user JS, and thatwrite_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.
|
Status: the change is complete and CI is green on every lane that ran.
|
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.from_js_without_defer_gc,src/runtime/webcore/Blob.rs) pushes every part, Blob parts included, into aStringJoiner, anddone()materializes one fresh flat buffer. A Blob has exactly one flat store, so a Blob part's bytes are copied once per construction.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
new Blob(parts)is a Blob viewing a whole in-memory store, the result is built on anAppendBuffer(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.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 lengthlen + 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.new Blob([blob, x])costs what it did before; headroom only appears once the same data has been appended onto twice.Bytes::allocatorexactly likeLinuxMemFdAllocator: each store'sBytesowns one reference and the free-only vtable'sfreedrops it, so the allocation goes away with the last store built on it.Lifetime::Transferinto_array_buffer_view_with_byteshands 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 intoawait new Response(middle).arrayBuffer()changed a longer Blob built frommiddle; 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 aVecwhen 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-offnew Blob([blob, x])result keeps its zero-copy consumption.Bytesdoc andSend/Syncjustification insrc/jsc/webcore_types.rspreviously said aBytesis the sole alias of its allocation; updated to the invariant that actually holds (nothing ever writes bytes aBytescan see; writers need exclusivity, seeas_array_list_leak).bench/snippets/blob-append.mjsadded.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 reachestake_unique_storage), and a Worker appending onto a store it received through a blob: URL while the main thread appends onto it too.cargo clippy -p bun_runtime -p bun_jscandcargo fmt --checkare 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:
new Blob([big1, big2, ...]), zero copies) and accepts a superset of this PR's first-part condition, so whichever lands second has to pick the order at that one dispatch point. Proposed rule: a first part that already is anAppendBufferstore, or a construction whose other parts are not Blobs, takes the append path (flat result, O(new bytes), and it stays linear when the Blob is read between appends, which a rope cannot do because each read flattens); everything else takes the rope. The rope alone does not fix this item: re-wrapping a rope re-splices all of its segments on every step, so the idiom stays quadratic in the number of chunks.newly_allocated_sizekeyed on the allocator vtable, which I will add to whichever of the two lands second.Background
Blob(src/jsc/webcore_types.rs) is a view (offset,size) onto a refcountedStore. A store's data isBytes(ptr,len,cap, plus theStdAllocatorthat 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.Bytesare immutable once created; readers on other threads rely on that, and this change keeps it.StdAllocatoris a (context pointer, vtable) pair.LinuxMemFdAllocatoralready attaches a refcounted object to aBytesthrough the context pointer and releases it from a free-only vtable when theBytesis dropped, using the vtable's address as the type tag.AppendBufferreuses 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 andnode_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.