Skip to content

child_process: apply kernel backpressure to stdout/stderr pipes - #34971

Merged
Jarred-Sumner merged 6 commits into
mainfrom
farm/9dc876f2/child-process-stdout-backpressure
Jul 21, 2026
Merged

child_process: apply kernel backpressure to stdout/stderr pipes#34971
Jarred-Sumner merged 6 commits into
mainfrom
farm/9dc876f2/child-process-stdout-backpressure

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

node:child_process stdout/stderr pipes had no backpressure: a child writing 100 MB into a pipe nobody reads was never blocked (completes in <4 s; Node blocks at the ~64 KiB kernel pipe buffer). The parent buffered the whole output in RAM with no ceiling, and if 'exit' fired before a 'data' consumer attached the entire buffered output was discarded, so a late reader got 0 of 104,857,600 bytes.

import { spawn } from 'node:child_process';
const c = spawn('sh', ['-c', 'dd if=/dev/zero bs=1M count=100 2>/dev/null'], { stdio: ['ignore', 'pipe', 'ignore'] });
await new Promise(r => setTimeout(r, 4000));
console.log('exitedBeforeAttach', c.exitCode !== null); // node: false  bun: true
let got = 0; for await (const d of c.stdout) got += d.length;
console.log('bytes', got, 'of', 100 * 1048576);         // node: 104857600  bun: 0

Cause

PipeReader::start() registered the fd with epoll/IOCP immediately, so the event loop drained the child's output into PosixBufferedReader._buffer (which has no HWM for non-streaming parents) before any JS consumer attached. When the child exited, #handleOnExit called resume() on a stream with no 'data' listener, dropping every buffered chunk.

Fix

When Bun.spawn is called with lazy: true (the documented "don't start reading stdout until accessed" option, which node:child_process uses internally), start the pipe reader paused: set IS_PAUSED before PosixBufferedReader::start() so it skips register_poll(), and on Windows wire the source without calling start_reading(). The kernel pipe buffer then provides backpressure and the child blocks until JS first reads, matching Node. FileReader::on_start() clears IS_PAUSED before its first read() so the transferred reader wakes up.

Also:

  • #handleOnExit now matches Node's flushStdio: only resume() stdout/stderr when readableFlowing === null (the stream was never touched), so an explicitly paused stream is not drained behind the user's back.
  • Fixed a debug-build $debug template literal in native-readable.ts that interpolated the resolved Uint8Array directly. Uint8Array.prototype.toString() comma-joins every byte, so a 220 KB chunk spent ~600 ms stringifying under ASAN. This was pre-existing but the chunkier delivery made it visible.
  • Dropped lazy: true from the huge() fixture in spawn.test.ts; its after exit subtest relied on the eager buffering this removes (with correct lazy semantics a 250 KB write cannot finish before stdout is read).

Verification

$ bun-debug repro.mjs
exitedBeforeAttach false
bytes 104857600 of 104857600

The new stdout pipe backpressure > blocks the child until a reader attaches and delivers every byte test in test/js/node/child_process/child_process.test.ts fails on main (child exits with exitCode: 0 within 10 ms) and passes with the fix.


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

node:child_process spawn() registered the stdout/stderr poll immediately,
so the native reader drained the child's output into an unbounded
in-memory buffer before any JS consumer attached. A child writing 100 MB
into a pipe nobody was reading finished in seconds (instead of blocking
at the ~64 KB kernel pipe buffer as it does under Node), the parent
buffered the whole output in RAM, and once 'exit' fired the autoResume
path discarded the entire buffer so a late reader received 0 bytes.

Defer poll registration / uv_read_start until JS first pulls when
Bun.spawn is called with lazy: true (the documented semantics and what
child_process uses internally). The kernel pipe buffer now provides
backpressure so the child blocks until stdout is read, matching Node.

Also:
- match Node's flushStdio: only resume stdout/stderr on exit when the
  stream was never touched (readableFlowing === null)
- fix a debug-build-only $debug template literal in native-readable that
  stringified a Uint8Array per chunk (~600 ms per 220 KB chunk under
  ASAN), which the chunkier delivery surfaced
- drop lazy: true from the spawn.test.ts huge() fixture, whose
  'after exit' subtest relied on the eager buffering this removes
@coderabbitai

coderabbitai Bot commented Jul 21, 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: 13 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: 3ff7b150-1a72-4e2c-a3bb-b2e93dde08c1

📥 Commits

Reviewing files that changed from the base of the PR and between e88559f and f88d7de.

📒 Files selected for processing (2)
  • src/js/node/child_process.ts
  • test/js/node/child_process/child_process.test.ts

Walkthrough

Changes

Subprocess pipe lifecycle

Layer / File(s) Summary
Lazy pipe initialization and poll state
src/runtime/api/bun/subprocess/SubprocessPipeReader.rs, src/runtime/api/bun/js_bun_spawn_bindings.rs, src/io/PipeReader.rs, src/runtime/webcore/FileReader.rs, test/js/bun/spawn/spawn.test.ts
Pipe startup accepts lazy mode, defers reads and poll registration while paused, preserves keep-alive state, and adds lazy pipe lifecycle coverage.
Post-exit draining and stream resume
src/runtime/api/bun/subprocess.rs, src/js/node/child_process.ts, src/js/internal/streams/native-readable.ts, test/js/node/child_process/child_process.test.ts
Process-exit handling unpauses and reads pipe readers, stream resumption checks readable state, debug output is reformatted, and POSIX backpressure coverage is added.

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 is concise and accurately summarizes the main change: applying kernel backpressure to child_process stdout/stderr pipes.
Description check ✅ Passed It includes the problem, cause, fix, and verification details required by the template, though the headings differ from the requested format.

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

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:21 PM PT - Jul 21st, 2026

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


🧪   To try this PR locally:

bunx bun-pr 34971

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

bun-34971 --bun

Comment thread src/runtime/api/bun/subprocess/SubprocessPipeReader.rs
… drain

A lazy pipe reader that JS never accesses stayed IS_PAUSED forever, so
on_process_exit's reader.read() early-returned, on_reader_done never
fired, and has_pending_activity() kept the Subprocess Strong-rooted.
Call unpause() before read() in the on_process_exit Readable::Pipe arms
so the remaining kernel-buffered bytes are drained and the slot
transitions to Readable::Buffer. Backpressure is moot once the direct
child has exited.
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green on build 77082: the new stdout pipe backpressure tests, the lazy: true releases an unread pipe leak test, and esbuild-child_process.test.ts pass on all lanes. No subprocess/child_process/FileReader/native-readable test failed anywhere.

Remaining red is unrelated flake, all either tagged [flaky] or also failing on main:

  • bun-server.test.ts websocket-idle CPU on darwin-14-aarch64 (4.8% vs 2% threshold): also failing on main build 76984, same lane; the fixture does not spawn a subprocess
  • complex-workspace.test.ts install/migration on debian x64-asan
  • bun-install-registry.test.ts Windows hoisting (reported as a main break)
  • test-http-server-connections-checking-leak.js Alpine
  • es-module-lexer.test.ts Windows x64
  • require-cache.test.ts / 20144.test.ts darwin-26

Both bot review threads are resolved (on_process_exit unpause in 755e1d2, KEEP_ALIVE flag in e88559f, readableFlowing gate removed in f88d7de). Ready for review.

With a lazy subprocess pipe reader the FilePoll is not created until
on_start() runs, so an unref() issued before the first _read (as esbuild
does: child.stdout.unref() synchronously after spawn) was a no-op and the
poll later registered with keep-alive enabled, pinning the event loop.

Track the desired state in PosixFlags::KEEP_ALIVE (default true) so
update_ref() records it before the poll exists and register_poll()
honours it when the poll is finally created.
Comment thread src/js/node/child_process.ts Outdated
Node v26's flushStdio resumes any readable stdio stream regardless of
readableFlowing; the extra === null guard prevented a lazily-started
reader that was explicitly pause()d from ever draining after the child
exited, so ChildProcess 'close' never fired and the fd leaked. Keep the
readable/!destroyed checks and drop the readableFlowing clause.

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

Both earlier findings (the lazy-reader Strong-root leak and the readableFlowing === null drain gate) are addressed in 755e1d2 and f88d7de, and this pass found nothing new. Deferring to a human given the scope — this rewires subprocess pipe start/pause/keep-alive semantics across POSIX and Windows and changes what lazy: true means for the public Bun.spawn API.

What was reviewed:

  • on_process_exit now unpause()s before read() for both stdout/stderr arms; Windows was already covered via read()start_reading().
  • PosixFlags::KEEP_ALIVE propagates through from() so an unref() before first poll registration is honoured after transfer to FileReader.
  • Windows lazy start() path leaves the source wired (set_parent already set .data) and IS_PAUSED set so a later start_reading() is well-formed.
  • #handleOnExit now gates on readable && !destroyed only, matching Node's flushStdio; the new pause()-before-exit test covers the regression.
Extended reasoning...

Overview

Nine files: PipeReader.rs (new KEEP_ALIVE flag, deferred register_poll() when IS_PAUSED, update_ref now &mut), SubprocessPipeReader.rs (start() gains lazy param; POSIX inserts IS_PAUSED pre-start, Windows wires source without uv_read_start), subprocess.rs (on_process_exit unpauses lazy readers before draining), FileReader.rs (on_start unpauses a from_pipe reader before first read()), js_bun_spawn_bindings.rs (threads !IS_SYNC && lazy into start()), child_process.ts (#handleOnExit resume gate), native-readable.ts (debug-log stringification fix), plus tests in spawn.test.ts and child_process.test.ts.

Security risks

None identified. No parsing of untrusted input, no auth/crypto surface. The change is I/O lifecycle plumbing.

Level of scrutiny

High. This is core subprocess pipe lifecycle across two platforms with hand-managed refcounts, FilePoll keep-alive accounting, and GC-visible pending-activity state. The PR already surfaced two real bugs during review (a Strong-root/fd leak and a 'close'-never-fires hang) plus one CI regression (esbuild unref() before first poll). It also changes observable semantics of the documented Bun.spawn({lazy: true}) option — a child writing more than the kernel pipe buffer will now block until stdout is read, which is the point of the fix but is a behaviour change for direct Bun.spawn users (the huge() fixture in spawn.test.ts had to drop lazy: true for exactly this reason).

Other factors

  • Both of my prior inline findings are resolved with targeted fixes and regression tests (WeakRef GC test in spawn.test.ts; pause()-then-close test in child_process.test.ts).
  • The Windows lazy path is untested here (both new tests are skipIf(isWindows) / skipIf(!isPosix)); coverage relies on existing Windows CI lanes exercising node:child_process generally.
  • Build #77082 was still running at time of review; the previous build surfaced the esbuild unref() regression, so green CI on the latest commit is worth confirming before merge.
  • The update_ref &self&mut self signature change ripples to disable/enable_keeping_process_alive; any callers holding only &PosixBufferedReader would fail to compile, so if it builds it's consistent.

@Jarred-Sumner
Jarred-Sumner merged commit 6593ca2 into main Jul 21, 2026
51 of 53 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/9dc876f2/child-process-stdout-backpressure branch July 21, 2026 22:24
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