Skip to content

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

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

Bun.file().slice(): enforce the slice window on unknown-size sources (chardev over-read + stream hang)#31680
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 26ac746windows-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.

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

2 participants