From 6986df06bdda08c1d3bd28ec79b2e1cc9d3b2885 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:36:50 +0000 Subject: [PATCH 1/6] node:net: stop the listener when listen() fails after the socket is bound A tls.Server listen() creates the native listener, then loads the addContext() entries into it. When one entry throws, the server emitted 'error' but kept the bound listener: 'listening' was true, address() returned the port, and the port accepted TLS handshakes. kRealListen now stops the listener and clears _handle when any step after Bun.listen() throws. The chmod failure path already did this and now shares the same cleanup. In a cluster worker the native listener adopts the fd that the primary sent. kRealListen marks the shared handle as adopted as soon as that happens, so the catch in listenInCluster does not close the fd a second time after the listener closed it. --- src/js/node/net.ts | 75 +++++++++++++----------- test/js/node/cluster.test.ts | 61 +++++++++++++++++++ test/js/node/tls/node-tls-server.test.ts | 25 ++++++++ 3 files changed, 128 insertions(+), 33 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 904facfe16b6..0b2fd0088912 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -4084,26 +4084,6 @@ Server.prototype[kRealListen] = function ( data: this, pauseOnConnect: this.pauseOnConnect, }); - // Mirror libuv uv_pipe_chmod: readableAll/writableAll relax the unix socket - // file's group/other permission bits. Skipped on Windows and abstract - // sockets (no filesystem entry). uSockets binds synchronously, so the file - // exists by the time Bun.listen returns. - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 - if ((readableAll || writableAll) && process.platform !== "win32" && path.charCodeAt(0) !== 0) { - let desired = 0; - if (readableAll) desired |= 0o44; // S_IRGRP | S_IROTH - if (writableAll) desired |= 0o22; // S_IWGRP | S_IWOTH - try { - const fs = require("node:fs"); - const cur = fs.statSync(path).mode; - if ((cur & desired) !== desired) fs.chmodSync(path, cur | desired); - } catch (e) { - // _handle is a Bun.listen SocketListener: it exposes stop(), not close(). - this._handle?.stop?.(true); - this._handle = null; - throw e; - } - } } else if (fd != null) { this._handle = Bun.listen({ fd, @@ -4117,6 +4097,10 @@ Server.prototype[kRealListen] = function ( data: this, pauseOnConnect: this.pauseOnConnect, }); + // The native listener owns the fd from here on, and stop() closes it. A + // cluster worker's shared handle must not close the same fd again. + const clusterHandle = this[kClusterHandle]; + if (clusterHandle != null && clusterHandle.sharedFd === fd) clusterHandle.adopted = true; } else { this._handle = Bun.listen({ port, @@ -4132,21 +4116,45 @@ Server.prototype[kRealListen] = function ( }); } - this._handle[owner_symbol] = this; - this._handle.onconnection = onconnection; + // The listener is bound and accepting from here on. If a later step throws, + // stop it, so a failed listen() leaves the server closed (no 'listening', + // listening === false, address() === null) and only 'error' fires. + try { + // Mirror libuv uv_pipe_chmod: readableAll/writableAll relax the unix socket + // file's group/other permission bits. Skipped on Windows and abstract + // sockets (no filesystem entry). uSockets binds synchronously, so the file + // exists by the time Bun.listen returns. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 + if (path && (readableAll || writableAll) && process.platform !== "win32" && path.charCodeAt(0) !== 0) { + let desired = 0; + if (readableAll) desired |= 0o44; // S_IRGRP | S_IROTH + if (writableAll) desired |= 0o22; // S_IWGRP | S_IWOTH + const fs = require("node:fs"); + const cur = fs.statSync(path).mode; + if ((cur & desired) !== desired) fs.chmodSync(path, cur | desired); + } - const addr = this.address(); - if (addr && typeof addr === "object") { - const familyLast = String(addr.family).slice(-1); - this._connectionKey = `${familyLast}:${addr.address}:${port}`; - } + this._handle[owner_symbol] = this; + this._handle.onconnection = onconnection; - if (contexts) { - for (const [name, context] of contexts) { - // tls.ts stores the InternalSecureContext wrapper; the native side wants - // the native SSL_CTX wrapper at `.context`. - addServerName(this._handle, name, context.context ?? context); + const addr = this.address(); + if (addr && typeof addr === "object") { + const familyLast = String(addr.family).slice(-1); + this._connectionKey = `${familyLast}:${addr.address}:${port}`; } + + if (contexts) { + for (const [name, context] of contexts) { + // tls.ts stores the InternalSecureContext wrapper; the native side wants + // the native SSL_CTX wrapper at `.context`. + addServerName(this._handle, name, context.context ?? context); + } + } + } catch (e) { + // _handle is a Bun.listen SocketListener: it exposes stop(), not close(). + this._handle.stop(true); + this._handle = null; + throw e; } // Unref the handle if the server was unref'ed prior to listening @@ -4336,8 +4344,9 @@ function listenInCluster( onListen, sharedFd, ); - handle.adopted = true; } catch (err) { + // kRealListen marks `handle.adopted` once the native listener owns the + // fd. Then this close() releases the primary's key and leaves the fd alone. server[kClusterHandle] = null; server[kClusterUnixPath] = undefined; handle[kClusterOwner] = null; diff --git a/test/js/node/cluster.test.ts b/test/js/node/cluster.test.ts index 3248646bd3b0..545595429a05 100644 --- a/test/js/node/cluster.test.ts +++ b/test/js/node/cluster.test.ts @@ -853,6 +853,67 @@ if (cluster.isPrimary) { expect(stdout).toContain("reply: echo:hi"); }, 30_000); +test("TLS cluster worker whose listen() fails while it loads addContext() entries ends up closed", async () => { + const dir = tempDirWithFiles("bun-test", { + "cert.pem": tlsCerts.cert, + "key.pem": tlsCerts.key, + "main.ts": ` +const cluster = require("node:cluster"); +const tls = require("node:tls"); +const fs = require("node:fs"); +const path = require("node:path"); +const key = fs.readFileSync(path.join(__dirname, "key.pem")); +const cert = fs.readFileSync(path.join(__dirname, "cert.pem")); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", msg => { + console.log("after failed listen:", JSON.stringify(msg)); + if (msg.listeningEvent) { + worker.kill(); + process.exit(1); + } + }); + cluster.on("listening", (w, address) => { + const c = tls.connect({ port: address.port, host: "127.0.0.1", rejectUnauthorized: false }); + c.setEncoding("utf8"); + c.on("data", d => { + console.log("reply:", d); + c.end(); + worker.kill(); + process.exit(0); + }); + c.on("error", e => { + console.log("client error:", e.code); + process.exit(1); + }); + }); +} else { + const bad = tls.createServer({ key, cert }, socket => socket.end()); + // Two names with more than 10 labels land on one node of the native SNI tree + // (#43092), so listen() rejects the second one as a duplicate after the bind. + const name = "a.b.c.d.e.f.g.h.i.j.k.example"; + bad.addContext(name, { key, cert }); + bad.addContext(name + ".", { key, cert }); + bad.on("listening", () => process.send({ listeningEvent: true })); + bad.on("error", err => { + process.send({ error: err.message, listening: bad.listening, address: bad.address() }); + // The worker's cluster state is still usable after the failure. + const good = tls.createServer({ key, cert }, socket => socket.end("ok")); + good.listen(0); + }); + bad.listen(0); +} +`, + }); + const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain( + 'after failed listen: {"error":"Failed to register SNI for \'a.b.c.d.e.f.g.h.i.j.k.example.\'","listening":false,"address":null}', + ); + expect(stdout).not.toContain("listeningEvent"); + expect(stdout).toContain("reply: ok"); +}, 30_000); + test("plain worker listening on a key already owned by a TLS shared-only handle fails with EINVAL", async () => { const dir = tempDirWithFiles("bun-test", { "cert.pem": tlsCerts.cert, diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index a672381930ae..4be01e4b5a8a 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1437,6 +1437,31 @@ it("an asynchronous SNICallback resolving cb(null, null) still honors addContext await once(server, "close"); }); +it("a listen() that fails while it loads addContext() entries leaves the server closed", async () => { + // The native SNI tree adds names of any label count but only removes names + // of up to 10 labels (#43092). Two names that land on one node make the + // second add a duplicate, so listen() throws after the socket is bound. When + // that limit goes away, this test needs another input that throws there. + const altCert = { key: rawKey, cert: cert }; + const server: Server = createServer(COMMON_CERT, socket => socket.end()); + const name = "a.b.c.d.e.f.g.h.i.j.k.example"; + server.addContext(name, altCert); + server.addContext(name + ".", altCert); + server.listen(0, "127.0.0.1"); + const outcome = await new Promise(resolve => { + server.once("listening", () => resolve("listening")); + server.once("error", err => resolve(`error: ${err.message}`)); + }); + expect(outcome).toStartWith("error: Failed to register SNI for 'a.b.c.d.e.f.g.h.i.j.k.example.'"); + expect({ + listening: server.listening, + address: server.address(), + handleIsNull: (server as any)._handle === null, + }).toEqual({ listening: false, address: null, handleIsNull: true }); + const closeErr = await new Promise(resolve => server.close(resolve)); + expect(closeErr.code).toBe("ERR_SERVER_NOT_RUNNING"); +}); + describe("tls.Server socket destroySoon", () => { // destroySoon() after end(big) must deliver every byte even when the TLS write // batcher's final flush spills (#31584). The spill/kernel-buffer race hits ~4% of From 8b1bc95b11399b1d931ea50f1674bd47d1d8effe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:40:30 +0000 Subject: [PATCH 2/6] test: make the post-bind listen() failure tests independent of the SNI label limit Both tests reached the throw through the 10-label asymmetry of the native SNI tree (#43092). They now make address() throw once after the bind, so a fix for #43092 does not turn them red. --- test/js/node/cluster.test.ts | 16 +++++++++------- test/js/node/tls/node-tls-server.test.ts | 22 ++++++++++++---------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/test/js/node/cluster.test.ts b/test/js/node/cluster.test.ts index 545595429a05..5d26052500eb 100644 --- a/test/js/node/cluster.test.ts +++ b/test/js/node/cluster.test.ts @@ -853,7 +853,7 @@ if (cluster.isPrimary) { expect(stdout).toContain("reply: echo:hi"); }, 30_000); -test("TLS cluster worker whose listen() fails while it loads addContext() entries ends up closed", async () => { +test("TLS cluster worker whose listen() fails after it adopts the shared fd ends up closed", async () => { const dir = tempDirWithFiles("bun-test", { "cert.pem": tlsCerts.cert, "key.pem": tlsCerts.key, @@ -890,11 +890,13 @@ if (cluster.isPrimary) { }); } else { const bad = tls.createServer({ key, cert }, socket => socket.end()); - // Two names with more than 10 labels land on one node of the native SNI tree - // (#43092), so listen() rejects the second one as a duplicate after the bind. - const name = "a.b.c.d.e.f.g.h.i.j.k.example"; - bad.addContext(name, { key, cert }); - bad.addContext(name + ".", { key, cert }); + // address() is the first step after the native listener adopts the fd that a + // test can make throw. It stands in for an addContext() entry the listener rejects. + const realAddress = bad.address; + bad.address = function () { + bad.address = realAddress; + throw new Error("address() failed after the adopt"); + }; bad.on("listening", () => process.send({ listeningEvent: true })); bad.on("error", err => { process.send({ error: err.message, listening: bad.listening, address: bad.address() }); @@ -908,7 +910,7 @@ if (cluster.isPrimary) { }); const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); expect(stdout).toContain( - 'after failed listen: {"error":"Failed to register SNI for \'a.b.c.d.e.f.g.h.i.j.k.example.\'","listening":false,"address":null}', + 'after failed listen: {"error":"address() failed after the adopt","listening":false,"address":null}', ); expect(stdout).not.toContain("listeningEvent"); expect(stdout).toContain("reply: ok"); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 4be01e4b5a8a..1c0952399754 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1437,22 +1437,24 @@ it("an asynchronous SNICallback resolving cb(null, null) still honors addContext await once(server, "close"); }); -it("a listen() that fails while it loads addContext() entries leaves the server closed", async () => { - // The native SNI tree adds names of any label count but only removes names - // of up to 10 labels (#43092). Two names that land on one node make the - // second add a duplicate, so listen() throws after the socket is bound. When - // that limit goes away, this test needs another input that throws there. - const altCert = { key: rawKey, cert: cert }; +it("a listen() that fails after the socket is bound leaves the server closed", async () => { + // listen() binds the socket, then wires the handle and loads the addContext() + // entries. A throw from any of those steps (an entry the native listener + // rejects, for example) must stop the bound listener. address() is the first + // step after the bind that a test can make throw, so it stands in for them. const server: Server = createServer(COMMON_CERT, socket => socket.end()); - const name = "a.b.c.d.e.f.g.h.i.j.k.example"; - server.addContext(name, altCert); - server.addContext(name + ".", altCert); + server.addContext("alt.example.com", { key: rawKey, cert: cert }); + const realAddress = server.address; + server.address = function () { + server.address = realAddress; + throw new Error("address() failed after the bind"); + }; server.listen(0, "127.0.0.1"); const outcome = await new Promise(resolve => { server.once("listening", () => resolve("listening")); server.once("error", err => resolve(`error: ${err.message}`)); }); - expect(outcome).toStartWith("error: Failed to register SNI for 'a.b.c.d.e.f.g.h.i.j.k.example.'"); + expect(outcome).toBe("error: address() failed after the bind"); expect({ listening: server.listening, address: server.address(), From ca5164990496d16658525a324960fd3ef4524ee0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:41:28 +0000 Subject: [PATCH 3/6] node:net: shorten the kRealListen cleanup comments --- src/js/node/net.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 0b2fd0088912..6947293bd890 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -4097,8 +4097,7 @@ Server.prototype[kRealListen] = function ( data: this, pauseOnConnect: this.pauseOnConnect, }); - // The native listener owns the fd from here on, and stop() closes it. A - // cluster worker's shared handle must not close the same fd again. + // The native listener owns the fd now, so the cluster handle must not close it. const clusterHandle = this[kClusterHandle]; if (clusterHandle != null && clusterHandle.sharedFd === fd) clusterHandle.adopted = true; } else { @@ -4116,14 +4115,9 @@ Server.prototype[kRealListen] = function ( }); } - // The listener is bound and accepting from here on. If a later step throws, - // stop it, so a failed listen() leaves the server closed (no 'listening', - // listening === false, address() === null) and only 'error' fires. + // A throw past this point stops the bound listener, like node's setupListenHandle. try { - // Mirror libuv uv_pipe_chmod: readableAll/writableAll relax the unix socket - // file's group/other permission bits. Skipped on Windows and abstract - // sockets (no filesystem entry). uSockets binds synchronously, so the file - // exists by the time Bun.listen returns. + // uv_pipe_chmod: relax the socket file's group/other bits. No file on Windows or abstract sockets. // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 if (path && (readableAll || writableAll) && process.platform !== "win32" && path.charCodeAt(0) !== 0) { let desired = 0; @@ -4345,8 +4339,7 @@ function listenInCluster( sharedFd, ); } catch (err) { - // kRealListen marks `handle.adopted` once the native listener owns the - // fd. Then this close() releases the primary's key and leaves the fd alone. + // With `handle.adopted` set by kRealListen, close() releases the key but not the fd. server[kClusterHandle] = null; server[kClusterUnixPath] = undefined; handle[kClusterOwner] = null; From 563f6ac76566eedaf0bc2c89fbafcaa489c2b71e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:42:10 +0000 Subject: [PATCH 4/6] node:net: drop the try block comment in kRealListen --- src/js/node/net.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 6947293bd890..b0a0d7f36b22 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -4115,10 +4115,8 @@ Server.prototype[kRealListen] = function ( }); } - // A throw past this point stops the bound listener, like node's setupListenHandle. try { - // uv_pipe_chmod: relax the socket file's group/other bits. No file on Windows or abstract sockets. - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 + // uv_pipe_chmod: https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 if (path && (readableAll || writableAll) && process.platform !== "win32" && path.charCodeAt(0) !== 0) { let desired = 0; if (readableAll) desired |= 0o44; // S_IRGRP | S_IROTH From 12024f0fccb8b9a21928e66b52b74292ee2ee9ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:00:29 +0000 Subject: [PATCH 5/6] ci: retrigger From 045757abc785fffb1616b0d5b19f1b524024a8b1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:09:08 +0000 Subject: [PATCH 6/6] test: assert the server state after listen() matches the event, on Node and Bun Node does not call address() during listen(), so the address() override never failed there. The tests now use two addContext() names that Bun rejects when it loads them into the listener and that Node accepts. They assert that the state matches whichever event ends listen(). --- test/js/node/cluster.test.ts | 52 ++++++++++++------------ test/js/node/tls/node-tls-server.test.ts | 43 +++++++++++--------- 2 files changed, 50 insertions(+), 45 deletions(-) diff --git a/test/js/node/cluster.test.ts b/test/js/node/cluster.test.ts index 5d26052500eb..e08180b02327 100644 --- a/test/js/node/cluster.test.ts +++ b/test/js/node/cluster.test.ts @@ -853,7 +853,7 @@ if (cluster.isPrimary) { expect(stdout).toContain("reply: echo:hi"); }, 30_000); -test("TLS cluster worker whose listen() fails after it adopts the shared fd ends up closed", async () => { +test("TLS cluster worker state matches the event that ends listen(): 'listening' or 'error'", async () => { const dir = tempDirWithFiles("bun-test", { "cert.pem": tlsCerts.cert, "key.pem": tlsCerts.key, @@ -867,13 +867,7 @@ const cert = fs.readFileSync(path.join(__dirname, "cert.pem")); if (cluster.isPrimary) { const worker = cluster.fork(); - worker.on("message", msg => { - console.log("after failed listen:", JSON.stringify(msg)); - if (msg.listeningEvent) { - worker.kill(); - process.exit(1); - } - }); + worker.on("message", msg => console.log("state:", JSON.stringify(msg))); cluster.on("listening", (w, address) => { const c = tls.connect({ port: address.port, host: "127.0.0.1", rejectUnauthorized: false }); c.setEncoding("utf8"); @@ -889,30 +883,36 @@ if (cluster.isPrimary) { }); }); } else { - const bad = tls.createServer({ key, cert }, socket => socket.end()); - // address() is the first step after the native listener adopts the fd that a - // test can make throw. It stands in for an addContext() entry the listener rejects. - const realAddress = bad.address; - bad.address = function () { - bad.address = realAddress; - throw new Error("address() failed after the adopt"); - }; - bad.on("listening", () => process.send({ listeningEvent: true })); - bad.on("error", err => { - process.send({ error: err.message, listening: bad.listening, address: bad.address() }); + const onConnection = socket => socket.end("ok"); + const first = tls.createServer({ key, cert }, onConnection); + // Bun rejects the second name when it loads the entries into the listener + // that adopted the shared fd (#43092). Node accepts both. + const name = "a.b.c.d.e.f.g.h.i.j.k.example"; + first.addContext(name, { key, cert }); + first.addContext(name + ".", { key, cert }); + const report = outcome => + process.send({ + outcome, + listening: first.listening, + hasAddress: first.address() !== null, + hasHandle: first._handle != null, + }); + first.on("listening", () => report("listening")); + first.on("error", () => { + report("error"); // The worker's cluster state is still usable after the failure. - const good = tls.createServer({ key, cert }, socket => socket.end("ok")); - good.listen(0); + tls.createServer({ key, cert }, onConnection).listen(0); }); - bad.listen(0); + first.listen(0); } `, }); const { stdout } = await bunRun(joinP(dir, "main.ts"), bunEnv); - expect(stdout).toContain( - 'after failed listen: {"error":"address() failed after the adopt","listening":false,"address":null}', - ); - expect(stdout).not.toContain("listeningEvent"); + const state = stdout.match(/^state: (.*)$/m)?.[1]; + expect([ + '{"outcome":"listening","listening":true,"hasAddress":true,"hasHandle":true}', + '{"outcome":"error","listening":false,"hasAddress":false,"hasHandle":false}', + ]).toContain(state); expect(stdout).toContain("reply: ok"); }, 30_000); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 1c0952399754..87b6314b80c4 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1437,31 +1437,36 @@ it("an asynchronous SNICallback resolving cb(null, null) still honors addContext await once(server, "close"); }); -it("a listen() that fails after the socket is bound leaves the server closed", async () => { - // listen() binds the socket, then wires the handle and loads the addContext() - // entries. A throw from any of those steps (an entry the native listener - // rejects, for example) must stop the bound listener. address() is the first - // step after the bind that a test can make throw, so it stands in for them. +it("the server state matches the event that ends listen(): 'listening' or 'error'", async () => { + // Bun loads the addContext() entries into the native listener after the + // bind, and rejects the second of these two names there (#43092). Node + // accepts both and emits 'listening'. After either event the server state + // has to match it: a failed listen() leaves the server closed, as in Node. + const altCert = { key: rawKey, cert: cert }; const server: Server = createServer(COMMON_CERT, socket => socket.end()); - server.addContext("alt.example.com", { key: rawKey, cert: cert }); - const realAddress = server.address; - server.address = function () { - server.address = realAddress; - throw new Error("address() failed after the bind"); - }; + const name = "a.b.c.d.e.f.g.h.i.j.k.example"; + server.addContext(name, altCert); + server.addContext(name + ".", altCert); server.listen(0, "127.0.0.1"); const outcome = await new Promise(resolve => { server.once("listening", () => resolve("listening")); - server.once("error", err => resolve(`error: ${err.message}`)); + server.once("error", () => resolve("error")); }); - expect(outcome).toBe("error: address() failed after the bind"); - expect({ + const state = { + outcome, listening: server.listening, - address: server.address(), - handleIsNull: (server as any)._handle === null, - }).toEqual({ listening: false, address: null, handleIsNull: true }); - const closeErr = await new Promise(resolve => server.close(resolve)); - expect(closeErr.code).toBe("ERR_SERVER_NOT_RUNNING"); + hasAddress: server.address() !== null, + hasHandle: (server as any)._handle != null, + }; + if (outcome === "listening") { + expect(state).toEqual({ outcome: "listening", listening: true, hasAddress: true, hasHandle: true }); + server.close(); + await once(server, "close"); + } else { + expect(state).toEqual({ outcome: "error", listening: false, hasAddress: false, hasHandle: false }); + const closeErr = await new Promise(resolve => server.close(resolve)); + expect(closeErr.code).toBe("ERR_SERVER_NOT_RUNNING"); + } }); describe("tls.Server socket destroySoon", () => {