From 35ad52583898d4473b18685a158e32fc9af107fc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:21:37 +0000 Subject: [PATCH 1/5] node:net: emit 'listening' on the next tick instead of a 1ms timer net.Server emitted 'listening' from setTimeout(1), so the event loop polled the new listening socket, and accepted whatever had connected, before user code ran the 'listening' handler. Code that probes for a free port by listening and closing from that handler (vite's tryListen, get-port, detect-port) therefore ended up owning an accepted connection it never reads. Since accepted sockets got Node's half-open semantics, such a connection no longer goes away when the peer hangs up, and server.close() waits on it forever; `bun --bun vite dev` hung at startup whenever something connected during the probe. Bun.listen() has already called listen(2) when kRealListen returns, so emit on process.nextTick like Node's setupListenHandle does. close() from the handler then closes the fd before the loop ever polls it and the kernel resets the pending peer, which is what Node does too. The cluster round-robin worker path gets the same treatment. --- src/js/node/net.ts | 17 +++++---- test/js/node/net/node-net-server.test.ts | 45 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 1e9d83b4dda9..2a6a5f3bf2d0 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -3961,14 +3961,13 @@ Server.prototype[kRealListen] = function ( // Unref the handle if the server was unref'ed prior to listening if (this._unref) this.unref(); - // We must schedule the emitListeningNextTick() only after the next run of - // the event loop's IO queue. Otherwise, the server may not actually be listening - // when the 'listening' event is emitted. - // - // That leads to all sorts of confusion. - // - // process.nextTick() is not sufficient because it will run before the IO queue. - setTimeout(emitListeningNextTick, 1, this); + // Bun.listen() has already bound and called listen(2). Emitting on the next + // tick like Node means a server.close() from the 'listening' handler closes + // the listening fd before the event loop polls it, so a peer that connected + // in between is reset by the kernel instead of being accepted by a server + // that is already closing (vite probes free ports with exactly that pattern). + // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2034-L2037 + process.nextTick(emitListeningNextTick, this); }; Server.prototype[EventEmitter.captureRejectionSymbol] = function (err, event, sock) { @@ -4177,7 +4176,7 @@ Server.prototype[kClusterFauxListen] = function (handle, backlog, path) { handle[kClusterOwner] = this; handle.listen(backlog || 511); if (this._unref) this.unref(); - setTimeout(emitListeningNextTick, 1, this); + process.nextTick(emitListeningNextTick, this); }; function onClusterConnection(err, clientHandle) { diff --git a/test/js/node/net/node-net-server.test.ts b/test/js/node/net/node-net-server.test.ts index 2fb444c2e07e..009e087cb88c 100644 --- a/test/js/node/net/node-net-server.test.ts +++ b/test/js/node/net/node-net-server.test.ts @@ -227,6 +227,51 @@ describe("net.createServer listen", () => { }), ); }); + + it("emits 'listening' on the next tick, before the event loop polls", async () => { + const server: Server = createServer(); + const order: string[] = []; + server.on("listening", () => order.push("listening")); + server.listen(0); + process.nextTick(() => order.push("nextTick")); + await once(server, "listening"); + server.close(); + await once(server, "close"); + expect(order).toEqual(["listening", "nextTick"]); + }); + + // How vite, get-port and friends probe for a free port: listen, then close() + // from the 'listening' handler. A peer that connects in between must be reset + // by the kernel when the listening fd closes, not accepted into the closing + // server, whose close() would then wait on a connection nobody is reading. + it("close() from 'listening' does not accept a peer that connected in between", async () => { + const server: Server = createServer(); + let accepted = 0; + server.on("connection", () => accepted++); + server.listen(0, "127.0.0.1"); + + // Bun.connect() issues connect(2) synchronously, so the peer is already + // sitting in the listen backlog when the 'listening' handler runs. + const { port } = server.address() as AddressInfo; + const peer = Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { + data() {}, + error() {}, + connectError() {}, + }, + }).catch(() => null); + + const { promise: closed, resolve: onClosed } = Promise.withResolvers(); + server.once("listening", () => { + server.close(() => onClosed()); + peer.then(socket => socket?.end()); + }); + + await closed; + expect(accepted).toBe(0); + }); }); describe("net.createServer events", () => { From 1b2946919b6b6864b4b8cb7becdd21f82ea7a3d4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:23:40 +0000 Subject: [PATCH 2/5] node:net: hold the loop for one turn when close() closes the listener With 'listening' on the next tick, a server listened and closed from a 'beforeExit' handler is gone again before the tick drain returns, so nothing is alive and 'beforeExit' is not re-emitted. In Node the handle's uv_close() completes on the next loop turn, which keeps the loop alive until then (test-process-beforeexit relies on it). Queue a no-op immediate from close() for the same effect, as closeSocketHandle already does for sockets, and cover it with a test. Also wire the listen probe test's 'error' event to its promise. --- src/js/node/net.ts | 6 ++++ test/js/node/net/node-net-server.test.ts | 39 +++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 2a6a5f3bf2d0..b09c7885b606 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -3573,6 +3573,12 @@ Server.prototype.close = function close(callback) { if (this._handle) { if (typeof this._handle.stop === "function") { this._handle.stop(false); + // stop() closes the listening socket synchronously. In Node the handle's + // uv_close() completes on the next loop turn, so the loop counts as alive + // until then; this is what re-emits 'beforeExit' after a server is closed + // from a 'beforeExit' chain (test-process-beforeexit). Hold the loop for + // that one turn the same way closeSocketHandle does for sockets. + setImmediate(noop); // Released here, not on 'close': https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2434-L2437 const clusterHandle = this[kClusterHandle]; if (clusterHandle) { diff --git a/test/js/node/net/node-net-server.test.ts b/test/js/node/net/node-net-server.test.ts index 009e087cb88c..ac79933dbe45 100644 --- a/test/js/node/net/node-net-server.test.ts +++ b/test/js/node/net/node-net-server.test.ts @@ -248,6 +248,8 @@ describe("net.createServer listen", () => { const server: Server = createServer(); let accepted = 0; server.on("connection", () => accepted++); + const { promise: closed, resolve: onClosed, reject } = Promise.withResolvers(); + server.on("error", reject); server.listen(0, "127.0.0.1"); // Bun.connect() issues connect(2) synchronously, so the peer is already @@ -263,7 +265,6 @@ describe("net.createServer listen", () => { }, }).catch(() => null); - const { promise: closed, resolve: onClosed } = Promise.withResolvers(); server.once("listening", () => { server.close(() => onClosed()); peer.then(socket => socket?.end()); @@ -272,6 +273,42 @@ describe("net.createServer listen", () => { await closed; expect(accepted).toBe(0); }); + + // Node's server.close() completes the handle's uv_close() on the next loop + // turn, so a server listened and closed from a 'beforeExit' handler brings + // the loop back to life once more and 'beforeExit' fires again + // (upstream test-process-beforeexit). 'listening' itself is only a nextTick + // now, so close() has to hold the loop for that turn on its own. + it("closing a server listened from 'beforeExit' re-emits 'beforeExit'", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const net = require("net"); + process.once("beforeExit", () => { + net + .createServer() + .listen(0) + .on("listening", function () { + this.close(); + process.once("beforeExit", () => console.log("beforeExit again")); + }); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // stderr only matters on failure: debug builds may print benign warnings. + expect({ stdout, exitCode, failureDetail: exitCode === 0 ? "" : stderr }).toEqual({ + stdout: "beforeExit again\n", + exitCode: 0, + failureDetail: "", + }); + }); }); describe("net.createServer events", () => { From b29fdf0e639bbf69948be4d4e49975f0cfa435d5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:12:51 +0000 Subject: [PATCH 3/5] ci: retrigger From fc1932f6650197e0fa19a18fbb15f739a9785a34 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:11 +0000 Subject: [PATCH 4/5] node:net: shorten the listen/close comments --- src/js/node/net.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index b09c7885b606..61b89aff7539 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -3573,11 +3573,8 @@ Server.prototype.close = function close(callback) { if (this._handle) { if (typeof this._handle.stop === "function") { this._handle.stop(false); - // stop() closes the listening socket synchronously. In Node the handle's - // uv_close() completes on the next loop turn, so the loop counts as alive - // until then; this is what re-emits 'beforeExit' after a server is closed - // from a 'beforeExit' chain (test-process-beforeexit). Hold the loop for - // that one turn the same way closeSocketHandle does for sockets. + // Node's uv_close() keeps the loop alive one more turn (test-process-beforeexit), + // as closeSocketHandle's setImmediate does for sockets. setImmediate(noop); // Released here, not on 'close': https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2434-L2437 const clusterHandle = this[kClusterHandle]; @@ -3967,11 +3964,8 @@ Server.prototype[kRealListen] = function ( // Unref the handle if the server was unref'ed prior to listening if (this._unref) this.unref(); - // Bun.listen() has already bound and called listen(2). Emitting on the next - // tick like Node means a server.close() from the 'listening' handler closes - // the listening fd before the event loop polls it, so a peer that connected - // in between is reset by the kernel instead of being accepted by a server - // that is already closing (vite probes free ports with exactly that pattern). + // Bun.listen() has already called listen(2). A tick, not a timer, so a close() from + // the 'listening' handler runs before the loop accepts anything, as in Node: // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2034-L2037 process.nextTick(emitListeningNextTick, this); }; From fba573f1a8b79a8a06c382a016fc73651369f15b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:17:46 +0000 Subject: [PATCH 5/5] node:net: one-line comments at the listen/close sites --- src/js/node/net.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 61b89aff7539..b6205d278117 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -3573,8 +3573,7 @@ Server.prototype.close = function close(callback) { if (this._handle) { if (typeof this._handle.stop === "function") { this._handle.stop(false); - // Node's uv_close() keeps the loop alive one more turn (test-process-beforeexit), - // as closeSocketHandle's setImmediate does for sockets. + // Node's uv_close() keeps the loop alive for one more turn (test-process-beforeexit); so does this. setImmediate(noop); // Released here, not on 'close': https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2434-L2437 const clusterHandle = this[kClusterHandle]; @@ -3964,9 +3963,7 @@ Server.prototype[kRealListen] = function ( // Unref the handle if the server was unref'ed prior to listening if (this._unref) this.unref(); - // Bun.listen() has already called listen(2). A tick, not a timer, so a close() from - // the 'listening' handler runs before the loop accepts anything, as in Node: - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2034-L2037 + // A tick, not a timer, so a close() from 'listening' runs before the loop accepts anything (as in Node). process.nextTick(emitListeningNextTick, this); };