Blob: read file-backed parts in multi-part new Blob([...]) - #33600
Blob: read file-backed parts in multi-part new Blob([...])#33600robobun wants to merge 1 commit into
Conversation
WalkthroughThis PR changes Blob construction to read bytes from file-backed parts during ChangesBlob part byte contribution fix
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 1:20 PM PT - Jul 25th, 2026
⏳ @robobun, your commit 2f30b03 is still building in
|
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
CI status: Recent red builds were unrelated infra:
Ready for review. |
c97ea66 to
4ff7093
Compare
4a5b278 to
bfd3bc2
Compare
bfd3bc2 to
ee3facf
Compare
There was a problem hiding this comment.
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.
ee3facf to
5e59f0f
Compare
5e59f0f to
2acd433
Compare
2acd433 to
dc6cee1
Compare
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
dc6cee1 to
2f30b03
Compare
There was a problem hiding this comment.
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 viatry_reserve_exact, grow loop viatry_reserve) — no infallible allocation remains. 8 << 30literal is inferred asusizevia.min(), so no i32 overflow on 64-bit targets.- Path arm:
push_clonedbeforebuf.destroy(), so no UAF; encoding fixed toBuffer. - Both call sites (array iterator and deferred-stack arm) route through the helper; the deferred arm passes
borrow_bytes=falsematching 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==0→initial = 8192.min(cap) = 8192, firstpreadreturns 0, loop breaks. Test added. - Sparse file (
st_size=1e12):initial = cap = 1e12, buttry_reserve_exact(initial.min(8<<30))caps at 8 GiB and throwsENOMEMon failure; the grow loop'stry_reserveis likewise fallible. Novec[...]or infallibleresize-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.
Problem
A file-backed Blob (
Bun.file(p)or a slice of one) used as one part of a multi-partnew Blob([...])/new File([...])contributed zero bytes. The single-part casenew Blob([Bun.file(p)])worked, which made growing from one part to two flip from correct to silent data loss..sizeon the resulting Blob was consistent with the dropped bytes, so nothing downstream could detect it.Node.js (
fs.openAsBlobparts) 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)), aBunFilereceived viapostMessagein 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 pushesblob.shared_view()for every Blob part into the byte joiner.shared_view_raw()returns an empty slice for any store that is not in-memoryBytes, so file-backed and S3-backed stores were silently treated as empty. The single-part fast path callsdupe()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 cloneshared_view()as before).Filewith a path: synchronousNodeFS::read_filehonouring the part'soffset/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.Filewith an fd:preadat 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_fileon 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
ENOENTfrom the constructor instead of being silently ignored.Verification
New tests cover: file before/after a string, sliced file,
Fileconstructor, the same file used twice, an empty-string sibling,structuredCloneof aBun.file,new Response(Bun.file(p)).blob(), fd-backedBun.file(fd)(including the same fd used twice and slices of it), and that a nonexistent file path throwsENOENTinstead of silently contributing nothing. All 49 tests inblob.test.tspass.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