Conversation
Node.js wraps each piped child stdio fd in a net.Socket (via lib/internal/child_process.js createSocket). Bun was returning a plain fs.WriteStream for stdin and a plain Readable for stdout/stderr, so `child.stdout instanceof net.Socket` was false and stdin lacked the Duplex surface (setEncoding, pause, resume, ref, unref). Packages like Nx rely on the instanceof check to decide whether a stdio stream can be unref'd. python-shell and say call setEncoding on all three stdio streams unconditionally and threw on stdin. Wrap stdin/stdout/stderr in thin net.Socket subclasses that call Duplex directly and route reads/writes to the existing FileSink / native readable, so the prototype chain matches Node while the data path is unchanged. constructNativeReadable now accepts an optional base class so child_process can supply the Socket subclass. The now-unused writableFromFileSink helper is removed. Also fixes stdin.write(str, encoding) which previously always wrote UTF-8 regardless of the requested encoding. Fixes #26505 Fixes #11011
|
Warning Review limit reached
Next review available in: 21 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Updated 2:31 AM PT - Aug 13th, 2026
✅ @robobun, your commit e7ce0d11839fd7a9babe6df7a2af4fb9f0ed5bc4 passed in 🧪 To try this PR locally: bunx bun-pr 36316That installs a local version of the PR into your bun-36316 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
Now that child_process stdout/stderr are net.Socket instances, these
need to honor net.Socket's documented contract (ref/unref return the
socket). Previously they returned undefined which broke chaining like
child.stdout.unref().on('data', ...).
There was a problem hiding this comment.
No new issues found — the earlier ref()/unref() return-value nit is fixed in 96d0dab and now covered by the test. Deferring to a human reviewer given this replaces the entire child.stdin write path and puts net.Socket.prototype (with its ~30 methods/getters) into every piped stdio stream's chain; a maintainer should sign off on the subclass-but-skip-Socket-constructor approach.
What was reviewed:
writableFromFileSinkremoval — confirmed no remaining callers insrc/._write/_final/_destroyeach null the sink before callingsink.end()and settlecbon every path (sync throw, promise reject, no-sink).- Encoding transcode in
stdinSocketWrite—decodeStrings: falseis set, and the utf8/buffer fast-path skips theBuffer.fromcopy. constructNativeReadableBaseparam: only opt-in from child_process;Readable.fromWeband other callers are unchanged.
Extended reasoning...
Overview
This PR changes child_process piped stdio from plain fs.WriteStream (stdin) / Readable (stdout/stderr) to thin net.Socket subclasses so instanceof net.Socket and Duplex checks pass, matching Node's createSocket wrapping. It touches four files: src/js/node/child_process.ts (+~170 lines: new StdinSocket/StdoutSocket lazy classes with hand-written _write/_writev/_final/_destroy routing to the existing FileSink), src/js/internal/streams/native-readable.ts (optional Base ctor param; ref/unref now return this), src/js/internal/fs/streams.ts (deletes now-unused writableFromFileSink), and adds four tests.
Security risks
None identified. No parsing of untrusted input, no auth/crypto, no new syscall surface. The change is prototype-chain reshaping around existing data paths.
Level of scrutiny
High. child.stdin/stdout/stderr are touched by essentially every child_process.spawn consumer, and this PR:
- Fully replaces the stdin write path — the old
WriteStreamfast-path (writeFast/underscoreWriteFast) is gone for stdin; writes now flow through standardWritablemachinery into a new_writethat manually handles encoding transcoding and promise-vs-sync FileSink returns. Backpressure semantics, cork/uncork, and the write-after-destroy contract all shift to a different code path. - Exposes the full
net.Socketprototype on stdout/stderr —setTimeout,setNoDelay,setKeepAlive,address(),remoteAddress,bytesRead/bytesWrittengetters,connect(), etc. are now all reachable. The subclass skips thenet.Socketconstructor entirely (callsDuplexdirectly) and hand-initializes only_handle/_parent/connecting/server/_server; whether that subset is sufficient for every prototype method that reads instance state is a design call a maintainer should confirm.
Other factors
- My earlier finding (native-readable
ref/unrefreturningundefined) was fixed in 96d0dab and the test now assertsunref() === thisfor all three streams. - Verified
writableFromFileSinkhas no remaining callers. $ERR_SOCKET_CLOSEDis a registered error code.- The comment-cop bot flags are all resolved.
- Tests exercise instanceof/shape, end-to-end read/write, encoding, and
destroySoon, and the PR shows they fail on main and pass with the fix.
Given the breadth of downstream consumers and the architectural choice (Socket subclass that bypasses Socket's own constructor), this warrants a human sign-off rather than bot approval.
FileSink's JS ref counter starts at 1, and spawn's eager stdio ref loop now reaches stdin.ref() (it was a no-op before this branch because WriteStream had no ref). Forwarding every call meant a single user unref() only brought the sink back to its initial state instead of reaching updateRef(false). Track a boolean so ref/unref behave like net.Socket's idempotent flag and the eager ref() is a no-op.
net.Socket's unref()/ref() are documented idempotent; unref();unref();ref() on stdout left the counter at -1->0 without re-refing. Also initialize kStdinUnrefed in the StdinSocket constructor so the first unref() doesn't add a new own property.
There was a problem hiding this comment.
I didn't find any new issues after the last round of fixes, but this replaces the class of every piped child.stdin/stdout/stderr with a net.Socket subclass — a wide user-facing surface change that deserves a human look.
What was reviewed:
ref()/unref()semantics for all three streams (returnthis, idempotent, eager-ref loop interaction with FileSink'sm_refCount) — fixed across three iterations.- Inherited
net.Socket.prototypemethods (read/pause/resume/_read/destroySoon/end) reachable on the new stdio sockets — all fall through safely with_handle = null/connecting = false. writableFromFileSinkremoval — no remaining callers.- New stdin
_writeencoding path and_writev/_final/_destroyerror handling — cb called on every branch.
Extended reasoning...
Overview
The PR changes child_process piped stdio streams from a plain fs.WriteStream (stdin) and Readable (stdout/stderr) into thin net.Socket subclasses, so instanceof net.Socket and Duplex succeed and methods like setEncoding/ref/unref/destroySoon are available on all three, matching Node's createSocket wrapper. constructNativeReadable gains an optional Base parameter so stdout/stderr can be constructed as the Socket subclass while keeping the existing native pull path. Stdin gets a fresh _write/_writev/_final/_destroy implementation over FileSink, and writableFromFileSink (the old stdin path) is deleted. ~180 net new lines in child_process.ts, small edits to native-readable.ts and internal/fs/streams.ts, plus 4 new tests.
Security risks
None identified. This is stream-class plumbing in the Node compat layer; no parsing of untrusted input, no auth/crypto/permissions.
Level of scrutiny
High. child_process stdio is one of the most-exercised code paths in the Node compat surface, and this PR changes both the prototype chain (every net.Socket.prototype method — read, pause, resume, setTimeout, setNoDelay, address, _read, _final, _destroy, etc. — is now reachable on these objects) and stdin's entire write path (previously the fs.WriteStream fast path, now a custom Duplex _write with decodeStrings: false and manual encoding handling). The $toClass(…, net.Socket) approach — subclassing for instanceof while calling Duplex directly and setting _handle = null — is a design decision a maintainer should sign off on; the alternative would be to actually construct a net.Socket({ fd }) like Node does.
Other factors
I reviewed this PR across three prior runs and each finding (native-readable ref/unref not returning this; stdin unref() off-by-one against the eager-ref loop; stdout unref() counter going negative; missing kStdinUnrefed field initializer) was fixed in follow-up commits. This run I additionally traced the inherited Socket.prototype.read/pause/resume through drainOnreadTail and confirmed they no-op with _handle = null and kOnreadTail unset, and confirmed writableFromFileSink has no remaining callers. The new tests cover the shape checks, end-to-end write/read, encoding, and destroySoon, but the broader ecosystem impact of swapping the stream class (packages that duck-type on the old shape, or that call other net.Socket methods now newly reachable) is best judged by a human.
|
CI on 5299c8a: 192/196 jobs passed. The 4 remaining are Ready for review. |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
If we're going this approach, can we instead make it so stdout and stderr are file descriptors we then pass to net.Socket?
|
Looked into the fd approach. On POSIX it's clean: stdin/stdout/stderr are already full-duplex
Lifting those and having child_process map Windows is the catch: Want me to go ahead with: POSIX uses real fd-backed |
…child-process-stdio-socket
…n stdin shape assertions from #32432
| // Internal $fastPath callers discard .path; do not resolve it - | ||
| // path.resolve("") needs process.cwd(), which throws when the cwd has | ||
| // been deleted (Node still spawns children in that state). |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| function ref(this: NativeReadable) { | ||
| const ptr = this.$bunNativePtr; | ||
| if (ptr === undefined) return; | ||
| if (this[kRefCount]++ === 0) { | ||
| if (ptr !== undefined && this[kRefCount]++ === 0) { | ||
| ptr.updateRef(true); | ||
| } | ||
| return this; | ||
| } |
There was a problem hiding this comment.
🟡 child.stdout.ref() is not idempotent: 5299c8a clamped unref() at 0 but ref() still does this[kRefCount]++ uncapped, so after spawn's eager ref (kRefCount=1) a redundant ref() bumps it to 2 and a subsequent unref() only brings it back to 1 — updateRef(false) never fires. Node documents socket.ref() as idempotent, and child.stdin already uses the fully-idempotent kStdinUnrefed flag, so the three stdio streams disagree. Cap ref() symmetrically (treat kRefCount as a 0/1 flag) or route stdout/stderr through the same flag path stdin uses.
Extended reasoning...
What the bug is
net.Socket.prototype.ref() is documented as idempotent: "If the socket is refed calling ref again will have no effect." Commit 5299c8a in this PR fixed the mirror case for unref() by clamping the decrement at 0 (this[kRefCount] > 0 && --this[kRefCount] === 0), but left ref() at native-readable.ts:263-269 unchanged:
function ref(this: NativeReadable) {
const ptr = this.$bunNativePtr;
if (ptr !== undefined && this[kRefCount]++ === 0) {
ptr.updateRef(true);
}
return this;
}The post-increment has no upper cap, so a redundant ref() on an already-refed stream silently drifts the counter above 1, and the next unref() fails to reach the 1→0 transition that fires updateRef(false).
Code path / step-by-step proof
For a live child.stdout pipe, constructNativeReadable installs the instance-level stream.ref = ref; stream.unref = unref; (native-readable.ts:82-83), which shadow StdoutSocket.prototype.ref/unref (the idempotent stdioSocketRef/Unref). Then:
ChildProcess#spawn's eager loopfor (let item of this.stdio) item?.ref?.();calls native-readable'sref()→kRefCount0→1 (post-increment returns 0, soupdateRef(true)fires). ✓- User (redundantly) calls
child.stdout.ref()→kRefCount++post-increment returns 1 (≠ 0), noupdateRef;kRefCount= 2. - User calls
child.stdout.unref()→kRefCount > 0 && --kRefCount === 0→ 2→1, 1 ≠ 0, noupdateRef(false).
Result: after ref(); unref() the pipe stays refed. In Node the same sequence leaves the socket unrefed.
Why existing code doesn't prevent it
The 5299c8a fix only touched the decrement side. Nothing caps the increment. And the fully-idempotent StdoutSocket.prototype.ref/unref (which use the kStdinUnrefed flag) are unreachable for live pipes because constructNativeReadable installs own-property ref/unref that shadow them — the comment in getStdoutSocket() ("Live pipes get instance _read/_destroy/ref/unref from constructNativeReadable") acknowledges this.
Internal inconsistency
Within this PR's own diff, the three stdio streams now disagree on ref/unref semantics: child.stdin uses the kStdinUnrefed boolean flag (fully idempotent both ways), while live child.stdout/stderr use a counter that's clamped only on decrement. REVIEW.md's "fix the whole class in the same PR" applies — this is the exact same pattern as the already-fixed unref() side, just on the increment.
Repro
const { spawn } = require('child_process');
const c = spawn(process.execPath, ['-e', 'setTimeout(()=>{},1e6)'], { stdio: 'pipe' });
c.stdin.ref(); c.stdin.unref(); // stdin: unrefed (flag path)
c.stdout.ref(); c.stdout.unref(); // stdout: still refed (counter went 1→2→1)Impact / severity
Nit. Triggering it requires a redundant ref() call on a stream that's already refed, which is uncommon (the motivating Nx use case only calls unref()), and pre-PR child.stdout had no ref/unref contract at all — so this is a gap in newly-added surface, not a regression. But it's a real Node-compat divergence on a stream this PR now advertises as instanceof net.Socket, and the asymmetry with stdin invites confusion.
Fix
Cap ref() symmetrically so kRefCount is effectively a 0/1 flag:
function ref(this: NativeReadable) {
const ptr = this.$bunNativePtr;
if (ptr !== undefined && this[kRefCount] === 0) {
this[kRefCount] = 1;
ptr.updateRef(true);
}
return this;
}Or drop the instance-level ref/unref install when Base is supplied and let StdoutSocket.prototype.ref/unref (the kStdinUnrefed flag path) handle it, so all three stdio streams share one idempotent implementation.
| // Internal $fastPath callers discard .path; do not resolve it - | ||
| // path.resolve("") needs process.cwd(), which throws when the cwd has | ||
| // been deleted (Node still spawns children in that state). | ||
| this.path = fastPath ? path : getValidatedPath(path); |
There was a problem hiding this comment.
🟡 Removing writableFromFileSink leaves no $fastPath: true caller that can reach the fd == null branch — the two remaining callers (ProcessObjectInternals.ts:55, tty.ts:116) both pass an fd. The fastPath ? path : getValidatedPath(path) ternary and its 3-line comment are now dead; per REVIEW.md "delete dead code in the same PR that makes it dead", drop them rather than rewording the comment (which also resolves the open comment-cop finding on line 389).
Extended reasoning...
What the finding is
This PR removes writableFromFileSink, which was the only caller that constructed a WriteStream with $fastPath: true and no fd:
// removed by this PR
const w = new WriteStream("", { $fastPath: true }); // no fd → takes the fd == null branchThe two remaining $fastPath callers both pass an fd:
src/js/builtins/ProcessObjectInternals.ts:55—new fs.WriteStream(null, { autoClose: false, fd, $fastPath: true })src/js/node/tty.ts:116—fs.WriteStream.$call(this, null, { fd, $fastPath: true, autoClose: false })
Both take the typeof options.fd === "number" branch at streams.ts:397, never the fd == null branch. $fastPath is a builtin-private name (registered in BunBuiltinNames.h), so user code cannot set it on an options object. That leaves the fastPath value inside the fd == null branch always undefined.
The specific dead code
if (fd == null) {
this[kFs] = customFs || fs;
this.fd = null;
// Internal $fastPath callers discard .path; do not resolve it -
// path.resolve("") needs process.cwd(), which throws when the cwd has
// been deleted (Node still spawns children in that state).
this.path = fastPath ? path : getValidatedPath(path);
...The ternary at line 390 now always evaluates to getValidatedPath(path), and the 3-line comment (which this PR just reworded to drop the writableFromFileSink mention) describes a "child_process spawn from deleted cwd" scenario that no longer routes through this line. This PR touched exactly these lines to update the comment rather than delete it.
Note that fastPath is still live in the later if (fastPath) { this[kWriteStreamFastPath] = ... } block further down in WriteStream(), which the fd-bearing callers do reach — so only this ternary + comment are dead, not the whole $fastPath mechanism.
Step-by-step proof
- Grep confirms exactly two
$fastPathsites remain insrc/js/after this PR: ProcessObjectInternals.ts:55 and tty.ts:116. - ProcessObjectInternals.ts:55 is the process.stdout/stderr constructor;
fdis asserted to be 1 or 2 before the call. - tty.ts:116 is
tty.WriteStream(fd);fdis the user's argument. If a user passedundefined, the code would reach thefd == nullbranch withfastPathtruthy — but that's a degenerate user error Node rejects withERR_INVALID_FD, and removing the ternary would just make Bun throw at construction (ERR_INVALID_ARG_TYPEforpath=null) instead of deferring the failure to write time — arguably an improvement, and certainly not a case the comment's "spawn from deleted cwd" rationale covers. $fastPathcompiles to a private symbol, so no external options object can set it.- Therefore, on every path that reaches
fd == nullinWriteStream(),fastPathisundefined, andfastPath ? path : getValidatedPath(path)reduces togetValidatedPath(path).
Why it should change in this PR
REVIEW.md, Code style & idioms: "Delete dead code in the same PR that makes it dead." This PR's own diff (the writableFromFileSink removal) is what kills this branch, and the PR already touched these exact lines to reword the comment — so it's in scope.
This is also the root-cause fix for the still-open comment-cop finding on streams.ts:389 ("If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong"): the workaround the comment justifies has no caller, so the right fix is deletion, not shortening.
Impact
Zero functional impact — the ternary's dead arm is never taken, so behavior is unchanged. This is a nit: harmless but directly created by (and touched in) this PR.
Fix
Replace lines 387–390 with:
this.path = getValidatedPath(path);…#43879) Fixes #43875 ### Problem - `child.stdin.end(cb)` with no data fires `cb` and `'finish'` on the next tick while earlier writes still wait behind pipe backpressure. Node waits for the drain. A parent that exits in `cb` truncates the child's input. - `child.stdin` is a fast-path `fs.WriteStream` (`src/js/internal/fs/streams.ts:474`). `writeFast` hands chunks to the native `FileSink` and never updates `_writableState`, so `end()` sees nothing pending. #28635 fixed this for `process.stdout` only, with a per-instance `_final`. ### Fix - Install `finalFast` as `_final` where the constructor installs the fast path. It awaits `fileSink.flush()`, which returns the backlog's promise when writes are pending. - Delete the copy in `getStdioWriteStream`. `process.stdout` and `process.stderr` now get it from the constructor. - Suppress `'drain'` from the fast-path sites once the stream is ending or destroyed, as `Writable`'s `afterWrite` does. The stream now stays alive while the backlog drains, which made that late `'drain'` visible. - Verified: `test/js/node/child_process/child_process.test.ts` and `test/js/node/tty.test.ts` (one new case each, both fail on 1.4.3). Also `test/regression/issue/25432.test.ts`, the `process` and `child_process` suites, and the stdio cases in `test/js/node/test/parallel`. - Self-reviewed: 1 concern raised, 1 addressed (the tty test held the backlog with a `sleep`. It now gates the read on an IPC message). ### Background - The fast path (`$fastPath: true`) has three callers: `child.stdin`, `process.stdout`/`stderr`, and `tty.WriteStream`. It replaces `write` with `writeFast`, which skips the `Writable` bookkeeping. `Writable` emits `'finish'` when `_final(cb)` calls back, or at once when there is no `_final`. - Considered a `_final` on `child.stdin` alone: it repeats #28635 and leaves `tty.WriteStream` broken. Considered routing `writeFast` through `Writable.prototype.write`: it adds bookkeeping to every `process.stdout.write`. ### Downsides - `child.stdin` and `tty.WriteStream` gain one own property: 16 to 17 and 15 to 16 own keys (`Object.getOwnPropertyNames`, 1.4.3 vs this build). - Each `end()` on those streams makes one `flush()` host call. On an empty buffer that is 0 syscalls (`IOWriter::flush` returns `Wrote(0)`, `src/io/PipeWriter.rs:969`). `strace` is not installed here, so the count is from the code. - Code that relied on `'finish'` from `child.stdin` firing before the pipe drained now sees it later. - On Windows a single in-flight `uv_write` is not reported by `flush()` (`src/io/PipeWriter.rs:2099`), so one unsent chunk can still be lost when `cb` exits. This PR fixes the multi-chunk backlog. The single-chunk case is pre-existing and needs a change in `FileSink`. <details><summary>Notes</summary> Ordering on the repro (3 untracked writes, `write(chunk, cb)`, `'finish'` listener, `end(cb)`): ``` bun 1.4.3: end(cb) 1ms, 'finish' 1ms, write(cb) 243ms this PR: write(cb), end(cb), 'finish' (same order as node) ``` `FileSink.flush()` (`src/runtime/webcore/FileSink.rs:983`) returns the pending write promise when one exists, and a number when the buffer is empty. Open PR #36316 makes `child.stdin` a `net.Socket` subclass whose `_write` goes through `Duplex`, which would also fix this for `child.stdin`. It is a larger parity change and leaves `tty.WriteStream` on the fast path. This PR does not depend on it. Related issues with the same shape in other layers: #43155 (`http.ServerResponse`, fix in #41822) and #43874 (`TLSSocket` over a `Duplex`). `bun x tsc --noEmit -p src/js/tsconfig.json` reports the same 385 pre-existing errors with and without this change. Two tests in the `child_process` suite fail on main in this container without the change: "should allow us to spawn in the default shell" and "extra stdio pipes are not double-closed on GC". </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/tty.test.ts, test/js/node/child_process/child_process.test.ts <!-- robobun:evidence:end -->
What does this PR do?
Fixes #26505.
Fixes #11011.
Node.js wraps each piped child stdio fd in a
net.Socket(vialib/internal/child_process.jscreateSocket), sochild.stdin/stdout/stderrareDuplexstreams withnet.Socketin their prototype chain. Bun was returning a plainfs.WriteStreamforstdinand a plainReadableforstdout/stderr:Packages rely on this:
instanceof net.Socketon worker stdio to decide whether tounref()the stream (fix(core): do not throw error if worker.stdout is not instanceof socket nrwl/nx#34224).setEncodingon all three stdio streams unconditionally and throw onstdinbecause it was a plainWritable.Fix
Wrap
stdin/stdout/stderrin thinnet.Socketsubclasses that callDuplexdirectly and route reads/writes to the existingFileSink/ native readable, so the prototype chain matches Node while the data path is unchanged.constructNativeReadablenow accepts an optional base class sochild_processcan supply theSocketsubclass forstdout/stderr. The now-unusedwritableFromFileSinkhelper is removed.This also fixes
child.stdin.write(str, encoding), which previously always wrote the string as UTF-8 regardless of the requested encoding becauseFileSink.writedoes not take an encoding argument.After:
Supersedes #32432 (which made
stdinaDuplexbut not anet.Socket); #32432 is closed and its remaining assertions (thepause/resume/write/end/destroySoonsurface and thesignal === nullcheck) are folded into the tests here.After merging
main,streamFdOf()(added by #31829 so that one child'sstdincan be passed as another child's stdio target) also reads the fd from the new stdin socket's sink, since the sink no longer lives onfs.WriteStream's fast-path slot.child_process.test.ts"accepts another subprocess's stdin as a stdio target" covers that path.How did you verify your code works?
Added tests in
test/js/node/child_process/child-process-stdio.test.jsthat assert all three streams areinstanceof net.Socket/Duplexwith the expected surface, that writes and reads still flow end to end, thatstdin.writehonours theencodingargument, and thatstdin.destroySoonflushes then closes. The tests fail on the current release and pass with this change. Existingchild_processtests continue to pass.[review] gate passed · iteration 1 · 4 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 5 passed · 0 rejected · iteration 1
evidence per changed file