From b4c7c60a19f1a746a717b602331464605a2d351b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:04:23 +0000 Subject: [PATCH 1/7] node:http: defer server 'close' until every tracked connection has ended server.close(cb) was firing as soon as the native all-closed promise resolved (pending_requests == 0), even though a keep-alive connection that was mid-request at close() time is still open at that point and keeps accepting requests. Node's net.Server#_emitCloseIfDrained holds 'close' until every accepted connection has ended. Gate emitCloseServer on kTrackedConnections.size and re-check when the last tracked connection's #onClose runs. --- src/js/node/_http_server.ts | 21 ++++++++- test/js/node/http/node-http.test.ts | 71 +++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 282248b5675b..afdb2323650b 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -86,6 +86,7 @@ const OutgoingMessagePrototype = OutgoingMessage.prototype; const { kIncomingMessage } = require("node:_http_common"); const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const kTrackedConnections = Symbol("http.server.trackedConnections"); +const kPendingDrainClose = Symbol("http.server.pendingDrainClose"); const kHttpAllowHalfOpen = Symbol("http.server.httpAllowHalfOpen"); // node.http trace events ('http.server.request' b/e). The agent module is @@ -120,6 +121,16 @@ const DateNow = Date.now; let cluster; function emitCloseServer(self: Server) { + // Like Node.js's net.Server#_emitCloseIfDrained: 'close' waits for every + // accepted connection to end. The native all-closed promise resolves on + // pending_requests == 0, so a keep-alive connection that was serving a + // request when close() ran is still open when that promise resolves. + const connections = self[kTrackedConnections]; + if (connections && connections.size > 0) { + self[kPendingDrainClose] = true; + return; + } + self[kPendingDrainClose] = false; callCloseCallback(self); self.emit("close"); } @@ -309,6 +320,7 @@ function Server(options, callback): void { defineHttpAllowHalfOpen(this); this[kInternalSocketData] = undefined; this[kTrackedConnections] = new Set(); + this[kPendingDrainClose] = false; this[tlsSymbol] = null; this.noDelay = true; if (typeof options === "function") { @@ -1667,7 +1679,14 @@ const NodeHTTPServerSocket = class Socket extends NetSocket { // released parser (free() invoked, kOnTimeout nulled). releaseServerParserShim(this); this[kHandle] = null; - this.server?.[kTrackedConnections]?.delete(this); + const server = this.server; + const tracked = server?.[kTrackedConnections]; + if (tracked) { + tracked.delete(this); + if (tracked.size === 0 && server[kPendingDrainClose]) { + process.nextTick(emitCloseServer, server); + } + } const timer = this[kSocketTimeoutTimer]; if (timer) { clearTimeout(timer); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index a204e6d37259..757d8c9e75b3 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -3467,6 +3467,77 @@ it("server.close(cb) completes after a raw upgrade once both sockets are destroy await closed; }); +it("server.close(cb) does not fire while a keep-alive connection is still open", async () => { + // Node's net.Server#close callback (and the 'close' event) only fires once + // every accepted connection has ended. A connection that was mid-request + // when close() ran stays open after the response is delivered, so the + // callback must be withheld until the client closes the socket. + const inHandler = Promise.withResolvers(); + let releaseResponse!: () => void; + const paths: string[] = []; + const server = createServer((req, res) => { + paths.push(req.url as string); + if (paths.length === 1) { + inHandler.resolve(); + releaseResponse = () => res.end("resp:" + req.url); + } else { + res.end("resp:" + req.url); + } + }); + server.keepAliveTimeout = 60000; + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const socket = connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + let body = ""; + socket.on("data", chunk => (body += chunk)); + socket.on("error", () => {}); + + // First request: handler is entered but response held until after close(). + socket.write("GET /first HTTP/1.1\r\nHost: x\r\n\r\n"); + await inHandler.promise; + + let closeEventFired = false; + server.once("close", () => (closeEventFired = true)); + const closed = Promise.withResolvers(); + let closeCbFired = false; + server.close(() => { + closeCbFired = true; + closed.resolve(); + }); + + // Handler finishes: response is delivered, connection stays open. + // Yield a few event-loop turns after the bytes arrive so the server's + // "all requests done" task chain has run before the callback is checked. + releaseResponse(); + while (!body.includes("resp:/first")) await once(socket, "data"); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(closeCbFired).toBe(false); + expect(closeEventFired).toBe(false); + + // A second request on the same connection is still served (matching Node), + // and the close callback must still be withheld afterwards. + socket.write("GET /second HTTP/1.1\r\nHost: x\r\n\r\n"); + while (!body.includes("resp:/second")) await once(socket, "data"); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(closeCbFired).toBe(false); + expect(closeEventFired).toBe(false); + expect(paths).toEqual(["/first", "/second"]); + + // Closing the connection drains the server and releases the callback. + socket.destroy(); + await closed.promise; + expect(closeCbFired).toBe(true); + expect(closeEventFired).toBe(true); + } finally { + socket.destroy(); + server.closeAllConnections(); + } +}); + it("req.upgrade is true inside the 'connect' listener", async () => { let upgradeValue: unknown = "unset"; const { promise: sawConnect, resolve: onConnect } = Promise.withResolvers(); From a691af8e9b2ea2ef11403ed98af6071e46d4064f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:23:42 +0000 Subject: [PATCH 2/7] move the server-close drain test to its own file node-http.test.ts has an environment-dependent proxy test that fails in this CI container; a standalone file lets the mechanical fail-before / pass-after check run cleanly. --- .../http/node-http-server-close-drain.test.ts | 104 ++++++++++++++++++ test/js/node/http/node-http.test.ts | 71 ------------ 2 files changed, 104 insertions(+), 71 deletions(-) create mode 100644 test/js/node/http/node-http-server-close-drain.test.ts diff --git a/test/js/node/http/node-http-server-close-drain.test.ts b/test/js/node/http/node-http-server-close-drain.test.ts new file mode 100644 index 000000000000..17824b149984 --- /dev/null +++ b/test/js/node/http/node-http-server-close-drain.test.ts @@ -0,0 +1,104 @@ +import { test, expect } from "bun:test"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { connect } from "node:net"; + +// Node's net.Server#close callback (and the 'close' event) only fires once +// every accepted connection has ended. A connection that was mid-request +// when close() ran stays open after the response is delivered, so the +// callback must be withheld until that connection closes. +test("server.close(cb) does not fire while a keep-alive connection is still open", async () => { + const inHandler = Promise.withResolvers(); + let releaseResponse!: () => void; + const paths: string[] = []; + const server = createServer((req, res) => { + paths.push(req.url as string); + if (paths.length === 1) { + inHandler.resolve(); + releaseResponse = () => res.end("resp:" + req.url); + } else { + res.end("resp:" + req.url); + } + }); + server.keepAliveTimeout = 60000; + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const socket = connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + let body = ""; + socket.on("data", chunk => (body += chunk)); + socket.on("error", () => {}); + + // First request: handler is entered but response held until after close(). + socket.write("GET /first HTTP/1.1\r\nHost: x\r\n\r\n"); + await inHandler.promise; + + let closeEventFired = false; + server.once("close", () => (closeEventFired = true)); + const closed = Promise.withResolvers(); + let closeCbFired = false; + server.close(() => { + closeCbFired = true; + closed.resolve(); + }); + + // Handler finishes: response is delivered, connection stays open. + // Yield a few event-loop turns after the bytes arrive so the server's + // "all requests done" task chain has run before the callback is checked. + releaseResponse(); + while (!body.includes("resp:/first")) await once(socket, "data"); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(closeCbFired).toBe(false); + expect(closeEventFired).toBe(false); + + // A second request on the same connection is still served (matching Node), + // and the close callback must still be withheld afterwards. + socket.write("GET /second HTTP/1.1\r\nHost: x\r\n\r\n"); + while (!body.includes("resp:/second")) await once(socket, "data"); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(closeCbFired).toBe(false); + expect(closeEventFired).toBe(false); + expect(paths).toEqual(["/first", "/second"]); + + // Closing the connection drains the server and releases the callback. + socket.destroy(); + await closed.promise; + expect(closeCbFired).toBe(true); + expect(closeEventFired).toBe(true); + } finally { + socket.destroy(); + server.closeAllConnections(); + } +}); + +// Sanity: an idle keep-alive connection at close() time is reaped by +// closeIdleConnections(), so the callback fires promptly like before. +test("server.close(cb) fires once an idle keep-alive connection is reaped", async () => { + const server = createServer((req, res) => res.end("ok")); + server.keepAliveTimeout = 60000; + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const socket = connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + let body = ""; + socket.on("data", chunk => (body += chunk)); + socket.on("error", () => {}); + socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + while (!body.includes("ok")) await once(socket, "data"); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + + const closed = Promise.withResolvers(); + server.close(() => closed.resolve()); + await closed.promise; + } finally { + socket.destroy(); + server.closeAllConnections(); + } +}); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 757d8c9e75b3..a204e6d37259 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -3467,77 +3467,6 @@ it("server.close(cb) completes after a raw upgrade once both sockets are destroy await closed; }); -it("server.close(cb) does not fire while a keep-alive connection is still open", async () => { - // Node's net.Server#close callback (and the 'close' event) only fires once - // every accepted connection has ended. A connection that was mid-request - // when close() ran stays open after the response is delivered, so the - // callback must be withheld until the client closes the socket. - const inHandler = Promise.withResolvers(); - let releaseResponse!: () => void; - const paths: string[] = []; - const server = createServer((req, res) => { - paths.push(req.url as string); - if (paths.length === 1) { - inHandler.resolve(); - releaseResponse = () => res.end("resp:" + req.url); - } else { - res.end("resp:" + req.url); - } - }); - server.keepAliveTimeout = 60000; - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const { port } = server.address() as AddressInfo; - - const socket = connect(port, "127.0.0.1"); - try { - await once(socket, "connect"); - let body = ""; - socket.on("data", chunk => (body += chunk)); - socket.on("error", () => {}); - - // First request: handler is entered but response held until after close(). - socket.write("GET /first HTTP/1.1\r\nHost: x\r\n\r\n"); - await inHandler.promise; - - let closeEventFired = false; - server.once("close", () => (closeEventFired = true)); - const closed = Promise.withResolvers(); - let closeCbFired = false; - server.close(() => { - closeCbFired = true; - closed.resolve(); - }); - - // Handler finishes: response is delivered, connection stays open. - // Yield a few event-loop turns after the bytes arrive so the server's - // "all requests done" task chain has run before the callback is checked. - releaseResponse(); - while (!body.includes("resp:/first")) await once(socket, "data"); - for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); - expect(closeCbFired).toBe(false); - expect(closeEventFired).toBe(false); - - // A second request on the same connection is still served (matching Node), - // and the close callback must still be withheld afterwards. - socket.write("GET /second HTTP/1.1\r\nHost: x\r\n\r\n"); - while (!body.includes("resp:/second")) await once(socket, "data"); - for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); - expect(closeCbFired).toBe(false); - expect(closeEventFired).toBe(false); - expect(paths).toEqual(["/first", "/second"]); - - // Closing the connection drains the server and releases the callback. - socket.destroy(); - await closed.promise; - expect(closeCbFired).toBe(true); - expect(closeEventFired).toBe(true); - } finally { - socket.destroy(); - server.closeAllConnections(); - } -}); - it("req.upgrade is true inside the 'connect' listener", async () => { let upgradeValue: unknown = "unset"; const { promise: sawConnect, resolve: onConnect } = Promise.withResolvers(); From 88c18a49533faada26b91e73bd549c92cfc14571 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:25:55 +0000 Subject: [PATCH 3/7] [autofix.ci] apply automated fixes --- test/js/node/http/node-http-server-close-drain.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/http/node-http-server-close-drain.test.ts b/test/js/node/http/node-http-server-close-drain.test.ts index 17824b149984..19959d16c8eb 100644 --- a/test/js/node/http/node-http-server-close-drain.test.ts +++ b/test/js/node/http/node-http-server-close-drain.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test"; +import { expect, test } from "bun:test"; import { once } from "node:events"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; From 68845f55ad7764adaa2018df2e9dd99230edfeb8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:26:16 +0000 Subject: [PATCH 4/7] trim the emitCloseServer gate comment --- src/js/node/_http_server.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index afdb2323650b..df7be1a795e9 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -121,10 +121,7 @@ const DateNow = Date.now; let cluster; function emitCloseServer(self: Server) { - // Like Node.js's net.Server#_emitCloseIfDrained: 'close' waits for every - // accepted connection to end. The native all-closed promise resolves on - // pending_requests == 0, so a keep-alive connection that was serving a - // request when close() ran is still open when that promise resolves. + // The native all-closed promise tracks pending requests, not open connections. const connections = self[kTrackedConnections]; if (connections && connections.size > 0) { self[kPendingDrainClose] = true; From fa8d38aebe6766347395d0f4914edccace07c9d7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:47:43 +0000 Subject: [PATCH 5/7] closeAllConnections/closeIdleConnections: fall back to kTrackedConnections after close() close() clears the native handle, which made both methods no-ops and left no way to force-drain a connection that had been mid-request at close() time. Iterate the tracked set directly in that state. Also guard emitCloseServer against a re-listened server and reset the pending-drain flag in kRealListen so the previous cycle's flag cannot fire 'close' on the new server. --- src/js/node/_http_server.ts | 29 +++-- .../http/node-http-server-close-drain.test.ts | 102 ++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index df7be1a795e9..c727117a23b9 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -122,6 +122,7 @@ let cluster; function emitCloseServer(self: Server) { // The native all-closed promise tracks pending requests, not open connections. + if (self[serverSymbol]) return; const connections = self[kTrackedConnections]; if (connections && connections.size > 0) { self[kPendingDrainClose] = true; @@ -482,14 +483,18 @@ Server.prototype.unref = function () { Server.prototype.closeAllConnections = function () { const server = this[serverSymbol]; - if (!server) { + if (server) { + this[serverSymbol] = undefined; + clearInterval(this[kConnectionsCheckingInterval]); + this.listening = false; + server.stop(true); return; } - this[serverSymbol] = undefined; - clearInterval(this[kConnectionsCheckingInterval]); - this.listening = false; - - server.stop(true); + // close() already dropped the native handle; destroy what is still tracked. + const tracked = this[kTrackedConnections]; + if (tracked && tracked.size > 0) { + for (const socket of [...tracked]) socket.destroy(); + } }; Server.prototype.getConnections = function (callback) { @@ -504,7 +509,16 @@ Server.prototype.getConnections = function (callback) { Server.prototype.closeIdleConnections = function () { const server = this[serverSymbol]; - server?.closeIdleConnections(); + if (server) { + server.closeIdleConnections(); + return; + } + const tracked = this[kTrackedConnections]; + if (tracked && tracked.size > 0) { + for (const socket of [...tracked]) { + if (!socket._httpMessage) socket.destroy(); + } + } }; Server.prototype.close = function (optionalCallback?) { @@ -1157,6 +1171,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // }, }); + this[kPendingDrainClose] = false; getBunServerAllClosedPromise(this[serverSymbol]).$then(emitCloseNTServer.bind(this)); isHTTPS = this[serverSymbol].protocol === "https"; applyServerCustomOptions(this); diff --git a/test/js/node/http/node-http-server-close-drain.test.ts b/test/js/node/http/node-http-server-close-drain.test.ts index 19959d16c8eb..1083ce53b04e 100644 --- a/test/js/node/http/node-http-server-close-drain.test.ts +++ b/test/js/node/http/node-http-server-close-drain.test.ts @@ -102,3 +102,105 @@ test("server.close(cb) fires once an idle keep-alive connection is reaped", asyn server.closeAllConnections(); } }); + +// The graceful-drain-with-deadline pattern: close(), then force via +// closeAllConnections() once the caller has waited long enough. The force +// step must work even though close() already dropped the native handle. +test("closeAllConnections() after close() force-drains the withheld callback", async () => { + const inHandler = Promise.withResolvers(); + let releaseResponse!: () => void; + const server = createServer((req, res) => { + inHandler.resolve(); + releaseResponse = () => res.end("ok"); + }); + server.keepAliveTimeout = 60000; + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const socket = connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + let body = ""; + socket.on("data", chunk => (body += chunk)); + socket.on("error", () => {}); + socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + await inHandler.promise; + + const closed = Promise.withResolvers(); + let closeCbFired = false; + server.close(() => { + closeCbFired = true; + closed.resolve(); + }); + releaseResponse(); + while (!body.includes("ok")) await once(socket, "data"); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(closeCbFired).toBe(false); + + server.closeAllConnections(); + await closed.promise; + expect(closeCbFired).toBe(true); + } finally { + socket.destroy(); + server.closeAllConnections(); + } +}); + +// Re-listening after close() while a keep-alive connection from the previous +// cycle is still open must not fire 'close' on the new (listening) server +// when that old connection finally ends. +test("no 'close' is emitted on a re-listened server when an earlier connection ends", async () => { + const inHandler = Promise.withResolvers(); + let releaseResponse!: () => void; + let requests = 0; + const server = createServer((req, res) => { + if (++requests === 1) { + inHandler.resolve(); + releaseResponse = () => res.end("ok"); + } else { + res.end("ok"); + } + }); + server.keepAliveTimeout = 60000; + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port1 = (server.address() as AddressInfo).port; + + const socket = connect(port1, "127.0.0.1"); + try { + await once(socket, "connect"); + let body = ""; + socket.on("data", chunk => (body += chunk)); + socket.on("error", () => {}); + socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + await inHandler.promise; + + server.close(); + releaseResponse(); + while (!body.includes("ok")) await once(socket, "data"); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + + // Re-listen while the old connection is still open. + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + let closeEmitted = 0; + server.on("close", () => closeEmitted++); + + // Old connection ends. No 'close' must fire on the listening server. + socket.destroy(); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(closeEmitted).toBe(0); + expect(server.listening).toBe(true); + + // Closing the new server then emits 'close' exactly once. + const closed = Promise.withResolvers(); + server.close(() => closed.resolve()); + await closed.promise; + expect(closeEmitted).toBe(1); + } finally { + socket.destroy(); + server.closeAllConnections(); + } +}); From 1dcca2f5791671b525847979eeea4b040ef93a4b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:08:46 +0000 Subject: [PATCH 6/7] use Array.from over spread for the tracked-set snapshot (no-useless-spread) --- src/js/node/_http_server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index c727117a23b9..79fa3fb7603b 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -493,7 +493,7 @@ Server.prototype.closeAllConnections = function () { // close() already dropped the native handle; destroy what is still tracked. const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { - for (const socket of [...tracked]) socket.destroy(); + for (const socket of Array.from(tracked)) socket.destroy(); } }; @@ -515,7 +515,7 @@ Server.prototype.closeIdleConnections = function () { } const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { - for (const socket of [...tracked]) { + for (const socket of Array.from(tracked)) { if (!socket._httpMessage) socket.destroy(); } } From 765455a4a003b8ddfe13aa254f07b37a2cf58db9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:19:22 +0000 Subject: [PATCH 7/7] register the server close callback via once('close') like Node setCloseCallback throws when a callback is already stored, so a deferred callback that was never reached (because emitCloseServer bailed on open connections) would block the next close(cb) after a re-listen. Node's net.Server#close uses once('close'), which stacks listeners across cycles and needs no separate clear step. --- src/js/node/_http_server.ts | 3 +-- test/js/node/http/node-http-server-close-drain.test.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 79fa3fb7603b..623d85be3cbf 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -129,7 +129,6 @@ function emitCloseServer(self: Server) { return; } self[kPendingDrainClose] = false; - callCloseCallback(self); self.emit("close"); } function emitCloseNTServer(this: Server) { @@ -532,7 +531,7 @@ Server.prototype.close = function (optionalCallback?) { return this; } this[serverSymbol] = undefined; - if (typeof optionalCallback === "function") setCloseCallback(this, optionalCallback); + if (typeof optionalCallback === "function") this.once("close", optionalCallback); this.listening = false; server.closeIdleConnections(); server.stop(); diff --git a/test/js/node/http/node-http-server-close-drain.test.ts b/test/js/node/http/node-http-server-close-drain.test.ts index 1083ce53b04e..c23c9638c1c5 100644 --- a/test/js/node/http/node-http-server-close-drain.test.ts +++ b/test/js/node/http/node-http-server-close-drain.test.ts @@ -176,7 +176,8 @@ test("no 'close' is emitted on a re-listened server when an earlier connection e socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); await inHandler.promise; - server.close(); + let cb1Fired = false; + server.close(() => (cb1Fired = true)); releaseResponse(); while (!body.includes("ok")) await once(socket, "data"); for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); @@ -192,13 +193,17 @@ test("no 'close' is emitted on a re-listened server when an earlier connection e socket.destroy(); for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); expect(closeEmitted).toBe(0); + expect(cb1Fired).toBe(false); expect(server.listening).toBe(true); - // Closing the new server then emits 'close' exactly once. + // Closing the new server then emits 'close' exactly once. The first + // cycle's callback was registered via once('close') and fires now too, + // like Node (and passing a second callback does not throw). const closed = Promise.withResolvers(); server.close(() => closed.resolve()); await closed.promise; expect(closeEmitted).toBe(1); + expect(cb1Fired).toBe(true); } finally { socket.destroy(); server.closeAllConnections();