diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 046a392948e9..b167fa4d81d4 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -1891,6 +1891,15 @@ impl WindowsNamedPipeListeningContext { // return error.FailedChmodPipe; //} + // `uv_listen` made the pipe an active+ref'd uv handle. Strip libuv's + // loop ref so the owning `Listener`'s `poll_ref` is the only thing + // keeping the process alive (the contract usockets' libuv backend + // applies to its handles); otherwise `server.unref()` drops the + // `poll_ref` but the uv handle still pins `uv_loop_alive` and the + // process never exits. + // SAFETY: `this` is live; `&mut uv_pipe` is scoped to this call. + unsafe { (*this).uv_pipe.unref() }; + let (this, _) = scopeguard::ScopeGuard::into_inner(cleanup); Ok(this) } diff --git a/test/js/node/net/node-net-server.test.ts b/test/js/node/net/node-net-server.test.ts index 028e3ad5e222..2fb444c2e07e 100644 --- a/test/js/node/net/node-net-server.test.ts +++ b/test/js/node/net/node-net-server.test.ts @@ -1,5 +1,5 @@ import { realpathSync } from "fs"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { AddressInfo, createServer, Server, Socket } from "net"; import { createTest } from "node-harness"; import { once } from "node:events"; @@ -629,3 +629,36 @@ describe("accepted socket event-loop hold matches Node (per-connection KeepAlive ).toEqual({ stdout: "fast", exitCode: 0, failureDetail: "" }); }); }); + +// The Windows named-pipe listener never stripped libuv's own loop ref from its +// uv_pipe_t (uv_listen marks the handle active+ref'd), so server.unref() +// dropped the Listener's KeepAlive but the uv handle still pinned +// uv_loop_alive and the process never exited. TCP and unix-socket listeners go +// through usockets, which unrefs its uv handles up front. +it("server.unref() on a pipe/unix-socket listener lets the process exit", async () => { + // The child exits without close() (natural exit is the observable), so the + // unix socket file must live in a tempDir the parent disposes. + using dir = tempDir("server-unref", {}); + const listenPath = + process.platform === "win32" + ? "\\\\.\\pipe\\bun-server-unref-" + process.pid + "-" + Math.random().toString(36).slice(2) + : join(String(dir), "server-unref.sock"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const server = require("net").createServer(); + server.listen(process.env.SERVER_UNREF_LISTEN_PATH, () => server.unref()); + setTimeout(() => { process.stdout.write("HUNG"); process.exit(1); }, 4000).unref(); + `, + ], + env: { ...bunEnv, SERVER_UNREF_LISTEN_PATH: listenPath }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + expect(exitCode === 0 ? "" : stderr).toBe(""); + expect(exitCode).toBe(0); +});