Skip to content

child_process: honor child.stdout.pause() once the stream has flowed - #36035

Merged
Jarred-Sumner merged 8 commits into
mainfrom
farm/7a82ecb1/child-process-stdout-pause-backpressure
Jul 29, 2026
Merged

child_process: honor child.stdout.pause() once the stream has flowed#36035
Jarred-Sumner merged 8 commits into
mainfrom
farm/7a82ecb1/child-process-stdout-pause-backpressure

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

child.stdout.pause() from node:child_process is ignored once the stream has started flowing: 'data' keeps firing while isPaused()===true, the child is never throttled, and the parent buffers the child's entire output.

import { spawn } from 'node:child_process';
const child = spawn('sh', ['-c', 'head -c 209715200 /dev/zero'], { stdio: ['ignore', 'pipe', 'ignore'] });
let ev = 0, bytes = 0;
child.stdout.on('data', d => { ev++; bytes += d.length; if (ev === 1) child.stdout.pause(); });
setTimeout(() => {
  console.log({ ev, bytesAfterPause: bytes, isPaused: child.stdout.isPaused(), exitCode: child.exitCode });
}, 2000);
// node:  { ev: 1, bytesAfterPause: 65536, isPaused: true, exitCode: null }
// bun:   { ev: 3, bytesAfterPause: 209715200, isPaused: false, exitCode: 0 }

#34971 covered the "paused before any read" case (the lazy reader stays paused until the first _read()), but once _read() has run once the native side never stops.

Cause

Two layers:

  • Native: FileReader::on_read_chunk computed keep-reading as !(buffered >= hwm && !pollable), which is always true for a pollable fd, so after the first pull the posix read loop re-armed the poll forever and self.buffered grew without bound.
  • Bridge: internal/streams/native-readable.ts never propagated push()===false back to the source. The setFlowing(false) hook (which unregisters the FilePoll / calls uv_read_stop) already existed, but only process.stdin called it.

Fix

  • FileReader::on_read_chunk: drop the pollable exemption so the highwater mark caps native buffering for pipes too. The cap only engages once on_start has run (a consumer has attached); a non-lazy Bun.spawn reader that is already delivering before anyone reads keeps its old eager-buffer behavior so it cannot deadlock a child that writes to both stdout and stderr while the caller only awaits one of them.
  • PosixBufferedReader::has_pending_read(): use is_watching() instead of is_registered(). A one-shot poll that has fired but not been re-armed will not deliver another callback, so on_pull must not be told a read is in flight (it would wait on a poll that never fires).
  • WindowsBufferedReader::on_read: clear _buffer regardless of should_continue so a FileReader that says stop at hwm does not also leave _buffer growing. Parents that want the reader paused call reader().pause() themselves; for FileReader that is the JS-side setFlowing(false) path from a microtask.
  • native-readable.ts: push()===false -> ptr.setFlowing(false); _read() -> ptr.setFlowing(true). Same readStop/readStart model as Node's net.Socket.

Verification

The new child.stdout.pause() after flowing stops native reads and blocks the child test in child_process.test.ts pauses a 20 MB writer after the first 'data', asserts the child is still blocked with zero extra events, then resumes and counts every byte. Fails on main (exitCode: 0, child finished), passes here on both POSIX and Windows.

Related: #35977 fixes the same FileReader root for the process.stdin face (blocking-pipe path) and will conflict on that file; whichever lands first, the other rebases cleanly.


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/child_process/child_process.test.ts

@coderabbitai

coderabbitai Bot commented Jul 27, 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: 17 minutes

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: add45bc6-325a-4bcb-83ab-8256fff79792

📥 Commits

Reviewing files that changed from the base of the PR and between da32d1b and 922288b.

📒 Files selected for processing (1)
  • src/io/PipeReader.rs

Walkthrough

Changes

The update coordinates native stream backpressure with reader lifecycle handling, including re-entrant closure safeguards, revised POSIX and Windows buffering behavior, native flowing toggles, FileReader pull gating, and a child-process stdout regression test.

Native stream backpressure

Layer / File(s) Summary
Reader lifecycle and callback handling
src/io/PipeReader.rs, src/runtime/webcore/FileReader.rs
Pending-read detection, callback completion checks, re-entrant closure handling, and Windows streaming-buffer clearing are updated.
Native flowing and pull coordination
src/js/internal/streams/native-readable.ts, src/runtime/webcore/FileReader.rs
push() backpressure toggles native flowing state, while FileReader pull gating and pending-reader arming are revised.
Child-process stdout backpressure validation
test/js/node/child_process/child_process.test.ts
Adds POSIX coverage for pausing stdout, blocking further delivery, resuming output, and completing the child process.

Possibly related PRs

  • oven-sh/bun#35975: Addresses POSIX pipe poll re-arming during re-entrant pause() handling.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: honoring child.stdout.pause() after streaming begins.
Description check ✅ Passed The description is detailed and covers problem, cause, fix, and verification, though it uses different headings than the template.
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.

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

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:17 AM PT - Jul 27th, 2026

@robobun, your commit 922288b3a87a42bfc45414de7fbdca536780a786 passed in Build #83264! 🎉


🧪   To try this PR locally:

bunx bun-pr 36035

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

bun-36035 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. New subprocess piping still consumes much CPU #20815 - Subprocess piping consumes excessive CPU; enforcing highwater marks and stopping native reads when push() returns false would apply proper backpressure instead of unbounded busy-reading
  2. bug: child_process.spawn() w/pipe on mac? first stdout chunk randomly out-of-order #13755 - First stdout chunk randomly out-of-order from child_process.spawn; the pushAndCheck() flow control and PipeReader fixes could address over-buffering that causes misordered delivery
  3. Ip-location-api updatedb.mjs broken as of bun 1.2.0 #18662 - ip-location-api updatedb.mjs exits prematurely since Bun 1.2.0; consistent with pipe reader not applying backpressure, causing premature process exit before all streamed data is processed

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

Fixes #20815
Fixes #13755
Fixes #18662

🤖 Generated with Claude Code

Comment thread src/io/PipeReader.rs Outdated
Comment thread src/io/PipeReader.rs
Comment thread src/io/PipeReader.rs Outdated
Comment thread src/js/internal/streams/native-readable.ts
Comment thread src/js/internal/streams/native-readable.ts
Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread src/runtime/webcore/FileReader.rs Outdated
Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread test/js/node/child_process/child_process.test.ts Outdated
Comment thread src/io/PipeReader.rs

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

d254877 addresses both prior findings (the Windows stop_reading() leak and the follow-up take_read_ref() UAF) by reverting both and letting the JS-side setFlowing(false) path pause the reader from outside the uv callback instead; the test nit is also picked up. No new issues found this pass. Deferring to a human because this is a three-layer change across both platforms' hot stdio path, the Windows backpressure now hinges on Readable's maybeReadMore→second push()===false rather than the native hwm return, and CI hasn't reported yet.

What was reviewed:

  • Unconditional _buffer.clear() in WindowsBufferedReader::on_read vs FileResponseStream::on_read_chunk's Backpressure/RESPONSE_DONE arms — resp.write copies and the arm already self-pauses, so no dangling slice / no re-leak.
  • POSIX has_pending_read()is_watching() pairs with on_read_chunk returning false at hwm so on_pull re-arms via reader().read().
  • setFlowing(true) placement before the kPendingRead early-return in _read() so a paused reader with an outstanding pull promise still un-pauses.
  • The new test's c.on('close', failFirst) firing after resume — rejects an already-resolved promise, harmless.
Extended reasoning...

Overview

Touches src/io/PipeReader.rs (both POSIX and Windows readers), src/runtime/webcore/FileReader.rs (native-side highwater backstop), src/js/internal/streams/native-readable.ts (propagate push()===falsesetFlowing(false)), and adds a test in child_process.test.ts. The goal is to make child.stdout.pause() actually stop the native reader once the stream has flowed, so kernel backpressure blocks the child instead of the parent buffering everything.

Prior findings resolved

My first pass flagged that WindowsBufferedReader::on_read calling stop_reading() on should_continue==false would leak FileResponseStream on client abort (its RESPONSE_DONE early-return relied on read-to-EOF). The author's cdbe688 fix added take_read_ref() there, which my second pass showed drops the refcount to 0 inside on_read_chunk — a UAF against the caller. d254877 resolves both by removing the stop_reading() call from on_read entirely and reverting the FileResponseStream change; Windows backpressure now comes from the JS side (pushAndChecksetFlowing(false)reader().pause()), which runs from a microtask outside the uv callback so it cannot free the parent under the caller.

What I checked this pass

The remaining Windows change is that _buffer.clear() is no longer gated on should_continue. I checked FileResponseStream::on_read_chunk: on Backpressure it calls resp.write(chunk) (copies into uWS) and self.reader.pause() before returning false, and on RESPONSE_DONE it just returns false and lets the pre-existing read-to-EOF fallthrough fire on_reader_done — clearing _buffer after either return doesn't strand data or change lifetime. On POSIX, the is_watching() change in has_pending_read() is load-bearing: after on_read_chunk returns false at hwm, read_with_fn returns without re-arming, and the next on_pull must see has_pending_read()==false to call reader().read() — with is_registered() it would have deadlocked.

Security / scrutiny

No security surface. Scrutiny is high: this is the shared BufferedReader used by child_process stdio, Bun.serve file responses, and shell IO on both platforms, with hand-rolled intrusive refcounting and an explicit "on_read_chunk never frees" contract that two earlier revisions violated. Windows behavior is untested locally (author is on POSIX; CI build #83071 pending).

Other factors

The Windows path now depends on Node Readable semantics: after the user pauses inside the first 'data' handler, push() returns true (buffer is empty), so setFlowing(false) doesn't fire yet; maybeReadMore schedules a second _read(), whose push() buffers (not flowing) and returns false, and that triggers setFlowing(false)uv_read_stop. This is one more chunk than POSIX (which stops at the native hwm), which the test tolerates (bytes < SIZE, eventsAfterPause === 0 since chunk 2 is buffered not emitted). It looks correct but is subtle enough that I'd like a maintainer to confirm and see Windows CI green before this lands.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #83071 has four failures, all tagged [flaky] (also failing on main) and none in code paths this PR touches:

  • test/js/bun/webview/webview-chrome.test.ts (chrome animation timing)
  • test/js/bun/cron/in-process-cron.test.ts (hot-reload timing)
  • test/js/web/websocket/websocket-subprotocol-strict.test.ts
  • test/regression/issue/20144/20144.test.ts (SIGINT vs SIGKILL race)

The Windows bun-install-security-provider.test.ts regression from build #83000 is fixed (the hwm cap now only engages once a consumer has attached). The new child.stdout.pause() after flowing test passes on every lane.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase or close

robobun added 3 commits July 27, 2026 06:55
FileReader.on_read_chunk exempted pollable fds from the highwater-mark
backstop, so once the first _read() started the native reader it re-armed
the poll forever and self.buffered grew unbounded. The Node Readable
bridge in native-readable.ts also never propagated push()===false back to
the source, so even setFlowing(false) (which unregisters the poll) was
never sent.

Apply the hwm cap to pollable sources; teach has_pending_read() that a
fired-not-rearmed one-shot poll is not pending so on_pull re-arms on the
next pull; on Windows stop uv_read when the streaming parent says stop;
and wire push()->setFlowing(false) / _read()->setFlowing(true) in the
NativeReadable bridge, matching net.Socket's readStop/readStart.
…ached

Non-lazy Bun.spawn eagerly starts the pipe reader before JS touches the
stream, so applying the cap in that window stalls a child that writes to
both stdout and stderr while the test only awaits one of them. Also
release FileResponseStream's in-flight read ref in the RESPONSE_DONE
early return now that returning false stops the Windows reader.
…ref change

Calling stop_reading() from inside the uv read callback could free a
parent (FileResponseStream on abort) whose caller still dereferences
this after return. Parents that want to pause call reader().pause()
themselves; for FileReader the JS-side setFlowing(false) path does that
from a microtask, outside the callback. Keep the _buffer.clear()
unconditional so a parent that says stop does not double-buffer.

Also wire error/close to reject firstData in the new test.
@robobun
robobun force-pushed the farm/7a82ecb1/child-process-stdout-pause-backpressure branch from d254877 to d7e12b1 Compare July 27, 2026 07:00
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto 6c12afd (main now includes #35977, which made the same FileReader::on_read_chunk change for the stdin face). Resolved the conflict by keeping #35977's flowing check and folding in this PR's started guard; the remaining diff is the native-readable.ts bridge, the has_pending_read() fix, the Windows _buffer.clear() tweak, and the new test.

Verified locally after rebase: child_process.test.ts pause test, spawn.test.ts (136/0), process-stdin.test.ts (17/0), and the stderr-first-read deadlock repro all pass.

Comment thread src/runtime/webcore/FileReader.rs
Comment thread src/io/PipeReader.rs
Comment thread src/io/PipeReader.rs
… watch()

has_pending_read() using is_watching() means a re-entrant on_pull reached
via the microtask drain inside p.run() can call reader().read() and hit
EOF; the outer loop would then re-read the captured fd. Re-check
reader().is_done() after p.run().

The trailing watch() in on_pull is now a guaranteed no-op (the state it
handled is covered by the !has_pending_read() branch above) and its
comment described the old semantics.
Comment thread src/runtime/webcore/FileReader.rs

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

Beyond the inline finding: the Windows _buffer.clear() change (dropping the should_continue gate) is safe for the other streaming parents — they consume slice synchronously and is_streaming_enabled() still protects accumulating parents. The JS-initiated setFlowing(true) → reader().read() re-entrancy path is covered by d204caa because PosixBufferedReader::read() passes received_hup=false; only the poll-callback HUP entry is uncovered.

Extended reasoning...

This is the fourth review pass. The new inline finding (HUP bypass of the d204caa guard) is the same re-entrancy class as the previous round, on the one entry path where read_with_fn's && !received_hup deliberately ignores a false return. I checked the two other things d204caa touched or left in place: the unconditional Windows _buffer.clear() does not regress shell IOReader / FileResponseStream (both consume the slice inside the callback), and the new setFlowing(true) call ahead of the kPendingRead gate cannot hit the HUP variant because the JS-initiated read() hard-codes received_hup=false. Nothing else new to flag.

Comment thread src/runtime/webcore/FileReader.rs
Comment thread src/js/internal/streams/native-readable.ts
robobun added 2 commits July 27, 2026 08:56
…the reader

The HUP-gated on_read_chunk callsites ignored a false return when
received_hup was set (to let the shell PipeReader drain to bytes_read==0),
so the is_done() guard in FileReader was insufficient there: a nested
on_pull that read to EOF and handed the fd to the async closer left the
outer loop to recv on the captured fd. Check parent.is_done() after each
of those calls and return unconditionally.

Also fix the NativeReadable.push type annotation to => boolean.
Comment thread src/io/PipeReader.rs
Comment thread src/io/PipeReader.rs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/io/PipeReader.rs (1)

1063-1074: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add the re-entrancy guard before continue. on_read_chunk can close the reader, so re-check parent.is_done() here before the loop reuses the captured fd on the next syscall.

🤖 Prompt for 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.

In `@src/io/PipeReader.rs` around lines 1063 - 1074, In the streaming branch of
the read loop, after `on_read_chunk` returns and before `continue`, re-check
`parent.is_done()` and return immediately when the reader was closed. Keep the
existing buffer clearing and `keep_going` handling unchanged, ensuring the
captured file descriptor is not reused after closure.

Source: Coding guidelines

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

Outside diff comments:
In `@src/io/PipeReader.rs`:
- Around line 1063-1074: In the streaming branch of the read loop, after
`on_read_chunk` returns and before `continue`, re-check `parent.is_done()` and
return immediately when the reader was closed. Keep the existing buffer clearing
and `keep_going` handling unchanged, ensuring the captured file descriptor is
not reused after closure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 24577d7d-5bc3-448b-8d5a-5a278b108535

📥 Commits

Reviewing files that changed from the base of the PR and between f93a5dc and da32d1b.

📒 Files selected for processing (4)
  • src/io/PipeReader.rs
  • src/js/internal/streams/native-readable.ts
  • src/runtime/webcore/FileReader.rs
  • test/js/node/child_process/child_process.test.ts

Comment thread src/io/PipeReader.rs

@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 new findings on 922288b; all prior inline comments are addressed. Deferring to a human — this reworks re-entrancy and fd-lifetime handling in the POSIX/Windows read loops and changes has_pending_read() semantics for every BufferedReaderParent, which is more than I'm comfortable auto-approving.

What was reviewed:

  • has_pending_read()is_watching(): traced the new re-entrant on_pull → reader().read() path; the five parent.is_done() guards now cover every read-loop continuation site including the HUP-bypass arms.
  • FileReader::on_read_chunk hwm backstop: the !started exemption preserves eager buffering for non-lazy Bun.spawn (stderr-first-read deadlock repro reported passing).
  • Windows on_read: dropping should_continue from the _buffer.clear() gate — checked that non-streaming parents are excluded by is_streaming_enabled() and streaming parents already own/copy the chunk before returning.
  • native-readable.ts: setFlowing is optional-chained so non-FileReader native ptrs are unaffected; push type updated.
Extended reasoning...

Overview

Four files: src/io/PipeReader.rs (POSIX has_pending_read semantics + five is_done() re-entrancy guards in read_blocking_pipe/read_with_fn; Windows on_read buffer-clear gating), src/runtime/webcore/FileReader.rs (hwm backstop now gated on started && flowing, post-p.run() guard extended to reader().is_done(), dead watch() block removed), src/js/internal/streams/native-readable.ts (pushAndChecksetFlowing(false) on push()===false, _readsetFlowing(true)), and a new cross-platform test in child_process.test.ts.

Security risks

None user-facing. The risk class here is memory safety: the has_pending_read() change opens a nested read_with_fn on a captured local fd while the outer frame is still on the stack, and the async Closer::close on the WorkPool means a stale-fd read races a threadpool close. The five parent.is_done() guards were added over three review rounds specifically to close that; I believe they now cover every continuation site, but the fact that it took three rounds to enumerate them is exactly why a human should confirm.

Level of scrutiny

High. This is core I/O plumbing shared by every BufferedReaderParent (shell PipeReader, FileResponseStream, subprocess readers, FileReader), on both platforms, with re-entrant JS in the middle of the read loop. The has_pending_read() contract change is observable to every caller, and the Windows should_continue drop changes behavior for any streaming parent that returns false.

Other factors

All four of my earlier inline findings (nested-read UAF, HUP-bypass variant, dead watch(), fifth sibling site) were addressed in d204caa/8a60853/eb220c52/922288b. CI #83071 was green modulo pre-existing flakes; no CI result posted yet for the last two commits. Jarred asked for rebase-or-close and it was rebased onto #35977; no human sign-off on the merged shape yet.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #83264 (922288b, rebased onto main with #35977): two failures, both tagged [flaky] and also failing on main:

  • test/cli/run/no-orphans.test.ts (perl fast-exit daemon, darwin x64)
  • test/regression/issue/20144/20144.test.ts (SIGINT vs SIGKILL race, darwin aarch64)

Neither touches the files this PR changes. The new child.stdout.pause() after flowing test and the existing process-stdin backpressure tests pass on every lane. All review feedback (re-entrancy guards at every on_read_chunk continuation site, dead watch() removal, type annotation) is addressed and resolved.

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