Skip to content

stdin: apply highwater backpressure to the pipe FileReader source - #35977

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/c4f90ada/stdin-pipe-backpressure
Jul 27, 2026
Merged

stdin: apply highwater backpressure to the pipe FileReader source#35977
Jarred-Sumner merged 4 commits into
mainfrom
farm/c4f90ada/stdin-pipe-backpressure

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

const rd = Bun.stdin.stream().getReader();
await rd.read();                       // one read
const base = process.memoryUsage().rss;
await new Promise(r => setTimeout(r, 1500));  // idle, zero demand
console.log(Math.round((process.memoryUsage().rss - base) / 1048576));

With 40 MB piped in: RSS grows by ~100 MB while the consumer is idle. The process.stdin.pause() variant is the same: 'data' delivery stops but the fd keeps being ingested into an internal buffer. Node stays flat at the kernel pipe buffer.

Cause

FileReader.on_read_chunk tells the read loop whether to keep reading. When no JS read is pending the chunk is appended to self.buffered and the continue decision was

!(buffered_len >= highwater_mark && !reader_is_pollable())

so for a pollable fd the highwater backstop was exempted and the loop always continued. The posix pipe read loop drains the fd until EAGAIN, and the blocking-pipe path (read_blocking_pipe, which stdin-from-a-pipe uses because fd 0 is not O_NONBLOCK) additionally discarded the return value of on_read_chunk and re-armed the poll unconditionally.

pause() set IS_PAUSED and unregistered the poll, but the in-flight read loop's own register_poll() did not check the flag (so a pause issued from inside the loop's JS re-entry was immediately undone), and on_poll did not check it either.

Fix

  • FileReader.on_read_chunk: drop the pollable exemption and fold !flowing into the continue decision. A full kernel pipe buffer blocking the writer is backpressure.
  • PosixBufferedReader::register_poll / on_poll: early-return when IS_PAUSED.
  • read_blocking_pipe stack-buffer streaming arm: honor the on_read_chunk return value the way the with-buffer arm and read_with_fn already do.
  • FileReader.on_pull: when returning Pending, re-arm the poll. After the backstop returns without re-arming, the one-shot poll is disarmed kernel-side while has_pending_read() (which checks the sticky PollReadable flag, not the arm state) still reports true, so the next pull would otherwise wait on a poll that will never fire.

onPull and setFlowing(true) already restart the reader on demand.

Only FileReader ever returned false with received_hup == false from on_read_chunk, so the read_blocking_pipe change does not affect shell/subprocess/Terminal readers.

Verification

test/js/node/process/process-stdin.test.ts adds three cases: a single Bun.stdin.stream() read with a 40 MB feed stays within a few MB of the baseline, process.stdin.pause() after 1 MB stops fd ingestion, and reading to EOF after the backstop engaged delivers every byte. Without the source change the first two fail with ~100 MB RSS growth under bun bd.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/process/process-stdin.test.ts

FileReader.on_read_chunk exempted pollable fds from the highwater
backstop ("for pipes we have to keep pulling"), so with no JS read
pending the posix read loop kept draining the fd into self.buffered
until EAGAIN. A single Bun.stdin.stream() read followed by an idle
consumer ingested the entire pipe; process.stdin.pause() stopped
delivery to JS but not the fd ingestion (RSS grew to match the feed).

Drop the pollable exemption and fold !flowing into the continue
decision. Make the blocking-pipe read path honor that return value
(the nonblocking path already did), and have register_poll / on_poll
respect IS_PAUSED so a pause issued from inside the read loop's JS
re-entry is not immediately undone. on_pull re-arms the poll when it
returns Pending, since the one-shot poll may be kernel-disarmed while
has_pending_read() still reports the sticky registration flag.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f2a4fd75-f270-4815-85ad-e4e98d86a108

📥 Commits

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

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

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

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:12 PM PT - Jul 26th, 2026

@robobun, your commit a772e2f has 1 failures in Build #82683 (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 35977

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

bun-35977 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. SIGTERM/SIGINT not delivered when process.stdin has flowing data listener #30189 - SIGTERM/SIGINT not delivered when process.stdin has flowing data listener. The PR makes the read loop respect the highwater mark and pause properly, which breaks the synchronous read chain that was starving the event loop and preventing signal delivery when stdin is flowing.

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

Fixes #30189

🤖 Generated with Claude Code

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Checked #30189: that reproduction (< /dev/zero with a flowing 'data' listener) is a character device read via the FileType::File path in read_with_fn, which never hits EAGAIN and never yields. This PR only touches the pollable-pipe backpressure path, so it does not change that behaviour (verified the repro still hangs on this branch). #35519 is the one that bounds the always-ready streaming loop.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. io(posix): do not re-arm the fd poll after a re-entrant process.stdin.pause() #35975 - Both fix unbounded RSS growth when piping into process.stdin by adding IS_PAUSED guard in PipeReader::register_poll and making FileReader::on_read_chunk respect backpressure/flowing state

🤖 Generated with Claude Code

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Overlaps with #35975 (opened a few minutes earlier from the same analysis). The register_poll() IS_PAUSED guard is identical in both.

#35975 fixes the explicit-pause() case only: it adds a !flowing check after p.run() in the pending-resolve path, so a pause issued from inside the 'data' handler stops the loop.

This PR additionally drops the pollable exemption in the no-pending path, which is what fixes the zero-demand case (Bun.stdin.stream().getReader().read() once, then idle; no pause() ever called). That case still grows RSS unbounded under #35975 because the no-pending on_read_chunk path is untouched there and flowing stays true:

const rd = Bun.stdin.stream().getReader();
await rd.read();
await new Promise(r => setTimeout(r, 1500));
// RSS grows to match the feed under #35975; stays flat here

The other differences here (on_poll guard, read_blocking_pipe honouring the return value, on_pull re-arming the disarmed poll) fall out of removing the exemption: once on_read_chunk can return false for a pollable fd without a pause(), the blocking-pipe path needs to honour it and on_pull needs to restart the read when demand returns.

Happy to rebase either direction.

Comment thread src/runtime/webcore/FileReader.rs Outdated
on_pull's watch() re-arm and the read loop's own register_poll both
land on the same one-shot poll per chunk in the streaming path, so
register_with_fd was issuing an idempotent epoll_ctl(CTL_MOD) /
kevent64 twice. Skip it when is_watching() already holds.
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/io/PipeReader.rs
Comment thread src/runtime/webcore/FileReader.rs
… watch

register_poll is called from the read loop's EAGAIN path, whose epoll_ctl
count the shell-pipe-read-fault tests fault-inject against. watch() is
the setup/restart path (FileReader.on_pull, subprocess bring-up); skipping
the redundant epoll_ctl there covers the per-chunk on_pull re-arm without
shifting the read loop's call pattern.
Comment thread src/io/PipeReader.rs
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI on a772e2f (build #82683): all test lanes green. The only red is :package: binary-size against stale canary #79916, which every recent PR build is hitting identically (checked 82629/82630/82632/82639/82640); the net Rust change here is ~20 lines. One retry-pass flaky in terminal-platform-gaps.test.ts on Windows ConPTY, unrelated to the posix PipeReader path.

Ready for review.

@Jarred-Sumner
Jarred-Sumner merged commit 027716a into main Jul 27, 2026
52 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/c4f90ada/stdin-pipe-backpressure branch July 27, 2026 05:35
Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
…5977)

## Reproduction

```js
const rd = Bun.stdin.stream().getReader();
await rd.read();                       // one read
const base = process.memoryUsage().rss;
await new Promise(r => setTimeout(r, 1500));  // idle, zero demand
console.log(Math.round((process.memoryUsage().rss - base) / 1048576));
```

With 40 MB piped in: RSS grows by ~100 MB while the consumer is idle.
The `process.stdin.pause()` variant is the same: `'data'` delivery stops
but the fd keeps being ingested into an internal buffer. Node stays flat
at the kernel pipe buffer.

## Cause

`FileReader.on_read_chunk` tells the read loop whether to keep reading.
When no JS read is pending the chunk is appended to `self.buffered` and
the continue decision was

```rust
!(buffered_len >= highwater_mark && !reader_is_pollable())
```

so for a pollable fd the highwater backstop was exempted and the loop
always continued. The posix pipe read loop drains the fd until EAGAIN,
and the blocking-pipe path (`read_blocking_pipe`, which
stdin-from-a-pipe uses because fd 0 is not O_NONBLOCK) additionally
discarded the return value of `on_read_chunk` and re-armed the poll
unconditionally.

`pause()` set `IS_PAUSED` and unregistered the poll, but the in-flight
read loop's own `register_poll()` did not check the flag (so a pause
issued from inside the loop's JS re-entry was immediately undone), and
`on_poll` did not check it either.

## Fix

- `FileReader.on_read_chunk`: drop the pollable exemption and fold
`!flowing` into the continue decision. A full kernel pipe buffer
blocking the writer is backpressure.
- `PosixBufferedReader::register_poll` / `on_poll`: early-return when
`IS_PAUSED`.
- `read_blocking_pipe` stack-buffer streaming arm: honor the
`on_read_chunk` return value the way the with-buffer arm and
`read_with_fn` already do.
- `FileReader.on_pull`: when returning `Pending`, re-arm the poll. After
the backstop returns without re-arming, the one-shot poll is disarmed
kernel-side while `has_pending_read()` (which checks the sticky
`PollReadable` flag, not the arm state) still reports true, so the next
pull would otherwise wait on a poll that will never fire.

`onPull` and `setFlowing(true)` already restart the reader on demand.

Only `FileReader` ever returned `false` with `received_hup == false`
from `on_read_chunk`, so the `read_blocking_pipe` change does not affect
shell/subprocess/Terminal readers.

## Verification

`test/js/node/process/process-stdin.test.ts` adds three cases: a single
`Bun.stdin.stream()` read with a 40 MB feed stays within a few MB of the
baseline, `process.stdin.pause()` after 1 MB stops fd ingestion, and
reading to EOF after the backstop engaged delivers every byte. Without
the source change the first two fail with ~100 MB RSS growth under `bun
bd`.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/process/process-stdin.test.ts

<!-- robobun:evidence:end -->
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