diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 07a32d4b70a5..bee84866b337 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -736,6 +736,21 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_socket(int domain, int type, int protocol, in } return apple_no_sigpipe(created_fd); +#elif defined(_WIN32) + /* Plain socket() returns an inheritable handle, so any child spawned with + * bInheritHandles=TRUE (e.g. node:child_process stdio) would duplicate it + * and keep listen sockets alive after the parent exits. */ + created_fd = WSASocketW(domain, type, protocol, NULL, 0, + WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT); + + if (UNLIKELY(created_fd == INVALID_SOCKET)) { + if (err != NULL) { + *err = WSAGetLastError(); + } + return LIBUS_SOCKET_ERROR; + } + + return bsd_set_nonblocking(created_fd); #else do { created_fd = socket(domain, type, protocol); @@ -872,6 +887,12 @@ LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd break; } +#ifdef _WIN32 + /* accept() returns an inheritable handle regardless of the listening + * socket's flags; keep it out of spawned children. */ + SetHandleInformation((HANDLE) accepted_fd, HANDLE_FLAG_INHERIT, 0); +#endif + internal_finalize_bsd_addr(addr); #if defined(SOCK_CLOEXEC) && defined(SOCK_NONBLOCK) diff --git a/test/js/node/child_process/child-process-socket-inherit.test.ts b/test/js/node/child_process/child-process-socket-inherit.test.ts new file mode 100644 index 000000000000..2c05e012bc3a --- /dev/null +++ b/test/js/node/child_process/child-process-socket-inherit.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { connect } from "node:net"; + +// https://github.com/oven-sh/bun/issues/36936 +// On Windows, sockets created with plain socket() are inheritable, so a +// detached child spawned while a server is listening would duplicate the +// listen handle and keep the port open after the parent exits. +test.skipIf(!isWindows)("detached child does not inherit the parent's listening socket", async () => { + using dir = tempDir("socket-inherit", { + "parent.mjs": ` + import { spawn } from "node:child_process"; + import { createServer } from "node:http"; + + const server = createServer((_request, response) => response.end("ok")); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + + // Spawn a detached child while the listen socket is open, then exit. + const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 100000)"], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + + await new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve()))); + console.log(JSON.stringify({ port, childPid: child.pid })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.mjs"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + const { port, childPid } = JSON.parse(stdout.trim()); + + try { + // The parent has exited and closed its server, so nothing may be listening + // on the port even though the detached child is still alive. + const result = await new Promise(resolve => { + const socket = connect({ port, host: "127.0.0.1" }); + socket.on("connect", () => { + socket.destroy(); + resolve("connected"); + }); + socket.on("error", error => resolve((error as NodeJS.ErrnoException).code ?? "error")); + }); + expect(result).toBe("ECONNREFUSED"); + } finally { + try { + process.kill(childPid); + } catch {} + } +});