From 012f1ff04fbb1003fad00f59ac6b41a0f460b0c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:52:46 +0000 Subject: [PATCH 1/9] child_process: make piped stdio streams instances of net.Socket 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 --- src/js/internal/fs/streams.ts | 12 - src/js/internal/streams/native-readable.ts | 4 +- src/js/node/child_process.ts | 219 ++++++++++++++---- .../child_process/child-process-stdio.test.js | 140 +++++++++++ 4 files changed, 320 insertions(+), 55 deletions(-) diff --git a/src/js/internal/fs/streams.ts b/src/js/internal/fs/streams.ts index 44e8d040fd7b..e11d9bf75a4b 100644 --- a/src/js/internal/fs/streams.ts +++ b/src/js/internal/fs/streams.ts @@ -812,20 +812,8 @@ function thenIfPromise(maybePromise: Promise | T, cb: any) { } } -function writableFromFileSink(fileSink: any) { - $assert(typeof fileSink === "object", "fileSink is not an object"); - $assert(typeof fileSink.write === "function", "fileSink.write is not a function"); - $assert(typeof fileSink.end === "function", "fileSink.end is not a function"); - const w = new WriteStream("", { $fastPath: true }); - $assert(w[kWriteStreamFastPath] === true, "fast path not enabled"); - w[kWriteStreamFastPath] = fileSink; - w.path = undefined; - return w; -} - export default { ReadStream, WriteStream, kWriteStreamFastPath, - writableFromFileSink, }; diff --git a/src/js/internal/streams/native-readable.ts b/src/js/internal/streams/native-readable.ts index c2e773947157..10d97841a3ca 100644 --- a/src/js/internal/streams/native-readable.ts +++ b/src/js/internal/streams/native-readable.ts @@ -52,12 +52,12 @@ interface NativePtr { let debugId = 0; -function constructNativeReadable(readableStream: ReadableStream, options): NativeReadable { +function constructNativeReadable(readableStream: ReadableStream, options, Base?): NativeReadable { $assert(typeof readableStream === "object" && readableStream instanceof ReadableStream, "Invalid readable stream"); const bunNativePtr = (readableStream as any).$bunNativePtr; $assert(typeof bunNativePtr === "object", "Invalid native ptr"); - const stream = new Readable(options); + const stream = Base !== undefined ? new Base(options) : new Readable(options); stream._read = read; stream._destroy = destroy; diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 5902816c49ec..704f29614c42 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1191,42 +1191,13 @@ class ChildProcess extends EventEmitter { switch (i) { case 0: { switch (io) { - case "pipe": { - const stdin = handle?.stdin; - - if (!stdin) { - // This can happen if the process was already killed. - const Writable = require("internal/streams/writable"); - const stream = new Writable({ - write(chunk, encoding, callback) { - // Gracefully handle writes - stream acts as if it's ended - if (callback) callback(); - return false; - }, - }); - // Mark as destroyed to indicate it's not usable - stream.destroy(); - return stream; - } - const result = require("internal/fs/streams").writableFromFileSink(stdin); - result.readable = false; - return result; - } + case "pipe": + // handle.stdin can be undefined if the process was already killed. + return createStdinSocket(handle?.stdin); case "inherit": return null; - case "destroyed": { - const Writable = require("internal/streams/writable"); - const stream = new Writable({ - write(chunk, encoding, callback) { - // Gracefully handle writes - stream acts as if it's ended - if (callback) callback(); - return false; - }, - }); - // Mark as destroyed to indicate it's not usable - stream.destroy(); - return stream; - } + case "destroyed": + return createStdinSocket(undefined); case "undefined": return undefined; default: @@ -1238,25 +1209,22 @@ class ChildProcess extends EventEmitter { switch (io) { case "pipe": { const value = handle?.[fdToStdioName(i as 1 | 2)!]; + const Ctor = getStdoutSocket(); // This can happen if the process was already killed. if (!value) { - const Readable = require("internal/streams/readable"); - const stream = new Readable({ read() {} }); - // Mark as destroyed to indicate it's not usable + const stream = new Ctor({ read() {} }); stream.destroy(); return stream; } - const pipe = require("internal/streams/native-readable").constructNativeReadable(value, {}); + const pipe = require("internal/streams/native-readable").constructNativeReadable(value, {}, Ctor); this.#closesNeeded++; pipe.once("close", () => this.#maybeClose()); if (autoResume) pipe.resume(); return pipe; } case "destroyed": { - const Readable = require("internal/streams/readable"); - const stream = new Readable({ read() {} }); - // Mark as destroyed to indicate it's not usable + const stream = new (getStdoutSocket())({ read() {} }); stream.destroy(); return stream; } @@ -1750,6 +1718,175 @@ function fdToStdioName(fd: number) { } } +// Node.js wraps each piped stdio fd in a `net.Socket` (see lib/internal/child_process.js +// createSocket). Bun's stdin/stdout/stderr are backed by a FileSink / native readable +// rather than a handle net.Socket understands, so use a thin subclass that inherits from +// net.Socket (so `instanceof net.Socket` holds) but drives Duplex directly and overrides +// the methods that would otherwise touch net.Socket's `_handle`. +const kStdinSink = Symbol("kStdinSink"); +let StdinSocket; +let StdoutSocket; + +function initStdioSocket(self, options) { + const Duplex = require("internal/streams/duplex"); + Duplex.$call(self, options); + // Enough of net.Socket's instance shape for its prototype methods to be safe + // with no native handle attached. + self._handle = null; + self._parent = null; + self.connecting = false; + self.server = null; + self._server = null; +} + +function stdioSocketRef(this: any) { + this[kStdinSink]?.ref?.(); + return this; +} + +function stdioSocketUnref(this: any) { + this[kStdinSink]?.unref?.(); + return this; +} + +function stdinSocketWrite(this: any, chunk: any, encoding: any, cb: any) { + const sink = this[kStdinSink]; + if (!sink) { + cb($ERR_SOCKET_CLOSED()); + return false; + } + try { + // FileSink treats string input as UTF-8; transcode other encodings here. + if ( + typeof chunk === "string" && + encoding !== undefined && + encoding !== "utf8" && + encoding !== "utf-8" && + encoding !== "buffer" + ) { + chunk = Buffer.from(chunk, encoding); + } + const result = sink.write(chunk); + if ($isPromise(result)) { + result.then(() => cb(), cb); + return false; + } + cb(); + return true; + } catch (err) { + cb(err); + return false; + } +} + +function stdinSocketWritev(this: any, chunks: any, cb: any) { + const buffers = new Array(chunks.length); + for (let i = 0; i < chunks.length; i++) { + const { chunk, encoding } = chunks[i]; + buffers[i] = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; + } + stdinSocketWrite.$call(this, BufferConcat(buffers), "buffer", cb); +} + +function stdinSocketFinal(this: any, cb: any) { + const sink = this[kStdinSink]; + if (sink) { + this[kStdinSink] = undefined; + try { + const result = sink.end(); + if ($isPromise(result)) { + result.then(() => cb(), cb); + return; + } + } catch (err) { + cb(err); + return; + } + } + cb(); +} + +function stdinSocketDestroy(this: any, err: any, cb: any) { + const sink = this[kStdinSink]; + if (sink) { + this[kStdinSink] = undefined; + try { + const result = sink.end(err); + if ($isPromise(result)) { + result.then( + () => cb(err), + (e: any) => cb(e || err), + ); + return; + } + } catch (e) { + cb(e || err); + return; + } + } + cb(err); +} + +function getStdinSocket() { + if (StdinSocket === undefined) { + if (!NetModule) NetModule = require("node:net"); + StdinSocket = function Socket(this: any) { + initStdioSocket(this, { + readable: false, + decodeStrings: false, + autoDestroy: true, + emitClose: true, + }); + this[kStdinSink] = undefined; + }; + $toClass(StdinSocket, "Socket", NetModule.Socket); + StdinSocket.prototype._write = stdinSocketWrite; + StdinSocket.prototype._writev = stdinSocketWritev; + StdinSocket.prototype._final = stdinSocketFinal; + StdinSocket.prototype._destroy = stdinSocketDestroy; + StdinSocket.prototype.ref = stdioSocketRef; + StdinSocket.prototype.unref = stdioSocketUnref; + } + return StdinSocket; +} + +function stdoutSocketDestroy(this: any, err: any, cb: any) { + cb(err); +} + +function getStdoutSocket() { + if (StdoutSocket === undefined) { + if (!NetModule) NetModule = require("node:net"); + StdoutSocket = function Socket(this: any, options: any) { + initStdioSocket(this, { + ...options, + writable: false, + allowHalfOpen: false, + autoDestroy: true, + emitClose: true, + }); + }; + $toClass(StdoutSocket, "Socket", NetModule.Socket); + // constructNativeReadable installs instance _read/_destroy/ref/unref for + // live pipes; these cover the already-exited-process fallback. + StdoutSocket.prototype._destroy = stdoutSocketDestroy; + StdoutSocket.prototype.ref = stdioSocketRef; + StdoutSocket.prototype.unref = stdioSocketUnref; + } + return StdoutSocket; +} + +function createStdinSocket(sink: any) { + const Ctor = getStdinSocket(); + const stream = new Ctor(); + if (sink) { + stream[kStdinSink] = sink; + } else { + stream.destroy(); + } + return stream; +} + function getBunStdioFromOptions(stdio) { const normalizedStdio = normalizeStdio(stdio); if (normalizedStdio.filter(v => v === "ipc").length > 1) throw $ERR_IPC_ONE_PIPE(); diff --git a/test/js/node/child_process/child-process-stdio.test.js b/test/js/node/child_process/child-process-stdio.test.js index 78b6454f9243..67d94efe1d22 100644 --- a/test/js/node/child_process/child-process-stdio.test.js +++ b/test/js/node/child_process/child-process-stdio.test.js @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe } from "harness"; import { execSync, spawn } from "node:child_process"; import { once } from "node:events"; +import net from "node:net"; +import { Duplex, Readable, Writable } from "node:stream"; const CHILD_PROCESS_FILE = import.meta.dir + "/spawned-child.js"; const OUT_FILE = import.meta.dir + "/stdio-test-out.txt"; @@ -166,3 +168,141 @@ describe("child.stdin", () => { }); }); }); + +// Node wraps piped stdio in `net.Socket` instances (lib/internal/child_process.js +// createSocket). Packages like Nx check `instanceof net.Socket` to decide whether +// to `unref()` the stream. +// https://github.com/oven-sh/bun/issues/26505 +// https://github.com/oven-sh/bun/issues/11011 +describe("ChildProcess stdio pipe streams are net.Socket", () => { + it("stdin/stdout/stderr pass instanceof and shape checks", async () => { + const child = spawn(bunExe(), ["-e", ""], { env: bunEnv, stdio: "pipe" }); + try { + const shape = name => ({ + "constructor.name": child[name].constructor.name, + "instanceof net.Socket": child[name] instanceof net.Socket, + "instanceof Duplex": child[name] instanceof Duplex, + "instanceof Readable": child[name] instanceof Readable, + "instanceof Writable": child[name] instanceof Writable, + "typeof ref": typeof child[name].ref, + "typeof unref": typeof child[name].unref, + "typeof setEncoding": typeof child[name].setEncoding, + }); + expect({ + stdin: shape("stdin"), + stdout: shape("stdout"), + stderr: shape("stderr"), + "stdin.readable": child.stdin.readable, + "stdin.writable": child.stdin.writable, + }).toEqual({ + stdin: { + "constructor.name": "Socket", + "instanceof net.Socket": true, + "instanceof Duplex": true, + "instanceof Readable": true, + "instanceof Writable": true, + "typeof ref": "function", + "typeof unref": "function", + "typeof setEncoding": "function", + }, + stdout: { + "constructor.name": "Socket", + "instanceof net.Socket": true, + "instanceof Duplex": true, + "instanceof Readable": true, + "instanceof Writable": true, + "typeof ref": "function", + "typeof unref": "function", + "typeof setEncoding": "function", + }, + stderr: { + "constructor.name": "Socket", + "instanceof net.Socket": true, + "instanceof Duplex": true, + "instanceof Readable": true, + "instanceof Writable": true, + "typeof ref": "function", + "typeof unref": "function", + "typeof setEncoding": "function", + }, + "stdin.readable": false, + "stdin.writable": true, + }); + } finally { + child.stdin.end(); + await once(child, "close"); + } + }); + + it("stdin still delivers writes to the child and stdout still delivers reads", async () => { + const child = spawn(bunExe(), [CHILD_PROCESS_FILE, "STDIN", "FLOWING"], { + env: bunEnv, + stdio: "pipe", + }); + expect(child.stdin instanceof net.Socket).toBe(true); + expect(child.stdout instanceof net.Socket).toBe(true); + + for (const name of ["stdin", "stdout", "stderr"]) { + expect(child[name].setEncoding("utf8")).toBe(child[name]); + } + + let data = ""; + child.stdout.on("data", chunk => { + data += chunk; + }); + + child.stdin.write("hello"); + child.stdin.write(" "); + child.stdin.write("world\n"); + child.stdin.end(); + + const [code] = await once(child, "close"); + expect(data).toBe("data: hello world\n"); + expect(code).toBe(0); + }); + + it("stdin.write respects the encoding argument", async () => { + const child = spawn( + bunExe(), + ["-e", `process.stdin.on("data", chunk => process.stdout.write(chunk.toString("hex")))`], + { env: bunEnv, stdio: ["pipe", "pipe", "ignore"] }, + ); + + let data = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", chunk => { + data += chunk; + }); + + child.stdin.write("\u00e9", "latin1"); + child.stdin.write("\u00e9", "utf8"); + child.stdin.write(Buffer.from([0xff])); + child.stdin.end(); + + const [code] = await once(child, "close"); + // latin1 é = 0xe9, utf8 é = 0xc3 0xa9, raw buffer = 0xff + expect(data).toBe("e9c3a9ff"); + expect(code).toBe(0); + }); + + it("stdin.destroySoon flushes pending writes then closes", async () => { + const child = spawn(bunExe(), [CHILD_PROCESS_FILE, "STDIN", "FLOWING"], { + env: bunEnv, + stdio: "pipe", + }); + + let data = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", chunk => { + data += chunk; + }); + + child.stdin.write("abc\n"); + child.stdin.destroySoon(); + + const [code] = await once(child, "close"); + expect(data).toBe("data: abc\n"); + expect(child.stdin.destroyed).toBe(true); + expect(code).toBe(0); + }); +}); From 96d0dabd7a131debe2dd6f916e25f4c36c918235 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:15:29 +0000 Subject: [PATCH 2/9] native-readable: return this from ref()/unref() 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', ...). --- src/js/internal/streams/native-readable.ts | 8 ++++---- test/js/node/child_process/child-process-stdio.test.js | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/js/internal/streams/native-readable.ts b/src/js/internal/streams/native-readable.ts index 10d97841a3ca..1609f1fdcc10 100644 --- a/src/js/internal/streams/native-readable.ts +++ b/src/js/internal/streams/native-readable.ts @@ -245,18 +245,18 @@ function destroy(this: NativeReadable, error: any, cb: () => void) { 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; } function unref(this: NativeReadable) { const ptr = this.$bunNativePtr; - if (ptr === undefined) return; - if (this[kRefCount]-- === 1) { + if (ptr !== undefined && this[kRefCount]-- === 1) { ptr.updateRef(false); } + return this; } export default { constructNativeReadable }; diff --git a/test/js/node/child_process/child-process-stdio.test.js b/test/js/node/child_process/child-process-stdio.test.js index 67d94efe1d22..2d7801af13b1 100644 --- a/test/js/node/child_process/child-process-stdio.test.js +++ b/test/js/node/child_process/child-process-stdio.test.js @@ -187,6 +187,8 @@ describe("ChildProcess stdio pipe streams are net.Socket", () => { "typeof ref": typeof child[name].ref, "typeof unref": typeof child[name].unref, "typeof setEncoding": typeof child[name].setEncoding, + "unref() === this": child[name].unref() === child[name], + "ref() === this": child[name].ref() === child[name], }); expect({ stdin: shape("stdin"), @@ -204,6 +206,8 @@ describe("ChildProcess stdio pipe streams are net.Socket", () => { "typeof ref": "function", "typeof unref": "function", "typeof setEncoding": "function", + "unref() === this": true, + "ref() === this": true, }, stdout: { "constructor.name": "Socket", @@ -214,6 +218,8 @@ describe("ChildProcess stdio pipe streams are net.Socket", () => { "typeof ref": "function", "typeof unref": "function", "typeof setEncoding": "function", + "unref() === this": true, + "ref() === this": true, }, stderr: { "constructor.name": "Socket", @@ -224,6 +230,8 @@ describe("ChildProcess stdio pipe streams are net.Socket", () => { "typeof ref": "function", "typeof unref": "function", "typeof setEncoding": "function", + "unref() === this": true, + "ref() === this": true, }, "stdin.readable": false, "stdin.writable": true, From 7ac0f5e220765de5656720d93c2947eb3f1dd95f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:18:28 +0000 Subject: [PATCH 3/9] trim comments --- src/js/node/child_process.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 704f29614c42..14869890bd55 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1718,11 +1718,8 @@ function fdToStdioName(fd: number) { } } -// Node.js wraps each piped stdio fd in a `net.Socket` (see lib/internal/child_process.js -// createSocket). Bun's stdin/stdout/stderr are backed by a FileSink / native readable -// rather than a handle net.Socket understands, so use a thin subclass that inherits from -// net.Socket (so `instanceof net.Socket` holds) but drives Duplex directly and overrides -// the methods that would otherwise touch net.Socket's `_handle`. +// Node's createSocket wraps each piped stdio in a net.Socket; subclass it for +// `instanceof` but route I/O to the FileSink / native readable that backs Bun's pipes. const kStdinSink = Symbol("kStdinSink"); let StdinSocket; let StdoutSocket; @@ -1730,8 +1727,7 @@ let StdoutSocket; function initStdioSocket(self, options) { const Duplex = require("internal/streams/duplex"); Duplex.$call(self, options); - // Enough of net.Socket's instance shape for its prototype methods to be safe - // with no native handle attached. + // net.Socket prototype methods read these; there is no native handle here. self._handle = null; self._parent = null; self.connecting = false; @@ -1867,8 +1863,7 @@ function getStdoutSocket() { }); }; $toClass(StdoutSocket, "Socket", NetModule.Socket); - // constructNativeReadable installs instance _read/_destroy/ref/unref for - // live pipes; these cover the already-exited-process fallback. + // Live pipes get instance _read/_destroy/ref/unref from constructNativeReadable. StdoutSocket.prototype._destroy = stdoutSocketDestroy; StdoutSocket.prototype.ref = stdioSocketRef; StdoutSocket.prototype.unref = stdioSocketUnref; From 071d3fb145fd241fab61ceb67f5eb1854b385b89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:24:43 +0000 Subject: [PATCH 4/9] ci: retrigger From 9d213dde4d6c63de645b93d2da6d8d09c2134947 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:58:57 +0000 Subject: [PATCH 5/9] child_process: make stdin ref/unref an idempotent flag 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. --- src/js/node/child_process.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 14869890bd55..958c9f9e967c 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1721,6 +1721,7 @@ function fdToStdioName(fd: number) { // Node's createSocket wraps each piped stdio in a net.Socket; subclass it for // `instanceof` but route I/O to the FileSink / native readable that backs Bun's pipes. const kStdinSink = Symbol("kStdinSink"); +const kStdinUnrefed = Symbol("kStdinUnrefed"); let StdinSocket; let StdoutSocket; @@ -1735,13 +1736,22 @@ function initStdioSocket(self, options) { self._server = null; } +// FileSink's own ref counter starts at 1, so treat ref/unref as an idempotent +// flag (like net.Socket) instead of forwarding every call; spawn's eager +// ref() loop is then a no-op and one user unref() reaches updateRef(false). function stdioSocketRef(this: any) { - this[kStdinSink]?.ref?.(); + if (this[kStdinUnrefed]) { + this[kStdinUnrefed] = false; + this[kStdinSink]?.ref?.(); + } return this; } function stdioSocketUnref(this: any) { - this[kStdinSink]?.unref?.(); + if (!this[kStdinUnrefed]) { + this[kStdinUnrefed] = true; + this[kStdinSink]?.unref?.(); + } return this; } From c6a39ad6f7ad3316ecfc686f964ffe28fe570901 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:59:33 +0000 Subject: [PATCH 6/9] trim comment --- src/js/node/child_process.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 958c9f9e967c..c57f0293692f 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1736,9 +1736,7 @@ function initStdioSocket(self, options) { self._server = null; } -// FileSink's own ref counter starts at 1, so treat ref/unref as an idempotent -// flag (like net.Socket) instead of forwarding every call; spawn's eager -// ref() loop is then a no-op and one user unref() reaches updateRef(false). +// Idempotent flag like net.Socket; FileSink's counter already starts at 1. function stdioSocketRef(this: any) { if (this[kStdinUnrefed]) { this[kStdinUnrefed] = false; From 5299c8ab952d57b76355cce04d8ddd0f3e1334ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:23:41 +0000 Subject: [PATCH 7/9] clamp native-readable unref() at 0 and pre-declare kStdinUnrefed 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. --- src/js/internal/streams/native-readable.ts | 2 +- src/js/node/child_process.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/internal/streams/native-readable.ts b/src/js/internal/streams/native-readable.ts index 1609f1fdcc10..fb0216e77499 100644 --- a/src/js/internal/streams/native-readable.ts +++ b/src/js/internal/streams/native-readable.ts @@ -253,7 +253,7 @@ function ref(this: NativeReadable) { function unref(this: NativeReadable) { const ptr = this.$bunNativePtr; - if (ptr !== undefined && this[kRefCount]-- === 1) { + if (ptr !== undefined && this[kRefCount] > 0 && --this[kRefCount] === 0) { ptr.updateRef(false); } return this; diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index c57f0293692f..280692320933 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1842,6 +1842,7 @@ function getStdinSocket() { emitClose: true, }); this[kStdinSink] = undefined; + this[kStdinUnrefed] = false; }; $toClass(StdinSocket, "Socket", NetModule.Socket); StdinSocket.prototype._write = stdinSocketWrite; From d9649f6d35a83a047f71049c6491abff41ce0f76 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 13 Aug 2026 03:58:32 +0000 Subject: [PATCH 8/9] child_process: let streamFdOf read the fd from a stdin socket; fold in stdin shape assertions from #32432 --- src/js/node/child_process.ts | 3 +- .../child_process/child-process-stdio.test.js | 69 +++++++++---------- 2 files changed, 33 insertions(+), 39 deletions(-) diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 64e0d3d8ea1a..ed390449a81a 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1668,7 +1668,8 @@ function streamFdOf(item): number | undefined { if (item.destroyed) return undefined; - const sink = item[require("internal/fs/streams").kWriteStreamFastPath]; + // Another child's stdin socket, or a fs.WriteStream on its FileSink fast path. + const sink = item[kStdinSink] ?? item[require("internal/fs/streams").kWriteStreamFastPath]; if (sink && sink !== true) { const fd = sink._getFd(); if (typeof fd === "number" && fd >= 0) return fd; diff --git a/test/js/node/child_process/child-process-stdio.test.js b/test/js/node/child_process/child-process-stdio.test.js index 2d7801af13b1..5eebf4accfc2 100644 --- a/test/js/node/child_process/child-process-stdio.test.js +++ b/test/js/node/child_process/child-process-stdio.test.js @@ -175,6 +175,8 @@ describe("child.stdin", () => { // https://github.com/oven-sh/bun/issues/26505 // https://github.com/oven-sh/bun/issues/11011 describe("ChildProcess stdio pipe streams are net.Socket", () => { + // https://github.com/oven-sh/bun/issues/11011 (setEncoding on child.stdin) + // https://github.com/oven-sh/bun/issues/26505 (instanceof net.Socket) it("stdin/stdout/stderr pass instanceof and shape checks", async () => { const child = spawn(bunExe(), ["-e", ""], { env: bunEnv, stdio: "pipe" }); try { @@ -187,9 +189,31 @@ describe("ChildProcess stdio pipe streams are net.Socket", () => { "typeof ref": typeof child[name].ref, "typeof unref": typeof child[name].unref, "typeof setEncoding": typeof child[name].setEncoding, + "typeof pause": typeof child[name].pause, + "typeof resume": typeof child[name].resume, + "typeof write": typeof child[name].write, + "typeof end": typeof child[name].end, + "typeof destroySoon": typeof child[name].destroySoon, "unref() === this": child[name].unref() === child[name], "ref() === this": child[name].ref() === child[name], }); + const expected = { + "constructor.name": "Socket", + "instanceof net.Socket": true, + "instanceof Duplex": true, + "instanceof Readable": true, + "instanceof Writable": true, + "typeof ref": "function", + "typeof unref": "function", + "typeof setEncoding": "function", + "typeof pause": "function", + "typeof resume": "function", + "typeof write": "function", + "typeof end": "function", + "typeof destroySoon": "function", + "unref() === this": true, + "ref() === this": true, + }; expect({ stdin: shape("stdin"), stdout: shape("stdout"), @@ -197,42 +221,9 @@ describe("ChildProcess stdio pipe streams are net.Socket", () => { "stdin.readable": child.stdin.readable, "stdin.writable": child.stdin.writable, }).toEqual({ - stdin: { - "constructor.name": "Socket", - "instanceof net.Socket": true, - "instanceof Duplex": true, - "instanceof Readable": true, - "instanceof Writable": true, - "typeof ref": "function", - "typeof unref": "function", - "typeof setEncoding": "function", - "unref() === this": true, - "ref() === this": true, - }, - stdout: { - "constructor.name": "Socket", - "instanceof net.Socket": true, - "instanceof Duplex": true, - "instanceof Readable": true, - "instanceof Writable": true, - "typeof ref": "function", - "typeof unref": "function", - "typeof setEncoding": "function", - "unref() === this": true, - "ref() === this": true, - }, - stderr: { - "constructor.name": "Socket", - "instanceof net.Socket": true, - "instanceof Duplex": true, - "instanceof Readable": true, - "instanceof Writable": true, - "typeof ref": "function", - "typeof unref": "function", - "typeof setEncoding": "function", - "unref() === this": true, - "ref() === this": true, - }, + stdin: expected, + stdout: expected, + stderr: expected, "stdin.readable": false, "stdin.writable": true, }); @@ -250,7 +241,8 @@ describe("ChildProcess stdio pipe streams are net.Socket", () => { expect(child.stdin instanceof net.Socket).toBe(true); expect(child.stdout instanceof net.Socket).toBe(true); - for (const name of ["stdin", "stdout", "stderr"]) { + // python-shell calls setEncoding on all three stdio streams unconditionally. + for (const name of ["stdout", "stdin", "stderr"]) { expect(child[name].setEncoding("utf8")).toBe(child[name]); } @@ -264,9 +256,10 @@ describe("ChildProcess stdio pipe streams are net.Socket", () => { child.stdin.write("world\n"); child.stdin.end(); - const [code] = await once(child, "close"); + const [code, signal] = await once(child, "close"); expect(data).toBe("data: hello world\n"); expect(code).toBe(0); + expect(signal).toBeNull(); }); it("stdin.write respects the encoding argument", async () => { From e7ce0d11839fd7a9babe6df7a2af4fb9f0ed5bc4 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 13 Aug 2026 04:16:02 +0000 Subject: [PATCH 9/9] fs/streams: drop the reference to the removed writableFromFileSink helper --- src/js/internal/fs/streams.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/internal/fs/streams.ts b/src/js/internal/fs/streams.ts index 13f36f2ea74c..21406f722e4d 100644 --- a/src/js/internal/fs/streams.ts +++ b/src/js/internal/fs/streams.ts @@ -384,9 +384,9 @@ function WriteStream(this: FSStream, path: string | null, options?: any): void { if (fd == null) { this[kFs] = customFs || fs; this.fd = null; - // Internal $fastPath callers (writableFromFileSink) 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). + // 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); const { flags, mode } = options; this.flags = flags === undefined ? "w" : flags;