Skip to content

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

Closed
robobun wants to merge 1 commit into
mainfrom
farm/ed53e37b/blob-array-body-keeps-source-store
Closed

robobun wants to merge 1 commit into
mainfrom
farm/ed53e37b/blob-array-body-keeps-source-store

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Passing a Blob as a body part empties the caller's Blob: after new Response([blob]) the response reads "hello world" but blob.text() resolves to "" while blob.size still reports 11.
  • Same for new Request(url, { body: [blob] }), and for server.fetch(url, { body: blob }) even without the array (that path calls the helper directly). A File part additionally loses its name, and a Bun.file() or BuildArtifact part reads back empty afterwards.
  • Cause: Body::Value::from_js (src/runtime/webcore/Body.rs:1053) and Server::on_fetch (src/runtime/server/server_body.rs:2513) called Blob::get::<MOVE = true, ..>. In the single-Blob-part fast path of from_js_without_defer_gc (src/runtime/webcore/Blob.rs, JSType::DOMWrapper arm) MOVE built the body blob with store: blob.take_store(), which takes the store out of the JS-visible Blob the user still holds. The non-MOVE arm and the direct new Response(blob) path (Body.rs:991) share the store with dupe() instead.
  • as_::<Blob>() also matches a BuildArtifact (it returns the artifact's inner blob), which is why artifacts were affected too.

Fix

  • The fast-path arm now returns blob.dupe() unconditionally (the one behavioral change in the diff). dupe() creates a new view on the same refcounted store, so the body is still zero-copy; the only difference from the removed field copy is the extra store reference, which is exactly what keeps the caller's Blob valid.
  • Correct because a JS-visible Blob owns its store reference for as long as its wrapper is alive: every other place that derives a Blob from a JS Blob (new Blob([blob]), new Response(blob), Bun.write(dst, [blob]), blob.slice()) takes its own reference, and Node/the spec never modify a Blob used as a body (Node stringifies the array to "[object Blob]" and leaves the Blob intact; Bun keeps its array-of-parts extension, it just stops mutating the part).
  • With no caller moving any more, the MOVE const generic and the from_js_move / from_js_clone / from_js_clone_optional_array wrappers it selected between are dead and are deleted; get now takes only REQUIRE_ARRAY. The might_only_be_one_thing || !MOVE condition becomes might_only_be_one_thing: for a multi-part array the fast-path block matched nothing and fell through, so this is behavior preserving. The now unused Cell / JsCell imports go with it.
  • Tests: test/js/web/fetch/body.test.ts, array containing a Blob (run for both Request and Response: plain Blob, the same Blob backing two bodies, File name + bytes, Bun.file(), BuildArtifact) and server.fetch() body option (bare Blob and [blob]). All 12 fail on the unfixed build (USE_SYSTEM_BUN=1) with the source reading back "" (and name: undefined for the File), and pass with bun bd test.
  • The rest of body.test.ts, blob.test.ts, blob-array-fast-path.test.ts, blob-file-name-ownership.test.ts, blob-write.test.ts, blob-cow.test.ts, FormData.test.ts, structured-clone-blob-file.test.ts and response.test.ts pass with the debug build (response.test.ts handle stack overflow times out locally under the debug build independently of this change).

Background

  • A Blob in bun is a small view (offset, size, metadata) onto a Store, the refcounted owner of the bytes (or of the file path / S3 key for Bun.file() / S3 blobs). Several Blobs can view one store; blob.dupe() makes another view and bumps the store's refcount, take_store() removes a Blob's view without touching the refcount, leaving that Blob with no store at all. A Blob without a store reads as empty but keeps its cached size, which is the 11 / "" mismatch above.
  • Blob::get::<REQUIRE_ARRAY> is the shared "blob parts to Blob" routine behind new Blob(parts) / new File(parts, name) (REQUIRE_ARRAY = true) and behind body inits and Bun.write() sources (false, a bare part is accepted as well). When the input is a single Blob part it takes a fast path that reuses the part's store instead of copying bytes; that fast path is the code changed here.
  • Array bodies (new Response([...])) are a bun extension: the fetch spec would stringify the array. That extension is unchanged by this PR.
Repro and outputs
const b = new Blob(["hello world"]);
const r = new Response([b]);
console.log(JSON.stringify(await r.text()), "|", b.size, JSON.stringify(await b.text()));
node v26.3.0:        "[object Blob]" | 11 "hello world"
bun 1.4.0 (before):  "hello world"   | 11 ""
this branch:         "hello world"   | 11 "hello world"

Other entry points on bun 1.4.0, all fixed by this branch:

Request body:[b]            -> b.text() === ""
server.fetch({ body: b })   -> b.text() === ""          (no array needed)
server.fetch({ body: [b] }) -> b.text() === ""
Response([file])            -> file.text() === "", file.name === undefined
Response([Bun.file(p)])     -> later Bun.file reads return ""
Response([buildArtifact])   -> artifact.text() === ""
Response([b]); Response([b])-> second response body is "" as well

…caller's Blob

new Response([blob]), new Request(url, { body: [blob] }) and
server.fetch(url, { body: blob }) built their body through the MOVE
variant of Blob::get, which took the StoreRef out of the JS-visible Blob.
The caller's Blob (or File, Bun.file(), BuildArtifact) kept reporting its
size but read back empty afterwards, and a byte-backed File lost its name.

The single-Blob-part fast path now always returns blob.dupe(), which
shares the refcounted store, the same thing the direct new Response(blob)
path and the Blob constructor already do. With no caller left that moves,
the MOVE const generic and the from_js_move / from_js_clone /
from_js_clone_optional_array wrappers are removed.
@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: 3 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: 96dcbc5d-1350-4c0a-9c54-f0e1e482377d

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and 0814a03.

📒 Files selected for processing (4)
  • src/runtime/server/server_body.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/Body.rs
  • test/js/web/fetch/body.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on bun 1.4.0 with new Response([blob]), new Request(url, { body: [blob] }) and server.fetch(url, { body: blob }): the body reads the bytes, the caller's Blob reads back "" afterwards (File parts also lose name; Bun.file() and BuildArtifact parts go empty too). Node leaves the Blob untouched.

The 12 new cases in test/js/web/fetch/body.test.ts fail on the unfixed build and pass with this branch; the related blob/body/FormData suites still pass.

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

LGTM — the fix replaces take_store() with the dupe() path already used everywhere else that derives a Blob from a JS-visible Blob, and deletes the now-dead MOVE generic.

What was reviewed:

  • The one behavioral change (blob.dupe() instead of the field-copy + take_store()) — refcount is balanced by the body Blob's eventual deinit().
  • The might_only_be_one_thing || !MOVE → might_only_be_one_thing simplification: for a multi-part array top_value is still the array itself, so none of the fast-path match arms fired and the block fell through — behavior-preserving.
  • All five Blob::get::<..> callers are updated to the single-generic form; no lingering from_js_move/from_js_clone* references anywhere.
  • Tests cover Request/Response × plain Blob/File/Bun.file()/BuildArtifact plus server.fetch({ body }), and assert both the body's bytes and the source Blob's bytes/name after use.
Extended reasoning...

Overview

The PR fixes new Response([blob]) / new Request(url, { body: [blob] }) / server.fetch(url, { body: blob }) emptying the caller's Blob. In Blob::from_js_without_defer_gc's single-Blob-part fast path, the MOVE = true arm built the body Blob with store: blob.take_store(), stealing the store out of the JS-visible Blob the user still holds. The fix drops the MOVE const generic entirely and returns blob.dupe() unconditionally — the same shared-store path already used for new Blob([blob]), new Response(blob), Bun.write(dst, [blob]), and the BuildArtifact arm. With no caller left that moved, the from_js_move/from_js_clone/from_js_clone_optional_array wrappers and the now-unused Cell/JsCell imports are deleted.

Security risks

None. This is a Blob store refcount fix; no auth/crypto/permissions surface.

Level of scrutiny

Medium — this touches the store-refcount path, which is memory-safety adjacent. But the change moves from a dangerous pattern (mutating a JS-visible object's owning field out from under the user) to the well-trodden safe pattern (dupe() bumps the store refcount, released when the body's Value::Blob is dropped via Value::drop → b.deinit()). I traced the might_only_be_one_thing || !MOVE → might_only_be_one_thing collapse: when might_only_be_one_thing is false, top_value is still the original array (JSType Array/DerivedArray), which matches none of the fast-path arms (string-like / array-buffer-like / DOMWrapper) and fail_if_top_value_is_not_typed_array_like stays false, so the block was a no-op there — the removal is behavior-preserving as the description claims.

Other factors

  • All five Blob::get::<..> call sites (Blob constructor, File constructor, write_file_internal, Body::Value::from_js, Server::on_fetch) are updated; a repo grep confirms no remaining references to the deleted wrappers.
  • The as_::<Blob>() → as_class_ref::<Blob>() swap removes one raw-pointer deref in favor of the safe accessor already used in the sibling BuildArtifact arm and in Body.rs:991.
  • Tests are thorough: they cover the whole variant matrix (Request AND Response × plain Blob / same Blob twice / File name+bytes / Bun.file() / BuildArtifact, plus server.fetch bare Blob and [blob]), assert both the body's read-back and the source Blob's read-back, and the description confirms the USE_SYSTEM_BUN=1-fails / bun bd test-passes discipline.
  • The dead MOVE machinery is deleted in the same PR, per the repo's "delete dead code in the same PR that makes it dead" rule.

@robobun

robobun commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #38503 now carries this change (the dupe() arm and the removal of MOVE) with its tests, on current main.

@robobun robobun closed this Oct 2, 2026
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