From 94ad35d263d061b500d37dd9646b73a0ada69539 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:55:13 +0000 Subject: [PATCH 1/7] node:http: abort the in-flight request when an HTTP/1 fallback connection closes On connections served by internal/http1_server_fallback (sockets handed to http.Server via server.emit("connection", socket), and HTTP/1.1 connections on http2.createSecureServer({ allowHTTP1: true })), a request whose response had not finished when the connection closed was never told about it: no 'aborted', no 'error', no 'close', and req.destroyed stayed false. Only the ServerResponse emitted 'close'. Node's socketOnClose runs abortIncoming(), which destroys every request still waiting for its response with a ConnResetException("aborted"), so the request emits 'aborted', then 'error' (ECONNRESET, only when a listener is attached) and 'close'. The native server socket path already does this in its close handler; the fallback's close listener only freed the parser. Do the same in the fallback's close listener. The request to abort is the one whose response is still assigned to the socket (socket._httpMessage.req), which is what node's state.incoming holds: a finished response detaches on 'finish', so a request whose response already went out is left alone, and the listener is registered before any response's own 'close' listener, so the request's 'aborted' precedes the response's 'close' like in node. The Upgrade and CONNECT handoff removes the close listener along with the other parser listeners, as node's onParserExecuteCommon does, so closing a tunnel does not abort the upgrade request. The req.destroy() in the socket 'end' handler was unreachable (llhttp's finish() reports HPE_INVALID_EOF_STATE for any request cut short, which takes the error path) and is subsumed by the close handler. --- src/js/internal/http1_server_fallback.ts | 24 ++- test/js/node/http/node-http.test.ts | 220 +++++++++++++++++++++++ test/js/node/http2/node-http2.test.js | 41 +++++ 3 files changed, 279 insertions(+), 6 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index f95a2f4c908a..198c8a2d1955 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -3,6 +3,7 @@ // See https://github.com/nodejs/node/blob/main/lib/_http_server.js connectionListener. const { STATUS_CODES } = require("internal/http"); const { SafeSet } = require("internal/primordials"); +const { ConnResetException } = require("internal/shared"); const kHttp1Connections = Symbol("http1Connections"); const kHttp1ActiveRequests = Symbol("http1ActiveRequests"); @@ -401,6 +402,7 @@ function connectionListenerHTTP1(server, socket, options) { socket.removeListener("data", onHttp1SocketData); socket.removeListener("error", onHttp1SocketErrorListener); socket.removeListener("end", onHttp1SocketEnd); + socket.removeListener("close", onHttp1SocketClose); connections.delete(socket); try { parser.close(); @@ -428,7 +430,6 @@ function connectionListenerHTTP1(server, socket, options) { return; } if (!server.httpAllowHalfOpen) { - if (req && !req.complete) req.destroy(); if (socket.writable) socket.end(); return; } @@ -439,15 +440,26 @@ function connectionListenerHTTP1(server, socket, options) { socket.end(); } } - socket.on("data", onHttp1SocketData); - socket.on("error", onHttp1SocketErrorListener); - socket.once("end", onHttp1SocketEnd); - socket.once("close", () => { + // Node's socketOnClose: free the parser, then abortIncoming(). The request + // node would still have in state.incoming is the one whose response is still + // assigned to the socket (a finished response detached itself on 'finish'); + // destroying it emits 'aborted' and 'close', and 'error' (ECONNRESET) when + // something listens for it. Registered before any response's assignSocket() + // 'close' listener so req 'aborted' precedes res 'close', as in node. + function onHttp1SocketClose() { connections.delete(socket); try { parser.close(); } catch {} - }); + const inflightReq = socket._httpMessage?.req; + if (inflightReq && !inflightReq.destroyed) { + inflightReq.destroy(new ConnResetException("aborted")); + } + } + socket.on("data", onHttp1SocketData); + socket.on("error", onHttp1SocketErrorListener); + socket.once("end", onHttp1SocketEnd); + socket.once("close", onHttp1SocketClose); } function closeIdleHttp1Connections(server) { diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index b9edb1f12756..87b505320220 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4139,3 +4139,223 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { expect(serverSide.destroyed).toBe(true); } }); + +// Feeds one request to a server through server.emit("connection", duplex) (the +// JS connectionListener) and records the request/response lifecycle events in +// the order they fire. A duplexPair does not propagate destroy() to the other +// side, so the scenarios close the connection from the server's half, the way +// a reset TCP connection would surface. +function connectionListenerRequest( + requestBytes: string, + handler?: (req: IncomingMessage, res: ServerResponse) => void, + { reqErrorListener = true } = {}, +) { + const events: string[] = []; + const dispatched = Promise.withResolvers<{ req: IncomingMessage; res: ServerResponse }>(); + const resClosed = Promise.withResolvers(); + const server = createServer((req, res) => { + req.on("aborted", () => events.push("req-aborted")); + if (reqErrorListener) req.on("error", (err: any) => events.push("req-error:" + err.code)); + req.on("close", () => events.push("req-close")); + res.on("finish", () => events.push("res-finish")); + res.on("close", () => { + events.push("res-close"); + resClosed.resolve(); + }); + handler?.(req, res); + dispatched.resolve({ req, res }); + }); + const [clientSide, serverSide] = duplexPair(); + const serverSideClosed = new Promise(resolve => serverSide.on("close", resolve)); + clientSide.resume(); + server.emit("connection", serverSide); + clientSide.write(requestBytes); + return { + server, + events, + clientSide, + serverSide, + dispatched: dispatched.promise, + resClosed: resClosed.promise, + // Resolves once the connection has closed and the request's deferred + // 'error'/'close' (process.nextTick hops behind the socket's 'close') have + // had their turn, so `events` is final either way. + async connectionClosed() { + await serverSideClosed; + await new Promise(resolve => setImmediate(resolve)); + }, + }; +} + +const PARTIAL_POST = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nabc"; + +it("connectionListener aborts the in-flight request when the connection closes, like Node", async () => { + // Node's socketOnClose runs abortIncoming(): every request whose response has + // not finished is destroyed with an ECONNRESET "aborted" error, so it emits + // 'aborted' (before the response's 'close'), then 'error' only if something + // listens, then 'close'. The event orders below are Node v26's. + const aborted = ["req-aborted", "res-close", "req-error:ECONNRESET", "req-close"]; + + // The connection is reset while the request body is still arriving. + { + const t = connectionListenerRequest(PARTIAL_POST); + const { req } = await t.dispatched; + t.serverSide.destroy(); + await t.connectionClosed(); + expect(t.events).toEqual(aborted); + expect({ + destroyed: req.destroyed, + aborted: req.aborted, + complete: req.complete, + errored: (req.errored as any)?.code, + }).toEqual({ destroyed: true, aborted: true, complete: false, errored: "ECONNRESET" }); + } + + // Without an 'error' listener the error is not emitted (it would otherwise be + // an uncaught exception), but req.errored still carries it. + { + const t = connectionListenerRequest(PARTIAL_POST, undefined, { reqErrorListener: false }); + const { req } = await t.dispatched; + t.serverSide.destroy(); + await t.connectionClosed(); + expect(t.events).toEqual(["req-aborted", "res-close", "req-close"]); + expect((req.errored as any)?.code).toBe("ECONNRESET"); + } + + // server.closeAllConnections() destroys the connection from the server side. + { + const t = connectionListenerRequest(PARTIAL_POST); + await t.dispatched; + t.server.closeAllConnections(); + await t.connectionClosed(); + expect(t.events).toEqual(aborted); + } + + // The peer hangs up (FIN) with the body cut short: the parser flags the + // truncated message, the connection is torn down, and the request is aborted. + { + const t = connectionListenerRequest(PARTIAL_POST); + await t.dispatched; + t.clientSide.end(); + await t.connectionClosed(); + expect(t.events).toEqual(aborted); + } + + // A body-less request is complete as far as the parser is concerned, but its + // response is still pending, so it is aborted too (Node keys this off the + // response, not off req.complete). + { + const t = connectionListenerRequest("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + const { req } = await t.dispatched; + t.serverSide.destroy(); + await t.connectionClosed(); + expect(t.events).toEqual(aborted); + expect({ complete: req.complete, aborted: req.aborted }).toEqual({ complete: true, aborted: true }); + } + + // res.destroy() tears the connection down, which aborts the request as well. + // (The response emits its own 'close' synchronously from destroy(), so only + // the request's events are order-checked here.) + { + const t = connectionListenerRequest(PARTIAL_POST); + const { req, res } = await t.dispatched; + res.destroy(); + await t.connectionClosed(); + expect(t.events.filter(event => event.startsWith("req-"))).toEqual([ + "req-aborted", + "req-error:ECONNRESET", + "req-close", + ]); + expect(t.events).toContain("res-close"); + expect(req.aborted).toBe(true); + } +}); + +it("connectionListener does not abort a request whose response already finished when the connection closes", async () => { + // Node's resOnFinish takes the request out of the abort list: a connection + // that dies afterwards (here with the request body still unfinished) leaves + // the request untouched. + const t = connectionListenerRequest(PARTIAL_POST, (_req, res) => res.end("answered early")); + const { req } = await t.dispatched; + await t.resClosed; + t.serverSide.destroy(); + await t.connectionClosed(); + expect(t.events).toEqual(["res-finish", "res-close"]); + expect({ destroyed: req.destroyed, aborted: req.aborted }).toEqual({ destroyed: false, aborted: false }); +}); + +it("connectionListener aborts only the keep-alive request that is in flight when the connection closes", async () => { + // The first exchange completed and released the connection; the second + // request's body is still arriving when the connection dies. Node aborts the + // second request and does not touch the first. + const events: Record = { "/1": [], "/2": [] }; + const requests: Record = {}; + const firstReqClosed = Promise.withResolvers(); + const firstResClosed = Promise.withResolvers(); + const server = createServer((req, res) => { + const tag = req.url!; + requests[tag] = req; + req.on("aborted", () => events[tag].push("aborted")); + req.on("error", (err: any) => events[tag].push("error:" + err.code)); + req.on("close", () => events[tag].push("close")); + res.on("close", () => events[tag].push("res-close")); + if (tag === "/1") { + req.on("close", () => firstReqClosed.resolve()); + res.on("close", () => firstResClosed.resolve()); + res.end("one"); + return; + } + serverSide.destroy(); + }); + const [clientSide, serverSide] = duplexPair(); + const serverSideClosed = new Promise(resolve => serverSide.on("close", resolve)); + let received = ""; + const firstAnswered = Promise.withResolvers(); + clientSide.on("data", chunk => { + received += chunk; + if (received.endsWith("one")) firstAnswered.resolve(); + }); + server.emit("connection", serverSide); + clientSide.write("GET /1 HTTP/1.1\r\nHost: x\r\n\r\n"); + await Promise.all([firstAnswered.promise, firstReqClosed.promise, firstResClosed.promise]); + const firstEvents = [...events["/1"]]; + expect([...firstEvents].sort()).toEqual(["close", "res-close"]); + clientSide.write("POST /2 HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nabc"); + await serverSideClosed; + await new Promise(resolve => setImmediate(resolve)); + expect(events["/2"]).toEqual(["aborted", "res-close", "error:ECONNRESET", "close"]); + // Nothing happened to the first request after it ended normally. + expect(events["/1"]).toEqual(firstEvents); + expect({ first: requests["/1"].aborted, second: requests["/2"].aborted }).toEqual({ first: false, second: true }); +}); + +it("connectionListener does not abort an upgraded request when the tunnel closes", async () => { + // After the Upgrade handoff the connection no longer belongs to HTTP (Node + // removes its close listener along with the parser), so closing the tunnel + // must not abort the upgrade request, even when the 'upgrade' listener + // answered through a ServerResponse it assigned to the socket itself. + const events: string[] = []; + const upgraded = Promise.withResolvers(); + const server = createServer(() => upgraded.reject(new Error("request handler must not run for a handled upgrade"))); + server.on("upgrade", (req, socket) => { + req.on("aborted", () => events.push("req-aborted")); + req.on("error", (err: any) => events.push("req-error:" + err.code)); + req.on("close", () => events.push("req-close")); + const res = new ServerResponse(req); + res.assignSocket(socket as any); + res.writeHead(400); + res.end(); + upgraded.resolve(req); + }); + const [clientSide, serverSide] = duplexPair(); + clientSide.resume(); + const serverSideClosed = new Promise(resolve => serverSide.on("close", resolve)); + server.emit("connection", serverSide); + clientSide.write("GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: ws\r\nConnection: Upgrade\r\n\r\n"); + const req = await upgraded.promise; + serverSide.destroy(); + await serverSideClosed; + await new Promise(resolve => setImmediate(resolve)); + expect(events).toEqual([]); + expect({ destroyed: req.destroyed, aborted: req.aborted }).toEqual({ destroyed: false, aborted: false }); +}); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index b89d8466fab2..da17269c05ab 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -4304,6 +4304,47 @@ it("http2 allowHTTP1 fallback omits the Connection header on a close-delimited r } }); +it("http2 allowHTTP1 fallback aborts the in-flight request when the HTTP/1.1 client goes away", async () => { + // Node's socketOnClose -> abortIncoming() applies to allowHTTP1 connections + // too: a request whose body was still arriving when the client disconnected + // emits 'aborted', then (after the response's 'close') 'error' ECONNRESET + // and 'close', and ends up destroyed. Event order is Node v26's. + const events = []; + const dispatched = Promise.withResolvers(); + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + req.on("aborted", () => events.push("req-aborted")); + req.on("error", err => events.push("req-error:" + err.code)); + req.on("close", () => events.push("req-close")); + res.on("close", () => events.push("res-close")); + const connectionClosed = new Promise(resolve => req.socket.on("close", resolve)); + dispatched.resolve({ req, connectionClosed }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const socket = tls.connect( + { host: "localhost", port: server.address().port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] }, + () => socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\nabc"), + ); + socket.on("error", dispatched.reject); + try { + const { req, connectionClosed } = await dispatched.promise; + expect(req.httpVersion).toBe("1.1"); + socket.destroy(); + await connectionClosed; + // The request's 'error'/'close' are process.nextTick hops behind the + // connection's 'close'; after a macrotask the event list is final. + await new Promise(resolve => setImmediate(resolve)); + expect(events).toEqual(["req-aborted", "res-close", "req-error:ECONNRESET", "req-close"]); + expect({ destroyed: req.destroyed, aborted: req.aborted, complete: req.complete }).toEqual({ + destroyed: true, + aborted: true, + complete: false, + }); + } finally { + socket.destroy(); + server.close(); + } +}); + // close() must not depend on the peer sending a SETTINGS ACK — Node's kMaybeDestroy // waits on nghttp2_session_want_write()/want_read(), which does not track outstanding // ACKs. A server that never ACKs a client-sent SETTINGS must not stall close(). From 577800fafe820764155c6372b8a5ba8f100d48f3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:10:57 +0000 Subject: [PATCH 2/7] ci: retrigger From d0bc9ab7247dad0e69bcdc074cb924c020876f2e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:15:26 +0000 Subject: [PATCH 3/7] node:http: shorten the fallback close handler comment --- src/js/internal/http1_server_fallback.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 198c8a2d1955..b672b8bff54d 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -440,17 +440,14 @@ function connectionListenerHTTP1(server, socket, options) { socket.end(); } } - // Node's socketOnClose: free the parser, then abortIncoming(). The request - // node would still have in state.incoming is the one whose response is still - // assigned to the socket (a finished response detached itself on 'finish'); - // destroying it emits 'aborted' and 'close', and 'error' (ECONNRESET) when - // something listens for it. Registered before any response's assignSocket() - // 'close' listener so req 'aborted' precedes res 'close', as in node. + // Node's socketOnClose (freeParser + abortIncoming). Must be registered before + // any response's assignSocket() 'close' listener: req 'aborted' precedes res 'close'. function onHttp1SocketClose() { connections.delete(socket); try { parser.close(); } catch {} + // Node's state.incoming equivalent: a finished response detached on 'finish'. const inflightReq = socket._httpMessage?.req; if (inflightReq && !inflightReq.destroyed) { inflightReq.destroy(new ConnResetException("aborted")); From b29a5d9a19f873474616c842002206fdc8e31d4a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:24:53 +0000 Subject: [PATCH 4/7] node:http: one test per connection-close trigger in the fallback abort tests --- src/js/internal/http1_server_fallback.ts | 4 +- test/js/node/http/node-http.test.ts | 124 ++++++++++------------- 2 files changed, 58 insertions(+), 70 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index b672b8bff54d..fe99f2316ebf 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -440,8 +440,7 @@ function connectionListenerHTTP1(server, socket, options) { socket.end(); } } - // Node's socketOnClose (freeParser + abortIncoming). Must be registered before - // any response's assignSocket() 'close' listener: req 'aborted' precedes res 'close'. + // Node's socketOnClose: freeParser, then abortIncoming. function onHttp1SocketClose() { connections.delete(socket); try { @@ -456,6 +455,7 @@ function connectionListenerHTTP1(server, socket, options) { socket.on("data", onHttp1SocketData); socket.on("error", onHttp1SocketErrorListener); socket.once("end", onHttp1SocketEnd); + // Ahead of every response's assignSocket() 'close' listener: req 'aborted' precedes res 'close'. socket.once("close", onHttp1SocketClose); } diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 87b505320220..28193b7982a9 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4189,86 +4189,74 @@ function connectionListenerRequest( const PARTIAL_POST = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nabc"; -it("connectionListener aborts the in-flight request when the connection closes, like Node", async () => { - // Node's socketOnClose runs abortIncoming(): every request whose response has - // not finished is destroyed with an ECONNRESET "aborted" error, so it emits - // 'aborted' (before the response's 'close'), then 'error' only if something - // listens, then 'close'. The event orders below are Node v26's. - const aborted = ["req-aborted", "res-close", "req-error:ECONNRESET", "req-close"]; - - // The connection is reset while the request body is still arriving. - { +// Node's socketOnClose runs abortIncoming(): a request whose response has not +// finished is destroyed with an ECONNRESET "aborted" error when its connection +// closes, so it emits 'aborted' (before the response's 'close'), then 'error' +// only if something listens, then 'close'. The event orders asserted below are +// Node v26's. +const ABORTED_EVENTS = ["req-aborted", "res-close", "req-error:ECONNRESET", "req-close"]; + +type ConnectionListenerRequest = ReturnType; +const connectionCloseTriggers: [string, (t: ConnectionListenerRequest) => void][] = [ + ["the connection is reset while the body is still arriving", t => t.serverSide.destroy()], + ["server.closeAllConnections() destroys the connection", t => t.server.closeAllConnections()], + // The parser flags the truncated message and the connection is torn down. + ["the peer hangs up with the body cut short", t => t.clientSide.end()], +]; + +for (const [trigger, closeConnection] of connectionCloseTriggers) { + it(`connectionListener aborts the in-flight request when ${trigger}`, async () => { const t = connectionListenerRequest(PARTIAL_POST); const { req } = await t.dispatched; - t.serverSide.destroy(); + closeConnection(t); await t.connectionClosed(); - expect(t.events).toEqual(aborted); + expect(t.events).toEqual(ABORTED_EVENTS); expect({ destroyed: req.destroyed, aborted: req.aborted, complete: req.complete, errored: (req.errored as any)?.code, }).toEqual({ destroyed: true, aborted: true, complete: false, errored: "ECONNRESET" }); - } - - // Without an 'error' listener the error is not emitted (it would otherwise be - // an uncaught exception), but req.errored still carries it. - { - const t = connectionListenerRequest(PARTIAL_POST, undefined, { reqErrorListener: false }); - const { req } = await t.dispatched; - t.serverSide.destroy(); - await t.connectionClosed(); - expect(t.events).toEqual(["req-aborted", "res-close", "req-close"]); - expect((req.errored as any)?.code).toBe("ECONNRESET"); - } - - // server.closeAllConnections() destroys the connection from the server side. - { - const t = connectionListenerRequest(PARTIAL_POST); - await t.dispatched; - t.server.closeAllConnections(); - await t.connectionClosed(); - expect(t.events).toEqual(aborted); - } + }); +} - // The peer hangs up (FIN) with the body cut short: the parser flags the - // truncated message, the connection is torn down, and the request is aborted. - { - const t = connectionListenerRequest(PARTIAL_POST); - await t.dispatched; - t.clientSide.end(); - await t.connectionClosed(); - expect(t.events).toEqual(aborted); - } +it("connectionListener abort does not emit 'error' on a request without an error listener", async () => { + // Like Node, the error is only emitted when something listens for it (it + // would otherwise be an uncaught exception), but req.errored still carries it. + const t = connectionListenerRequest(PARTIAL_POST, undefined, { reqErrorListener: false }); + const { req } = await t.dispatched; + t.serverSide.destroy(); + await t.connectionClosed(); + expect(t.events).toEqual(["req-aborted", "res-close", "req-close"]); + expect((req.errored as any)?.code).toBe("ECONNRESET"); +}); - // A body-less request is complete as far as the parser is concerned, but its - // response is still pending, so it is aborted too (Node keys this off the - // response, not off req.complete). - { - const t = connectionListenerRequest("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); - const { req } = await t.dispatched; - t.serverSide.destroy(); - await t.connectionClosed(); - expect(t.events).toEqual(aborted); - expect({ complete: req.complete, aborted: req.aborted }).toEqual({ complete: true, aborted: true }); - } +it("connectionListener aborts a body-less request whose response is still pending", async () => { + // The request is complete as far as the parser is concerned; Node keys the + // abort off the unfinished response, not off req.complete. + const t = connectionListenerRequest("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + const { req } = await t.dispatched; + t.serverSide.destroy(); + await t.connectionClosed(); + expect(t.events).toEqual(ABORTED_EVENTS); + expect({ complete: req.complete, aborted: req.aborted }).toEqual({ complete: true, aborted: true }); +}); - // res.destroy() tears the connection down, which aborts the request as well. - // (The response emits its own 'close' synchronously from destroy(), so only - // the request's events are order-checked here.) - { - const t = connectionListenerRequest(PARTIAL_POST); - const { req, res } = await t.dispatched; - res.destroy(); - await t.connectionClosed(); - expect(t.events.filter(event => event.startsWith("req-"))).toEqual([ - "req-aborted", - "req-error:ECONNRESET", - "req-close", - ]); - expect(t.events).toContain("res-close"); - expect(req.aborted).toBe(true); - } +it("connectionListener aborts the request when its response is destroyed", async () => { + // res.destroy() tears the connection down. The response emits its own + // 'close' synchronously from destroy(), so only the request's events are + // order-checked here. + const t = connectionListenerRequest(PARTIAL_POST); + const { req, res } = await t.dispatched; + res.destroy(); + await t.connectionClosed(); + expect(t.events.filter(event => event.startsWith("req-"))).toEqual([ + "req-aborted", + "req-error:ECONNRESET", + "req-close", + ]); + expect(t.events).toContain("res-close"); + expect(req.aborted).toBe(true); }); it("connectionListener does not abort a request whose response already finished when the connection closes", async () => { From 98ce1ca1e1ed10b53d16f43ec7df09fb12b325eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:20:59 +0000 Subject: [PATCH 5/7] node:http: keep a fallback response assigned once its connection stopped being writable The close handler finds the request to abort through the response still assigned to the socket, and the response releases itself on 'finish'. The fallback handle reported every end() as finishing the response, including one issued after the connection was destroyed or the peer had hung up, although nothing it wrote could reach the wire. On a net or TLS socket 'close' arrives a turn after destroy(), so a res.end() in that window (or after the 'end' handler ended the connection) emitted 'finish', released the response, and the request was never aborted; the end() after a peer FIN also surfaced as a 'clientError' from writing to the ended socket. Report NodeHTTPResponseFlags.socket_closed from the handle once the socket is no longer writable, like the native NodeHTTPResponse does once its connection closed. ServerResponse's write()/end() already return without writing or emitting 'finish' on that flag, so the response stays assigned and the close handler aborts its request, as node does (node never runs the finish callback for a response whose connection is gone). Tests cover res.end() after destroy() and after closeAllConnections() on a net socket, after the peer's FIN on a duplex, the same on an allowHTTP1 TLS connection, and the graceful-FIN case of a completed request with a streaming response, which is the shape that goes through the socket 'end' handler. --- src/js/internal/http1_server_fallback.ts | 8 ++- test/js/node/http/node-http.test.ts | 85 ++++++++++++++++++++++++ test/js/node/http2/node-http2.test.js | 50 ++++++++++---- 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index fe99f2316ebf..f20cefa58773 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -1,7 +1,7 @@ // JS HTTP/1 server path over an arbitrary Duplex with a JS stand-in for NodeHTTPResponse. // Used by http2's `allowHTTP1` ALPN fallback and http's `server.emit("connection", socket)`. // See https://github.com/nodejs/node/blob/main/lib/_http_server.js connectionListener. -const { STATUS_CODES } = require("internal/http"); +const { STATUS_CODES, NodeHTTPResponseFlags } = require("internal/http"); const { SafeSet } = require("internal/primordials"); const { ConnResetException } = require("internal/shared"); @@ -148,7 +148,11 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim } const handle = { - flags: 0, + // Like NodeHTTPResponse once the connection is gone: ServerResponse's write()/end() + // then emit no 'finish', so the response stays assigned for onHttp1SocketClose to abort. + get flags() { + return socket.writable ? 0 : NodeHTTPResponseFlags.socket_closed; + }, ended: false, finished: false, aborted: false, diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 28193b7982a9..fe6be0b85ff8 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4259,6 +4259,91 @@ it("connectionListener aborts the request when its response is destroyed", async expect(req.aborted).toBe(true); }); +const GET = "GET /events HTTP/1.1\r\nHost: x\r\n\r\n"; +// A long-poll / event-stream handler: the request completed with its headers +// and the response stays open. +function startStreamingResponse(_req: IncomingMessage, res: ServerResponse) { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write("data: hi\n\n"); +} + +it("connectionListener aborts a completed request with an open response when the peer hangs up", async () => { + // The parser has nothing to complain about at EOF, so node's socketOnEnd + // just ends the connection; the abort comes from the 'close' that follows. + const t = connectionListenerRequest(GET, startStreamingResponse); + const { req } = await t.dispatched; + t.clientSide.end(); + await t.connectionClosed(); + expect(t.events).toEqual(ABORTED_EVENTS); + expect({ complete: req.complete, aborted: req.aborted }).toEqual({ complete: true, aborted: true }); +}); + +it("connectionListener aborts the request when the response is ended after the peer hung up", async () => { + // Once the connection stopped being writable, a res.end() cannot reach the + // wire. Like node (the bytes are parked, nothing is written) it must not + // count as the response finishing, or the request would be left out of the + // abort, and it must not surface as a 'clientError' from writing to the + // ended connection. + const t = connectionListenerRequest(GET, (req, res) => { + // Runs after the listener's own 'end' handler has ended the connection. + req.socket.once("end", () => res.end("bye")); + }); + const clientErrors: string[] = []; + t.server.on("clientError", (err: any, socket) => { + clientErrors.push(err.code); + socket.destroy(); + }); + const { req } = await t.dispatched; + t.clientSide.end(); + await t.connectionClosed(); + expect({ events: t.events, clientErrors }).toEqual({ events: ABORTED_EVENTS, clientErrors: [] }); + expect(req.aborted).toBe(true); +}); + +const destroyThenEndTriggers: [string, (req: IncomingMessage, server: Server) => void][] = [ + ["req.socket.destroy()", req => req.socket.destroy()], + ["server.closeAllConnections()", (_req, server) => server.closeAllConnections()], +]; + +for (const [trigger, destroyConnection] of destroyThenEndTriggers) { + it(`connectionListener aborts the request when res.end() follows ${trigger} on a net socket`, async () => { + // A net.Socket emits 'close' a turn after destroy(). A res.end() issued in + // between cannot reach the wire, and like node (and the native server) it + // must not emit 'finish' and release the response, or the close path finds + // nothing to abort. A duplexPair emits 'close' too early to exercise this. + const events: string[] = []; + const connectionClosed = Promise.withResolvers(); + const httpServer = createServer((req, res) => { + req.on("aborted", () => events.push("req-aborted")); + req.on("error", (err: any) => events.push("req-error:" + err.code)); + req.on("close", () => events.push("req-close")); + res.on("finish", () => events.push("res-finish")); + res.on("close", () => events.push("res-close")); + req.socket.on("close", () => connectionClosed.resolve(req)); + destroyConnection(req, httpServer); + res.end("late"); + }); + const netServer = createNetServer(socket => httpServer.emit("connection", socket)); + netServer.listen(0, "127.0.0.1"); + await once(netServer, "listening"); + const { port } = netServer.address() as AddressInfo; + const client = connect(port, "127.0.0.1", () => { + client.write(PARTIAL_POST); + }); + client.on("error", () => {}); + try { + const req = await connectionClosed.promise; + // The request's 'error'/'close' are nextTick hops behind the socket's 'close'. + await new Promise(resolve => setImmediate(resolve)); + expect(events).toEqual(ABORTED_EVENTS); + expect({ destroyed: req.destroyed, aborted: req.aborted }).toEqual({ destroyed: true, aborted: true }); + } finally { + client.destroy(); + netServer.close(); + } + }); +} + it("connectionListener does not abort a request whose response already finished when the connection closes", async () => { // Node's resOnFinish takes the request out of the abort list: a connection // that dies afterwards (here with the request body still unfinished) leaves diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index da17269c05ab..8b7c60712848 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -4304,19 +4304,26 @@ it("http2 allowHTTP1 fallback omits the Connection header on a close-delimited r } }); -it("http2 allowHTTP1 fallback aborts the in-flight request when the HTTP/1.1 client goes away", async () => { - // Node's socketOnClose -> abortIncoming() applies to allowHTTP1 connections - // too: a request whose body was still arriving when the client disconnected - // emits 'aborted', then (after the response's 'close') 'error' ECONNRESET - // and 'close', and ends up destroyed. Event order is Node v26's. +// Node's socketOnClose -> abortIncoming() applies to allowHTTP1 connections too: +// a request whose response has not finished when the connection closes emits +// 'aborted', then (after the response's 'close') 'error' ECONNRESET and 'close', +// and ends up destroyed. Event order is Node v26's. +const HTTP1_ABORTED_EVENTS = ["req-aborted", "res-close", "req-error:ECONNRESET", "req-close"]; + +// Sends one HTTP/1.1 POST (body cut short) to an allowHTTP1 server over TLS and +// records the request/response lifecycle events. `onRequest` runs inside the +// request handler; `afterDispatch` runs once the request reached the server. +async function allowHTTP1AbortScenario({ onRequest, afterDispatch }) { const events = []; const dispatched = Promise.withResolvers(); const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { req.on("aborted", () => events.push("req-aborted")); req.on("error", err => events.push("req-error:" + err.code)); req.on("close", () => events.push("req-close")); + res.on("finish", () => events.push("res-finish")); res.on("close", () => events.push("res-close")); const connectionClosed = new Promise(resolve => req.socket.on("close", resolve)); + onRequest?.(req, res); dispatched.resolve({ req, connectionClosed }); }); await new Promise(resolve => server.listen(0, resolve)); @@ -4328,21 +4335,40 @@ it("http2 allowHTTP1 fallback aborts the in-flight request when the HTTP/1.1 cli try { const { req, connectionClosed } = await dispatched.promise; expect(req.httpVersion).toBe("1.1"); - socket.destroy(); + afterDispatch?.(socket); await connectionClosed; // The request's 'error'/'close' are process.nextTick hops behind the // connection's 'close'; after a macrotask the event list is final. await new Promise(resolve => setImmediate(resolve)); - expect(events).toEqual(["req-aborted", "res-close", "req-error:ECONNRESET", "req-close"]); - expect({ destroyed: req.destroyed, aborted: req.aborted, complete: req.complete }).toEqual({ - destroyed: true, - aborted: true, - complete: false, - }); + return { events, req }; } finally { socket.destroy(); server.close(); } +} + +it("http2 allowHTTP1 fallback aborts the in-flight request when the HTTP/1.1 client goes away", async () => { + const { events, req } = await allowHTTP1AbortScenario({ afterDispatch: socket => socket.destroy() }); + expect(events).toEqual(HTTP1_ABORTED_EVENTS); + expect({ destroyed: req.destroyed, aborted: req.aborted, complete: req.complete }).toEqual({ + destroyed: true, + aborted: true, + complete: false, + }); +}); + +it("http2 allowHTTP1 fallback aborts the request when the handler ends the response right after destroying the connection", async () => { + // A TLS socket emits 'close' a turn after destroy(). The res.end() issued in + // between cannot reach the wire; like node it must not count as the response + // finishing (no 'finish'), or the request would be left out of the abort. + const { events, req } = await allowHTTP1AbortScenario({ + onRequest: (req, res) => { + req.socket.destroy(); + res.end("late"); + }, + }); + expect(events).toEqual(HTTP1_ABORTED_EVENTS); + expect({ destroyed: req.destroyed, aborted: req.aborted }).toEqual({ destroyed: true, aborted: true }); }); // close() must not depend on the peer sending a SETTINGS ACK — Node's kMaybeDestroy From 5087201074b559dda0ba6e275fcb98cf376d6a0b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:27:40 +0000 Subject: [PATCH 6/7] node:http: one-line comment on the fallback handle flags getter --- src/js/internal/http1_server_fallback.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index f20cefa58773..fac73f9df4bf 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -148,8 +148,7 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim } const handle = { - // Like NodeHTTPResponse once the connection is gone: ServerResponse's write()/end() - // then emit no 'finish', so the response stays assigned for onHttp1SocketClose to abort. + // Like NodeHTTPResponse after its connection closed: ServerResponse's write()/end() then emit no 'finish'. get flags() { return socket.writable ? 0 : NodeHTTPResponseFlags.socket_closed; }, From b163e75381b099e84b369152370bbaad94e5b513 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:05:55 +0000 Subject: [PATCH 7/7] node:http: derive the fallback handle's aborted flag from the connection too NodeHTTPResponse reports `aborted` from the same socket-closed bit as `flags`, and ServerResponse.end() checks it first, returning the response itself. With only `flags` derived from the connection, an end() on a dead connection fell through to the flags check and returned true instead of the response. Derive `aborted` the same way (connection no longer writable, response not ended, so a normally completed response keeps its write-after-end reporting), and let abort() just destroy the socket. The net socket tests now also check that end() stays chainable in that state. --- src/js/internal/http1_server_fallback.ts | 7 ++++--- test/js/node/http/node-http.test.ts | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index fac73f9df4bf..202e0b632119 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -148,13 +148,15 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim } const handle = { - // Like NodeHTTPResponse after its connection closed: ServerResponse's write()/end() then emit no 'finish'. + // Derived from the connection like NodeHTTPResponse's socket-closed bit; write()/end() then emit no 'finish'. get flags() { return socket.writable ? 0 : NodeHTTPResponseFlags.socket_closed; }, + get aborted() { + return !handle.ended && !socket.writable; + }, ended: false, finished: false, - aborted: false, bufferedAmount: 0, shouldKeepAlive, onfinished: null, @@ -227,7 +229,6 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim return length; }, abort() { - this.aborted = true; if (!socket.destroyed) socket.destroy(); }, }; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index fe6be0b85ff8..fbbbecff1500 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4321,7 +4321,8 @@ for (const [trigger, destroyConnection] of destroyThenEndTriggers) { res.on("close", () => events.push("res-close")); req.socket.on("close", () => connectionClosed.resolve(req)); destroyConnection(req, httpServer); - res.end("late"); + // Still chainable, like node's end() (and the native response's on a closed connection). + events.push(res.end("late") === res ? "end-returned-res" : "end-returned-other"); }); const netServer = createNetServer(socket => httpServer.emit("connection", socket)); netServer.listen(0, "127.0.0.1"); @@ -4335,7 +4336,7 @@ for (const [trigger, destroyConnection] of destroyThenEndTriggers) { const req = await connectionClosed.promise; // The request's 'error'/'close' are nextTick hops behind the socket's 'close'. await new Promise(resolve => setImmediate(resolve)); - expect(events).toEqual(ABORTED_EVENTS); + expect(events).toEqual(["end-returned-res", ...ABORTED_EVENTS]); expect({ destroyed: req.destroyed, aborted: req.aborted }).toEqual({ destroyed: true, aborted: true }); } finally { client.destroy();