FileReader: apply the slice window on the read_into pull path and end the stream when it is used up - #39201
FileReader: apply the slice window on the read_into pull path and end the stream when it is used up#39201robobun wants to merge 4 commits into
Conversation
… the stream when it is used up A sliced Bun.file() streams through FileReader with max_size set to the slice length, but the clamp only lived in on_read_chunk. The direct read_into pull path added in #38886 never went through it, so new Response(file.slice(0, 5)).body streamed the whole file from the slice offset, and a zero-length slice streamed to EOF. Both delivery paths now share one window: on_pull cuts the read to what is left of it, on_read_chunk truncates the chunk to it, and whichever path uses it up closes the reader the way EOF does. Closing at the end of the window also fixes the stream never finishing when the file continues past the slice, which previously left the reader open after returning false from on_read_chunk (#18192, #31675).
|
Warning Review limit reached
Next review available in: 37 minutes Limit details: You’ve used all 5 included reviews currently available under your plan. 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 (3)
Comment |
|
Status: approved, CI green apart from a pre-existing main failure; ready to merge. Reproduced on a debug build of main (88a6398): Fail-before / pass-after against a build with main's CI (build 98774 at a2c2a37): the only red test is |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Neither is the same fix.
|
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes control flow in FileReader::on_read_chunk/on_pull — re-entrancy-sensitive code on the critical file-streaming path, with different execution paths on POSIX vs Windows — a human look would still be worthwhile.
What was reviewed:
- Window accounting:
on_read_chunktruncates toremainingbeforeconsume_window(chunk.len()), andon_pullcapslenatremainingwithamount_read <= len, so thedebug_assert!(total_readed <= max_size)holds on both paths. - Empty-destination
read_intoon POSIX returns(0, Progress)early (PipeReader.rs:864), so a zero-length slice closes on the first pull without issuing a read; on Windowsread_intoreturns(0, Progress)and all bytes arrive viaon_read_chunk, soconsume_window(0)inon_pullis a no-op there unlessmax_size == 0. end_at_windowis called after the chunk is delivered (sink write / pending resolve /IntoArrayAndDonereturn), matching the EOF ordering; it's guarded byis_done()so a window that ends exactly at real EOF doesn't double-close.read_intonever dispatcheson_read_chunk(onlydone()/error on EOF), so the window can't be double-charged between theon_pullcap and a re-entrant chunk delivery.
Extended reasoning...
Overview
The PR fixes a regression from #38886: FileReader::on_pull now reads via IOReader::read_into directly into the pull buffer, bypassing on_read_chunk, which was the only place the blob slice window (max_size/total_readed) was enforced. As a result, Bun.file(path).slice(a, b).stream() streamed the whole file from offset a. The fix extracts the window bookkeeping into three helpers (window_remaining, consume_window, end_at_window) and applies them on both delivery paths. It also fixes a pre-existing hang (#18192, #31675): when the window is exhausted, the reader is now closed so the stream ends instead of staying open forever. Tests add 21 cases across four consumers (.stream(), .stream().bytes(), Response(...).body, HTMLRewriter.transform) × five window shapes.
Security risks
None identified. The change tightens bounds enforcement (delivering fewer bytes than before, per the slice contract) rather than loosening it. No new user-controlled input reaches size arithmetic; max_size and start_offset are set at construction from the blob's own slice bounds.
Level of scrutiny
High. FileReader backs every file-based ReadableStream (Bun.file().stream(), new Response(file).body, HTMLRewriter file bodies, fetch file uploads). The changed methods are re-entrancy-sensitive (on_read_chunk runs inside the BufferedReader read loop; read_into may dispatch on_reader_done which runs user JS) and behave differently per platform (POSIX regular files hit read_into; Windows and pollable fds go through on_read_chunk). The old on_read_chunk early-returned when total_readed >= max_size; the new code instead truncates to zero and flows through the sink/pending delivery before closing — a control-flow change whose interaction with sink backpressure and parked reads deserves a maintainer's eye.
Other factors
The fix is well-argued and thoroughly tested (author verified 12/16 new cases fail on unfixed main, and ran the wider stream/stdin/spawn/HTMLRewriter suites). I traced read_into in src/io/PipeReader.rs to confirm the empty-buffer and re-entrancy claims hold. The drain() paths in on_pull do not charge the window, but that is pre-existing and those bytes come from buffered (already charged by on_read_chunk) or the reader's internal buffer on paths that don't use read_into. Nothing here looks wrong to me, but the combination of critical path + re-entrancy + cross-platform divergence + control-flow reshaping in on_read_chunk puts this outside what I'd approve without human review.
|
For whoever reviews this, the two interactions worth checking are the ones where the window-ending chunk goes through
|
|
Data point from main-break triage: this test is now failing on unrelated PRs' CI too (build 98665, debian 13 x64-asan: |
|
Updated 4:06 PM PT - Aug 15th, 2026
🔄 @robobun, the build for your commit |
alii
left a comment
There was a problem hiding this comment.
Fix looks right and this unbreaks main, so let's land it quickly. Two things on the tests below, both small. The bigger question of whether the window should live in the buffered reader itself (next to _offset, so FileReader and FileResponseStream stop hand-rolling it) is a follow-up, not for this PR.
…nd slices of a pipe that stays open The stdin cases take the parked-read branch of on_read_chunk, which a regular file never does on POSIX; both hang without the fix because nothing but the end of the slice can end the stream.
|
Pushed the test additions (b10085b): the two extra windows in Agreed on the follow-up: with |
alii
left a comment
There was a problem hiding this comment.
Both asks addressed; the pipe tests hit the parked-read branch as intended.
There was a problem hiding this comment.
I reviewed this PR and the automated bug-hunting pass found no issues. Since it changes re-entrancy-sensitive native streaming code (reader().close() firing on_reader_done mid-pull / mid-chunk-delivery) and a human reviewer is already engaged, a human sign-off is still worthwhile.
What was reviewed:
window_remaining/consume_windowinvariant (total_readed <= max_size) holds on both paths —on_pullcaps the read destination before charging,on_read_chunktruncates before charging.end_at_windowordering: called after the chunk is delivered on both paths, and theis_done()guard makes it a no-op when EOF or a re-entrant pull already closed the reader.- Refcount balance across
end_at_window→on_reader_doneon theon_pullpath (regular file,waiting_for_on_reader_doneunset) and theon_read_chunkpath (read-loop pin held) — no net decrement that could freeselfbefore the caller returns. - The old
on_read_chunkwindow block'sclose = truearm was dead (lenwas always > 0 there), confirming the pre-existing hang the new code fixes.
Extended reasoning...
Overview
This PR fixes a regression from #38886 where Bun.file(path).slice(start, end).stream() delivered the whole file from start instead of stopping at end. The fix adds three small helpers (window_remaining, consume_window, end_at_window) to FileReader and applies them on both delivery paths: the read_into pull path (new in #38886, previously unclamped) and the on_read_chunk path (previously clamped but never closed the reader when the window ran out — #18192/#31675). Tests cover four consumers (.stream(), .stream().bytes(), Response(...).body, HTMLRewriter.transform) × seven window shapes, plus two stdin-over-pipe cases for the parked-read branch.
Security risks
None. The change narrows what a file-backed stream delivers to match the slice contract; it does not touch input parsing, auth, or crypto. max_size and total_readed are usize and total_readed is only advanced by amounts already bounded to max_size - total_readed, so window_remaining's subtraction cannot underflow.
Level of scrutiny
High. FileReader.rs is core native streaming code with GC-rooting, refcount pins, and re-entrancy across reader().close() → on_reader_done() → user JS. The PR body and follow-up comment walk through the sink-backpressure and parked-read orderings in detail, and the tests exercise them, but this is exactly the class of change where a subtle sequencing mistake becomes a UAF or a hang on one platform. A human reviewer (alii) already left two rounds of feedback (both addressed and resolved) but has not yet approved.
Other factors
- alii's requested test additions (parked-read stdin cases, window-past-EOF, window-exactly-one-pull-buffer) are present in the diff and the threads are marked resolved.
- The comment-cop bot's three long-comment flags were addressed in a2c2a37 (doc comments shortened to one line each).
- CI on the head commit is still in progress; the one failure so far (
test/bake/deinitialization.test.tssegfault on Windows 2019 x64) is in an unrelated subsystem. - I traced the old
on_read_chunkwindow block: theif len == 0 { close = true }arm was unreachable (bothmax_size - total_readed > 0andchunk.len() > 0were guaranteed at that point), so the old code never closed on window exhaustion — matching the PR's description of the pre-existing hang.
|
CI for a2c2a37 (build 98774): 177 of 179 jobs passed with the last two finishing up. The one test still red is test/bake/deinitialization.test.ts, a segfault at teardown of the dev server fixture on Windows 2019 x64 that is also failing on main and does not involve this code; everything else listed on the build passed on retry. Should be good to merge. |
Problem
new Response(Bun.file(path).slice(0, 5)).bodyandBun.file(path).slice(0, 5).stream()deliver the whole file from the slice offset: 100 bytes for a 100-byte file, 1 MiB for a 1 MiB file. A zero-length slice streams to EOF.test/js/web/fetch/blob.test.ts"streams only the slice" fails on main (Expected: 5, Received: 100). Regressed in Streams: one PipeReader loop with owned chunks, hold-not-adopt buffer pins, right-sized native pulls #38886.FileReader.max_size/total_readed) is only applied inFileReader::on_read_chunk(src/runtime/webcore/FileReader.rs:637). Streams: one PipeReader loop with owned chunks, hold-not-adopt buffer pins, right-sized native pulls #38886 madeFileReader::on_pullread straight into the pull buffer withIOReader::read_into(FileReader.rs:832), which does not go throughon_read_chunk, and on POSIX that is the path every pull of a regular file takes.on_read_chunkreturnedfalsewithout closing the reader, so a slice of a file that continues past it never finished on the paths that still go throughon_read_chunk(native sinks such as HTMLRewriter, pollable fds, Windows), and before Streams: one PipeReader loop with owned chunks, hold-not-adopt buffer pins, right-sized native pulls #38886 on every path (streamon slicedBunfiledoesn't work #18192, file.slice(a, b).stream() buffered consumption never resolves for ~1MB+ files #31675).Fix
window_remaining/consume_window):on_pullcuts theread_intodestination to what is left of the window and charges what was read;on_read_chunktruncates the chunk to it, as before.end_at_window), after the bytes have been handed over. This is the same sequence as a real EOF on that path (the final chunk, thenon_reader_done), so sinks, parked reads and the JS adapter end the stream the way they already do at EOF. A zero-length window closes on the first pull without a read (read_intoreads nothing into an empty destination).ReadableStream::from_blob_copy_refsetsstart_offset/max_sizefrom the slice's offset and size, and.text()/.arrayBuffer()on the same slice already return exactly that window. The reader's offset was still honored (preadfromstart_offset); only the end of the window was lost.streamon slicedBunfiledoesn't work #18192 and file.slice(a, b).stream() buffered consumption never resolves for ~1MB+ files #31675 as a consequence: the window end now ends the stream instead of leaving the reader open.bun bd test test/js/web/fetch/blob.test.ts test/js/bun/util/bun-stdin-slice.test.ts(108 pass). The newblob.test.tscases under "a slice of a file that continues past it" cover.stream()+for await,.stream().bytes(),Response(...).body(allread_intopulls on POSIX,on_read_chunkon Windows) andHTMLRewriter.transform(new Response(slice))(native sink,on_read_chunk), each with a window inside the first read, of exactly the first pull buffer, spanning several pulls, ending at EOF, running past EOF, and empty, plus an unsliced file whose resolved size gives it a window ending at EOF. The newbun-stdin-slice.test.tscases streamBun.stdin.slice(0, N)over a pipe that is never closed, in one write and in two, which is the parked-read branch ofon_read_chunk. Against a build with main'sFileReader.rs, 20 of the 29blob.test.tscases fail (wrong byte counts, or a timeout where the stream never ends; the nine that pass are the EOF-bounded windows and the resolved-size guard) and both stdin cases time out.test/js/web/streams/streams.test.js,test/js/bun/util/bun-file*.test.ts,bun-stdin-slice.test.ts,test/js/workerd/html-rewriter.test.js, thespawnstdio stream tests,child_process.test.ts,process-stdin.test.ts,fetch-file-upload.test.ts,bun-serve-file.test.ts; manual checks ofBun.stdin.stream()/process.stdinover a pipe and a file redirect, a FIFO slice whose writer stays open, and/dev/zero//dev/urandomslices (now deliver exactly the slice; previously unbounded on main, hung before Streams: one PipeReader loop with owned chunks, hold-not-adopt buffer pins, right-sized native pulls #38886).cargo check -p bun_runtimefor the Windows and macOS targets.on_read_chunkblock for the window-end hang, which this PR makes unnecessary, but theread_intopath this PR is about did not exist when they were written. Their main changes (Bun.file().slice(): enforce the slice window on unknown-size sources (chardev over-read + stream hang) #31680: buffered reads of character devices inread_file.rs; Bun.file().slice(): resolve the file size before applying the W3C relative clamp #33601: negativeslice()indices inBlob::get_slice) are independent of this.Background
FileReaderis the native source behind a file-backedReadableStream(Bun.file().stream(),new Response(file).body, and the stream HTMLRewriter or fetch wire up for a file body). A file Blob carries anoffsetand asize; for a slice these describe the window, andfrom_blob_copy_refcopies them into the reader asstart_offsetandmax_size.on_pull) may read synchronously straight into the pull buffer viaBufferedReader::read_into. Everything else comes from theBufferedReaderread loop, which delivers throughon_read_chunk: native sinks (pull_into_sink), pollable fds whose poll fired, and all reads on Windows, where reads complete through libuv.reader().close()runson_reader_done, which ends an attached sink or settles a parked read and tells the JS adapter to close;on_pullreturnsDoneoncereader().is_done(). A reader that is merely no longer being read from leaves the stream open forever, which is what the old window-exhaustedreturn falsedid.