From 434d79b22ca52118559e48a62686598ee69b2fe5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Oct 2026 00:38:58 -0700 Subject: [PATCH] fix(net): schedule close events independently of user timers Rebase oven-sh/bun#44366 onto 9bd19c98eacc01530a4e7609bc427abffa87d77e. Preserve the upstream reconnect regressions alongside the timer-replacement close tests. --- docs/runtime/nodejs-compat.mdx | 2 + src/js/node/net.ts | 2 + test/js/node/net/node-net.test.ts | 79 +++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 2331b3a5a030..d916a66a615f 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -125,6 +125,8 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:net`](https://nodejs.org/api/net.html) +Socket close notifications use internal scheduling and continue to run when user code replaces `setImmediate`. + 🟢 Fully implemented, including `BlockList`, `SocketAddress`, `autoSelectFamily`, Unix domain sockets and `server.listen({ fd })`. `new net.Socket({ fd })` cannot read from an existing file descriptor (only write-only wrapping works). `server.listen(handle)` only accepts `{ fd }`. Missing `blockList.toJSON()`/`fromJSON()`. ### [`node:perf_hooks`](https://nodejs.org/api/perf_hooks.html) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index cf24f68b50e5..7b98563412d4 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -24,6 +24,8 @@ const Duplex = require("internal/streams/duplex"); const { isUint8Array } = require("node:util/types"); const { getDefaultHighWaterMark } = require("internal/streams/state"); const EventEmitter = require("node:events"); +// Socket lifecycle callbacks must run even when user code replaces timer APIs. +const setImmediate = $newCppFunction("node/NodeTimers.cpp", "functionSetImmediate", 1); let dns: typeof import("./dns").default; const normalizedArgsSymbol = Symbol("normalizedArgs"); diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 3efd0530162a..48b017132a7e 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3604,3 +3604,82 @@ it.each(Object.entries({ setDefaultAutoSelectFamily, setDefaultAutoSelectFamilyA expect(thrown).toEqual(thrownBy(() => set(undefined))); }, ); +describe("socket close is independent of user timer replacements", () => { + it.each(["peer", "local"])("preserves %s end/finish/close ordering", async mode => { + for (const replaceBeforeImport of [true, false]) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `(${closeWithReplacedTimers.toString()})(${JSON.stringify(mode)}, ${replaceBeforeImport})`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const sequence = + mode === "peer" + ? ["end", "end:nextTick", "finish", "end:immediate", "close:false"] + : ["finish", "end", "end:nextTick", "end:immediate", "close:false"]; + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: JSON.stringify(sequence) + "\n", + stderr: "", + exitCode: 0, + }); + } + }); +}); + +function closeWithReplacedTimers(mode, replaceBeforeImport) { + const timers = require("node:timers"); + const realImmediate = timers.setImmediate; + const replace = () => { + timers.setImmediate = () => {}; + globalThis.setImmediate = timers.setImmediate; + }; + if (replaceBeforeImport) replace(); + const net = require("node:net"); + if (!replaceBeforeImport) replace(); + const events: string[] = []; + const sockets: any[] = []; + const deadline = setTimeout(() => { + console.error("missing close: " + JSON.stringify(events)); + for (const socket of sockets) socket.destroy(); + server.close(); + process.exitCode = 1; + }, 3000); + const failed = error => { + console.error(error); + process.exitCode = 1; + }; + const server = net.createServer(socket => { + sockets.push(socket); + socket.on("error", failed); + socket.resume(); + if (mode === "peer") socket.end("hello"); + else socket.on("end", () => socket.end()); + }); + server.on("error", failed); + server.listen(0, "127.0.0.1", () => { + const client = net.connect(server.address().port, "127.0.0.1"); + sockets.push(client); + client.resume(); + client.on("error", failed); + client.on("connect", () => { + if (mode === "local") client.end("hello"); + }); + client.on("end", () => { + events.push("end"); + process.nextTick(() => events.push("end:nextTick")); + realImmediate(() => events.push("end:immediate")); + }); + client.on("finish", () => events.push("finish")); + client.on("close", hadError => { + events.push("close:" + hadError); + clearTimeout(deadline); + server.close(); + console.log(JSON.stringify(events)); + }); + }); +}