Skip to content

Blob: read file-backed parts in multi-part new Blob([...]) - #33600

Open
robobun wants to merge 1 commit into
mainfrom
farm/a5b5509f/blob-file-part-bytes
Open

Blob: read file-backed parts in multi-part new Blob([...])#33600
robobun wants to merge 1 commit into
mainfrom
farm/a5b5509f/blob-file-part-bytes

Conversation

@robobun

@robobun robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

A file-backed Blob (Bun.file(p) or a slice of one) used as one part of a multi-part new Blob([...]) / new File([...]) contributed zero bytes. The single-part case new Blob([Bun.file(p)]) worked, which made growing from one part to two flip from correct to silent data loss. .size on the resulting Blob was consistent with the dropped bytes, so nothing downstream could detect it.

const file = Bun.file(p); // 10 bytes: "ABCDEFGHIJ"
await new Blob([file]).text();           // "ABCDEFGHIJ"  (correct, dupe fast path)
await new Blob([file, "-tail"]).text();  // "-tail"       (file dropped)
await new Blob(["head-", file]).text();  // "head-"       (file dropped)
await new Blob(["", file]).text();       // ""            (an EMPTY sibling part defeats the fast path)

Node.js (fs.openAsBlob parts) produces "ABCDEFGHIJ-tail" / "head-ABCDEFGHIJ".

Every way of obtaining a file-store Blob is affected, not just Bun.file(path): Bun.file(fd), structuredClone(Bun.file(p)), a BunFile received via postMessage in a Worker, await new Response(Bun.file(p)).blob(), File([str, Bun.file(p)], name).

Cause

from_js_without_defer_gc's multi-part walk pushes blob.shared_view() for every Blob part into the byte joiner. shared_view_raw() returns an empty slice for any store that is not in-memory Bytes, so file-backed and S3-backed stores were silently treated as empty. The single-part fast path calls dupe() instead, which shares the lazy store and reads on demand.

Fix

Extract the per-part push into push_blob_part_bytes, which dispatches on the store variant:

  • Bytes: unchanged (borrow or clone shared_view() as before).
  • File with a path: synchronous NodeFS::read_file honouring the part's offset/size (the same approach already used by the FormData multipart serializer). The constructor is synchronous, so the spec's "process blob parts" step cannot be deferred.
  • File with an fd: pread at the Blob's absolute offset so each use of the same fd part reads the correct window regardless of the fd's cursor (NodeFS::read_file on a caller-owned fd would read from wherever the cursor was left).
  • S3: throw a clear error rather than silently dropping bytes; the constructor cannot perform network I/O synchronously.

Both call sites in the part-walk (inside the array iterator and the deferred-stack arm) now go through the helper.

A nonexistent file part now throws ENOENT from the constructor instead of being silently ignored.

Verification

bun bd test test/js/web/fetch/blob.test.ts -t "file-backed Blob part"

New tests cover: file before/after a string, sliced file, File constructor, the same file used twice, an empty-string sibling, structuredClone of a Bun.file, new Response(Bun.file(p)).blob(), fd-backed Bun.file(fd) (including the same fd used twice and slices of it), and that a nonexistent file path throws ENOENT instead of silently contributing nothing. All 49 tests in blob.test.ts pass.

Fixes #25851


no test proof · iteration 6 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/blob.test.ts

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR changes Blob construction to read bytes from file-backed parts during new Blob([...]), including path-backed and fd-backed sources, and adds tests for mixed parts, offsets, slicing, and unreadable file errors.

Changes

Blob part byte contribution fix

Layer / File(s) Summary
Blob part byte resolution
src/runtime/webcore/Blob.rs
Adds push_blob_part_bytes and routes the array-walk and single-Blob paths through it, with in-memory borrow-or-clone handling, synchronous path/fd reads for file-backed parts, and an invalid-arguments throw for S3-backed parts.
File-backed Blob constructor tests
test/js/web/fetch/blob.test.ts
Adds an fs import and tests for mixed part concatenation, fd-backed offset handling and slicing, caller fd position behavior on POSIX, and ENOENT failures for unreadable file-backed parts.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation addresses #25851 by making multi-part Blob/File constructors include file-backed bytes before and after other parts.
Out of Scope Changes check ✅ Passed The changes stay within Blob/File part handling and tests; no unrelated subsystems or features were introduced.
Title check ✅ Passed The title is concise and accurately captures the main change: fixing file-backed parts in multi-part Blob construction.
Description check ✅ Passed The description covers the bug, cause, fix, and verification, which satisfies the template's intent despite different headings.

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

@github-actions github-actions Bot added the claude label Jul 7, 2026
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:20 PM PT - Jul 25th, 2026

@robobun, your commit 2f30b03 is still building in Build #81258, but has 1 failures so far (All Failures):

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Blob constructor ignores File parts if more than one part is given #25851 - Blob constructor ignores File parts when more than one part is given, producing zero bytes — exactly the shared_view() bug this PR fixes in from_js_without_defer_gc

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #25851

🤖 Generated with Claude Code

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: test/js/web/fetch/blob.test.ts passes on every lane that ran it, including Windows. All review rounds (8 claude findings, comment-cop) addressed; 0 unresolved threads. The final claude review on 2f30b03 found no further issues and defers to a maintainer for the sync-I/O design sign-off.

Recent red builds were unrelated infra:

  • 81258: *-build-cpp jobs expired/timed out before any agent picked them up, so the link steps failed with "sibling step errored, nothing to link". This change is Rust-only and does not touch C++.
  • 80987: :package: binary-size flags +564 KB vs canary main #79916, but this branch is rebased onto df6c7ee (~1000 builds newer than the baseline); the delta is main's accumulated growth, not this ~120-line Rust helper.
  • 80892: freebsd-aarch64-build-cpp errored.
  • 71044: mysql_plain Docker health-check timeout on alpine aarch64.
  • 69645 / 69656: buildkite-agent artifact download timed out on darwin 26 aarch64.

Ready for review.

@robobun
robobun force-pushed the farm/a5b5509f/blob-file-part-bytes branch from c97ea66 to 4ff7093 Compare July 9, 2026 14:14
Comment thread test/js/web/fetch/blob.test.ts Outdated
Comment thread src/runtime/webcore/Blob.rs
@robobun
robobun force-pushed the farm/a5b5509f/blob-file-part-bytes branch from 4a5b278 to bfd3bc2 Compare July 25, 2026 13:25
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
@robobun
robobun force-pushed the farm/a5b5509f/blob-file-part-bytes branch from bfd3bc2 to ee3facf Compare July 25, 2026 14:03
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated

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

No new issues found — all five concerns from my earlier rounds (fd cursor via pread, the size==0 ENOENT guard, the S3 error wording, Windows pread pointer semantics, and the st_size==0 procfs case) are addressed in the current diff. Deferring to a maintainer because this introduces synchronous disk I/O into the new Blob([...]) constructor path and changes observable behavior (ENOENT now thrown where file parts were previously silently dropped), which is a design call worth a human sign-off. Note comment-cop has left several unresolved flags on the new code comments; the ones I see in the current diff are 2-3 line invariant notes rather than workaround justifications, so they may just need dismissing.

What was reviewed: the borrow-vs-clone split in push_blob_part_bytes and its detach_lifetime safety contract against the caller's prescan; the Path arm's read_file ownership/free of the returned buffer; the Fd arm's pread grow-loop for cap sizing, offset arithmetic, and short-read handling; and the new tests for hermeticity and platform gating.

Extended reasoning...

Overview

The PR fixes #25851: file-backed Blob parts (Bun.file(path), Bun.file(fd), slices/clones thereof) contributed zero bytes when used as one of multiple parts in new Blob([...]) / new File([...]). The fix extracts a push_blob_part_bytes helper that dispatches on the store variant: in-memory bytes borrow or clone as before, path-backed files go through NodeFS::read_file, fd-backed files use a pread grow-until-EOF loop at the Blob's absolute offset, and S3-backed parts throw a clear error. ~120 lines of new Rust in src/runtime/webcore/Blob.rs plus ~70 lines of tests in blob.test.ts.

Security risks

None identified. Inputs are the caller's own file paths/fds; there is no untrusted-length parsing. Buffer sizing uses saturating_sub/saturating_mul/saturating_add and clamps against fstat size or the slice's concrete size. The one unsafe block (detach_lifetime on the borrowed bytes view) is gated on the same prescan invariant the pre-PR code relied on and is documented at both the call site and the helper.

Level of scrutiny

Medium-high. This is core Web API surface (Blob/File constructors) and native code with an unsafe block, cross-platform I/O, and a user-visible behavior change: constructing a Blob with a nonexistent file part now throws ENOENT synchronously instead of silently contributing nothing. It also introduces synchronous disk reads into a constructor that was previously pure over its arguments — that is the correct-per-spec behavior (the constructor is sync and "process blob parts" cannot be deferred), but it is an architectural choice a maintainer should confirm rather than something I should approve unilaterally.

Other factors

This PR has been through two rounds of my review; all five prior findings were addressed with follow-up commits and tests (fd-twice, ENOENT-after-.size, POSIX-only cursor check, procfs grow loop). The bug-hunting pass this run found nothing new. Test coverage is thorough across path/fd/slice/clone/Response.blob() variants and the error path. The outstanding comment-cop bot flags target 2-3 line invariant comments (the push_blob_part_bytes doc comment, the pread-vs-read_file rationale) that read as durable non-obvious content to me, but they are unresolved and the author should either trim or dismiss them.

@robobun
robobun force-pushed the farm/a5b5509f/blob-file-part-bytes branch from ee3facf to 5e59f0f Compare July 25, 2026 15:20
Comment thread src/runtime/webcore/Blob.rs
@robobun
robobun force-pushed the farm/a5b5509f/blob-file-part-bytes branch from 5e59f0f to 2acd433 Compare July 25, 2026 17:08
Comment thread src/runtime/webcore/Blob.rs Outdated
@robobun
robobun force-pushed the farm/a5b5509f/blob-file-part-bytes branch from 2acd433 to dc6cee1 Compare July 25, 2026 17:50
Comment thread src/runtime/webcore/Blob.rs Outdated
The multi-part join in from_js_without_defer_gc pushed blob.shared_view()
for every Blob part, but shared_view() returns an empty slice for any
non-Bytes store (Data::File, Data::S3). Every file-store Blob used as one
part of a multi-part new Blob([...]) or new File([...]) therefore
contributed zero bytes. The single-part fast path (new Blob([Bun.file(p)]))
was unaffected because it shares the store instead of joining bytes.

Fix: dispatch on the part's store. Data::Bytes keeps the shared_view()
path. Data::File reads the file synchronously (the constructor is sync and
the spec's "process blob parts" requires the bytes at construction time):
path stores use NodeFS::read_file; fd stores use pread so repeated use of
the same fd part reads the correct window regardless of cursor position.
Data::S3 throws rather than silently contributing zero bytes, since there
is no synchronous S3 read path.

A nonexistent file part now throws ENOENT from the constructor instead of
being silently ignored.

Fixes #25851
@robobun
robobun force-pushed the farm/a5b5509f/blob-file-part-bytes branch from dc6cee1 to 2f30b03 Compare July 25, 2026 18:57

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

The over-allocation concerns from the last round are addressed in 2f30b03 — both the empty-fd-huge-slice and sparse-file triggers now go through try_reserve* with the 8 GiB cap and surface ENOMEM instead of aborting, and the grow loop is likewise fallible. I didn't find anything further.

Deferring to a human for the design/behaviour sign-off rather than the mechanics: new Blob([...]) now does synchronous disk I/O and throws (ENOENT for a missing path, an error for S3 parts) where it previously returned successfully — that's the right fix for #25851 but is a user-visible change a maintainer should confirm.

What was reviewed:

  • Fd arm buffer sizing: traced empty-file+huge-slice (starts at 8K, first pread returns 0) and sparse st_size (capped at 8 GiB via try_reserve_exact, grow loop via try_reserve) — no infallible allocation remains.
  • 8 << 30 literal is inferred as usize via .min(), so no i32 overflow on 64-bit targets.
  • Path arm: push_cloned before buf.destroy(), so no UAF; encoding fixed to Buffer.
  • Both call sites (array iterator and deferred-stack arm) route through the helper; the deferred arm passes borrow_bytes=false matching the prior always-copy behaviour.
Extended reasoning...

Overview

Adds push_blob_part_bytes (~110 lines) to src/runtime/webcore/Blob.rs so file-backed Blob parts in a multi-part new Blob([...]) contribute their bytes instead of being silently dropped. The Path arm delegates to NodeFS::read_file; the Fd arm hand-rolls a pread loop with fstat-derived sizing, an 8 GiB initial cap, fallible try_reserve*, and grow-until-EOF for st_size==0 virtual files. S3-backed parts throw. Adds ~70 lines of tests in blob.test.ts covering path/fd, slices, repeated fds, past-EOF slices, empty-fd huge slices, structuredClone, Response.blob(), and ENOENT.

Verification of last round's fix

I traced 2f30b03 against both triggers from my previous review:

  • Empty file + .slice(0, 1e12): file_len==0initial = 8192.min(cap) = 8192, first pread returns 0, loop breaks. Test added.
  • Sparse file (st_size=1e12): initial = cap = 1e12, but try_reserve_exact(initial.min(8<<30)) caps at 8 GiB and throws ENOMEM on failure; the grow loop's try_reserve is likewise fallible. No vec[...] or infallible resize-past-capacity remains.

The resize calls follow a successful try_reserve* for the same delta, so they cannot reallocate. new_len - buf.len() cannot underflow because new_len = buf.len().saturating_mul(2).min(cap) and the branch is only entered when buf.len() < cap.

Security risks

The user-controlled-size → infallible-allocation abort was the security-relevant issue; it is now closed. The Path arm inherits read_file's existing safeguards. No path traversal or injection surface — inputs are already-constructed Bun.file handles.

Level of scrutiny

High. This is a Web-standard constructor (new Blob/new File) that now performs synchronous disk I/O and can throw where it previously could not. Four prior review rounds each found a real bug (Windows pread cursor semantics, procfs st_size==0, realloc churn, two over-allocation aborts), which is evidence the code is subtle enough to warrant a maintainer's eyes even though this pass found nothing.

Other factors

All prior inline threads are resolved and the comment-cop paragraph-comment flags were addressed (comments in the current diff are terse). Test coverage is broad and each earlier finding has a corresponding regression assertion. The remaining question is design intent — whether synchronous read-on-construct (and the new throw behaviour) is the approach the maintainers want, versus e.g. keeping the store lazy and materializing on first read. That's a maintainer call, not a correctness bug, so I'm deferring rather than approving.

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.

Blob constructor ignores File parts if more than one part is given

2 participants