Skip to content

io(posix): bound the streaming read loop so always-ready sources yield to the event loop - #35519

Closed
robobun wants to merge 8 commits into
mainfrom
farm/d40b42e7/filereader-chardev-stream-wedge
Closed

io(posix): bound the streaming read loop so always-ready sources yield to the event loop#35519
robobun wants to merge 8 commits into
mainfrom
farm/d40b42e7/filereader-chardev-stream-wedge

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

Bun.file("/dev/urandom").stream().getReader().read() wedges the event loop on POSIX: the first read() never resolves, timers never fire, and RSS grows ~1 GB/s until OOM. Same for /dev/zero, /dev/full, and new Response(Bun.file(dev)).body.

let ticks = 0; setInterval(() => ticks++, 25);
setTimeout(() => { console.log("WATCHDOG ticks=" + ticks); process.exit(0); }, 3000);
const { value } = await Bun.file("/dev/urandom").stream().getReader().read();
console.log("read() resolved", value?.length); // never prints; watchdog never fires

strace on the stuck thread: back-to-back preadv2(fd, [{iov_len=262144}], 1, -1, RWF_NOWAIT) = 262144, no epoll.

Cause

PosixBufferedReader::read_with_fn's streaming stack-buffer path is an unbounded loop that only exits on read() == 0, EAGAIN, or the consumer returning false:

while parent._buffer.capacity() == 0 {
    while ... {
        sys_fn(fd, buf);  // preadv2(RWF_NOWAIT) for NonblockingPipe
        if bytes_read == 0 { close; done; return; }
        if past_cutoff { on_read_chunk(...); head_start = 0; }  // loops forever
    }
}

/dev/urandom, /dev/zero and friends never return 0 and never EAGAIN, so the loop spins on the JS thread forever. FileReader::on_read_chunk would normally return false once buffered >= highwater_mark, but that check is gated on !reader_is_pollable(), so for pollable sources it always returns true and every 256 KiB chunk is appended to FileReader::buffered without bound.

Fix

Bound the loop and apply backpressure to pollable sources:

  • read_with_fn: after delivering one stack-buffer chunk, re-arm the poll (if the consumer wants more) and return instead of looping. Pipes, sockets and TTYs are unaffected in practice since they hit EAGAIN before the cutoff; always-ready sources now yield once per chunk.
  • FileReader::on_read_chunk: drop the !reader_is_pollable() gate so the highwater-mark check applies to pollable readers too. Returning false makes read_with_fn return without re-arming, so an idle stream stops buffering at ~one chunk.
  • PosixBufferedReader::has_pending_read: use is_watching() so a one-shot poll that has fired and not been re-armed counts as no pending read; on_pull then takes the synchronous read path (which re-arms on EAGAIN) instead of parking on a dead poll.

This keeps event-driven character devices (/dev/input/*, /dev/hidraw*, tun/tap) on the pollable path where EAGAIN re-arms the poll as before.

Verification

$ bun bd test test/js/bun/util/bun-file.test.ts -t "infinite chardev"
(pass) Bun.file(dev).stream() on /dev/urandom [1399ms]
(pass) new Response(Bun.file(dev)).body on /dev/zero [1576ms]
(pass) Bun.file('/dev/null').stream() EOFs immediately [10ms]

On stock bun the /dev/urandom and /dev/zero cases wedge and are SIGTERM'd by the hang guard.


[review] gate passed · iteration 3 · 3 files touched

fails on main (without fix)
ASAN without fix: 2 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 (e71ca610d)

test/js/bun/util/bun-file.test.ts:
(pass) delete() and stat() should work with unicode paths [60.82ms]
(pass) writer.end() should not close the fd if it does not own the fd [610.78ms]
(pass) Bun.file() read errors include async stack frames [35.29ms]
(pass) Bun.write() errors include async stack frames [61.73ms]
(pass) Bun.file().arrayBuffer() errors include async stack frames [22.53ms]
(pass) Bun.file().json() with UTF-8 BOM does not free an interior pointer [649.36ms]
203 |           stderr: "pipe",
204 |           signal: AbortSignal.timeout(hangGuard),
205 |         });
206 |         const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
207 | 
208 |         expect({ stderr, exitCode, signalCode: proc.signalCode }).toEqual({
                                                                        ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "signalCode": null,
+   "exitCode": 143,
+   "signa
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (979ee4bee)

test/js/bun/util/bun-file.test.ts:
(pass) delete() and stat() should work with unicode paths [19.18ms]
(pass) writer.end() should not close the fd if it does not own the fd [11.47ms]
(pass) Bun.file() read errors include async stack frames [0.62ms]
(pass) Bun.write() errors include async stack frames [1.56ms]
(pass) Bun.file().arrayBuffer() errors include async stack frames [0.29ms]
(pass) Bun.file().json() with UTF-8 BOM does not free an interior pointer [27.23ms]
(pass) Bun.file(<infinite chardev>).stream() yields to the event loop > Bun.file(dev).stream() on /dev/urandom [55.43ms]
(pass) Bun.file(<infinite chardev>).stream() yields to the event loop > new Response(Bun.file(dev)).body on /dev/zero [50.56ms]
(pass) Bun.file(<infinite chardev>).stream() yields to the event loop > Bun.file('/dev/null').stream() EOFs immediately [0.45ms]

 9 pass
 0 fail
 64 expect() calls
Ran 9 tests across 1 file. [480.00ms]
__F:0:S:0
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 (e71ca610d)

test/js/bun/util/bun-file.test.ts:
(pass) delete() and stat() should work with unicode paths [70.79ms]
(pass) writer.end() should not close the fd if it does not own the fd [603.21ms]
(pass) Bun.file() read errors include async stack frames [37.75ms]
(pass) Bun.write() errors include async stack frames [38.00ms]
(pass) Bun.file().arrayBuffer() errors include async stack frames [20.11ms]
(pass) Bun.file().json() with UTF-8 BOM does not free an interior pointer [671.31ms]
(pass) Bun.file(<infinite chardev>).stream() yields to the event loop > Bun.file(dev).stream() on /dev/urandom [1615.77ms]
(pass) Bun.file(<infinite chardev>).stream() yields to the event loop > new Response(Bun.file(dev)).body on /dev/zero [1424.23ms]
(pass) Bun.file(<infinite chardev>).stream() yields to the event loop > Bun.file('/dev/null').stream() EOFs immediately [10.66ms]

 9 pass
 0 fail
 64 expect() calls
Ran 9 tests across 1 file. [7.84s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1128ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/138] gen ErrorCode+*.h
[2/138] gen bindgenv2
[3/138] gen cpp.rs (cppbind)
[4/138] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[5/138] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /w
... (truncated)
diff hotspot
src/io/PipeReader.rs              | 40 ++++++++++-----------
 src/runtime/webcore/FileReader.rs | 22 ++----------
 test/js/bun/util/bun-file.test.ts | 76 +++++++++++++++++++++++++++++++++++++--
 3 files changed, 95 insertions(+), 43 deletions(-)

gate history · 3 passed · 3 rejected · iteration 3

evidence per changed file
file                               reads  edits  tests
src/io/PipeReader.rs                   7      7      0
src/runtime/webcore/FileReader.rs      5      7      0
test/js/bun/util/bun-file.test.ts      3      8      0

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR updates buffered file-stream polling and drain continuation, simplifies FileReader continuation checks, and adds POSIX subprocess coverage for infinite character devices and /dev/null EOF behavior.

Buffered file stream lifecycle

Layer / File(s) Summary
Polling and drain control
src/io/PipeReader.rs
PosixBufferedReader uses the poll’s watching state and changes streaming re-drain, re-registration, and return conditions.
FileReader continuation rules
src/runtime/webcore/FileReader.rs
Windows lazy-blob setup no longer sets POLLABLE, and read continuation uses the pull mode and highwater mark.
Infinite character-device coverage
test/js/bun/util/bun-file.test.ts
POSIX subprocess tests cover bounded reads, event-loop timers, cancellation, RSS limits, and immediate /dev/null EOF.

Possibly related PRs

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: bounding POSIX streaming reads so always-ready sources yield to the event loop.
Description check ✅ Passed The description covers what the PR does and how it was verified, even though it uses custom section headings instead of the template.

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 AM PT - Jul 25th, 2026

@autofix-ci[bot], your commit e71ca61 has 2 failures in Build #80232 (All Failures):

  • 📦 Binary size — 8 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.82 MB84.31 MB+528.0 KB
    bun-windows-x6480.26 MB79.70 MB+570.0 KB
    bun-windows-aarch6470.86 MB70.34 MB+532.5 KB

    Add [skip size check] to the commit message if this increase is intentional.

  • step failed outside runner - exit 1 on 🐧 aarch64-android - build-bun
  • step failed outside runner - exit 1 on 🐧 x64 - build-bun
  • step failed outside runner - exit 1 on 🐧 aarch64 - build-bun
  • step failed outside runner - exit 1 on 🍎 x64 - build-bun
  • step failed outside runner - exit 1 on 🐧 x64-asan - build-bun

🧪   To try this PR locally:

bunx bun-pr 35519

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

bun-35519 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. fs.readFile() on non-terminating sources never settles and can consume unbounded memory #29184 - fs.readFile('/dev/urandom') never settles and consumes unbounded memory — same root cause of non-TTY character devices being misclassified as pollable

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

Fixes #29184

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Re the find-issues suggestion: #29184 is about fs.readFile('/dev/urandom'), which goes through src/runtime/node/fs/ and does not touch FileReader::open_file_blob. Checked both before and after this change: the event loop stays alive in both cases and the promise never settles in both cases, so this PR doesn't change that behavior. Leaving it open.

Comment thread src/runtime/webcore/FileReader.rs Outdated
Comment thread src/runtime/webcore/FileReader.rs Outdated
Comment thread test/js/bun/util/bun-file.test.ts
Comment thread test/js/bun/util/bun-file.test.ts
Comment thread test/js/bun/util/bun-file.test.ts Outdated
Comment thread test/js/bun/util/bun-file.test.ts Outdated
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/io/PipeReader.rs Outdated
@robobun robobun changed the title FileReader: route non-TTY character devices through the non-pollable File path io(posix): bound the streaming read loop so always-ready sources yield to the event loop Jul 25, 2026
@robobun
robobun force-pushed the farm/d40b42e7/filereader-chardev-stream-wedge branch from 37f95ce to a607336 Compare July 25, 2026 03:33

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/io/PipeReader.rs:896-911 — Bounding the streaming read loop to one stack-buffer chunk per call breaks Bun.serve's FileResponseStream for regular files >~128 KB: after the first chunk, file_type == File skips register_poll() and returns, but FileResponseStream is push-driven and only re-enters reader.read() from on_writable, which is registered solely on the Backpressure arm — so WriteResult::WantMore (the common case) leaves nothing armed and the response hangs after ~128–256 KB. This regresses new Response(Bun.file(path)) and static routes: {"/": Bun.file(...)} on macOS (all transports), Linux TLS/H2/H3, and Linux plain HTTP for files 128 KB–1 MB (below the sendfile threshold). For file_type == File with keep_going == true, keep the old head_start = 0; continue behaviour — regular files hit bytes_read == 0 in bounded time, and /dev/urandom is classified NonblockingPipe so it already takes the register_poll()+return path.

    Extended reasoning...

    What the bug is

    Commit ce697ed changed PosixBufferedReader::read_with_fn's streaming stack-buffer branch from an unbounded drain loop (while _buffer.capacity() == 0 { … head_start = 0; continue }) to a bounded one: after delivering one ≥128 KiB chunk it does

    if keep_going && file_type != FileType::File {
        parent.register_poll();
    }
    return;

    For pollable file types this is fine — the re-armed poll re-drives on_pollread_with_fn. For FileType::File (non-pollable regular files) register_poll() is correctly skipped, so the caller is responsible for re-invoking reader.read(). FileReader does this: the next JS pull() calls on_pullreader().read(). But FileResponseStream — the Bun.serve path for return new Response(Bun.file(path)) and routes: { "/x": Bun.file(...) } — is push-driven: start() calls reader.read() exactly once (FileResponseStream.rs:228-229), and the only re-driver is on_writable (line 351-352), which is registered only when resp.write() returns WriteResult::Backpressure (line 288-298). When resp.write() returns WriteResult::WantMore — the common case on localhost, HTTP/2 stream buffers, or any socket with a send buffer ≥256 KB — on_read_chunk returns true (line 303) and nothing is armed on the event loop.

    Step-by-step trace

    For Bun.serve({fetch: () => new Response(Bun.file("big.bin"))}) with a 500 KB regular file, on macOS (or Linux TLS/H2/H3, or Linux plain HTTP for a file <1 MB — anything where can_sendfile() at FileResponseStream.rs:559 returns false):

    1. RequestContext computes file_type=File, pollable=false and calls FileResponseStream::start with use_sendfile=false.
    2. start sets POLLABLE=false, calls reader.start(fd, false)handle = PollOrFd::Fd(fd), _buffer cleared (capacity 0), no poll. Then hold_read_ref(); reader.read().
    3. read()get_file_type()==Fileread_fileread_with_fn(File, fd, 0, received_hup=false, sys::read). is_streaming_enabled()==true (has_on_read_chunk=true at FileResponseStream.rs:536), _buffer.capacity()==0 → enters the new bounded block.
    4. stack_buffer_len=256 KiB, stack_buffer_cutoff=128 KiB. First sys::read fills ~256 KiB → head_start≈256 KiB(256K−256K)=0 < 128K → cutoff branch at PipeReader.rs:879.
    5. on_read_chunk(256 KiB, Progress)FileResponseStream::on_read_chunk: max_size decrements 500K→244K (≠0, no eof_task), state==Progress skips the EOF end() branch, resp.write(256K)WriteResult::WantMorereturns true. on_writable is not registered.
    6. Back at PipeReader.rs:896-911: received_hup==false → skip the HUP continue. keep_going==true && file_type != File is falseregister_poll() skipped. return.
    7. Control returns to FileResponseStream::start line 229. Nothing is armed: no poll (File is non-pollable), no on_writable (only registered on Backpressure), no eof_task (max_size≠0). READ_REF_HELD keeps the allocation alive so it isn't even finalized.
    8. The client receives one ~128–256 KB chunk and the remaining ~244 KB are never read. resp.end() is never called; the connection hangs until the server's idle timeout aborts it with a truncated body.

    Files <128 KB escape (the first read leaves >128 KiB headroom, the inner while iterates, the second read returns 0 → EOF path). Files ≥128 KB stall.

    Why nothing else catches it

    The only re-driver of reader.read() on the File path is FileResponseStream::on_writable, and it is registered exclusively on the Backpressure arm. WantMore has no continuation. can_sendfile() (FileResponseStream.rs:559-582) returns false unconditionally on macOS, and on Linux for anything except plain-TCP + FileType::File + length≥1 MB — so macOS all files, Linux TLS/H2/H3 all files, and Linux plain-HTTP files 128 KB–1 MB all take the BufferedReader path and hit this.

    Old behaviour: the cutoff branch reset head_start=0 and continued the inner while (and the outer while _buffer.capacity()==0 looped again), so a single reader.read() synchronously drained a regular file to bytes_read==0done(). Not ideal for very large files, but correct — and bounded, since regular files EOF.

    Impact

    return new Response(Bun.file(regularFile)) from a Bun.serve handler, and routes: { "/x": Bun.file(...) } static routes, hang for regular files larger than ~128 KB on macOS (all transports), Linux TLS/H2/H3 (all sizes), and Linux plain HTTP for files between ~128 KB and 1 MB. That is a core-feature regression in a very common production pattern.

    Suggested fix

    For file_type == File with keep_going == true, keep the old head_start = 0; continue behaviour instead of returning:

    if received_hup || (keep_going && file_type == FileType::File) {
        head_start = 0;
        continue;
    }
    if keep_going {
        parent.register_poll();
    }
    return;

    Regular files reach bytes_read == 0 in bounded time, so this cannot spin. The /dev/urandom case this PR fixes is unaffected: after ce697ed reverted the chardev reclassification, /dev/urandom is still opened O_NONBLOCK and classified NonblockingPipe (not File), so it takes the register_poll()+return path — and the FileReader::on_read_chunk highwater-mark change makes it return false after one chunk, so the poll isn't re-armed until the next pull. The same reasoning applies at the second new return (PipeReader.rs:962-966, the fall-through after the inner while exits on <16 KiB headroom).

    Alternatively, only bound the loop when the consumer returned false — the highwater-mark change already makes FileReader::on_read_chunk do that after one chunk from an infinite source, which is sufficient to fix the original bug without capping the keep_going==true case at all.

  • 🟡 src/runtime/webcore/FileReader.rs:796-807 — The PR title and the entire Fix section describe the abandoned reclassification approach from commit ec06736 ("clear is_nonblocking", "isatty() is now checked for any S_ISCHR fd", "the fd-based path … is also covered"), which ce697ed replaced — the current diff makes no changes to Lazy::open_file_blob and instead bounds read_with_fn to one stack-buffer pass per call and removes the reader_is_pollable() bypass so highwater-mark backpressure applies to pollable readers. Please update the title/description before merge (it becomes the squash commit message), and note that the earlier inline comments about the event-driven-chardev regression, S_IFBLK fallthrough, and fd-path test coverage were filed against open_file_blob code that no longer appears in the diff — they can be resolved rather than actioned.

    Extended reasoning...

    What's stale

    The PR title — "FileReader: route non-TTY character devices through the non-pollable File path" — and the entire Fix section of the description document the mechanism from commit ec067364:

    Treat non-TTY S_ISCHR fds the same as regular files: clear is_nonblocking so they go through the non-pollable FileType::File path … TTYs keep the pollable path via the existing is_atty term (and isatty() is now checked for any S_ISCHR fd, not just stdio) … the fd-based path (Bun.file(fd) with or without O_NONBLOCK) is also covered.

    Commit ce697ed5 ("Bound the streaming read loop instead of reclassifying chardevs") replaced that approach, and the current diff reflects only the replacement.

    Step-by-step verification against the diff

    1. Lazy::open_file_blob is untouched. The two FileReader.rs hunks are at @@ -563,22 +563,6 @@ (delete reader_is_pollable()) and @@ -809,14 +793,18 @@ (this hunk — the highwater-mark predicate). Reading the preloaded FileReader.rs:140-260: there is no else if sys::S::ISCHR(mode) branch, no widened isatty() check, and is_nonblocking is only cleared for ISREG. A path-opened /dev/urandom is still classified pollable = true, file_type = NonblockingPipe — exactly as before this PR.
    2. What actually changed is in PipeReader.rs::read_with_fn: the outer while parent._buffer.capacity() == 0 became if, and after delivering one stack-buffer chunk (either at the < stack_buffer_cutoff flush or when the inner loop exits) the function now calls register_poll() (for non-File types) and returns, instead of looping until EAGAIN/EOF. has_pending_read() also switched from is_registered() to is_watching() so a fired one-shot poll counts as no-pending-read and on_pull drives the next read.
    3. This hunk removes the && !self.reader_is_pollable() exemption from on_read_chunk's return value, so once buffered.len() + reader_buffer.len() >= highwater_mark the consumer returns false for pollable readers too — which now means "return without re-arming the poll" (backpressure), and the next JS pull() drains buffered and re-arms.

    So the actual mechanism that lands is "bound the pollable read loop to one stack-buffer chunk per call and apply highwater-mark backpressure to pollable readers" — chardevs remain NonblockingPipe, they just can no longer spin. None of "clear is_nonblocking", "isatty() is now checked for any S_ISCHR fd", or "the fd-based path is also covered" appears in the diff.

    Why it matters

    The description becomes the squash commit message. A future bisecter landing on this commit for a pipe-throughput or poll-re-arm regression will read "route non-TTY character devices through the non-pollable File path", grep open_file_blob for an S_ISCHR branch that doesn't exist, and conclude the commit is unrelated. .claude/docs/landing-prs.md (PR process) covers exactly this — the description must accurately state the mechanism.

    Prior-round comments that are now stale

    Three of the earlier inline comments were filed against open_file_blob code that no longer appears in the diff and should be resolved, not actioned:

    • 🔴 event-driven chardev regression (/dev/input/event*FileType::File + O_NONBLOCK → EAGAIN with no poll armed) — chardevs are still NonblockingPipe, so the register_poll()-on-EAGAIN arm still fires; the regression cannot occur.
    • 🟡 S_IFBLK fallthrough — the fix no longer enumerates st_mode types; block devices are now bounded by the same one-chunk-per-call yield as everything else.
    • 🟡 fd-path / /dev/null test coverage — the reasoning ("the reclassified device set", "goes through the PathOrFileDescriptor::Fd arm's get_fcntl_flags") targets the reclassification approach; the /dev/null EOF test was already added in the current diff.

    The remaining nits (test.concurrent, hang-guard branching, upper-bound assertion) were addressed in the current test file (isASAN || isDebug ? 20_000 : 4_000, signalCode in the asserted object, toBeLessThanOrEqual(1024*1024), rssGrowthMB check, "Sequential on purpose" comment).

    Fix

    Update the title to something like "PipeReader: bound the streaming read loop and apply highwater backpressure to pollable readers", and rewrite the Fix section to describe the whileif + register_poll()-and-return change, the has_pending_read() is_watching() switch, and the removal of the reader_is_pollable() bypass in on_read_chunk. The Problem and Cause sections remain accurate.

    Severity

    Nit — PR metadata only; no shipped code is wrong. Filed so the squash commit message doesn't mislead future readers and so the stale prior-round threads get resolved rather than reopened.

Comment thread src/runtime/webcore/FileReader.rs
Comment thread test/js/bun/util/bun-file.test.ts Outdated
Comment thread src/io/PipeReader.rs
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Re the FileResponseStream regression finding: fixed in b86733a. FileType::File with keep_going == true now keeps the drain-to-EOF loop as before (regular files reach bytes_read == 0 in bounded time); only pollable types yield per chunk. Verified with a 500 KB new Response(Bun.file(path)) served over Bun.serve, plus bun-serve-static.test.ts, issue-29181/20965 regression tests, and the file-backed-slice serve tests.

The PR title and body were already updated to describe the bounded-read-loop approach.

Comment thread src/io/PipeReader.rs
Comment thread test/js/bun/util/bun-file.test.ts Outdated
robobun and others added 8 commits July 25, 2026 04:47
Bun.file("/dev/urandom").stream().getReader().read() wedged the event
loop: open_file_blob opened the chardev with O_NONBLOCK, saw
is_nonblocking=true, marked it POLLABLE + NonblockingPipe, and the
PosixBufferedReader read loop then spun preadv2(RWF_NOWAIT) forever on
the JS thread. /dev/urandom, /dev/zero and /dev/full never EAGAIN and
never return 0, so the loop had no exit; timers never fired and RSS
grew ~1GB/s until OOM.

Route non-TTY S_ISCHR fds through the same non-pollable File path as
regular files (one bounded read per pull; the existing highwater-mark
backpressure in on_read_chunk applies). TTYs keep the pollable path
via the existing is_atty term.
The previous commit routed non-TTY S_ISCHR fds through the non-pollable
File path. That regresses event-driven character devices (/dev/input/*,
/dev/hidraw*, tun/tap) which DO honour O_NONBLOCK: on the File path an
EAGAIN only debug_warn!s and never arms a poll, so the pending read()
promise would never resolve.

Rework so every pollable source stays pollable and the read loop itself
is bounded:

- PosixBufferedReader::read_with_fn: deliver at most one stack-buffer
  chunk per call, then re-arm the poll (when the consumer said keep
  going) and return. An always-ready source now yields to the event
  loop once per chunk instead of spinning; pipes/sockets/TTYs are
  unchanged because they hit EAGAIN before the cutoff anyway.

- FileReader::on_read_chunk: apply the highwater-mark backpressure to
  pollable readers too. Returning false here makes read_with_fn return
  without re-arming, so an idle stream stops buffering at ~one chunk
  instead of growing RSS without bound.

- PosixBufferedReader::has_pending_read: use is_watching() so a one-shot
  poll that has fired and not been re-armed counts as no pending read.
  on_pull then takes the synchronous read path (which re-arms on EAGAIN)
  instead of parking on a poll that will never fire.

Test: assert chunk size upper bound, RSS growth cap, and /dev/null EOF;
scale the hang guard for debug/ASAN lanes.
FileResponseStream (Bun.serve file bodies on the non-sendfile path) is
push-driven: it calls reader.read() once and relies on read_with_fn
draining a regular file to EOF. The previous commit returned after one
chunk for FileType::File without re-arming anything, so responses
stalled after the first ~128-256 KiB when resp.write() returned
WantMore. Only yield for pollable types; File keeps looping to EOF as
before (regular files reach bytes_read == 0 in bounded time).

Also drop the now-dead WindowsFlags::POLLABLE store in FileReader and
branch the test's RSS cap on isASAN/isDebug.
@robobun
robobun force-pushed the farm/d40b42e7/filereader-chardev-stream-wedge branch from 137e1da to e71ca61 Compare July 25, 2026 04:52
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 80232 is red on lanes unrelated to this diff:

The gate (robobun/evidence) passed on this commit: the test fails without the fix and passes with it on both ASAN and release builds. Ready for review.

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this was fixed on main by #35977 (027716a), which dropped the same reader_is_pollable() exemption in FileReader::on_read_chunk and made register_poll/on_poll honour IS_PAUSED plus re-arm the poll from on_pull on Pending. Verified on canary 1.4.0-canary.1+e82022145:

read() resolved 262144
WATCHDOG ticks=119
exit=0

The /dev/urandom / /dev/zero / /dev/full wedge no longer reproduces.

@robobun robobun closed this Jul 29, 2026
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.

2 participants