Skip to content

io(posix): do not re-arm the fd poll after a re-entrant process.stdin.pause() - #35975

Closed
robobun wants to merge 6 commits into
mainfrom
claude/farm/bac43e35/stdin-pause-rearm
Closed

io(posix): do not re-arm the fd poll after a re-entrant process.stdin.pause()#35975
robobun wants to merge 6 commits into
mainfrom
claude/farm/bac43e35/stdin-pause-rearm

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

for await (const line of readline.createInterface({input: process.stdin})) applies no backpressure on Bun: the whole piped input is buffered in native memory. With a 100 MB input and any per-line async work, Node pauses stdin ~95 times and stays flat at ~85 MB RSS; Bun pauses ~3 times and buffers everything (peak RSS ~2.5x input, scaling linearly, so big pipes OOM).

// child: for await (line of readline) { await setTimeout(0); }
// parent pumps 100 MB via drain-throttled child.stdin
node  -> {"lines":100000,"stdinPauses":96,"peakRssMB":83}
bun   -> {"lines":100000,"stdinPauses":3, "peakRssMB":381}

More directly, a child that calls process.stdin.pause() from inside its first 'data' handler does not block the writer:

node  : parent wrote  309 lines (pipe full, child paused)
bun   : parent wrote 50000 lines (entire 50 MB input) while child RSS sits at 181 MB

fs.createReadStream(file) is bounded; this is stdin-specific.

Cause

FileReader::on_read_chunk resolves the pending JS read, which drains microtasks and runs the 'data' handler synchronously. When that handler calls process.stdin.pause(), the nextTick disown() path reaches FileReader::set_flowing(false) which calls PosixBufferedReader::pause(): IS_PAUSED is set and the FilePoll is unregistered.

Control then returns to PosixBufferedReader::read_with_fn / read_blocking_pipe, whose EAGAIN path calls register_poll() unconditionally. The poll is re-armed, undoing the pause. The next readable event fires, on_read_chunk finds no pending promise and appends to FileReader.buffered (returning "keep reading" for pollable fds), and the cycle repeats until EOF, buffering the entire input natively.

Windows is not affected: on_file_read already checks IS_PAUSED before re-arming and the pipe/tty path relies on uv_read_stop().

Fix

  • PosixBufferedReader::register_poll() returns early when IS_PAUSED is set, so a read loop's own re-arm cannot undo a re-entrant pause(). unpause() followed by read() re-registers when JS resumes.
  • FileReader::on_read_chunk reports "stop" after p.run() when re-entrant JS cleared flowing, so the inner read loop exits promptly instead of continuing until EAGAIN.

Verification

$ bun bd /tmp/repro.mjs
{"runtime":"bun","lines":100000,"stdinPauses":95,"peakRssMB":...}   # was stdinPauses:3

$ bun bd test test/js/node/process/process-stdin.test.ts -t "applies kernel backpressure"
(pass) process.stdin.pause() from inside a 'data' handler applies kernel backpressure

The new test pumps 10 MB via drain-throttled child.stdin into a child that pauses stdin on the first chunk, then asserts the writer was blocked (writtenWhilePaused < 2000). On main the writer drains all 10000 lines.


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

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/process/process-stdin.test.ts
bun test v1.4.0 (a3373d3fe)

test/js/node/process/process-stdin.test.ts:
(pass) pipe does the right thing [1275.85ms]
(pass) file does the right thing [1386.42ms]
(pass) paused mode read(n) returns the buffered remainder at EOF [1516.09ms]
(pass) stdin with 'readable' event handler should receive data when paused [2242.57ms]
(pass) stdin with 'data' event handler should NOT receive data when paused [2314.27ms]
(pass) a read() that throws does not keep the process alive [1196.51ms]
(pass) explicit read(n) with no 'readable' listener still pulls from stdin [1542.53ms]
(pass) touching stdin again after 'end' does not keep the process alive [1578.95ms]
(pass) 'end' is not emitted when the buffer is never drained, and the process still exits [1380.36ms]
(pass) stdin should not allow process to exit when not paused [1076.97ms]
(pass) stdin should allow process to exit when paused [1415.13ms]
(pass) a throw from a 'data' listener is an uncaughtException, and stdin keeps reading [1626.90ms]
(pass) a t
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (0276aedc2)

test/js/node/process/process-stdin.test.ts:
(pass) pipe does the right thing [34.72ms]
(pass) a read() that throws does not keep the process alive [26.59ms]
(pass) 'end' is not emitted when the buffer is never drained, and the process still exits [25.31ms]
(pass) touching stdin again after 'end' does not keep the process alive [32.96ms]
(pass) a throw from a 'readable' listener is an uncaughtException, including the EOF emission [30.93ms]
(pass) stdin should allow process to exit when paused [34.20ms]
(pass) paused mode read(n) returns the buffered remainder at EOF [39.63ms]
(pass) file does the right thing [46.42ms]
(pass) a throw from a 'data' listener is an uncaughtException, and stdin keeps reading [46.68ms]
(pass) explicit read(n) with no 'readable' listener still pulls from stdin [52.40ms]
485 |   const result = JSON.parse(stdout.trim());
486 | 
487 |   // Before the fix the parent drained all 10000 lines through the pipe while
488 |   // the child was paused. With kernel backpressure only the pipe buffer plus
489 |   // one in-flight chunk fit (a few hundred KB). Node lands at ~300 here.
490 |   expect(result.writtenWhile
... (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/node/process/process-stdin.test.ts
bun test v1.4.0 (a3373d3fe)

test/js/node/process/process-stdin.test.ts:
(pass) pipe does the right thing [1238.59ms]
(pass) paused mode read(n) returns the buffered remainder at EOF [1540.67ms]
(pass) file does the right thing [1634.42ms]
(pass) stdin with 'data' event handler should NOT receive data when paused [2361.64ms]
(pass) stdin with 'readable' event handler should receive data when paused [2580.19ms]
(pass) explicit read(n) with no 'readable' listener still pulls from stdin [1447.70ms]
(pass) a read() that throws does not keep the process alive [1283.05ms]
(pass) touching stdin again after 'end' does not keep the process alive [1664.37ms]
(pass) stdin should not allow process to exit when not paused [1054.02ms]
(pass) 'end' is not emitted when the buffer is never drained, and the process still exits [1572.54ms]
(pass) stdin should allow process to exit when paused [1661.82ms]
(pass) a throw from a 'data' listener is an uncaughtException, and stdin keeps reading [1871.18ms]
(pass) a t
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 791ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v
... (truncated)
diff hotspot
src/io/PipeReader.rs                       |  4 ++
 src/runtime/webcore/FileReader.rs          |  8 +++-
 test/js/node/process/process-stdin.test.ts | 73 ++++++++++++++++++++++++++++++
 3 files changed, 83 insertions(+), 2 deletions(-)

gate history · 1 passed · 3 rejected · iteration 3

evidence per changed file
file                                        reads  edits  tests
src/io/PipeReader.rs                           11      6      0
src/runtime/webcore/FileReader.rs               9      4      0
test/js/node/process/process-stdin.test.ts      5     15      0

process.stdin.pause() called from inside a 'data' handler (as readline's
async iterator does via the events.on 1024-line highWaterMark) unregistered
the FilePoll and set IS_PAUSED, but control then returned to the
PosixBufferedReader read loop, which called register_poll() on EAGAIN and
re-armed the fd. The native reader kept draining the pipe into
FileReader.buffered with no bound, so a drain-throttled writer could push
the whole input through while the consumer was paused: for 100 MB piped
stdin with a slow async-iterator consumer, node pauses stdin ~95 times and
stays at ~85 MB RSS while bun paused ~3 times and buffered everything
(peak RSS ~2.5x input).

register_poll() now returns early when IS_PAUSED is set so the loop's own
re-arm cannot undo a re-entrant pause; unpause() + read() re-register when
JS resumes. on_read_chunk also reports 'stop' after p.run() when re-entrant
JS cleared flowing, so the inner read loop exits promptly instead of
continuing until EAGAIN.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change updates paused-read handling in the POSIX pipe reader and file reader, then adds a concurrent child-process test that verifies stdin writes stop progressing under kernel backpressure after pausing inside a data handler.

Changes

Stdin pause backpressure

Layer / File(s) Summary
Pause state propagation
src/io/PipeReader.rs, src/runtime/webcore/FileReader.rs
Poll registration now exits when paused, and pending reads return false when the stream is no longer flowing.
Concurrent stdin backpressure validation
test/js/node/process/process-stdin.test.ts
A child-process test pauses stdin from a data handler, pumps input with drain-based flow control, and asserts write progress stalls before all input is consumed.

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 is concise, specific, and accurately summarizes the main POSIX stdin poll re-arm fix.
Description check ✅ Passed The description clearly explains the problem, fix, and verification, though it uses different headings than the template.

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

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:01 PM PT - Jul 26th, 2026

@robobun, your commit a3373d3 has 1 failures in Build #82719 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.47 MB71.95 MB+528.0 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-aarch64-android78.47 MB77.97 MB+512.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.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+570.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+533.0 KB

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


🧪   To try this PR locally:

bunx bun-pr 35975

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

bun-35975 --bun

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Status at a3373d3:

Fix verified locally on both profiles. Clean release build succeeds and all 15 process-stdin.test.ts tests pass; debug+ASAN likewise. With src/ reverted to main the new test fails 5/5 on both profiles (writtenWhilePaused: 10000 vs < 2000).

Gate: three of four checks prove the fix (ASAN without fix: fail, release without fix: fail, ASAN with fix: pass). release with fix reports BUILD FAILED (no junit output) on consecutive runs; a clean local release build at the same sha completes in ~6 min, so this looks like a gate environment issue rather than this diff.

CI (builds 82627/82680/82695/82719): the new test passed on every lane. Remaining red is unrelated:

  • binary-size compares against stale canary #79916 and is failing on every recent PR build with the same ~500 KB delta; this change is 12 lines of Rust.
  • terminal-platform-gaps.test.ts, bun-install-registry.test.ts, complex-workspace.test.ts, bun-upgrade.test.ts, in-process-cron.test.ts, cpu-prof.test.ts all passed on retry and touch nothing this diff does.

Ready for review.

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

No bugs found. The two-line native change looks correct and I traced the other register_poll() / IS_PAUSED consumers (lazy subprocess stdio, spawnSync watch loop, FileResponseStream) and none regress — but this is shared POSIX I/O plumbing that every pipe reader flows through, so it's worth a human look, and CI hasn't reported yet.

What was reviewed:

  • register_poll() early-return: spawnSync's per-tick .watch() never sees IS_PAUSED (start passes lazy = !IS_SYNC && lazy); the lazy async-spawn path clears it via on_start()'s unpause() before reading; FileResponseStream's pause() calls are #[cfg(not(unix))] so don't intersect.
  • on_read_chunk's new !flowing stop: in read_blocking_pipe's capacity==0 streaming arm the return value is discarded, so the fix there relies on the register_poll() gate — which it gets. The capacity>0 arm and read_with_fn honor the return.
  • Resume path re-registers correctly (set_flowing(true)unpause() clears the flag → read() reaches register_poll()).
  • Test: threshold (2000 lines ≈ 2 MB) is well above any platform's pipe+HWM buffer; bunEnv propagates to the grandchild so stderr should stay empty in debug.
Extended reasoning...

Overview

Two small native changes plus a regression test:

  1. PosixBufferedReader::register_poll() returns early (as success) when IS_PAUSED is set, so the read loop's own re-arm on EAGAIN cannot undo a re-entrant pause().
  2. FileReader::on_read_chunk now returns false after p.run() when re-entrant JS cleared flowing, so the read loop exits promptly instead of continuing to EAGAIN.
  3. New test.concurrent in process-stdin.test.ts that pumps 10 MB into a child that pauses on the first 'data' and asserts the drain-throttled writer stalls (< 2000 lines vs 10000 without the fix).

Security risks

None. This is backpressure/flow-control logic on an already-open fd; no new input parsing, no auth, no bounds arithmetic.

Level of scrutiny

High. PosixBufferedReader is shared plumbing for subprocess stdout/stderr, shell IOReader, Bun.serve file streaming, and stdin. I traced every consumer that touches IS_PAUSED or calls register_poll()/watch():

  • spawnSync watch loop (js_bun_spawn_bindings.rs:1936/1940): start() is called with lazy = !IS_SYNC && lazy, so IS_PAUSED is never set on the sync path — .watch() still registers.
  • Lazy async subprocess stdio (SubprocessPipeReader.rs:194 sets IS_PAUSED): the flag is transferred into a FileReader via from_pipe, and FileReader::on_start() explicitly calls unpause() before read(), so the new gate is not reached with the flag set.
  • FileResponseStream pause() calls are #[cfg(not(unix))], so they don't touch the POSIX register_poll().
  • Resume: set_flowing(true)unpause() clears IS_PAUSEDread()register_poll() re-arms. The read() path for blocking pipes handles the NotReady case by calling register_poll() directly.

I also verified the fix actually covers the bug path: for the streaming blocking-pipe case (_buffer.capacity() == 0), on_read_chunk's return value is discarded (let _), so change (2) alone doesn't help there — but the subsequent register_poll() is now a no-op via change (1), so the loop exits without re-arming. In the capacity>0 arm and in read_with_fn, change (2) short-circuits earlier. The HUP drain paths (&& !received_hup guards) still drain to EOF, which is bounded.

Other factors

  • The PR description's mechanism explanation is precise and matches the code.
  • CI build #82627 is still running; no test results yet.
  • The test uses timing-based stall detection (3×100 ms idle ticks) rather than a hard sleep, and the 2000-line threshold gives ~10× headroom over the expected ~150-300 lines on Linux/macOS/Windows pipe buffers, so it should be robust — but a maintainer may want to eyeball the Windows pipe-buffer margin.

Given this is a subtle re-entrancy fix in shared native I/O code, deferring to a human reviewer.

The stall detector previously started immediately after spawn, so on slow
builds it observed the pipe-buffer stall that happens before the child
has even attached its 'data' listener, not the pause-backpressure stall,
and the test passed without the fix. The child now prints PAUSED after
calling pause() and the writer only begins stall detection once that
arrives.
Comment thread src/io/PipeReader.rs Outdated
Comment thread src/runtime/webcore/FileReader.rs Outdated

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/process/process-stdin.test.ts`:
- Around line 446-470: Update the stdin error handling in the test around the
pump and stall-detection logic: reject or otherwise fail the test for unexpected
stdin errors before the deliberate child.kill() path begins, while allowing
errors caused by that intentional shutdown. Ensure the test’s promise/assertion
flow observes this failure so early child exit or EPIPE cannot be masked, and
retain the existing stall result reporting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 77f62fa9-d7d6-4670-b817-b98bc74ff36c

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and abcb964.

📒 Files selected for processing (3)
  • src/io/PipeReader.rs
  • src/runtime/webcore/FileReader.rs
  • test/js/node/process/process-stdin.test.ts

Comment thread test/js/node/process/process-stdin.test.ts Outdated
Comment thread test/js/node/process/process-stdin.test.ts 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.

No issues found, but this touches PosixBufferedReader::register_poll() — the shared re-arm path under every POSIX pipe/tty/socket reader — so it's worth a human look from someone who owns the I/O layer.

What was reviewed:

  • Traced IS_PAUSED on POSIX: only FileReader::set_flowing(false) sets it (FileResponseStream pauses only on cfg(not(unix)); subprocess only unpause()s), so the new early-return's blast radius is FileReader-backed readers.
  • Resume path: set_flowing(true)unpause() clears the flag → read() reaches register_poll()/read_with_fn with the guard inactive, so the poll re-arms.
  • read_blocking_pipe's cap==0 streaming arm ignores on_read_chunk's return — the register_poll() guard is what actually stops re-arming there; the !flowing return in FileReader covers the other loops.
  • Test: prior grandchild-orphan and pre-kill stdin-error concerns are addressed; stall detector converges on both fixed and unfixed builds.
Extended reasoning...

Overview

Two-line native fix plus a subprocess test. PosixBufferedReader::register_poll() gains an IS_PAUSED early-return so a re-entrant process.stdin.pause() (reached via on_read_chunkp.run() → microtasks → 'data' handler → set_flowing(false)) is not immediately undone by the read loop's own EAGAIN/tail re-arm. FileReader::on_read_chunk additionally reports "stop" when !flowing after p.run(), so the inner read loops that do honour the return value exit promptly instead of spinning to EAGAIN. The new process-stdin.test.ts case pumps 10 MB into a grandchild that pauses on the first chunk and asserts the writer stalls (< 2000 lines vs. 10000 unfixed).

Security risks

None. This is a backpressure/resource-usage fix on a local fd; no parsing of untrusted input, no auth/crypto, no new surface.

Level of scrutiny

Medium-high. The diff is tiny and the mechanism is well-argued, but register_poll() is the single re-arm choke point for every PosixBufferedReader (process.stdin, subprocess stdout/stderr after to_readable_stream, shell PipeReader, Bun.file().stream()). I verified that on POSIX IS_PAUSED is only ever set from FileReader::set_flowing(false) (the FileResponseStream reader.pause() calls are #[cfg(not(unix))], and subprocess only calls unpause()), so in practice the guard is inert for shell/subprocess non-stream readers. The resume path (set_flowing(true)unpause()read()) re-registers because IS_PAUSED is cleared before read() reaches register_poll(). Still, this is exactly the kind of shared-helper change REVIEW.md flags for "enumerate every consumer", and a maintainer who owns src/io/ should confirm no lazy-start path relies on register_poll() firing while paused.

Other factors

  • All prior review feedback is addressed: comment-cop (comments shortened), CodeRabbit (pre-kill stdin errors now throw), and my earlier nit (grandchild keep-alive bounded to 30 s with exit(1)).
  • CI: the new test passed on every lane per the robobun status; remaining red is unrelated flakes/binary-size.
  • Windows is untouched by design (on_file_read already checks IS_PAUSED; pipe/tty use uv_read_stop).
  • One thing I did not exercise: whether any consumer relies on a pending-read Drained chunk after register_poll() in read_with_fn's EAGAIN arm — there register_poll() runs before the trailing on_read_chunk(.., Drained), so a pause during that final chunk still unregisters via pause() itself; the new guard only matters when on_read_chunk runs before register_poll, which is the read_blocking_pipe and mid-buffer-flush paths.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #35977, which landed the same register_poll() IS_PAUSED guard plus the broader highwater/!flowing backstop in FileReader::on_read_chunk. Verified on current main: the readline repro from the report now matches node (stdinPauses: 19 for 20 k lines; was ~3 before), and the writer-stall test from this branch passes 3/3 against main's src/.

Closing; nothing left to merge.

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