From bf10bd93100759037f82528411ebc0813b80b72b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:13:11 +0000 Subject: [PATCH 1/4] child_process: accept stdio entries that carry an fd (server._handle, { fd }) nodeToBun() only translated numbers, streams and the string table, so spawning with a listening server's handle in stdio (the way Node hands a listen socket to a child as fd 3) threw `Invalid stdio option[3]`. Node's getValidStdio shares the descriptor of any entry with a numeric `fd` property; Bun's Listener, TCPSocket and UDP handles all expose one, so do the same for non-stream objects. Remove test-listen-fd-detached{,-inherit}.js: until now their grandchild died on this error and the files exited 0 without exercising anything. With spawn working, their child runs http.Server#listen({ fd: 3 }), which Bun still ignores (it listens on a fresh port), so the request to the inherited port hangs the test and leaks a detached child. They can come back once http listen({ fd }) is implemented. --- src/js/node/child_process.ts | 18 ++- .../child_process/child-process-stdio.test.js | 139 +++++++++++++++++- .../test-listen-fd-detached-inherit.js | 118 --------------- .../test/parallel/test-listen-fd-detached.js | 115 --------------- 4 files changed, 149 insertions(+), 241 deletions(-) delete mode 100644 test/js/node/test/parallel/test-listen-fd-detached-inherit.js delete mode 100644 test/js/node/test/parallel/test-listen-fd-detached.js diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index ed70f054cd77..ca9ec5629fab 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1709,7 +1709,7 @@ function streamFdOf(item): number | undefined { return undefined; } -function nodeToBun(item: string, index: number): string | number | null | NodeJS.TypedArray | ArrayBufferView { +function nodeToBun(item, index: number): string | number | null | NodeJS.TypedArray | ArrayBufferView { // If not defined, use the default. // For stdin/stdout/stderr, it's pipe. For others, it's ignore. if (item == null) { @@ -1728,6 +1728,14 @@ function nodeToBun(item: string, index: number): string | number | null | NodeJS `Passing a stream.${kind} without an underlying file descriptor as stdio[${index}] is not yet implemented in Bun`, ); } + // Any other object carrying a descriptor is shared with the child, like + // node's `typeof stdio.fd === 'number'` branch. This is how a handle such as + // `server._handle` (a listening socket) ends up as the child's fd 3: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L1058 + if (typeof item === "object") { + const fd = item.fd; + if (typeof fd === "number") return fd; + } const result = nodeToBunLookup[item]; if (result === undefined) { throw new Error(`Invalid stdio option[${index}] "${item}"`); @@ -1785,7 +1793,7 @@ function getBunStdioFromOptions(stdio) { // overlapped -- same as pipe on Unix based systems // inherit -- 'inherit': equivalent to ['inherit', 'inherit', 'inherit'] or [0, 1, 2] // ignore -- > /dev/null, more or less same as null option for Bun.spawn stdio - // TODO: Stream -- use this stream + // Stream, handle or { fd } object -- its underlying FD is shared with the child // number -- used as FD // null, undefined: Use default value. Not same as ignore, which is Bun.spawn null. // null/undefined: For stdio fds 0, 1, and 2 (in other words, stdin, stdout, and stderr) a pipe is created. For fd 3 and up, the default is 'ignore' @@ -1800,7 +1808,7 @@ function getBunStdioFromOptions(stdio) { // overlapped -> pipe // ignore -> null // inherit -> inherit (stdin/stdout/stderr) - // Stream -> throw err for now + // Stream / handle / { fd } -> fd (a stream without an fd throws) const bunStdio = normalizedStdio.map(nodeToBun); return bunStdio; } @@ -1820,9 +1828,7 @@ function normalizeStdio(stdio): string[] { throw ERR_INVALID_OPT_VALUE("stdio", stdio); } } else if ($isJSArray(stdio)) { - // Validate if each is a valid stdio type - // TODO: Support wrapped types here - + // Each entry is validated and translated by nodeToBun. let processedStdio; if (stdio.length === 0) processedStdio = ["pipe", "pipe", "pipe"]; else if (stdio.length === 1) processedStdio = [stdio[0], "pipe", "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..0c6f5a47c03c 100644 --- a/test/js/node/child_process/child-process-stdio.test.js +++ b/test/js/node/child_process/child-process-stdio.test.js @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; -import { execSync, spawn } from "node:child_process"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { execSync, spawn, spawnSync } from "node:child_process"; import { once } from "node:events"; +import { closeSync, openSync, readFileSync } from "node:fs"; +import { connect, createServer } from "node:net"; +import { join } from "node:path"; const CHILD_PROCESS_FILE = import.meta.dir + "/spawned-child.js"; const OUT_FILE = import.meta.dir + "/stdio-test-out.txt"; @@ -166,3 +169,135 @@ describe("child.stdin", () => { }); }); }); + +// Node shares the descriptor of any stdio entry that has a numeric `fd` +// property (lib/internal/child_process.js, getValidStdio). Its own +// test-listen-fd-* tests rely on that to hand `server._handle` to a child as +// fd 3; in Bun that handle is the Bun.listen() Listener, which exposes `fd`. +// Socket descriptors cannot be inherited as stdio on Windows (same as Node), +// so the socket cases are POSIX only. +describe("stdio entries carrying a file descriptor", () => { + async function listeningServer(onConnection) { + const server = createServer(onConnection); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return { server, port: server.address().port }; + } + + it.skipIf(isWindows)("a listening server's handle is inherited by the child as that fd", async () => { + const { server, port } = await listeningServer(conn => conn.end("hello from parent")); + let child; + try { + child = spawn( + bunExe(), + [ + "-e", + `require("net").createServer(c => c.end("hello from child")).listen({ fd: 3 }, () => console.log("listening"));`, + ], + { env: bunEnv, stdio: ["ignore", "pipe", "inherit", server._handle] }, + ); + // Like Node, a shared descriptor gets no stream in child.stdio. + expect(child.stdio[3]).toBeNull(); + + // Close the parent's copy so the child's inherited descriptor is the only + // thing keeping the port open. + const closed = once(server, "close"); + server.close(); + await closed; + + let out = ""; + await new Promise((resolve, reject) => { + child.stdout.setEncoding("utf8"); + child.stdout.on("data", chunk => { + out += chunk; + if (out.includes("listening")) resolve(); + }); + child.once("error", reject); + child.once("exit", (code, signal) => + reject(new Error(`child exited (${code ?? signal}) before listening: ${out}`)), + ); + }); + + const reply = await new Promise((resolve, reject) => { + const socket = connect({ port, host: "127.0.0.1" }); + let data = ""; + socket.setEncoding("utf8"); + socket.on("data", chunk => (data += chunk)); + socket.once("end", () => resolve(data)); + socket.once("error", reject); + }); + expect(reply).toBe("hello from child"); + } finally { + child?.kill(); + server.close(); + } + }); + + it.skipIf(isWindows)("spawnSync shares listening and connected socket handles with the child", async () => { + // The server leaves the connection open so the client's handle is still + // live while the child is spawned. + const { server, port } = await listeningServer(() => {}); + const client = connect({ port, host: "127.0.0.1" }); + try { + await once(client, "connect"); + const { stdout, status } = spawnSync( + bunExe(), + [ + "-e", + `const { fstatSync } = require("fs"); console.log(JSON.stringify([3, 4].map(fd => fstatSync(fd).isSocket())));`, + ], + { + env: bunEnv, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit", server._handle, client._handle], + }, + ); + expect(stdout).toBe("[true,true]\n"); + expect(status).toBe(0); + } finally { + client.destroy(); + server.close(); + } + }); + + it("an object with a numeric fd property is used as that fd", () => { + using dir = tempDir("stdio-fd-object", {}); + const file = join(String(dir), "stdout.txt"); + const fd = openSync(file, "w"); + let status; + try { + ({ status } = spawnSync(bunExe(), ["-e", `console.log("written through { fd }")`], { + env: bunEnv, + stdio: ["ignore", { fd }, "inherit"], + })); + } finally { + closeSync(fd); + } + expect(readFileSync(file, "utf8")).toBe("written through { fd }\n"); + expect(status).toBe(0); + }); + + it.each([ + ["an object without an fd", {}], + ["an object whose fd is not a number", { fd: "3" }], + ])("%s is still rejected before anything is spawned", (_label, entry) => { + const options = { env: bunEnv, stdio: ["ignore", "ignore", "ignore", entry] }; + expect(() => spawn(bunExe(), ["-e", "0"], options)).toThrow(/stdio/); + expect(() => spawnSync(bunExe(), ["-e", "0"], options)).toThrow(/stdio/); + }); + + it("a net.Server itself is rejected; only its handle carries the fd", async () => { + // Node throws for a server as well (it is not a stream); what its tests + // pass is server._handle, as above. + const { server } = await listeningServer(() => {}); + try { + const options = { env: bunEnv, stdio: ["ignore", "ignore", "ignore", server] }; + expect(() => spawn(bunExe(), ["-e", "0"], options)).toThrow(/stdio/); + expect(() => spawnSync(bunExe(), ["-e", "0"], options)).toThrow(/stdio/); + } finally { + server.close(); + } + }); +}); diff --git a/test/js/node/test/parallel/test-listen-fd-detached-inherit.js b/test/js/node/test/parallel/test-listen-fd-detached-inherit.js deleted file mode 100644 index 2a8e70f0f942..000000000000 --- a/test/js/node/test/parallel/test-listen-fd-detached-inherit.js +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright Joyent, Inc. and other Node contributors. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to permit -// persons to whom the Software is furnished to do so, subject to the -// following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -// USE OR OTHER DEALINGS IN THE SOFTWARE. - -'use strict'; -const common = require('../common'); -if (common.isWindows) - common.skip('This test is disabled on windows.'); - -const assert = require('assert'); -const http = require('http'); -const net = require('net'); -const spawn = require('child_process').spawn; - -switch (process.argv[2]) { - case 'child': return child(); - case 'parent': return parent(); - default: return test(); -} - -// Spawn the parent, and listen for it to tell us the pid of the child. -// WARNING: This is an example of listening on some arbitrary FD number -// that has already been bound elsewhere in advance. However, binding -// server handles to stdio fd's is NOT a good or reliable way to do -// concurrency in HTTP servers! Use the cluster module, or if you want -// a more low-level approach, use child process IPC manually. -function test() { - const parent = spawn(process.execPath, [__filename, 'parent'], { - stdio: [ 0, 'pipe', 2 ] - }); - let json = ''; - parent.stdout.on('data', function(c) { - json += c.toString(); - if (json.includes('\n')) next(); - }); - function next() { - console.error('output from parent = %s', json); - const child = JSON.parse(json); - // Now make sure that we can request to the subprocess, then kill it. - http.get({ - server: 'localhost', - port: child.port, - path: '/', - }).on('response', function(res) { - let s = ''; - res.on('data', function(c) { - s += c.toString(); - }); - res.on('end', function() { - // Kill the subprocess before we start doing asserts. - // It's really annoying when tests leave orphans! - process.kill(child.pid, 'SIGKILL'); - try { - parent.kill(); - } catch { - // Continue regardless of error. - } - - assert.strictEqual(s, 'hello from child\n'); - assert.strictEqual(res.statusCode, 200); - }); - }); - } -} - -// Listen on port, and then pass the handle to the detached child. -// Then output the child's pid, and immediately exit. -function parent() { - const server = net.createServer(function(conn) { - conn.end('HTTP/1.1 403 Forbidden\r\n\r\nI got problems.\r\n'); - throw new Error('Should not see connections on parent'); - }).listen(0, function() { - console.error('server listening on %d', this.address().port); - - const child = spawn(process.execPath, [__filename, 'child'], { - stdio: [ 0, 1, 2, server._handle ], - detached: true - }); - - console.log('%j\n', { pid: child.pid, port: this.address().port }); - - // Now close the parent, so that the child is the only thing - // referencing that handle. Note that connections will still - // be accepted, because the child has the fd open, but the parent - // will exit gracefully. - server.close(); - child.unref(); - }); -} - -// Run as a child of the parent() mode. -function child() { - // Start a server on fd=3 - http.createServer(function(req, res) { - console.error('request on child'); - console.error('%s %s', req.method, req.url, req.headers); - res.end('hello from child\n'); - }).listen({ fd: 3 }, function() { - console.error('child listening on fd=3'); - }); -} diff --git a/test/js/node/test/parallel/test-listen-fd-detached.js b/test/js/node/test/parallel/test-listen-fd-detached.js deleted file mode 100644 index fba96a112f89..000000000000 --- a/test/js/node/test/parallel/test-listen-fd-detached.js +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright Joyent, Inc. and other Node contributors. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to permit -// persons to whom the Software is furnished to do so, subject to the -// following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -// USE OR OTHER DEALINGS IN THE SOFTWARE. - -'use strict'; -const common = require('../common'); -if (common.isWindows) - common.skip('This test is disabled on windows.'); - -const assert = require('assert'); -const http = require('http'); -const net = require('net'); -const spawn = require('child_process').spawn; - -switch (process.argv[2]) { - case 'child': return child(); - case 'parent': return parent(); - default: return test(); -} - -// Spawn the parent, and listen for it to tell us the pid of the child. -// WARNING: This is an example of listening on some arbitrary FD number -// that has already been bound elsewhere in advance. However, binding -// server handles to stdio fd's is NOT a good or reliable way to do -// concurrency in HTTP servers! Use the cluster module, or if you want -// a more low-level approach, use child process IPC manually. -function test() { - const parent = spawn(process.execPath, [__filename, 'parent'], { - stdio: [ 0, 'pipe', 2 ] - }); - let json = ''; - parent.stdout.on('data', function(c) { - json += c.toString(); - if (json.includes('\n')) next(); - }); - function next() { - console.error('output from parent = %s', json); - const child = JSON.parse(json); - // Now make sure that we can request to the subprocess, then kill it. - http.get({ - server: 'localhost', - port: child.port, - path: '/', - }).on('response', function(res) { - let s = ''; - res.on('data', function(c) { - s += c.toString(); - }); - res.on('end', function() { - // Kill the subprocess before we start doing asserts. - // it's really annoying when tests leave orphans! - process.kill(child.pid, 'SIGKILL'); - try { - parent.kill(); - } catch { - // Continue regardless of error. - } - - assert.strictEqual(s, 'hello from child\n'); - assert.strictEqual(res.statusCode, 200); - }); - }); - } -} - -function parent() { - const server = net.createServer(function(conn) { - console.error('connection on parent'); - conn.end('hello from parent\n'); - }).listen(0, function() { - console.error('server listening on %d', this.address().port); - - const child = spawn(process.execPath, [__filename, 'child'], { - stdio: [ 'ignore', 'ignore', 'ignore', server._handle ], - detached: true - }); - - console.log('%j\n', { pid: child.pid, port: this.address().port }); - - // Now close the parent, so that the child is the only thing - // referencing that handle. Note that connections will still - // be accepted, because the child has the fd open, but the parent - // will exit gracefully. - server.close(); - child.unref(); - }); -} - -function child() { - // Start a server on fd=3 - http.createServer(function(req, res) { - console.error('request on child'); - console.error('%s %s', req.method, req.url, req.headers); - res.end('hello from child\n'); - }).listen({ fd: 3 }, function() { - console.error('child listening on fd=3'); - }); -} From d1a37c3972f9143ad919e1537b5bdd4bd6d7ac92 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:02:57 +0000 Subject: [PATCH 2/4] child_process: take stdio `fd` before the stream check; quarantine listen-fd tests instead of deleting Read `.fd` off any object before the stream branch, in node's order, so a FileHandle (an EventEmitter with write(), which the stream check claims) shares its descriptor too; streamFdOf's own-property fd check is now redundant and goes away. The value is passed through unchanged, so a closed handle behaves exactly like passing its fd number directly. Restore test-listen-fd-detached{,-inherit}.js and list them in test/expectations.txt: with the spawn working they hang on the child's http listen({ fd: 3 }), which still ignores the fd, so they stay out of the run until that lands rather than being removed from the tree. --- src/js/node/child_process.ts | 21 ++-- test/expectations.txt | 12 ++ .../child_process/child-process-stdio.test.js | 82 ++++++++---- .../test-listen-fd-detached-inherit.js | 118 ++++++++++++++++++ .../test/parallel/test-listen-fd-detached.js | 115 +++++++++++++++++ 5 files changed, 316 insertions(+), 32 deletions(-) create mode 100644 test/js/node/test/parallel/test-listen-fd-detached-inherit.js create mode 100644 test/js/node/test/parallel/test-listen-fd-detached.js diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index ca9ec5629fab..74f52f8f1184 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1691,9 +1691,6 @@ function isInternalIpcMessage(message) { } function streamFdOf(item): number | undefined { - const itemFd = ObjectHasOwn(item, "fd") ? item.fd : undefined; - if (typeof itemFd === "number") return itemFd; - const handle = item._handle; const handleFd = handle ? handle.fd : undefined; if (typeof handleFd === "number") return handleFd; @@ -1720,6 +1717,16 @@ function nodeToBun(item, index: number): string | number | null | NodeJS.TypedAr if (typeof item === "number") { return item; } + // Anything exposing a numeric `fd` (a server's or socket's _handle, a + // FileHandle, an fs/tty stream, a plain { fd }) shares that descriptor with + // the child, like node's `typeof stdio.fd === 'number'` branch: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L1058 + // The value is passed through as-is, so a closed handle's negative fd is + // refused by Bun.spawn exactly like the same number passed directly. + if (typeof item === "object") { + const fd = item.fd; + if (typeof fd === "number") return fd; + } if (isNodeStreamReadable(item) || isNodeStreamWritable(item)) { const fd = streamFdOf(item); if (fd !== undefined) return fd; @@ -1728,14 +1735,6 @@ function nodeToBun(item, index: number): string | number | null | NodeJS.TypedAr `Passing a stream.${kind} without an underlying file descriptor as stdio[${index}] is not yet implemented in Bun`, ); } - // Any other object carrying a descriptor is shared with the child, like - // node's `typeof stdio.fd === 'number'` branch. This is how a handle such as - // `server._handle` (a listening socket) ends up as the child's fd 3: - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L1058 - if (typeof item === "object") { - const fd = item.fd; - if (typeof fd === "number") return fd; - } const result = nodeToBunLookup[item]; if (result === undefined) { throw new Error(`Invalid stdio option[${index}] "${item}"`); diff --git a/test/expectations.txt b/test/expectations.txt index 87c8b99dc0f6..e30a559aeae9 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -35,3 +35,15 @@ # only hold on the distributing model. Passes on macOS and Windows. # (Verified still failing on every Linux lane, build 87834.) [ LINUX ] test/js/node/test/sequential/test-net-listen-shared-ports.js [ FAIL ] # SO_REUSEPORT shared-listener semantics on Linux + +# Both files pass `server._handle` to a detached child, which then serves the +# inherited socket with http.createServer().listen({ fd: 3 }). The spawn half +# works; http.Server#listen ignores `fd` (it listens on a fresh port), so the +# test's request to the inherited port is never answered: the file hangs until +# the runner kills it and leaves the detached child running. Before #39220 the +# spawn threw inside the intermediate process and both files exited 0 without +# testing anything. Remove these entries when http listen({ fd }) lands +# (#36491 / #34659); the Bun-side coverage of the spawn half is in +# test/js/node/child_process/child-process-stdio.test.js. +test/js/node/test/parallel/test-listen-fd-detached.js [ TIMEOUT ] # child's http listen({ fd: 3 }) ignores the fd +test/js/node/test/parallel/test-listen-fd-detached-inherit.js [ TIMEOUT ] # child's http listen({ fd: 3 }) ignores the 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 0c6f5a47c03c..8af19f9e3f49 100644 --- a/test/js/node/child_process/child-process-stdio.test.js +++ b/test/js/node/child_process/child-process-stdio.test.js @@ -3,6 +3,7 @@ import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { execSync, spawn, spawnSync } from "node:child_process"; import { once } from "node:events"; import { closeSync, openSync, readFileSync } from "node:fs"; +import { open } from "node:fs/promises"; import { connect, createServer } from "node:net"; import { join } from "node:path"; @@ -171,11 +172,12 @@ describe("child.stdin", () => { }); // Node shares the descriptor of any stdio entry that has a numeric `fd` -// property (lib/internal/child_process.js, getValidStdio). Its own -// test-listen-fd-* tests rely on that to hand `server._handle` to a child as -// fd 3; in Bun that handle is the Bun.listen() Listener, which exposes `fd`. -// Socket descriptors cannot be inherited as stdio on Windows (same as Node), -// so the socket cases are POSIX only. +// property (lib/internal/child_process.js, getValidStdio): handle wraps, +// FileHandles, fs/tty streams, plain { fd } objects. Its own test-listen-fd-* +// tests rely on that to hand `server._handle` to a child as fd 3; in Bun that +// handle is the Bun.listen() Listener, which exposes `fd`. Socket descriptors +// cannot be inherited as stdio on Windows (same as Node), so the cases that +// actually inherit a socket are POSIX only. describe("stdio entries carrying a file descriptor", () => { async function listeningServer(onConnection) { const server = createServer(onConnection); @@ -262,20 +264,35 @@ describe("stdio entries carrying a file descriptor", () => { } }); - it("an object with a numeric fd property is used as that fd", () => { + it.each([ + [ + "a plain { fd } object", + file => { + const fd = openSync(file, "w"); + return { entry: { fd }, close: () => closeSync(fd) }; + }, + ], + [ + "a FileHandle", + async file => { + const handle = await open(file, "w"); + return { entry: handle, close: () => handle.close() }; + }, + ], + ])("%s as stdout makes the child write to that descriptor", async (_label, prepare) => { using dir = tempDir("stdio-fd-object", {}); const file = join(String(dir), "stdout.txt"); - const fd = openSync(file, "w"); + const { entry, close } = await prepare(file); let status; try { - ({ status } = spawnSync(bunExe(), ["-e", `console.log("written through { fd }")`], { + ({ status } = spawnSync(bunExe(), ["-e", `console.log("written through the shared fd")`], { env: bunEnv, - stdio: ["ignore", { fd }, "inherit"], + stdio: ["ignore", entry, "inherit"], })); } finally { - closeSync(fd); + await close(); } - expect(readFileSync(file, "utf8")).toBe("written through { fd }\n"); + expect(readFileSync(file, "utf8")).toBe("written through the shared fd\n"); expect(status).toBe(0); }); @@ -288,16 +305,39 @@ describe("stdio entries carrying a file descriptor", () => { expect(() => spawnSync(bunExe(), ["-e", "0"], options)).toThrow(/stdio/); }); - it("a net.Server itself is rejected; only its handle carries the fd", async () => { - // Node throws for a server as well (it is not a stream); what its tests - // pass is server._handle, as above. + it("a handle whose descriptor is already closed is refused, like passing its fd directly", async () => { const { server } = await listeningServer(() => {}); - try { - const options = { env: bunEnv, stdio: ["ignore", "ignore", "ignore", server] }; - expect(() => spawn(bunExe(), ["-e", "0"], options)).toThrow(/stdio/); - expect(() => spawnSync(bunExe(), ["-e", "0"], options)).toThrow(/stdio/); - } finally { - server.close(); - } + const handle = server._handle; + const closed = once(server, "close"); + server.close(); + await closed; + expect(handle.fd).toBe(-1); + + // The handle is only sugar for its `.fd`, so this takes the path a bare -1 + // takes: Bun.spawn refuses the descriptor (spawn() throws, spawnSync() + // returns the error). Node's libuv layer instead spawns the child with + // the slot left closed for the negative errno a closed wrap reports + // (EINVAL only for exactly -1); silently dropping the socket is not worth + // reproducing. + const optionsFor = entry => ({ env: bunEnv, stdio: ["ignore", "ignore", "ignore", entry] }); + const spawnFailure = entry => { + try { + spawn(bunExe(), ["-e", "0"], optionsFor(entry)); + } catch (error) { + return { code: error.code, message: error.message }; + } + }; + const spawnSyncFailure = entry => { + const { error } = spawnSync(bunExe(), ["-e", "0"], optionsFor(entry)); + return error && { code: error.code, message: error.message }; + }; + + const viaHandle = spawnFailure(handle); + expect(viaHandle).toBeDefined(); + expect(viaHandle).toEqual(spawnFailure(handle.fd)); + + const viaHandleSync = spawnSyncFailure(handle); + expect(viaHandleSync).toBeDefined(); + expect(viaHandleSync).toEqual(spawnSyncFailure(handle.fd)); }); }); diff --git a/test/js/node/test/parallel/test-listen-fd-detached-inherit.js b/test/js/node/test/parallel/test-listen-fd-detached-inherit.js new file mode 100644 index 000000000000..2a8e70f0f942 --- /dev/null +++ b/test/js/node/test/parallel/test-listen-fd-detached-inherit.js @@ -0,0 +1,118 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (common.isWindows) + common.skip('This test is disabled on windows.'); + +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const spawn = require('child_process').spawn; + +switch (process.argv[2]) { + case 'child': return child(); + case 'parent': return parent(); + default: return test(); +} + +// Spawn the parent, and listen for it to tell us the pid of the child. +// WARNING: This is an example of listening on some arbitrary FD number +// that has already been bound elsewhere in advance. However, binding +// server handles to stdio fd's is NOT a good or reliable way to do +// concurrency in HTTP servers! Use the cluster module, or if you want +// a more low-level approach, use child process IPC manually. +function test() { + const parent = spawn(process.execPath, [__filename, 'parent'], { + stdio: [ 0, 'pipe', 2 ] + }); + let json = ''; + parent.stdout.on('data', function(c) { + json += c.toString(); + if (json.includes('\n')) next(); + }); + function next() { + console.error('output from parent = %s', json); + const child = JSON.parse(json); + // Now make sure that we can request to the subprocess, then kill it. + http.get({ + server: 'localhost', + port: child.port, + path: '/', + }).on('response', function(res) { + let s = ''; + res.on('data', function(c) { + s += c.toString(); + }); + res.on('end', function() { + // Kill the subprocess before we start doing asserts. + // It's really annoying when tests leave orphans! + process.kill(child.pid, 'SIGKILL'); + try { + parent.kill(); + } catch { + // Continue regardless of error. + } + + assert.strictEqual(s, 'hello from child\n'); + assert.strictEqual(res.statusCode, 200); + }); + }); + } +} + +// Listen on port, and then pass the handle to the detached child. +// Then output the child's pid, and immediately exit. +function parent() { + const server = net.createServer(function(conn) { + conn.end('HTTP/1.1 403 Forbidden\r\n\r\nI got problems.\r\n'); + throw new Error('Should not see connections on parent'); + }).listen(0, function() { + console.error('server listening on %d', this.address().port); + + const child = spawn(process.execPath, [__filename, 'child'], { + stdio: [ 0, 1, 2, server._handle ], + detached: true + }); + + console.log('%j\n', { pid: child.pid, port: this.address().port }); + + // Now close the parent, so that the child is the only thing + // referencing that handle. Note that connections will still + // be accepted, because the child has the fd open, but the parent + // will exit gracefully. + server.close(); + child.unref(); + }); +} + +// Run as a child of the parent() mode. +function child() { + // Start a server on fd=3 + http.createServer(function(req, res) { + console.error('request on child'); + console.error('%s %s', req.method, req.url, req.headers); + res.end('hello from child\n'); + }).listen({ fd: 3 }, function() { + console.error('child listening on fd=3'); + }); +} diff --git a/test/js/node/test/parallel/test-listen-fd-detached.js b/test/js/node/test/parallel/test-listen-fd-detached.js new file mode 100644 index 000000000000..fba96a112f89 --- /dev/null +++ b/test/js/node/test/parallel/test-listen-fd-detached.js @@ -0,0 +1,115 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (common.isWindows) + common.skip('This test is disabled on windows.'); + +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const spawn = require('child_process').spawn; + +switch (process.argv[2]) { + case 'child': return child(); + case 'parent': return parent(); + default: return test(); +} + +// Spawn the parent, and listen for it to tell us the pid of the child. +// WARNING: This is an example of listening on some arbitrary FD number +// that has already been bound elsewhere in advance. However, binding +// server handles to stdio fd's is NOT a good or reliable way to do +// concurrency in HTTP servers! Use the cluster module, or if you want +// a more low-level approach, use child process IPC manually. +function test() { + const parent = spawn(process.execPath, [__filename, 'parent'], { + stdio: [ 0, 'pipe', 2 ] + }); + let json = ''; + parent.stdout.on('data', function(c) { + json += c.toString(); + if (json.includes('\n')) next(); + }); + function next() { + console.error('output from parent = %s', json); + const child = JSON.parse(json); + // Now make sure that we can request to the subprocess, then kill it. + http.get({ + server: 'localhost', + port: child.port, + path: '/', + }).on('response', function(res) { + let s = ''; + res.on('data', function(c) { + s += c.toString(); + }); + res.on('end', function() { + // Kill the subprocess before we start doing asserts. + // it's really annoying when tests leave orphans! + process.kill(child.pid, 'SIGKILL'); + try { + parent.kill(); + } catch { + // Continue regardless of error. + } + + assert.strictEqual(s, 'hello from child\n'); + assert.strictEqual(res.statusCode, 200); + }); + }); + } +} + +function parent() { + const server = net.createServer(function(conn) { + console.error('connection on parent'); + conn.end('hello from parent\n'); + }).listen(0, function() { + console.error('server listening on %d', this.address().port); + + const child = spawn(process.execPath, [__filename, 'child'], { + stdio: [ 'ignore', 'ignore', 'ignore', server._handle ], + detached: true + }); + + console.log('%j\n', { pid: child.pid, port: this.address().port }); + + // Now close the parent, so that the child is the only thing + // referencing that handle. Note that connections will still + // be accepted, because the child has the fd open, but the parent + // will exit gracefully. + server.close(); + child.unref(); + }); +} + +function child() { + // Start a server on fd=3 + http.createServer(function(req, res) { + console.error('request on child'); + console.error('%s %s', req.method, req.url, req.headers); + res.end('hello from child\n'); + }).listen({ fd: 3 }, function() { + console.error('child listening on fd=3'); + }); +} From 20aa7af844a9dd293560df52f6101fa8213400ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:06:40 +0000 Subject: [PATCH 3/4] child_process: shorten the nodeToBun fd comment --- src/js/node/child_process.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 74f52f8f1184..65a416dcde68 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1717,12 +1717,8 @@ function nodeToBun(item, index: number): string | number | null | NodeJS.TypedAr if (typeof item === "number") { return item; } - // Anything exposing a numeric `fd` (a server's or socket's _handle, a - // FileHandle, an fs/tty stream, a plain { fd }) shares that descriptor with - // the child, like node's `typeof stdio.fd === 'number'` branch: - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L1058 - // The value is passed through as-is, so a closed handle's negative fd is - // refused by Bun.spawn exactly like the same number passed directly. + // Handle wraps, FileHandles, fs/tty streams and { fd } objects all share their + // `fd`: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L1058 if (typeof item === "object") { const fd = item.fd; if (typeof fd === "number") return fd; From 77ec2fb8b11f9a8d6832cf3c5bcd099dad1e7728 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:07:58 +0000 Subject: [PATCH 4/4] child_process: keep only the node permalink above the stdio fd check --- src/js/node/child_process.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 65a416dcde68..7f12e2be705a 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1717,8 +1717,7 @@ function nodeToBun(item, index: number): string | number | null | NodeJS.TypedAr if (typeof item === "number") { return item; } - // Handle wraps, FileHandles, fs/tty streams and { fd } objects all share their - // `fd`: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L1058 + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L1058 if (typeof item === "object") { const fd = item.fd; if (typeof fd === "number") return fd;