From 051ab4659e557093966d805dabb3e99914cc7c39 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:15:35 +0000 Subject: [PATCH 1/5] node:http: defer server 'close' until every tracked connection has ended server.close(cb) fired 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 on the native handle being gone; the last tracked connection's #onClose re-checks. - kRealListen resets the pending-drain flag so a re-listen does not inherit the previous cycle's state. - close(cb) registers the callback via once('close') like Node, so a deferred callback cannot block a later close(cb) after a re-listen. - closeAllConnections()/closeIdleConnections() fall back to iterating kTrackedConnections once close() has dropped the native handle, so the close(); setTimeout(closeAllConnections) deadline pattern can force the drain. https.createServer shares this Server class, so the gate covers a TLS keep-alive connection too. A test drives that face through https.Agent. --- src/js/node/_http_server.ts | 50 +++- .../http/node-http-server-close-drain.test.ts | 280 ++++++++++++++++++ 2 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 test/js/node/http/node-http-server-close-drain.test.ts diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 45fb072718c6..5807f5ede37f 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -70,6 +70,7 @@ const { kIncomingMessage } = require("node:_http_common"); let http1Fallback; 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 @@ -102,7 +103,14 @@ const DateNow = Date.now; let cluster; function emitCloseServer(self: Server) { - callCloseCallback(self); + // 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; + return; + } + self[kPendingDrainClose] = false; self.emit("close"); } function emitCloseNTServer(this: Server) { @@ -278,6 +286,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") { @@ -445,14 +454,18 @@ Server.prototype.unref = function () { Server.prototype.closeAllConnections = function () { http1Fallback?.closeAllHttp1Connections(this); 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 Array.from(tracked)) socket.destroy(); + } }; Server.prototype.getConnections = function (callback) { @@ -468,7 +481,16 @@ Server.prototype.getConnections = function (callback) { Server.prototype.closeIdleConnections = function () { http1Fallback?.closeIdleHttp1Connections(this); const server = this[serverSymbol]; - server?.closeIdleConnections(); + if (server) { + server.closeIdleConnections(); + return; + } + const tracked = this[kTrackedConnections]; + if (tracked && tracked.size > 0) { + for (const socket of Array.from(tracked)) { + if (!socket._httpMessage) socket.destroy(); + } + } }; Server.prototype.close = function (optionalCallback?) { @@ -483,7 +505,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(); // stop() queues the task that emits 'close', which holds the loop one more turn, as node's uv_close() does. @@ -1059,6 +1081,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // Bun.serve() has bound and listened by now, so the flag is true at once, as node's getter is. this.listening = true; + this[kPendingDrainClose] = false; getBunServerAllClosedPromise(this[serverSymbol]).$then(emitCloseNTServer.bind(this)); applyServerCustomOptions(this); @@ -1612,7 +1635,14 @@ function getNodeHTTPServerSocket() { // 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-server-close-drain.test.ts b/test/js/node/http/node-http-server-close-drain.test.ts new file mode 100644 index 000000000000..a90e9829a92e --- /dev/null +++ b/test/js/node/http/node-http-server-close-drain.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from "bun:test"; +import { tls as tlsCert } from "harness"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { Agent as HttpsAgent, createServer as createHttpsServer, get as httpsGet } from "node:https"; +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(); + } +}); + +// https.createServer goes through the same Server class, so the same gate must +// hold for a TLS keep-alive connection driven by a real client agent (the shape +// a browser produces: one connection, a slow response, then another request). +test("https server.close(cb) does not fire while a keep-alive TLS connection is still open", async () => { + const inHandler = Promise.withResolvers(); + let releaseResponse!: () => void; + const paths: string[] = []; + const clientPorts: (number | undefined)[] = []; + const server = createHttpsServer({ key: tlsCert.key, cert: tlsCert.cert }, (req, res) => { + paths.push(req.url as string); + clientPorts.push(req.socket.remotePort); + if (paths.length === 1) { + inHandler.resolve(); + releaseResponse = () => res.end("first"); + } else { + res.end("second"); + } + }); + server.keepAliveTimeout = 60000; + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const agent = new HttpsAgent({ keepAlive: true, maxSockets: 1, rejectUnauthorized: false }); + const get = (path: string) => + new Promise((resolve, reject) => { + const req = httpsGet({ port, host: "127.0.0.1", path, agent, rejectUnauthorized: false }, res => { + res.resume(); + res.on("end", () => resolve(res.statusCode as number)); + }); + req.on("error", reject); + }); + + try { + const first = get("/first"); + await inHandler.promise; + + let closeCbFired = false; + const closed = Promise.withResolvers(); + server.close(() => { + closeCbFired = true; + closed.resolve(); + }); + + // The in-flight response is delivered, and the TLS connection stays open. + releaseResponse(); + expect(await first).toBe(200); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(closeCbFired).toBe(false); + + // The agent reuses that same connection for a second request. + expect(await get("/second")).toBe(200); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(paths).toEqual(["/first", "/second"]); + expect(clientPorts[1]).toBe(clientPorts[0]); + expect(closeCbFired).toBe(false); + + // Closing the client side drains the server and releases the callback. + agent.destroy(); + await closed.promise; + expect(closeCbFired).toBe(true); + } finally { + agent.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(); + } +}); + +// 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; + + 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)); + + // 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(cb1Fired).toBe(false); + expect(server.listening).toBe(true); + + // 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(); + } +}); From d65da482ad7bb4ec3a86a73095136a03fe79c302 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:28:25 +0000 Subject: [PATCH 2/5] test: drop narrative step comments from the close-drain tests --- .../http/node-http-server-close-drain.test.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) 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 a90e9829a92e..7d458c1621ac 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 @@ -35,7 +35,6 @@ test("server.close(cb) does not fire while a keep-alive connection is still open 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; @@ -48,7 +47,6 @@ test("server.close(cb) does not fire while a keep-alive connection is still open 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(); @@ -57,8 +55,6 @@ test("server.close(cb) does not fire while a keep-alive connection is still open 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)); @@ -66,7 +62,6 @@ test("server.close(cb) does not fire while a keep-alive connection is still open 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); @@ -121,20 +116,17 @@ test("https server.close(cb) does not fire while a keep-alive TLS connection is closed.resolve(); }); - // The in-flight response is delivered, and the TLS connection stays open. releaseResponse(); expect(await first).toBe(200); for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); expect(closeCbFired).toBe(false); - // The agent reuses that same connection for a second request. expect(await get("/second")).toBe(200); for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); expect(paths).toEqual(["/first", "/second"]); expect(clientPorts[1]).toBe(clientPorts[0]); expect(closeCbFired).toBe(false); - // Closing the client side drains the server and releases the callback. agent.destroy(); await closed.promise; expect(closeCbFired).toBe(true); @@ -251,23 +243,20 @@ test("no 'close' is emitted on a re-listened server when an earlier connection e 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(cb1Fired).toBe(false); expect(server.listening).toBe(true); - // 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). + // Like Node, the first cycle's callback is a once('close') listener: it + // fires here too, and passing a second callback does not throw. const closed = Promise.withResolvers(); server.close(() => closed.resolve()); await closed.promise; From a90b6020997cf67933d3e8dc4147ae874031feaf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:02:41 +0000 Subject: [PATCH 3/5] use $Array.from for the tracked-set snapshot --- 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 5807f5ede37f..deb80613e572 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -464,7 +464,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 Array.from(tracked)) socket.destroy(); + for (const socket of $Array.from(tracked)) socket.destroy(); } }; @@ -487,7 +487,7 @@ Server.prototype.closeIdleConnections = function () { } const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { - for (const socket of Array.from(tracked)) { + for (const socket of $Array.from(tracked)) { if (!socket._httpMessage) socket.destroy(); } } From 196b137966a1228c80e23c6a1743f0ebaa80a630 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:36:26 +0000 Subject: [PATCH 4/5] leave handed-off sockets alone in the post-close() drain fallbacks Node frees the parser when a socket is handed to 'upgrade'/'connect', which removes it from the connections list that closeAllConnections() and closeIdleConnections() iterate. Mark the handoff on the socket and skip marked sockets in both fallbacks, so an upgraded or CONNECT tunnel survives them like in Node. The socket still counts for 'close'. --- src/js/node/_http_server.ts | 12 ++- .../http/node-http-server-close-drain.test.ts | 83 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index deb80613e572..8f0d364509ef 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -71,6 +71,10 @@ let http1Fallback; const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const kTrackedConnections = Symbol("http.server.trackedConnections"); const kPendingDrainClose = Symbol("http.server.pendingDrainClose"); +// Set once a socket is handed to 'connect'/'upgrade'. Node frees the parser +// there, which takes the socket off the list that closeAllConnections() and +// closeIdleConnections() iterate; it still counts as a connection for 'close'. +const kHandedOff = Symbol("http.server.socketHandedOff"); const kHttpAllowHalfOpen = Symbol("http.server.httpAllowHalfOpen"); // node.http trace events ('http.server.request' b/e). The agent module is @@ -464,7 +468,9 @@ 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 $Array.from(tracked)) socket.destroy(); + for (const socket of $Array.from(tracked)) { + if (!socket[kHandedOff]) socket.destroy(); + } } }; @@ -488,7 +494,7 @@ Server.prototype.closeIdleConnections = function () { const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { for (const socket of $Array.from(tracked)) { - if (!socket._httpMessage) socket.destroy(); + if (!socket[kHandedOff] && !socket._httpMessage) socket.destroy(); } } }; @@ -1347,6 +1353,7 @@ function clearUpgradeIncoming(socket) { // close/drain/error/timeout listeners) and only net.Socket's own 'end' listener // left in place. function detachSocketListenersForHandoff(socket) { + socket[kHandedOff] = true; socket.removeListener("error", socketOnError); socket.removeListener("timeout", onNodeHTTPServerSocketTimeout); socket.on("end", onReadableStreamEnd); @@ -1508,6 +1515,7 @@ function getNodeHTTPServerSocket() { [kBytesWritten] = 0; [kHandle]; [kUpgradeIncoming] = undefined; + [kHandedOff] = false; server: Server; _httpMessage; _secureEstablished = false; 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 7d458c1621ac..2e831ac6aa73 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 @@ -208,6 +208,89 @@ test("closeAllConnections() after close() force-drains the withheld callback", a } }); +// Node frees the parser when it hands a socket to 'upgrade'/'connect', which +// takes it off the list closeIdleConnections()/closeAllConnections() walk. After +// close() those two must reap a plain keep-alive socket but leave an upgraded +// one alone, while 'close' itself still waits for the upgraded socket to end. +test("closeIdleConnections()/closeAllConnections() after close() leave an upgraded socket open", async () => { + const inHandler = Promise.withResolvers(); + let releaseResponse!: () => void; + let upgradedServerSocket!: import("node:net").Socket; + const server = createServer((req, res) => { + inHandler.resolve(); + releaseResponse = () => res.end("ok"); + }); + server.on("upgrade", (req, socket) => { + upgradedServerSocket = socket; + socket.on("error", () => {}); + socket.on("data", chunk => socket.write(chunk)); + socket.write("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: echo\r\n\r\n"); + }); + server.keepAliveTimeout = 60000; + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const upgraded = connect(port, "127.0.0.1"); + const plain = connect(port, "127.0.0.1"); + try { + await Promise.all([once(upgraded, "connect"), once(plain, "connect")]); + upgraded.on("error", () => {}); + plain.on("error", () => {}); + let upgradedData = ""; + upgraded.on("data", chunk => (upgradedData += chunk)); + const upgradedClosed = Promise.withResolvers(); + upgraded.on("close", () => upgradedClosed.resolve()); + let plainData = ""; + plain.on("data", chunk => (plainData += chunk)); + const plainClosed = Promise.withResolvers(); + plain.on("close", () => plainClosed.resolve()); + // Round-trips a token through the upgraded connection; false if it closed instead. + const echo = async (token: string) => { + upgraded.write(token); + while (!upgradedData.includes(token) && !upgraded.destroyed) { + await Promise.race([once(upgraded, "data"), upgradedClosed.promise]); + } + return upgradedData.includes(token); + }; + + upgraded.write("GET /up HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: echo\r\n\r\n"); + while (!upgradedData.includes("101 Switching Protocols")) await once(upgraded, "data"); + plain.write("GET /slow HTTP/1.1\r\nHost: x\r\n\r\n"); + await inHandler.promise; + + let closeCbFired = false; + const closed = Promise.withResolvers(); + server.close(() => { + closeCbFired = true; + closed.resolve(); + }); + releaseResponse(); + while (!plainData.includes("ok")) await once(plain, "data"); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(closeCbFired).toBe(false); + + server.closeIdleConnections(); + expect(upgradedServerSocket.destroyed).toBe(false); + await plainClosed.promise; + expect(await echo("ping1")).toBe(true); + expect(closeCbFired).toBe(false); + + server.closeAllConnections(); + expect(upgradedServerSocket.destroyed).toBe(false); + expect(await echo("ping2")).toBe(true); + expect(closeCbFired).toBe(false); + + upgradedServerSocket.destroy(); + await closed.promise; + expect(closeCbFired).toBe(true); + } finally { + upgraded.destroy(); + plain.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. From 54af0a00c9b10d6d80df7021a20a5124b92da3c7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:38:31 +0000 Subject: [PATCH 5/5] trim the kHandedOff comment --- src/js/node/_http_server.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 8f0d364509ef..7a94475e2297 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -71,9 +71,7 @@ let http1Fallback; const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const kTrackedConnections = Symbol("http.server.trackedConnections"); const kPendingDrainClose = Symbol("http.server.pendingDrainClose"); -// Set once a socket is handed to 'connect'/'upgrade'. Node frees the parser -// there, which takes the socket off the list that closeAllConnections() and -// closeIdleConnections() iterate; it still counts as a connection for 'close'. +// Set on the 'connect'/'upgrade' handoff; closeAll/closeIdleConnections() skip these, as in Node. const kHandedOff = Symbol("http.server.socketHandedOff"); const kHttpAllowHalfOpen = Symbol("http.server.httpAllowHalfOpen");