Skip to content

Bun.file().slice(): enforce the slice window on unknown-size sources (chardev over-read + stream hang) - #31680

Closed
robobun wants to merge 3 commits into
mainfrom
farm/47265618/file-slice-stream-hang
Closed

robobun wants to merge 3 commits into
mainfrom
farm/47265618/file-slice-stream-hang

Conversation

@robobun

@robobun robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

Bun.file(path).slice(start, end) does not enforce the slice window when the underlying source's stat size is unknown, and the same slice's consumers disagree with each other:

// character device
const sl = Bun.file("/dev/zero").slice(0, 1_000_000);
sl.size;                         // 1000000
(await sl.arrayBuffer()).byteLength;   // 1048576  (over-read, rounded up to the read-chunk quantum)
(await sl.text()).length;              // 1048576
for await (const c of sl.stream()) {}  // delivers exactly 1000000 bytes, then never closes

// regular file larger than the streaming read buffer
await Bun.write("/tmp/data.bin", Buffer.alloc(1024 * 1024, 0x42));
await Bun.file("/tmp/data.bin").slice(100, 1124).stream().bytes();  // never resolves

Observed sizes from the buffered consumers: slice(0, 500_000) returns 524_288, slice(0, 999_999) returns 1_048_576, slice(0, 3_000_000) returns 4_194_304. So .size (and by extension Content-Length framing) understates the delivered bytes, and "give me N random bytes from /dev/urandom" hands back N rounded up.

Fixes #31675
Fixes #18192

Why

Two independent paths:

Buffered read over-read (src/runtime/webcore/blob/read_file.rs): the POSIX do_read_loop passes self.read_off to remaining_buffer() as "bytes read so far", but never increments it (Windows ReadFileUV does). So each read is capped at max_length rather than max_length - bytes_read_so_far; the Vec capacity doubles, the 64 KB stack read fills the spare, and the loop breaks at buffer.len() >= max_length without truncating. For regular files this does not bite because the initial allocation is stat.st_size + 16 and the first read is exact; for could_block sources the initial allocation is 4 KB and the loop keeps reading.

Stream hang (src/runtime/webcore/FileReader.rs): on_read_chunk caps the stream at max_size, but the chunk that satisfies the window was truncated and delivered as a non-final chunk (has_more / close stay unset because the if buf.is_empty() branch is dead code after the total_readed >= max_size guard). The next chunk then hit total_readed >= max_size and returned false, which stops the read_with_fn drain loop without calling done() or closing the reader. Regular files are not pollable so nothing re-arms a read, and /dev/zero just keeps producing on the next pull; the pending read promise leaks and the consumer hangs.

How

  • read_file.rs: keep read_off in sync with buffer.len() so remaining_buffer() computes the actual remaining window, and truncate to max_length at the >= break.
  • FileReader.rs: treat reaching max_size as this stream's EOF. Set close = true / has_more = false when the window is satisfied, fold close into was_done so the pending path yields *AndDone, return false from the fallthrough ret, and close the reader on the already-exhausted early return.
  • PipeReader.rs: stop the posix drain loops when on_read_chunk finished the reader; the captured fd is stale after the callback closes it.

Verification

New tests in test/js/bun/util/bun-file.test.ts cover the /dev/zero and /dev/urandom slice windows across .arrayBuffer() / .bytes() / .text() / .stream() / getReader().read(), plus a 1 MiB regular file (window inside the first chunk, spanning multiple chunks, and empty). On main the buffered-read cases fail with the rounded-up byte counts and every .stream() case times out; with the fix they pass in about 6 seconds.

Supersedes #27213 (same logic applied to the pre-port FileReader.zig).


[review] gate passed · iteration 13 · 4 files touched

fails on main (without fix)
ASAN without fix: 13 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/bun-file.test.ts
bun test v1.4.0 (6a5e98a03)

test/js/bun/util/bun-file.test.ts:
(pass) delete() and stat() should work with unicode paths [326.80ms]
(pass) writer.end() should not close the fd if it does not own the fd [982.81ms]
(pass) Bun.file() read errors include async stack frames [31.44ms]
(pass) Bun.write() errors include async stack frames [35.81ms]
(pass) Bun.file().arrayBuffer() errors include async stack frames [21.55ms]
(pass) Bun.file().json() with UTF-8 BOM does not free an interior pointer [740.78ms]
173 |     const want = b - a;
174 |     const sl = Bun.file("/dev/zero").slice(a, b);
175 |     expect(sl.size).toBe(want);
176 | 
177 |     const buf = await sl.arrayBuffer();
178 |     expect(buf.byteLength).toBe(want);
                                 ^
error: expect(received).toBe(expected)

Expected: 100000
Received: 131072

      at <anonymous> (/workspace/bun/test/js/bun/util/bun-file.test.ts:178:28)
173 |     const want = b - a;
174 |     const sl = Bun.file("/dev/zero").slice(a, b);
175 |     expec
... (truncated)

release without fix: 13 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/util/bun-file.test.ts:
(pass) delete() and stat() should work with unicode paths [5.44ms]
(pass) writer.end() should not close the fd if it does not own the fd [4.98ms]
(pass) Bun.file() read errors include async stack frames [0.76ms]
(pass) Bun.write() errors include async stack frames [2.48ms]
(pass) Bun.file().arrayBuffer() errors include async stack frames [0.39ms]
(pass) Bun.file().json() with UTF-8 BOM does not free an interior pointer [385.53ms]
173 |     const want = b - a;
174 |     const sl = Bun.file("/dev/zero").slice(a, b);
175 |     expect(sl.size).toBe(want);
176 | 
177 |     const buf = await sl.arrayBuffer();
178 |     expect(buf.byteLength).toBe(want);
                                 ^
error: expect(received).toBe(expected)

Expected: 100000
Received: 131072

      at <anonymous> (/workspace/bun/test/js/bun/util/bun-file.test.ts:178:28)
184 |     expect(text.length).toBe(want);
185 |   });
186 | 
187 |   test.concurrent("buffered read returns the window's contents", async () => {
188 |     const bytes = await Bun.file("/dev/zero").slice(0, 100_000).bytes();
189 |     expect(bytes.byteLength).toBe(1
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/bun-file.test.ts
bun test v1.4.0 (6a5e98a03)

test/js/bun/util/bun-file.test.ts:
(pass) delete() and stat() should work with unicode paths [244.36ms]
(pass) writer.end() should not close the fd if it does not own the fd [795.13ms]
(pass) Bun.file() read errors include async stack frames [27.83ms]
(pass) Bun.write() errors include async stack frames [37.36ms]
(pass) Bun.file().arrayBuffer() errors include async stack frames [19.69ms]
(pass) Bun.file().json() with UTF-8 BOM does not free an interior pointer [868.46ms]
(pass) Bun.file().slice() on a character device > .arrayBuffer()/.bytes()/.text() return exactly the window [0, 1) [52.69ms]
(pass) Bun.file().slice() on a character device > .arrayBuffer()/.bytes()/.text() return exactly the window [0, 100000) [42.80ms]
(pass) Bun.file().slice() on a character device > .arrayBuffer()/.bytes()/.text() return exactly the window [0, 999999) [49.92ms]
(pass) Bun.file().slice() on a character device > .stream() delivers exactly the window [0, 1) and then closes [21.88ms]
(pass)
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     6a5e98a03b
  features     baseline

22 deps, 108 codegen, 1171 objects in 4804ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] fetch tinycc
[tinycc] up to date
[2/1234] gen ErrorCode+*.h
[3/1234] gen bindgenv2
[4/1234] gen .bind.ts → GeneratedBindings.cpp
[5/1234] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[6/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[7/1234] fetch zlib
[zlib] up to date
[8/1234] fetch picohttpparser
[picohttpparser] up to date
[9/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[11/1234] subst deps/zlib/zconf.h
[12/1234] subst deps/zlib/zlib.
... (truncated)
diff hotspot
src/io/PipeReader.rs                  | 15 +++---
 src/runtime/webcore/FileReader.rs     | 28 +++++++----
 src/runtime/webcore/blob/read_file.rs |  2 +
 test/js/bun/util/bun-file.test.ts     | 89 ++++++++++++++++++++++++++++++++++-
 4 files changed, 116 insertions(+), 18 deletions(-)

gate history · 1 passed · 0 rejected · iteration 13

evidence per changed file
file                                   reads  edits  tests
src/io/PipeReader.rs                      11      4      3
src/runtime/webcore/FileReader.rs          7     10      4
src/runtime/webcore/blob/read_file.rs      0      0      1
test/js/bun/util/bun-file.test.ts          0      0      1

@coderabbitai

coderabbitai Bot commented Jun 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR fixes sliced file stream completion on large files by stopping streaming reads after callback-driven completion and by treating exhausted slice windows as finished. It also adds a regression test for large sliced streams and reformats two documentation pages.

Changes

Sliced stream completion fix

Layer / File(s) Summary
PipeReader callback completion checks
src/io/PipeReader.rs
After on_read_chunk callbacks in blocking and non-blocking streaming loops, parent.is_done() now short-circuits further reads when the callback completes or closes the reader.
FileReader max_size completion handling
src/runtime/webcore/FileReader.rs
When the slice window is exhausted, the reader buffer is cleared, the reader is closed, was_done includes the scheduled close, and the final return state stops continued reading.
Sliced stream regression test
test/js/web/streams/streams.test.js
Adds a 1 MiB Bun.file().slice(...).stream() test covering in-chunk, cross-chunk, and empty slice ranges with byte-for-byte assertions.

Documentation formatting

Layer / File(s) Summary
Docs table and example reflow
docs/guides/util/base64.mdx, docs/runtime/web-apis.mdx
Reformats the Base64 warning example and the Web APIs support table without changing their listed content.

Possibly related PRs

  • oven-sh/bun#32921: Also changes FileReader.rs callback-driven completion behavior when the reader becomes done during JS dispatch.
  • oven-sh/bun#31674: Related file-slice streaming behavior around Response handling and sliced blob paths.

Suggested reviewers

  • dylan-conway
  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The markdown reformatting in the docs files is unrelated to the sliced FileReader bug fix and tests. Remove the docs-only formatting changes or split them into a separate cleanup PR so this PR stays focused on the stream hang fix.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The FileReader close-on-max_size fix, drain-loop stop, and added slice-stream test address the hangs described in #31675 and #18192.
Title check ✅ Passed The title clearly matches the main change: enforcing slice-window bounds and fixing related stream hangs on sliced Bun.file sources.
Description check ✅ Passed The description covers the bug, fix, and verification, though its headings don’t exactly match the template wording.

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

@robobun

robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Jul 25th, 2026

❌ @robobun, your commit 6a5e98a has 2 failures in Build #81677 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31680

That installs a local version of the PR into your bun-31680 executable, so you can run:

bun-31680 --bun

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(io): Prevent hang in sliced BunFile stream on large files #25796 - Both fix the same sliced BunFile stream hang on large files (issue stream on sliced Bunfile doesn't work #18192) by closing the FileReader when the max_size window is exhausted in on_read_chunk; fix(io): Prevent hang in sliced BunFile stream on large files #25796 targets the old Zig source while this PR targets the Rust port

🤖 Generated with Claude Code

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Re #25796: same underlying bug (#18192), but that PR patches src/bun.js/webcore/FileReader.zig, a path that no longer exists — the implementation moved to src/runtime/webcore/ and was ported to Rust, so the fix has to land in FileReader.rs (this PR). Verified this PR also covers the #21175 shape (slice(0, 16384) + pipeThrough on a multi-MB file) and windows at the 16 KiB highwater-mark boundary (16383/16384/16385).

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

I didn't find correctness issues, but this touches the core posix drain loops in PipeReader.rs (shared by all pipe/file/socket reads, including the HUP-drain path) and the Windows reader-buffer handling needed a follow-up commit — worth a human pass, and please confirm the Windows CI failure on streams.test.js is cleared by 26ac746.

Extended reasoning...

Overview

The PR fixes a hang in Bun.file(path).slice(start, end).stream() on files larger than the per-loop read buffer by treating max_size exhaustion as the stream's EOF. It changes three files: src/runtime/webcore/FileReader.rs (the on_read_chunk max_size window, was_done propagation, and the final ret computation), src/io/PipeReader.rs (adds parent.is_done() checks after on_read_chunk callbacks in three drain-loop sites so a callback that closes the reader stops further reads on the now-stale fd), and a regression test in streams.test.js.

Security risks

None apparent. No auth, crypto, or untrusted-input parsing is involved; the change adjusts internal stream-termination control flow. The buffer().clear() before close() on the already-exhausted path prevents over-delivery of bytes past the slice window on Windows, which is a correctness (not security) concern.

Level of scrutiny

Medium-high. PipeReader.rs::read_with_fn and read_blocking_pipe are the shared posix read loops used by every BufferedReader parent (subprocess stdout/stderr, shell pipes, FIFOs, sockets, file streams), and the new parent.is_done() early-returns sit in the HUP-drain path where the existing logic is already subtle (the !keep_reading && !received_hup asymmetry is documented as load-bearing). The FileReader.rs change also threads close through was_done in four result-construction branches and into the trailing ret, which interacts with the pending-read state machine and the Windows path (where chunks land in the reader's own buffer rather than the stack buffer — the second commit 26ac746 was specifically to handle that). These are not mechanical edits.

Other factors

  • CI: robobun's last status update reports streams.test.js failing on all three Windows targets for commit 8a31275; commit 26ac746 (the reader-buffer clear) was pushed afterward to address it but I don't see an updated CI result for it yet.
  • The PR description and verification section are thorough and the added test covers the small-window, multi-chunk-spanning, and empty-window cases.
  • No human reviewer has looked at this yet; given the blast radius of the PipeReader.rs drain-loop changes and the platform-specific follow-up, a human sign-off is appropriate.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed: the streams.test.js failure on the Windows lanes is cleared by 26ac746 — windows-2019-x64-test-bun, windows-2019-x64-baseline-test-bun, and windows-11-aarch64-test-bun all pass on build #59737 (70/70 checks green, umbrella still settling).

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

I didn't find any bugs, but this touches the shared POSIX drain loops in PipeReader.rs (used by subprocess/shell/file readers) and the FileReader close/done lifecycle across both platforms, so it's worth a human look at the fd-staleness and is_done() ordering changes.

Extended reasoning...

Overview

This PR fixes a hang where Bun.file(path).slice(start, end).stream() never signals completion on files larger than ~640 KiB. It modifies three files: src/runtime/webcore/FileReader.rs (treat reaching max_size as the stream's EOF — set close/was_done, clear the reader buffer, and call reader().close() on the already-exhausted early return), src/io/PipeReader.rs (in three POSIX drain-loop sites, check parent.is_done() after the on_read_chunk callback so a callback that closes the reader mid-drain stops the loop instead of reading a stale fd), and a new regression test in streams.test.js.

Security risks

None apparent. This is internal stream-completion / fd-lifecycle logic with no auth, crypto, parsing of untrusted input, or permission surface. The reader().buffer().clear() before close() actually tightens behavior (prevents past-window bytes from being delivered).

Level of scrutiny

High. PosixBufferedReader::read_with_fn and read_blocking_pipe are the shared drain loops for all POSIX buffered readers — subprocess stdout/stderr, shell pipes, FIFOs, sockets, and file streams — not just sliced Bun.file. The new parent.is_done() checks change loop-exit conditions on the HUP-drain path for every consumer of BufferedReaderVTable. The FileReader changes also alter when results are tagged *AndDone vs not, and re-enter reader().close() from inside on_read_chunk (which is itself invoked from inside a &mut BufferedReader borrow per the file's own aliasing notes). The PR author has clearly thought about this (extensive comments, cross-platform Windows buffer-clear handling, broad test-suite verification), but the interaction surface is subtle enough that a maintainer familiar with the reader lifecycle should confirm the ordering is sound.

Other factors

  • No prior claude[bot] reviews on this PR.
  • Bug-hunting system found nothing.
  • CI reported green on the latest commit per the author's follow-up (70/70 checks on build #59737, including the Windows lanes that initially failed before 26ac746).
  • Good regression test coverage for the specific bug (small window, multi-chunk window, empty window).
  • The diff is well-commented and the description is thorough, which lowers risk — but does not make the change mechanical.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI state for maintainers: every lane that actually ran is green across two consecutive builds (#59737, #59803) — Linux (glibc/musl, x64/aarch64, baseline), all Windows lanes, and the darwin build steps. The only red is darwin-14-x64/darwin-14-aarch64/darwin-26-aarch64 test jobs reporting Expired: they were never scheduled (macOS agent capacity), 0 job failures in both builds. The diff itself is green; ready for review.

@robobun

robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI state for the current head (#81677, sha 6a5e98a — comment-only trims + merge of main), now settled:

  • Zero compile errors. The Rust build completed on every lane that got an agent.
  • build-cpp agent starvation: build-cpp for darwin-x64, windows-aarch64, linux-aarch64(-musl), linux-x64-android, and freebsd-x64 never scheduled, so their dependent build-bun steps finished compiling and then timed out/failed waiting for artifacts (2 timed_out + freebsd-x64-build-bun reporting Sibling step freebsd-x64-build-cpp errored — nothing to link), cascading to 18 expired + 135 waiting_failed test jobs.
  • debian-13 x64-asan - test-bun shard: only failure is test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts SIGABRT at ExceptionScope::assertNoException — the known ASAN-only main break tracked in ASAN CI: ExceptionScope::assertNoException during worker terminate (worker-transfer-terminate-stress, separate from #34095) #34690, unrelated to file streams. That shard does not contain this PR's test.
  • The ASAN shards that did get agents are otherwise 10/10 passed.

The diff is 4 files (FileReader.rs, PipeReader.rs, blob/read_file.rs, bun-file.test.ts); all review threads resolved; four bot reviews found no issues.

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

I didn't find any bugs and CI is green, but this changes shared drain-loop control flow in PosixBufferedReader (used by subprocess/shell/socket/FIFO readers, not just sliced files) and adds a close-from-inside-callback re-entrancy path — worth a human look at the is_done() early-returns and the Windows buffer().clear() before close().

Extended reasoning...

Overview

The PR fixes a hang in Bun.file(path).slice(start, end).stream() where the stream delivers the slice's bytes but never signals done on files larger than the per-loop read buffer. It touches three files: src/runtime/webcore/FileReader.rs (treat reaching max_size as EOF in on_read_chunk, propagate close into was_done for all pending-result variants, and close the reader on the already-exhausted early return), src/io/PipeReader.rs (three new parent.is_done() checks after on_read_chunk in the streaming drain loops so a callback that closes the reader stops the loop instead of reading a stale fd), and a new regression test in streams.test.js.

Security risks

None identified. This is internal stream-termination/control-flow logic; no parsing, auth, crypto, or user-controlled-path handling is introduced. The change strictly narrows behavior (stops reading earlier when the window is satisfied) rather than exposing new surface.

Level of scrutiny

Moderate-to-high. The FileReader.rs half is scoped to the max_size (sliced-file) path and is straightforward to reason about. The PipeReader.rs half, however, modifies the streaming drain loops in read_with_fn and read_blocking_pipe — code shared by every BufferedReader consumer (subprocess stdout/stderr, shell pipes, sockets, FIFOs), with extensive existing commentary about noalias laundering and re-entrancy hazards. The new pattern — on_read_chunk calling self.reader().close() from inside the reader's own drain loop, then the loop detecting that via is_done() and bailing — is a re-entrancy interaction that someone familiar with this layer's invariants should confirm, particularly the HUP-drain paths where the previous logic deliberately kept reading even when the callback returned false.

Other factors

The PR description is thorough, the new test exercises three window shapes (in-first-chunk, spanning-chunks, empty), and CI is green across Linux/Windows/macOS (the only red lanes are unrelated macOS agent-provisioning failures). The author also ran the subprocess/shell/spawn streaming suites. The Windows-specific reader().buffer().clear() before close() in the early-return path has a plausible rationale comment but is the kind of platform-asymmetric detail that benefits from a maintainer's eye. No CODEOWNERS apply to these paths.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (9487c65, clean merge) and re-verified. The bug still reproduces on today's main: the repro below hangs forever under 1.4.0-canary.1, so this is still live.

await Bun.write(p, new Uint8Array(1024 * 1024));
for await (const c of Bun.file(p).slice(100, 164).stream()) console.log(c.length); // 64, then never exits

Re-ran the proof against the merged head:

  • test/js/web/streams/streams.test.js -t "buffered consumption resolves": hangs (5s timeout) with main's src/, passes in 329ms with this diff.
  • Suites that share the touched PipeReader.rs drain loops all pass with the debug build: shell-blocking-pipe.test.ts, spawn-streaming-stdout.test.ts, spawn-streaming-stdin.test.ts, readable-stream-blob-consumed.test.ts, and body-stream.test.ts (9086/9086).
  • main picked up webcore: pin the FileReader across re-entrant cancel in on_read_chunk and on_reader_error #32921 since this PR's base, which wraps on_read_chunk's p.run() and close_if_needed!() in an increment_count/decrement_count pin. That composes correctly with the close this PR makes live: the re-entrant on_reader_done from the deferred reader().close() now runs under that pin.

On the previous build (#59919): every red lane was macOS agent or container infra (squid did not start, role "bun_sql_test" does not exist, autobahn container, docker build --progress unsupported on the darwin-26 agents). None of the failures came from this PR's test or touched files.

This is ready for a maintainer. The fresh CI run from this push should confirm.

@mintlify

mintlify Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 29, 2026, 3:19 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Re the out-of-scope warning and the Mintlify preview: the two docs files in the diff are not hand edits from this change. autofix.ci pushed b3e52f1 after the branch was updated with main, reformatting docs/guides/util/base64.mdx and docs/runtime/web-apis.mdx, which landed on main (#33040, #33065) without the formatter applied. Reverting that here would just get re-applied by autofix.ci on the next push; it drops out of the diff once main itself is formatted. The functional change is still only FileReader.rs, PipeReader.rs, and the regression test.

Comment thread src/io/PipeReader.rs Outdated
Comment thread src/io/PipeReader.rs Outdated
Comment thread src/io/PipeReader.rs Outdated
Comment thread src/runtime/webcore/FileReader.rs Outdated
Comment thread src/runtime/webcore/FileReader.rs Outdated
Comment thread src/runtime/webcore/FileReader.rs Outdated
@robobun robobun changed the title Close the FileReader when a sliced Bun.file stream exhausts its max_size window Bun.file().slice(): enforce the slice window on unknown-size sources (chardev over-read + stream hang) Jul 25, 2026
When the underlying file has no stat size (a character device like
/dev/zero or /dev/urandom, or a regular file larger than the streaming
read buffer) the same sliced Blob disagreed between consumers:

- .arrayBuffer()/.bytes()/.text() returned more than the slice, rounded
  up to the internal read-chunk quantum: slice(0, 1_000_000) returned
  1_048_576 bytes. The POSIX do_read_loop never advanced read_off, so
  remaining_buffer capped each read at max_length instead of
  max_length - bytes_read_so_far; the loop broke at >= max_length
  without truncating. Track progress via buffer.len() and truncate at
  the break.

- .stream() delivered exactly end - start bytes and then never closed:
  on_read_chunk truncated the chunk to the window but left close /
  has_more unset, so the final chunk was delivered as non-final and the
  already-at-max early return stopped the drain loop without closing
  the reader or resolving the pending read. Nothing re-arms a read on a
  regular file and /dev/zero never drains, so the consumer pends
  forever. Treat reaching max_size as the stream's EOF: deliver the
  chunk as done, close the reader, and stop the drain loop when the
  callback finishes the reader.

Fixes #31675
Fixes #18192
@robobun
robobun force-pushed the farm/47265618/file-slice-stream-hang branch from 54e09a6 to c0ddfaa Compare July 25, 2026 22:22
Comment thread src/io/PipeReader.rs Outdated
Comment thread src/runtime/webcore/FileReader.rs Outdated
Comment thread src/runtime/webcore/FileReader.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.

I didn't find bugs, but this touches the shared PosixBufferedReader drain loops (used by shell, subprocess stdio, and file streams) plus re-entrant reader().close() inside on_read_chunk, so it's worth a maintainer look.

What was reviewed:

  • read_file.rs: read_off = buffer.len() + truncate — checked that read_off has no other POSIX-side reader and max_length as usize matches the existing >= guard.
  • FileReader.rs: traced close_if_needed!() → reader().close() → on_reader_done() refcount flow; the JS-finalizer ref keeps the Source box live so the caller's post-callback parent.is_done() in PipeReader.rs is not a UAF.
  • PipeReader.rs: checked the un-touched on_read_chunk sites (the >128_000 flush and the read_blocking_pipe capacity>0 branch) — both already return on !keep_going, and the new ret = !close && ... makes that fire when the window is exhausted.
Extended reasoning...

Overview

Fixes two independent bugs in Bun.file(path).slice(start, end) on sources whose stat size is unknown: (1) the buffered POSIX do_read_loop never advanced read_off, so remaining_buffer() capped each read at max_length instead of max_length - bytes_read and over-read to the next chunk quantum; (2) FileReader::on_read_chunk truncated the window-satisfying chunk but never marked it final, so the stream delivered the right bytes and then hung. Touches src/runtime/webcore/blob/read_file.rs (2 lines), src/runtime/webcore/FileReader.rs (~10 lines across the max_size guard, was_done computation, and the fallthrough ret), and src/io/PipeReader.rs (3 is_done() short-circuits after on_read_chunk callbacks in the POSIX drain loops). New tests cover /dev/zero, /dev/urandom, and a 1 MiB regular file across .arrayBuffer()/.bytes()/.text()/.stream()/getReader().

Security risks

None identified. The change tightens an over-read (delivers fewer bytes, exactly what the slice requested) and closes a hang. No new user-controlled input reaches size arithmetic; max_size/max_length were already the bounds — this just enforces them.

Level of scrutiny

High. PosixBufferedReader::read_with_fn / read_blocking_pipe are the shared drain loops for shell pipes, subprocess stdio, and file streams; a mis-step here hangs or double-closes across all of them. FileReader::on_read_chunk now calls self.reader().close() from inside a vtable callback while the caller holds &mut BufferedReader — the exact re-entrancy shape the R-2 laundering comments in PipeReader.rs warn about. I traced the refcount flow (waiting_for_on_reader_done + the #32921 pin around p.run()/close_if_needed!() + the JS-finalizer ref) and believe the Source box stays live through the post-callback parent.is_done() read, but this is subtle enough that a maintainer who owns the BufferedReaderParent aliasing contract should confirm.

Other factors

  • The PR author already ran the shared-consumer suites (shell-blocking-pipe, spawn-streaming-stdout/stdin, body-stream) against the debug build and reports green; CI across Linux/Windows/macOS is green on multiple builds.
  • The comment-cop bot flagged verbose comments, which were trimmed in 6a5e98a (all threads resolved).
  • The read_blocking_pipe capacity>0 streaming branch and the >128_000 flush in read_with_fn were not given explicit is_done() guards, but both already return on !keep_going, and on_read_chunk now returns false (ret = !close && ...) when it closes — so those sites stop correctly without the extra check.
  • I did not find a path where the early-return total_readed >= max_size block reaches !is_done() on POSIX (the prior chunk's close_if_needed!() should have closed it), but it's reachable on Windows where the read continuation is callback-driven, and the guard is harmless either way.

alii pushed a commit that referenced this pull request Aug 16, 2026
… the stream when it is used up (#39201)

### Problem
- `new Response(Bun.file(path).slice(0, 5)).body` and
`Bun.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 #38886.
- Cause: the slice window (`FileReader.max_size` / `total_readed`) is
only applied in `FileReader::on_read_chunk`
(`src/runtime/webcore/FileReader.rs:637`). #38886 made
`FileReader::on_pull` read straight into the pull buffer with
`IOReader::read_into` (`FileReader.rs:832`), which does not go through
`on_read_chunk`, and on POSIX that is the path every pull of a regular
file takes.
- Pre-existing, same mechanism: when the window was used up,
`on_read_chunk` returned `false` without closing the reader, so a slice
of a file that continues past it never finished on the paths that still
go through `on_read_chunk` (native sinks such as HTMLRewriter, pollable
fds, Windows), and before #38886 on every path (#18192, #31675).

### Fix
- Both delivery paths share one window (`window_remaining` /
`consume_window`): `on_pull` cuts the `read_into` destination to what is
left of the window and charges what was read; `on_read_chunk` truncates
the chunk to it, as before.
- Whichever path uses the window up closes the reader (`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, then `on_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_into` reads nothing into an empty destination).
- Correct because the window is the blob's contract:
`ReadableStream::from_blob_copy_ref` sets `start_offset`/`max_size` from
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 (`pread` from `start_offset`); only the end of the window
was lost.
- Fixes #18192 and #31675 as a consequence: the window end now ends the
stream instead of leaving the reader open.
- Verified: `bun bd test test/js/web/fetch/blob.test.ts
test/js/bun/util/bun-stdin-slice.test.ts` (108 pass). The new
`blob.test.ts` cases under "a slice of a file that continues past it"
cover `.stream()` + `for await`, `.stream().bytes()`,
`Response(...).body` (all `read_into` pulls on POSIX, `on_read_chunk` on
Windows) and `HTMLRewriter.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 new `bun-stdin-slice.test.ts` cases stream
`Bun.stdin.slice(0, N)` over a pipe that is never closed, in one write
and in two, which is the parked-read branch of `on_read_chunk`. Against
a build with main's `FileReader.rs`, 20 of the 29 `blob.test.ts` cases
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.
- Also run with the fix: `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`, the `spawn` stdio stream tests,
`child_process.test.ts`, `process-stdin.test.ts`,
`fetch-file-upload.test.ts`, `bun-serve-file.test.ts`; manual checks of
`Bun.stdin.stream()` / `process.stdin` over a pipe and a file redirect,
a FIFO slice whose writer stays open, and `/dev/zero` / `/dev/urandom`
slices (now deliver exactly the slice; previously unbounded on main,
hung before #38886). `cargo check -p bun_runtime` for the Windows and
macOS targets.
- Not a replacement for #31680 or #33601: both predate #38886 and carry
a patch of the old `on_read_chunk` block for the window-end hang, which
this PR makes unnecessary, but the `read_into` path this PR is about did
not exist when they were written. Their main changes (#31680: buffered
reads of character devices in `read_file.rs`; #33601: negative `slice()`
indices in `Blob::get_slice`) are independent of this.

### Background
- `FileReader` is the native source behind a file-backed
`ReadableStream` (`Bun.file().stream()`, `new Response(file).body`, and
the stream HTMLRewriter or fetch wire up for a file body). A file Blob
carries an `offset` and a `size`; for a slice these describe the window,
and `from_blob_copy_ref` copies them into the reader as `start_offset`
and `max_size`.
- It gets bytes two ways. A JS pull (`on_pull`) may read synchronously
straight into the pull buffer via `BufferedReader::read_into`.
Everything else comes from the `BufferedReader` read loop, which
delivers through `on_read_chunk`: native sinks (`pull_into_sink`),
pollable fds whose poll fired, and all reads on Windows, where reads
complete through libuv.
- The stream only ends when the reader reports done: `reader().close()`
runs `on_reader_done`, which ends an attached sink or settles a parked
read and tells the JS adapter to close; `on_pull` returns `Done` once
`reader().is_done()`. A reader that is merely no longer being read from
leaves the stream open forever, which is what the old window-exhausted
`return false` did.
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

#39201 (merged as eec9c8b) rewrote the window handling in FileReader.rs on top of #38886 and closes #18192 and #31675 for the stream path, so the FileReader.rs half of this PR is no longer needed and will conflict on rebase. The read_file.rs half (buffered reads of a sliced character device rounding up to the read quantum; /dev/zero slice(0, 1e6) still returns 1048576 bytes on main) is still a live bug and still the only fix for it, so this PR is worth rebasing down to that change plus its buffered-read tests.

@alii alii closed this Aug 16, 2026
Jarred-Sumner pushed a commit that referenced this pull request Aug 16, 2026
…after it (#39296)

Follow-up to #39201, where moving the slice window into the reader was
agreed as a separate PR.

### Problem
- Streaming a slice of a pipe, socket or stdin consumes bytes past the
slice. With `0123456789` written to a child's stdin, `for await (const c
of Bun.stdin.slice(0, 3).stream())` delivers `012` and whoever reads
stdin next gets nothing: the other 7 bytes were read and dropped. Same
through a native sink (`HTMLRewriter.transform(new
Response(Bun.stdin.slice(0, 3)))`). With 60000 bytes written, the stream
leaves 0 of them in the pipe. Same on Windows.
- `Bun.stdin.slice(0, 0).stream()` (any empty slice of a pollable
source) never ends while the pipe stays open and silent; it ends only
when a byte arrives, which it drops, or at EOF. Same through a native
sink.
- `Bun.serve` responding with `Bun.file(p).slice(0, 2048)` of a 4 KiB
file issues two `read()`s per response (a 256 KiB request that returns
the whole file, then an EOF probe) and ends the response from a queued
task. Harmless for regular files, but it is the same after-the-fact
window as above, implemented a second time.
- Cause: the window is applied by the reader's parents after the bytes
have been read. `FileReader::on_read_chunk` cuts the delivered chunk
down (`src/runtime/webcore/FileReader.rs:636`); the only place that cut
the read itself was `on_pull`'s `read_into` branch
(`FileReader.rs:849`), which is skipped whenever a poll is registered,
i.e. for every pollable fd, and `read_into` is a stub on Windows. The
read loop in `src/io/PipeReader.rs` reads up to its 256 KiB scratch
buffer (`fill_scratch`, line 653) and the Windows reader hands libuv its
whole spare capacity (`get_read_buffer_with_stable_memory_address`, line
1166); neither knows about the window. The empty-window hang is the same
gap: the check lived on the `read_into` branch, and a pollable source
parks on its poll before reaching it. `FileResponseStream` keeps a third
copy of the window (`src/runtime/server/FileResponseStream.rs:277`) and
needs the `FileResponseStreamEof` task to end itself.

### Fix
- `PosixBufferedReader` and `WindowsBufferedReader` get a `ReadLimit`
next to the pread offset, set with `set_limit` before starting.
`read_once` (every POSIX read: loop, buffer and `read_into`) and the
libuv buffer on Windows cut each read to what is left of it, and
charging it down to zero is reported the way EOF already is (`Stop::Eof`
/ `ReadState::Eof`, then `close` and `on_reader_done`). A limit that
starts at zero makes `start` register no poll and issue no read; the
parent's first read request (`read`, `read_into`, Windows `unpause`)
reports EOF without touching the fd.
- `FileReader` passes the blob's size to the reader at start and
`FileResponseStream` passes the body length; both now treat the end of
the window exactly like EOF, because that is what the reader reports.
Deleted: `FileReader`'s `total_readed` / `window_remaining` /
`consume_window` / `end_at_window`, `FileResponseStream`'s `max_size`
and the `FileResponseStreamEof` task (tag, dispatch arm, `Taskable`
impl), and `Chunk::truncate`, which only existed for this.
- `FileResponseStream` implements the reader's `ref_` / `deref` (as
`FileReader` does). The chunk that fills the window is now followed by
`on_reader_done`, which releases the stream's owning ref, while the
reader's read loop (POSIX) or libuv completion (Windows, which re-checks
the reader's flags after delivering a data read) is still on the stack
with the reader embedded in the stream; the pin keeps the stream alive
until those frames return. This is the job the queued task used to do.
- Why this is the right place: the window is a bound on how many bytes
may be taken out of the source, and on anything that is not seekable the
bytes a read returns are gone, so the bound has to be applied to the
read, and only the reader knows whether a read is needed at all.
Reporting it as EOF is correct because for this reader the source does
end there, and every consumer already handles EOF (real EOF and
`maxBuffer` exhaustion end streams through the same path), which is what
lets the two parent implementations (three code paths, two of which
#39201 had to bring back into agreement) go away. For regular files the
delivered bytes are unchanged; only the reads get shorter.
- Verified with `bun bd test test/js/bun/util/bun-stdin-slice.test.ts
test/js/bun/http/serve-file-slice-read-error.test.ts`. The five new
`bun-stdin-slice` cases run the slice in a grandchild sharing the
child's stdin and then read what is left: a write larger than the window
and an empty window with nothing written, each via `stream()` and via
HTMLRewriter, plus a window filled by a write larger than its remainder.
With `src/` stashed, the three over-read cases get `""` instead of
`3456789` / `456` back and the two empty-window cases time out; with the
fix all pass on Linux and on a Windows x64 debug build (the block is not
Windows-skipped; the same protocol reproduces the over-read with the
current Windows canary). `serve-file-slice-read-error.test.ts` kept its
ptrace read counter: 3 responses now take 3 `read()`s and the EIO
injected into the second one fails that response alone (`DONE 2`);
without the fix it is 6 reads and `DONE 3`.
- Also run with the fix: `blob.test.ts` (the #39201 window cases:
`read_into`, sink, Windows `on_read_chunk`, empty window on a regular
file), `bun-serve-file`, `bun-serve-static`, `serve-directory-routes`
(empty files give `FileResponseStream` a zero length), `bun-file*`,
`spawn.test.ts`, `spawn-maxbuf` (the budget shares `read_once` /
`on_read`), `spawn-streaming-stdout`, `spawn-stream-serve`,
`spawn-stdout-filereader-gc-uaf`, `streams.test.js`, `html-rewriter*`,
`body*.test.ts`, `process-stdin`, `bun-write`; on Windows additionally
`blob`, `bun-serve-file`, `bun-serve-static`, `spawn-maxbuf`,
`bun-file*`. `cargo fmt` clean.

### Background
- `BufferedReader` (`src/io/PipeReader.rs`, one POSIX and one libuv
implementation behind a cfg alias) reads an fd on behalf of a parent
embedded around it and reports through a vtable: `on_read_chunk` per
read, then `on_reader_done` at EOF or `on_reader_error`. `FileReader` is
the parent behind a file-backed `ReadableStream` (`Bun.file().stream()`,
`new Response(file).body`, the stream a native sink such as HTMLRewriter
or a fetch body pulls from); `FileResponseStream` is the parent that
writes a `Bun.serve` file body to the socket.
- A file blob carries an offset and a size; for a slice these describe
the window. `ReadableStream::from_blob_copy_ref` copies them into
`FileReader` as `start_offset` / `max_size`; `Bun.serve` computes the
body's offset and length from the blob (or a Range header) and passes
them to `FileResponseStream`. The offset was already the reader's job
(`start_file_offset`); this PR gives it the length as well.
- On POSIX a pollable fd (pipe, socket, tty) is read when its poll
fires, from the reader's own loop; a regular file is read synchronously
when the parent asks (`read`, or `read_into` straight into the JS pull
buffer). `FileReader::on_pull` only uses `read_into` when no poll is
registered, which is why a window applied there never affected pollable
sources. On Windows every read completes through libuv into a buffer the
reader allocates up front, so that allocation is where a read's size is
decided.
- `ref_` / `deref` on the parent vtable are taken by the reader around a
read dispatch (`ParentKeepAlive`) because the reader touches its own
fields after calling into the parent; a parent whose `on_reader_done`
can free it needs them so that free happens after the dispatch returns.
`maxBuffer` (`MaxBuf`) is the reader's other byte bound; the limit is
charged and reported alongside it.

<details>
<summary>Related open PRs</summary>

- #39257 (alii) fixes the empty-window hang inside `FileReader` with the
helpers this PR deletes; if it lands first this PR will be rebased to
drop those two guards (the reader covers them) and keep its tests, which
pass here as well. The over-read is not covered there.
- #31680 and #33510 are about the buffered (`.text()` /
`.arrayBuffer()`) path in `blob/read_file.rs`, which does not use
`BufferedReader`; #31680's `FileReader.rs` hunk was made unnecessary by
#39201.
- #34185 wanted `FileResponseStream`'s remaining count to tell a short
file from a complete one; with this PR that is the bytes it has written
versus the length it was given.
</details>
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.

file.slice(a, b).stream() buffered consumption never resolves for ~1MB+ files stream on sliced Bunfile doesn't work

3 participants