child_process: support piping subprocess stdio streams between spawns#30850
child_process: support piping subprocess stdio streams between spawns#30850robobun wants to merge 19 commits into
Conversation
|
Updated 11:40 PM PT - Jun 20th, 2026
❌ @robobun, your commit 4ddde40 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 30850That installs a local version of the PR into your bun-30850 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughExpose readable/writable stream fds to JS and forward them into child stdio with POSIX prep and JS-side quiescing; add regression tests for multiple piping scenarios; and JSON-stringify the built OVERLAY_CSS define to avoid lexer issues on older Bun versions. ChangesStream fd exposure and child_process stdio integration
CSS build serialization fix
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
17d22b5 to
452d9c4
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@src/js/node/child_process.ts`:
- Around line 515-516: The call to markStreamsAsStdio is happening after
Bun.spawnSync returns, so it cannot prevent parent-side readers racing the
child; move the quiescing step to before the synchronous spawn. Specifically, in
the spawnSync flow that uses getBunStdioFromOptions (referencing bunStdio and
streamsToQuiesce) call markStreamsAsStdio(streamsToQuiesce) (and perform any
necessary quiesce/forwarding setup) before invoking Bun.spawnSync(...) so
parent-side stream readers are marked/stopped while the child runs; apply the
same change to the parallel block around the 553-556 region.
In `@test/regression/issue/30831.test.ts`:
- Around line 105-118: The test never asserts that the source process (pSource)
exited successfully, so add an explicit exit-code capture and assertion for
pSource similar to how pFilter's exit is captured: create a Promise for
sourceExit that resolves to pSource.exitCode or waits for the "exit" event,
include that Promise in the Promise.all alongside
filterStdout/filterStderr/sourceStderr, then assert sourceExit === 0 (place this
assertion after asserting stdout/stderr and before asserting the filter exit) in
both the reverse-path and failure-path tests that reference
pSource/pFilter/out/filterErr/sourceErr/filterExit so the source process exit is
verified and exit-code assertions remain last.
🪄 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: 9e89d03f-c765-4fc8-81e1-fc1601b7579e
📒 Files selected for processing (18)
src/codegen/bake-codegen.tssrc/crash_handler/lib.rssrc/errno/lib.rssrc/js/internal/fs/streams.tssrc/js/internal/streams/native-readable.tssrc/js/node/child_process.tssrc/perf/tracy.rssrc/runtime/api/streams.classes.tssrc/runtime/cli/Arguments.rssrc/runtime/cli/run_command.rssrc/runtime/cli/upgrade_command.rssrc/runtime/jsc_hooks.rssrc/runtime/webcore/FileReader.rssrc/runtime/webcore/ReadableStream.rssrc/runtime/webview/ChromeProcess.rssrc/spawn/process.rssrc/spawn_sys/spawn_process.rstest/regression/issue/30831.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/js/node/child_process.ts:1091-1102— Whenp1.stdoutis handed to another spawn,p1never emits'close': accessingp1.stdoutbumped#closesNeededand registeredpipe.once('close', …), butmarkStreamAsStdio'ssetFlowing(false)unregisters the FilePoll and this guard now skips theresume()that would have re-armed it — the NativeReadable never sees EOF, never emits'close', and#closesGotstalls one short of#closesNeeded. The reverse-direction regression test only awaits'exit'/exitCodeso this isn't caught. When skippingresume()for akIsUsedAsStdiostream, also.destroy()it (or otherwise call#maybeClose()for that slot) so the source process's'close'event can fire.Extended reasoning...
What the bug is
The source
ChildProcesswhosestdout/stderris passed to another spawn's stdio will never emit its'close'event.'exit'fires correctly, but'close'is gated on#closesGot === #closesNeeded, and the handed-off stream's slot in that count is never satisfied.This is the success-path counterpart to the already-filed 🔴 comment on
markStreamAsStdio(which covers the spawn-failure path leaving the stream irreversibly paused). Here both spawns succeed, the pipeline works, both children exit — butp1.on('close', cb)never fires.The code path
-
#closesNeededis bumped. Accessingp1.stdoutruns#getBunSpawnIo(1), which doesthis.#closesNeeded++andpipe.once('close', () => this.#maybeClose())(child_process.ts:1206-1208).p1now needs that NativeReadable to emit'close'before it can emit its own. -
The native poll is unregistered. Passing
p1.stdouttospawn(..., {stdio:[p1.stdout,...]})triggersnodeToBun → markStreamAsStdio, which callsptr.setFlowing(false).FileReader::set_flowing(false)(FileReader.rs:1040) callsself.reader().pause(), andPosixBufferedReader::pause(PipeReader.rs:301-312) setsIS_PAUSEDand unregisters the FilePoll (poll.unregister(loop, false)). On Windows the equivalent callsuv_read_stop. The parent's read end of the pipe is now off the event loop. -
Nothing re-arms it. When
p1exits,#handleOnExitruns. The new!stdout[kIsUsedAsStdio]guard (correctly) skipsstdout.resume?.()so the parent doesn't steal bytes from the child. But thatresume()was also the only thing that would have driven the stream toward EOF after exit. Even if it did run, JS-sideresume()→_read→ptr.pull→FileReader::on_pullwould seeflowing == falseand returnPendingwithout issuing aread(2), so it wouldn't help anyway —setFlowing(false)is sticky at the native layer. -
No EOF, no
'close'. With the poll unregistered, the parent never receives a readable/HUP notification whenp1's write end closes, soon_reader_donenever runs and the NativeReadable neverpush(null)s. Nothing destroys it either. It never emits'end'or'close'. -
'close'onp1never fires.#closesGotreaches#closesNeeded - 1(the base#maybeClose()from#handleOnExitplus any other stdio that did close) and stops.p1.emit('close', exitCode, signalCode)is never reached.
Why nothing else catches it
Subprocess.on_process_exit's "kick a final read" fallback (if let Readable::Pipe(pipe) = self.stdout.get() { reader.read() }) doesn't fire: oncep1.stdoutwas accessed,Readable::to_jsreplaced the nativeSubprocess.stdoutslot withReadable::Closed, so the match arm doesn't apply.updateRef(false)inmarkStreamAsStdiomeans the stuck stream doesn't keep the event loop alive, so a script that only awaits'close'will exit without the callback firing rather than hang forever — but the callback still never runs.- The PR's reverse-direction test (
stdio: [pSource.stdout!, ...]) exercises exactly this topology but only awaits'exit'andexitCode, never'close', so it passes.
Step-by-step proof
const p1 = spawn(bunExe(), ['-e', 'process.stdout.write("hi")'], { stdio: ['ignore','pipe','pipe'] }); // p1.stdout getter → #getBunSpawnIo(1): // #closesNeeded = 2; pipe.once('close', () => #maybeClose()) const p2 = spawn(bunExe(), ['-e', '...'], { stdio: [p1.stdout, 'pipe', 'pipe'] }); // nodeToBun(p1.stdout, 0) → markStreamAsStdio: // p1.stdout[kIsUsedAsStdio] = true // ptr.setFlowing(false) → reader().pause() → FilePoll unregistered // ptr.updateRef(false); p1.stdout.pause() p1.on('close', () => console.log('close')); // ← never fires // p1 exits → onExit → #handleOnExit: // stdout is defined, kIsUsedAsStdio=true → resume() SKIPPED // emit('exit', 0, null) // #maybeClose(): #closesGot=1, #closesNeeded=2 → no 'close' // p1.stdout's poll is unregistered; never sees EOF; never emits 'close' // #closesGot stuck at 1.
(In the actual regression test
#closesNeededis 3 becausepSource.stderris also accessed; stderr does close normally, leaving the count stuck at 2/3.)Comparison with Node
Node's
flushStdioalso skipskIsUsedAsStdiostreams, andreadStop()similarly stops libuv reads. The difference is that Node'snet.Socket._readcallshandle.readStart()on demand, so the pause is recoverable, and Node tracks stdio close via the handle's'close'rather than the stream's flowing state. In Bun,setFlowing(false)is sticky (no user-land path flips it back) and the FilePoll is fully removed from the loop, so there is no recovery path at all.How to fix
In
#handleOnExit, when thekIsUsedAsStdioguard suppressesresume(), the stream's role in close-accounting still needs to be satisfied. The simplest option is to destroy the wrapper at that point:} else if (stdout && this.#stdioOptions[1] === "pipe" && !stdout?.destroyed) { if (stdout[kIsUsedAsStdio]) { // fd was handed to another child; we'll never read it again. Destroy so // 'close' fires and #maybeClose can complete. stdout.destroy?.(); } else { stdout.resume?.(); } }
(and the same for
stderr). Alternatively, callthis.#maybeClose()directly for the handed-off slot, or havemarkStreamAsStdioarrange for the sourceChildProcessto decrement#closesNeeded— butdestroy()here is closest to the existing structure and also releases the parent's now-useless read fd. -
|
For the |
There was a problem hiding this comment.
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/regression/issue/30831.test.ts`:
- Around line 132-135: The test currently uses a timer-based guard via
Promise.race([... setTimeout(...)]) around sourceClose; remove the timing-based
logic and await the close condition directly by replacing the Promise.race block
with a direct await of sourceClose (i.e., await sourceClose) so the test waits
for the close event instead of relying on setTimeout and the "pSource 'close'
never fired" error.
🪄 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: e17bbef9-51f4-48f0-853a-ebbe0070d212
📒 Files selected for processing (2)
src/runtime/webcore/FileReader.rstest/regression/issue/30831.test.ts
|
CI flaking on different single shards each run — a single 1/20 shard of |
2da8234 to
0d72f4f
Compare
spawn({ stdio: [..., proc.stdin, ...] }) and spawn({ stdio: [proc.stdout, ...] })
both threw `TODO: stream.Readable/Writable stdio @ N`. Node's `subprocess.stdin`
is a `net.Socket` that exposes its pipe fd via `._handle.fd`; Bun's
`subprocess.stdin` is a WriteStream wrapping a FileSink, and `.stdout`/`.stderr`
is a native Readable wrapping a ReadableStreamSource — neither surfaced a fd
where `nodeToBun()` looks.
Three changes, all required — removing any one breaks a real shape:
1. Expose fd on the wrappers
- `writableFromFileSink` in internal/fs/streams.ts calls
`fileSink._getFd()` and sets `.fd` on the returned WriteStream.
- `constructNativeReadable` in internal/streams/native-readable.ts calls
`bunNativePtr.getFd()` (new method added to
FileInternalReadableStreamSource) and sets `.fd` on the returned
Readable.
Now `nodeToBun`'s existing `.fd` / `._handle.fd` lookup returns a fd
for both shapes.
2. Quiesce the source stream when its fd is handed off
`nodeToBun` now also marks the stream with a `kIsUsedAsStdio` symbol,
calls the native ptr's `setFlowing(false)` / `updateRef(false)`
(libuv-`readStop` equivalent), and `.pause()`s the JS Readable.
`#handleOnExit`'s drain logic skips `stdout.resume?.()` on
`kIsUsedAsStdio` streams. This mirrors Node's `getValidStdio`
post-spawn hook in `lib/internal/child_process.js` — without it our
parent-side PipeReader races the child for the pipe's bytes and the
child usually loses.
3. Clear `O_NONBLOCK` before dup2
Bun's `Bun.spawn` sets parent-side pipe fds non-blocking for its own
async reads. `dup2` shares the open file description so the child
inherits `O_NONBLOCK` and its first `read(2)` returns `EAGAIN` —
`cat` exits 1 with "Resource temporarily unavailable". For
`PosixStdio::Pipe(fd)` (caller-supplied fd), clear O_NONBLOCK before
dup2; the parent's own reader has been paused by (2) so it won't need
non-blocking anymore.
Fixes #30831
The diff-hygiene check flags any 'TODO' in added lines. The three hits were prose describing the old error message, not markers of undone work — rewrite them so the hygiene check is clean. No behavior change.
`OVERLAY_CSS` is a minified CSS string that starts with `*{...}`. The JSON
lexer rejects `*` / `?` / `(` / `)` before `parse_env_json`'s auto-quote
fallback runs, so any bun without the #30679 lexer recovery fix fails at
bake-codegen when building bun itself:
error: Unsupported syntax: Operators are not allowed in JSON
at defines.json:1:1
The real fix is already on main (lexer recovery), but build pipelines using
an older release bun to bootstrap can't bundle bake until the release bun is
re-rolled. Pre-stringifying here is cheap, forward-compatible (the new lexer
accepts a quoted JSON string unchanged), and unblocks those build lanes.
Fixes a correctness issue raised in review:
`markStreamAsStdio` ran inline in `nodeToBun` during option normalization,
\*before\* `Bun.spawn` runs. If spawn then threw
(ENOENT/EACCES/EMFILE/ENFILE/EAGAIN, or any other error), the source
stream's `$bunNativePtr.setFlowing(false)` had already been applied — and
that call is sticky at the native layer with no user-recoverable
counterpart. The user would be left with a paused subprocess stdout
that no public API can restart.
Refactor:
- `nodeToBun` now takes a `streamsToQuiesce` out-array and records the
stream instead of pausing it inline.
- `getBunStdioFromOptions` returns `{ bunStdio, streamsToQuiesce }`.
- Both `spawn` (async, in `ChildProcess#spawn`) and `spawnSync` apply
`markStreamsAsStdio(streamsToQuiesce)` only after the Bun.spawn call
returns successfully — inside the try block, before the `spawn` event
nextTick. The catch block never runs it, so a failed spawn leaves the
source stream untouched.
Also:
- Rewrite the `update_nonblocking` comment in `spawn_process.rs` to stop
using `markStreamAsStdio` as the load-bearing justification — the
`PosixStdio::Pipe(fd)` arm is shared by every caller-supplied fd
(direct numeric stdio, `Bun.file(fd)`, memfd, stream-fd extraction),
and `O_NONBLOCK` is per-OFD so the parent's fd unavoidably flips with
the child's. This matches libuv's post-fork `uv__nonblock_fcntl` and
is documented Node parity.
- Rewrite the third test to actually exercise the `process.stdout` /
`process.stderr` passthrough path via a sub-bun runner (previously the
test passed `"ignore", "pipe", "pipe"` strings and didn't exercise
`extractStreamFd` at all).
- Add a fourth test that asserts a passed-in source stream is still
fully consumable after a spawn failure (ENOENT) — this test fails
without the pre-spawn-quiesce fix.
- Drop the stale "three shapes" count from `extractStreamFd`'s JSDoc
(it only handles two).
When `p1.stdout` is passed as stdio to another spawn:
1. Accessing `p1.stdout` in the parent bumped `#closesNeeded` and
registered `pipe.once('close', #maybeClose)`.
2. `markStreamsAsStdio` then called `setFlowing(false)` →
`reader().pause()` → FilePoll unregistered. The stream never
sees EOF on its own.
3. At exit, `#handleOnExit`'s drain logic skipped `resume()` for
`kIsUsedAsStdio` streams (to avoid racing the child for bytes).
4. With no EOF and no resume, the NativeReadable never emitted
`'close'`; `#closesGot` stalled one short of `#closesNeeded`;
`p1` never emitted its own `'close'`.
Fix: in the skipped branch, call `.destroy()` on the handed-off
stream. `destroy()` fires `'close'` on the next tick and the
source ChildProcess's close-accounting completes. Releases the
parent-side read fd too. Libraries that wait on `'close'`
(e.g. `execa`) no longer hang.
Also extends the reverse-direction test to assert `'close'` fires
on pSource (with a 5s timeout guard) and adds explicit
`sourceExit` assertions to the reverse and failure-path tests per
review comment.
`FileReader::get_fd()` called `Fd::uv()` on the pipe handle. On Windows the pipe is a system-kind `Fd` (`Fd::from_system(pipe.fd())` from `WindowsBufferedReader::Source::Pipe`), and `Fd::uv()` panics for non-stdio system HANDLEs. Result: any `child_process.spawn` with piped stdout/stderr would crash the parent on Windows as soon as the user accessed `proc.stdout` — a regression to all existing piped spawn usage, not just the new inter-process feature. Guard the Windows path: return `-1` for now. `constructNativeReadable` then skips setting `stream.fd`, `extractStreamFd` returns undefined, and `nodeToBun` falls back to the stream-stdio-unsupported error — graceful degradation of the new feature rather than a crash. Also skip the 4 regression tests on Windows; the feature isn't implemented there yet (would need a HANDLE→uv-fd round-trip via `makeLibUVOwned`). `FileSink::get_fd()` already had the right shape (`DecodeWindows::Windows(_) => -1`) so no fix needed on the WriteStream side.
Review feedback (coderabbit): tests shouldn't use setTimeout to guard a condition — the bun:test per-test timeout already bounds the wait. If pSource's 'close' event never fires (pre-fix behavior), awaiting it directly times out rather than racing a self-imposed 5s deadline.
The two cross-references to bake-codegen.ts's OVERLAY_CSS as the example of a raw-unquoted-CSS define value are now stale — the previous commit wraps it in JSON.stringify(). Soften the two call sites to 'originally' / 'now pre-JSON.stringify'd' so readers clicking through don't get confused. Lexer recovery and test coverage still valid.
0931695 to
cdc2cdb
Compare
…ditional-property-access
|
The red The code here is clean under the linter: running oxlint 1.70.0 locally surfaced 3 real |
NativeReadable.destroy() now nulls the cached fd that constructNativeReadable exposes for stdio forwarding. ptr.cancel() closes the underlying pipe fd, so leaving stream.fd set let a destroyed subprocess stdout report a stale (closed or kernel-reused) descriptor to a later spawn, which would dup2 the wrong fd into the child. Mirrors the WriteStream fast-path close(). spawnSync no longer quiesces source streams. Bun.spawnSync blocks the JS thread until the child exits, so there is no parent/child read race to prevent and markStreamsAsStdio only ran once the child was already gone; its irreversible setFlowing(false) permanently bricked the source stream (e.g. process.stdin). Node likewise does not mark streams in spawnSync. Adds regression coverage for both.
Two documentation fixes for comments that went stale across this PR's own follow-up commits: - spawn_process.rs: the O_NONBLOCK comment said the parent's reader is "already paused" at the clear site, but markStreamsAsStdio now runs after Bun.spawn returns. Reword to "paused on the same JS tick once spawn returns" (no FilePoll fires in between), which is the accurate justification for why parent-going-blocking is moot. - child_process.ts: markStreamsAsStdio's JSDoc still listed Bun.spawnSync as a caller, but the sync path no longer quiesces. Drop the stale reference and note the sync path deliberately skips it.
…leak extractStreamFd now returns undefined for a destroyed stream. The WriteStream fast-path only nulls the cached fd in close(), which the async _destroy branch skips when the sink still has buffered bytes, so a destroyed subprocess.stdin could otherwise forward a stale (closed or kernel-reused) fd to a later spawn. Guarding at extractStreamFd covers the readable and writable sides and any future destroy-path drift. Also redesign the spawnSync regression test: its old runner did process.stdin.resume() + process.exit(0), which triggers a pre-existing LeakSanitizer report for the lazily-created Bun.stdin store Blob (BunObject.rs make_blob, unrelated to this PR) and failed the x64-asan lane. It now hands a live subprocess's stdout to spawnSync and asserts its flowing state is untouched, exercising the same fix without touching process.stdin. Adds a writable-side regression test for the destroyed stdin path.
There was a problem hiding this comment.
Thanks — 5476676 addresses the last stale-fd concern by guarding item.destroyed in extractStreamFd (with a writable-side regression test). No further issues from my side, but given the scope (fd hand-off across spawn, O_NONBLOCK clearing in spawn_process_posix, native stream quiescing) this still warrants a human look before merge.
Extended reasoning...
Overview
This PR enables passing one subprocess's stdio stream as another subprocess's stdio (e.g. spawn(..., { stdio: [p1.stdout, ...] })), matching Node.js. It touches: node:child_process stdio normalization (nodeToBun/extractStreamFd/markStreamsAsStdio/#handleOnExit); native stream fd exposure (FileReader::get_fd in Rust, getFd host-fn in ReadableStream.rs/streams.classes.ts, .fd assignment in native-readable.ts and fs/streams.ts); POSIX spawn fd prep (clearing O_NONBLOCK in spawn_process_posix); plus an unrelated drive-by fix wrapping OVERLAY_CSS in JSON.stringify in bake-codegen.ts with two cross-reference comments updated. ~300 lines of new regression tests.
Status of prior feedback
This PR has been through ~10 review rounds. All of my prior inline comments are resolved. The last one (🔴 writable-side stale-fd-after-destroy) was addressed in commit 5476676 via option (b) — extractStreamFd now rejects item.destroyed === true up front, with a JSDoc explaining why and a new test (a destroyed subprocess stdin does not leak a stale fd to a later spawn). The bug-hunting system found nothing this round.
Security risks
The change manipulates fd inheritance into child processes (dup2) and clears O_NONBLOCK on caller-supplied fds in the parent. The destroyed-stream guard prevents the obvious stale-fd hazard. No auth/crypto/permissions surface. Risk is correctness/lifecycle (fd reuse, hung streams, parent/child read races) rather than a security boundary.
Level of scrutiny
High. This is core child_process plumbing that affects every Bun user spawning subprocesses, with subtle cross-layer interactions (JS stream state ↔ native FilePoll ↔ posix_spawn fd actions), platform divergence (Windows returns -1 from get_fd to gracefully degrade), and several edge cases that took multiple rounds to get right (spawn-failure ordering, spawnSync, destroyed streams, 'close' accounting). Not appropriate for bot-only approval.
Other factors
Test coverage is good (7 regression tests covering forward/reverse piping, process.stdout passthrough, ENOENT, destroyed readable, destroyed writable, spawnSync). The bake-codegen change is an unrelated drive-by but trivial. CI status was previously flaky on unrelated shards per the author's note; the lint failure is a repo-wide issue (#32522) unrelated to this PR. No human reviewer has weighed in yet.
CI status (for a maintainer deciding to merge)The diff is green on the lanes it actually affects, and all review threads are resolved (CodeRabbit + Claude, last review: no further issues). The remaining red checks are all unrelated to this change and I cannot clear them from here:
The originally-reported x64-asan LeakSanitizer failure in the new test is fixed (the spawnSync test was redesigned off a pre-existing |
nodeToBun now throws "Cannot use a destroyed stream as stdio[N]" when a destroyed stream is passed as stdio, instead of the generic "TODO: stream.Readable/Writable stdio" which reads as "this stream type is not implemented". The generic message still covers genuinely unsupported shapes (no fd available, Windows, custom streams). Move the stdio-stream regression tests from test/regression/issue/30831.test.ts to test/js/node/child_process/child-process-stdio-streams.test.ts. Per CLAUDE.md, test/regression/issue/ is for true regressions; #30831 and #25498 are a feature gap (the behavior never worked, it threw a TODO placeholder), so the tests belong in the child_process module's dir.
Repro
HELLO WORLD.error: TODO: stream.Readable stdio @ 1.Also broken in reverse:
spawn(..., { stdio: [p1.stdout, 'pipe', 'inherit'] }).Cause
nodeToBun()innode:child_processlooks for.fdor._handle.fdon stream stdio. Node'ssubprocess.stdinis anet.Socketthat exposes its pipe fd on._handle.fd; Bun's is aWriteStreamwrapping aFileSink, and.stdout/.stderris a nativeReadablewrapping aReadableStreamSource— neither surfaced a fd wherenodeToBunlooks. Just forwarding the fd isn't enough either — two more problems follow (seetest/regression/issue/30831.test.tsfor the full reasoning).Fix
Three changes, all required — removing any one breaks one of the three cases in the test file:
.fdon the wrappers.writableFromFileSinknow readsfileSink._getFd();constructNativeReadablecalls a newgetFd()method onFileInternalReadableStreamSource(returnsreader.get_fd()).nodeToBun's existing.fd/._handle.fdlookup then finds it.nodeToBunextracts an fd from a stream, it also pauses the stream (setFlowing(false) + updateRef(false) on the native ptr,.pause()on the JS Readable) and tags itkIsUsedAsStdio.#handleOnExitskipsstdout.resume?.()on tagged streams. Mirrors Node'sgetValidStdiopost-spawn hook (readStop()+pause()+kIsUsedAsStdioinlib/internal/child_process.js) — without it the parent's own PipeReader races the child for the pipe's bytes.O_NONBLOCKbeforedup2. Bun sets pipe fds non-blocking in the parent for its own async reads;dup2shares the open file description so the child inherits non-blocking mode. A synchronous reader likecatseesEAGAINon its firstread(2)and exits 1 with 'Resource temporarily unavailable'.PosixStdio::Pipe(fd)now clearsO_NONBLOCKon caller-supplied fds beforedup2; safe because step (2) has paused the parent-side reader.Verification
test/regression/issue/30831.test.ts— three tests covering forward piping (p2.stdinas p1's stdout slot), reverse (p1.stdoutas p2's stdin slot), and a regression-guard forprocess.stdout/process.stderrpassthrough.Fixes #30831
Fixes #25498