From 711cce09e5a92d74640f88d5db6ff1c6305a4fba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:08:08 +0000 Subject: [PATCH 1/8] node:http: queue pipelined responses on fallback connections instead of throwing ERR_HTTP_SOCKET_ASSIGNED --- src/js/internal/http1_server_fallback.ts | 19 ++++-- src/js/node/_http_server.ts | 72 ++++++++++++++-------- test/js/node/http/node-http.test.ts | 78 ++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 28 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 32f240d8ac9b..aa3754747bc2 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -235,6 +235,7 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim function connectionListenerHTTP1(server, socket, options) { const http = require("node:http"); const { HTTPParser, prepareError, calculateLenientFlags, continueExpression } = require("node:_http_common"); + const { queuePipelinedResponse, advanceResponsePipeline } = require("node:_http_server"); const { kHandle: kHttp1ResponseHandle } = require("internal/http"); const { allMethods } = process.binding("http_parser"); @@ -326,12 +327,22 @@ function connectionListenerHTTP1(server, socket, options) { } }; res[kHttp1ResponseHandle] = handle; - res.assignSocket(socket); - // node's resOnFinish: release the socket once the response completes so the next - // keep-alive request's response can attach (assignSocket throws - // ERR_HTTP_SOCKET_ASSIGNED while a previous response is still assigned). + // Node's parserOnIncoming outgoing queue: while the previous response is + // still assigned (its deferred 'finish' has not detached it yet - + // pipelined requests always land here), this response is queued and its + // write()/end() buffer until the finish path below assigns it the socket. + // assignSocket would throw ERR_HTTP_SOCKET_ASSIGNED. + if (socket._httpMessage) { + queuePipelinedResponse(socket, res, versionMajor < 1 || versionMinor < 1); + } else { + res.assignSocket(socket); + } + // node's resOnFinish: release the socket once the response completes so + // the next keep-alive request's response can attach, then hand it to the + // next queued pipelined response (replaying whatever it buffered). res.on("finish", function onFallbackResponseFinish() { this.detachSocket(socket); + advanceResponsePipeline(server, socket); }); // Node's parserOnIncoming Expect routing (the native dispatcher applies the diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 7ea18c049190..43ab937daa62 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -948,16 +948,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // Node.js, this response is queued (res.socket === null) and its // writes are buffered until the in-flight response finishes and the // pipeline assigns it the socket (advanceResponsePipeline). - http_res[kPipelinedQueuedState] = { - ops: [], - bytes: 0, - headerBytes: 0, - needDrain: false, - ended: false, - isAncient: !!isAncientHTTP, - socket, - }; - (socket[kPipelinedResponses] ??= []).push(http_res); + queuePipelinedResponse(socket, http_res, !!isAncientHTTP); // A pipelined dispatch can arrive after the previous response finished and detached // (bytes still flushing keep it pending), leaving nothing in flight to advance the // queue. Kick the pipeline once this dispatch settles. @@ -2566,6 +2557,23 @@ function advancePipelineIfIdleNT(server, socket) { } } +// Like the dispatcher's pipelined branch and Node.js's parserOnIncoming +// outgoing queue: park the response behind the connection's in-flight one. +// Its write()/end() buffer (kPipelinedQueuedState) until +// advanceResponsePipeline assigns it the socket and replays them. +function queuePipelinedResponse(socket, res, isAncient) { + res[kPipelinedQueuedState] = { + ops: [], + bytes: 0, + headerBytes: 0, + needDrain: false, + ended: false, + isAncient, + socket, + }; + (socket[kPipelinedResponses] ??= []).push(res); +} + function advanceResponsePipeline(server, socket) { // The previous response on this connection closed it (Connection: close, // HTTP/1.0, maxRequestsPerSocket): like Node.js's resOnFinish, advancing @@ -2584,7 +2592,6 @@ function advanceResponsePipeline(server, socket) { res[kPipelinedQueuedState] = undefined; releasePipelineOutgoingData(socket, queued.bytes); const handle = res[kHandle]; - const socketHandle = socket[kHandle]; if (res.destroyed || !handle) { // The queued response was destroyed before it could be sent; the @@ -2595,22 +2602,36 @@ function advanceResponsePipeline(server, socket) { return; } - if ( - !socketHandle || - socket.destroyed || - !socketHandle.startPipelinedResponse(handle, !!queued.isAncient, !requestShouldKeepAlive(res.req)) - ) { - // The connection is already gone; the socket close path destroys queued - // responses, but make sure this (already dequeued) one is not skipped. - if (!res.destroyed) { - res.destroy(); + if (socket instanceof NodeHTTPServerSocket) { + const socketHandle = socket[kHandle]; + if ( + !socketHandle || + socket.destroyed || + !socketHandle.startPipelinedResponse(handle, !!queued.isAncient, !requestShouldKeepAlive(res.req)) + ) { + // The connection is already gone; the socket close path destroys queued + // responses, but make sure this (already dequeued) one is not skipped. + if (!res.destroyed) { + res.destroy(); + } + return; } - return; - } - if (res.assignSocket === ServerResponse.prototype.assignSocket) { - assignSocketInternal(res, socket); + if (res.assignSocket === ServerResponse.prototype.assignSocket) { + assignSocketInternal(res, socket); + } else { + res.assignSocket(socket); + } } else { + // internal/http1_server_fallback connection (a foreign duplex): each + // response's JS handle writes to the socket itself, so there is no native + // socket handle to switch. The prototype assignSocket also installs the + // 'close' listener a plain stream needs. Clear `finished` before the + // assignment: assignSocket's _flush would otherwise emit 'prefinish' for + // an already-ended queued response before its ops have been replayed. + if (queued.ended) { + res.finished = false; + } res.assignSocket(socket); } socket[kRequest] = res.req; @@ -4017,4 +4038,7 @@ export default { Server, ServerResponse, kConnectionsCheckingInterval, + // Pipelining internals shared with internal/http1_server_fallback. + queuePipelinedResponse, + advanceResponsePipeline, }; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index b9edb1f12756..356378110f4a 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4139,3 +4139,81 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { expect(serverSide.destroyed).toBe(true); } }); + +it("connectionListener queues pipelined responses like Node", async () => { + // Both requests parse in one socket 'data' event, so the second one's + // headers complete while the first response is still assigned (its 'finish' + // detach is deferred a tick). The second response must be queued, not + // assigned - assignSocket throws ERR_HTTP_SOCKET_ASSIGNED - and its output + // must be held back until the first response completes. + async function exchange(handler: (req: any, res: any) => void, requestBytes: string, done: (buf: string) => boolean) { + const server = createServer(handler); + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + try { + return await new Promise((resolve, reject) => { + let buf = ""; + clientSide.on("data", d => { + buf += d; + if (done(buf)) resolve(buf); + }); + clientSide.on("error", reject); + clientSide.on("close", () => reject(new Error("closed before expected output: " + buf))); + clientSide.write(requestBytes); + }); + } finally { + clientSide.destroy(); + serverSide.destroy(); + } + } + + // Handler finishes after the current tick: the second request is dispatched + // while the first response is still in flight. + { + const out = await exchange( + (req, res) => setImmediate(() => res.end("ok:" + req.url)), + "GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n", + buf => buf.includes("ok:/a") && buf.includes("ok:/b"), + ); + expect(out.indexOf("ok:/a")).toBeLessThan(out.indexOf("ok:/b")); + expect(out.match(/HTTP\/1\.1 200/g)).toHaveLength(2); + } + + // The queued response is written (write + end, chunked) before the first + // one finishes: its bytes must still go out second, after the first body. + { + let releaseFirst!: () => void; + const firstGate = new Promise(resolve => (releaseFirst = resolve)); + const out = await exchange( + (req, res) => { + if (req.url === "/a") { + firstGate.then(() => res.end("first")); + } else { + res.write("second-part1"); + res.end("second-part2"); + releaseFirst(); + } + }, + "GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n", + buf => buf.includes("second-part2") && buf.endsWith("0\r\n\r\n"), + ); + expect(out.indexOf("first")).toBeLessThan(out.indexOf("second-part1")); + expect(out.indexOf("second-part1")).toBeLessThan(out.indexOf("second-part2")); + const second = out.slice(out.indexOf("HTTP/1.1 200", 1)); + expect(second).toContain("Transfer-Encoding: chunked"); + } + + // Three pipelined requests answered synchronously: each finish hands the + // socket to the next queued response. + { + const out = await exchange( + (req, res) => res.end("r:" + req.url + ";"), + "GET /1 HTTP/1.1\r\nHost: x\r\n\r\nGET /2 HTTP/1.1\r\nHost: x\r\n\r\nGET /3 HTTP/1.1\r\nHost: x\r\n\r\n", + buf => buf.includes("r:/3;"), + ); + expect(out.indexOf("r:/1;")).toBeGreaterThanOrEqual(0); + expect(out.indexOf("r:/1;")).toBeLessThan(out.indexOf("r:/2;")); + expect(out.indexOf("r:/2;")).toBeLessThan(out.indexOf("r:/3;")); + expect(out.match(/HTTP\/1\.1 200/g)).toHaveLength(3); + } +}); From ff2ea1bd56ca4d99b249e2468913d1ecf3d8dc43 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:41:12 +0000 Subject: [PATCH 2/8] node:http: abort queued pipelined responses when the fallback connection dies Share the pipelining helpers with internal/http1_server_fallback through internal/http instead of the node:_http_server module exports, and mirror the native socket close path for responses still queued on a foreign duplex: destroy them and their requests so both emit 'close'. --- src/js/internal/http.ts | 13 +++++ src/js/internal/http1_server_fallback.ts | 9 +++- src/js/node/_http_server.ts | 62 ++++++++++++++---------- test/js/node/http/node-http.test.ts | 28 +++++++++++ 4 files changed, 85 insertions(+), 27 deletions(-) diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 96dee383fc92..779efa64c27f 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -101,6 +101,18 @@ const kCloseCallback = Symbol("closeCallback"); const kEmptyObject = Object.freeze(Object.create(null)); +// node:_http_server's pipelined-response machinery, registered onto this +// object when that module initializes. internal/http1_server_fallback drives +// the same per-connection queue over foreign duplex sockets through it, +// without putting the helpers on the user-visible node:_http_server surface. +// The fallback requires node:http (which loads _http_server) before reading +// these, so they are always populated by then. +const http1ServerPipeline: { + queuePipelinedResponse?: (socket: unknown, res: unknown, isAncient: boolean) => void; + advanceResponsePipeline?: (server: unknown, socket: unknown) => void; + abortQueuedPipelinedResponses?: (socket: unknown) => void; +} = {}; + export const enum ClientRequestEmitState { socket = 1, prefinish = 2, @@ -618,6 +630,7 @@ export { headerStateSymbol, headersSymbol, headersTuple, + http1ServerPipeline, isAbortError, isTlsSymbol, kAbortController, diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index aa3754747bc2..ab9678d3675a 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -235,8 +235,9 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim function connectionListenerHTTP1(server, socket, options) { const http = require("node:http"); const { HTTPParser, prepareError, calculateLenientFlags, continueExpression } = require("node:_http_common"); - const { queuePipelinedResponse, advanceResponsePipeline } = require("node:_http_server"); - const { kHandle: kHttp1ResponseHandle } = require("internal/http"); + const { kHandle: kHttp1ResponseHandle, http1ServerPipeline } = require("internal/http"); + // Populated by node:_http_server, which the require("node:http") above loads. + const { queuePipelinedResponse, advanceResponsePipeline, abortQueuedPipelinedResponses } = http1ServerPipeline; const { allMethods } = process.binding("http_parser"); const http1Options = options.http1Options || {}; @@ -455,6 +456,10 @@ function connectionListenerHTTP1(server, socket, options) { socket.once("end", onHttp1SocketEnd); socket.once("close", () => { connections.delete(socket); + // Like the native socket's close path: responses (and their requests) + // still queued behind the in-flight one when the connection dies are + // aborted, so they emit 'close' instead of hanging forever. + abortQueuedPipelinedResponses(socket); try { parser.close(); } catch {} diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 43ab937daa62..f72762348c6d 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -67,6 +67,7 @@ const { kOutHeaders, onDataIncomingMessage, validateMsecs, + http1ServerPipeline, } = require("internal/http"); const { FakeSocket } = require("internal/http/FakeSocket"); const NumberIsNaN = Number.isNaN; @@ -1722,28 +1723,7 @@ const NodeHTTPServerSocket = class Socket extends NetSocket { // Pipelined responses (and their requests) that were still queued behind // the in-flight response are aborted, like Node.js's socketOnClose // (abortIncoming + abortOutgoing). - const pipelined = this[kPipelinedResponses]; - const pipelinedLength = pipelined ? pipelined.length : 0; - if (pipelinedLength) { - this[kPipelinedResponses] = undefined; - for (let i = 0; i < pipelinedLength; i++) { - const queuedRes = pipelined[i]; - const queuedReq = queuedRes.req; - if (queuedReq && !queuedReq.destroyed) { - queuedReq[kHandle] = undefined; - if (queuedReq.listenerCount("error") > 0) { - queuedReq.destroy(new ConnResetException("aborted")); - } else { - queuedReq.destroy(); - } - } - if (!queuedRes.destroyed) { - queuedRes.destroy(); - } else if (!queuedRes._closed) { - process.nextTick(emitCloseNT, queuedRes); - } - } - } + abortQueuedPipelinedResponses(this); // Node's server connection socket emits 'close' whenever the TCP // connection closes, even with no request in flight (this also covers @@ -2574,6 +2554,35 @@ function queuePipelinedResponse(socket, res, isAncient) { (socket[kPipelinedResponses] ??= []).push(res); } +// When the connection dies with pipelined responses still queued behind the +// in-flight one, abort them and their requests, like Node.js's socketOnClose +// (abortIncoming). Runs from the native socket's close path and from the +// http1 fallback's socket 'close' listener. +function abortQueuedPipelinedResponses(socket) { + const pipelined = socket[kPipelinedResponses]; + const pipelinedLength = pipelined ? pipelined.length : 0; + if (pipelinedLength) { + socket[kPipelinedResponses] = undefined; + for (let i = 0; i < pipelinedLength; i++) { + const queuedRes = pipelined[i]; + const queuedReq = queuedRes.req; + if (queuedReq && !queuedReq.destroyed) { + queuedReq[kHandle] = undefined; + if (queuedReq.listenerCount("error") > 0) { + queuedReq.destroy(new ConnResetException("aborted")); + } else { + queuedReq.destroy(); + } + } + if (!queuedRes.destroyed) { + queuedRes.destroy(); + } else if (!queuedRes._closed) { + process.nextTick(emitCloseNT, queuedRes); + } + } + } +} + function advanceResponsePipeline(server, socket) { // The previous response on this connection closed it (Connection: close, // HTTP/1.0, maxRequestsPerSocket): like Node.js's resOnFinish, advancing @@ -4034,11 +4043,14 @@ function ensureReadableStreamController(run) { ); } +// Share the pipelining machinery with internal/http1_server_fallback through +// internal/http instead of the user-visible module exports. +http1ServerPipeline.queuePipelinedResponse = queuePipelinedResponse; +http1ServerPipeline.advanceResponsePipeline = advanceResponsePipeline; +http1ServerPipeline.abortQueuedPipelinedResponses = abortQueuedPipelinedResponses; + export default { Server, ServerResponse, kConnectionsCheckingInterval, - // Pipelining internals shared with internal/http1_server_fallback. - queuePipelinedResponse, - advanceResponsePipeline, }; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 356378110f4a..47f66c80ab34 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4217,3 +4217,31 @@ it("connectionListener queues pipelined responses like Node", async () => { expect(out.match(/HTTP\/1\.1 200/g)).toHaveLength(3); } }); + +it("connectionListener aborts queued pipelined responses when the connection dies", async () => { + // Like Node's socketOnClose (abortIncoming) and the native socket's close + // path: a response still queued behind the in-flight one when the socket + // closes is destroyed together with its request, so both emit 'close' + // instead of hanging forever. + const closedEvents: string[] = []; + const { promise: aborted, resolve: onAborted } = Promise.withResolvers(); + let closesPending = 2; + const onQueuedClose = (tag: string) => { + closedEvents.push(tag); + if (--closesPending === 0) onAborted(); + }; + const server = createServer((req, res) => { + // /a never responds, so its response keeps the socket and /b stays queued. + if (req.url !== "/b") return; + req.on("close", () => onQueuedClose("reqB")); + res.on("close", () => onQueuedClose("resB")); + // Kill the connection while /b is queued behind /a. + serverSide.destroy(); + }); + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + clientSide.write("GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n"); + await aborted; + expect(closedEvents.sort()).toEqual(["reqB", "resB"]); + clientSide.destroy(); +}); From 9bbddc8202cc9e8ada810e1fa0a3dd0f0cfe617a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:43:27 +0000 Subject: [PATCH 3/8] tighten comments --- src/js/internal/http.ts | 10 ++++------ src/js/internal/http1_server_fallback.ts | 14 ++++++-------- src/js/node/_http_server.ts | 11 +++++------ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 779efa64c27f..ef0cf34e7146 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -101,12 +101,10 @@ const kCloseCallback = Symbol("closeCallback"); const kEmptyObject = Object.freeze(Object.create(null)); -// node:_http_server's pipelined-response machinery, registered onto this -// object when that module initializes. internal/http1_server_fallback drives -// the same per-connection queue over foreign duplex sockets through it, -// without putting the helpers on the user-visible node:_http_server surface. -// The fallback requires node:http (which loads _http_server) before reading -// these, so they are always populated by then. +// node:_http_server registers its pipelined-response machinery here at module +// initialization, letting internal/http1_server_fallback drive the same +// per-connection queue without widening node:_http_server's exports. The +// fallback loads node:http (and with it _http_server) before reading these. const http1ServerPipeline: { queuePipelinedResponse?: (socket: unknown, res: unknown, isAncient: boolean) => void; advanceResponsePipeline?: (server: unknown, socket: unknown) => void; diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index ab9678d3675a..6bcfdf3f5ca0 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -328,11 +328,10 @@ function connectionListenerHTTP1(server, socket, options) { } }; res[kHttp1ResponseHandle] = handle; - // Node's parserOnIncoming outgoing queue: while the previous response is - // still assigned (its deferred 'finish' has not detached it yet - - // pipelined requests always land here), this response is queued and its - // write()/end() buffer until the finish path below assigns it the socket. - // assignSocket would throw ERR_HTTP_SOCKET_ASSIGNED. + // Node's parserOnIncoming outgoing queue: pipelined requests parse while + // the previous response is still assigned (its 'finish' detach is a tick + // away), so queue this response instead of letting assignSocket throw + // ERR_HTTP_SOCKET_ASSIGNED. if (socket._httpMessage) { queuePipelinedResponse(socket, res, versionMajor < 1 || versionMinor < 1); } else { @@ -456,9 +455,8 @@ function connectionListenerHTTP1(server, socket, options) { socket.once("end", onHttp1SocketEnd); socket.once("close", () => { connections.delete(socket); - // Like the native socket's close path: responses (and their requests) - // still queued behind the in-flight one when the connection dies are - // aborted, so they emit 'close' instead of hanging forever. + // Like the native socket's close path: abort responses (and requests) + // still queued behind the in-flight one so they emit 'close'. abortQueuedPipelinedResponses(socket); try { parser.close(); diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index f72762348c6d..2d8da853965a 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -2632,12 +2632,11 @@ function advanceResponsePipeline(server, socket) { res.assignSocket(socket); } } else { - // internal/http1_server_fallback connection (a foreign duplex): each - // response's JS handle writes to the socket itself, so there is no native - // socket handle to switch. The prototype assignSocket also installs the - // 'close' listener a plain stream needs. Clear `finished` before the - // assignment: assignSocket's _flush would otherwise emit 'prefinish' for - // an already-ended queued response before its ops have been replayed. + // internal/http1_server_fallback connection (a foreign duplex): the + // response's JS handle writes to the socket itself (nothing to switch + // natively), and the prototype assignSocket installs the 'close' listener + // a plain stream needs. Clear `finished` first, or assignSocket's _flush + // emits 'prefinish' before the ops replay below. if (queued.ended) { res.finished = false; } From 5bcf6ae4bcf58a625733c3cac8488b56d9158b0e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:06:32 +0000 Subject: [PATCH 4/8] node:http: port the pipelining read gate to fallback connections, pin queued-destroy and allowHTTP1 pipelining behavior Mirror Node's parserOnIncoming read gate on fallback duplexes: pause reads when the transport or the bytes buffered on queued pipelined responses pass the high water mark, resume from the pipeline advance or socket drain. Gate req._read so body reads cannot defeat the pause. Also pin two behaviors with tests: a destroyed queued response resets the connection (deliberate divergence from Node v26's wedge, matching the native path), and the http2 allowHTTP1 ALPN fallback serves pipelined requests in order and aborts queued ones when the connection dies. --- src/js/internal/http.ts | 2 + src/js/internal/http1_server_fallback.ts | 25 ++++++- src/js/node/_http_server.ts | 36 +++++++++- test/js/node/http/node-http.test.ts | 84 ++++++++++++++++++++++++ test/js/node/http2/node-http2.test.js | 62 +++++++++++++++++ 5 files changed, 205 insertions(+), 4 deletions(-) diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index ef0cf34e7146..74a88e76c1fd 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -109,6 +109,8 @@ const http1ServerPipeline: { queuePipelinedResponse?: (socket: unknown, res: unknown, isAncient: boolean) => void; advanceResponsePipeline?: (server: unknown, socket: unknown) => void; abortQueuedPipelinedResponses?: (socket: unknown) => void; + maybePauseFallbackReads?: (socket: unknown) => void; + resumeFallbackReadsOnDrain?: (socket: unknown) => void; } = {}; export const enum ClientRequestEmitState { diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 6bcfdf3f5ca0..910ca23c5787 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -237,7 +237,13 @@ function connectionListenerHTTP1(server, socket, options) { const { HTTPParser, prepareError, calculateLenientFlags, continueExpression } = require("node:_http_common"); const { kHandle: kHttp1ResponseHandle, http1ServerPipeline } = require("internal/http"); // Populated by node:_http_server, which the require("node:http") above loads. - const { queuePipelinedResponse, advanceResponsePipeline, abortQueuedPipelinedResponses } = http1ServerPipeline; + const { + queuePipelinedResponse, + advanceResponsePipeline, + abortQueuedPipelinedResponses, + maybePauseFallbackReads, + resumeFallbackReadsOnDrain, + } = http1ServerPipeline; const { allMethods } = process.binding("http_parser"); const http1Options = options.http1Options || {}; @@ -309,9 +315,11 @@ function connectionListenerHTTP1(server, socket, options) { return 2; } } - // The body is fed by the parser callbacks below; reading just resumes the socket. + // The body is fed by the parser callbacks below; reading just resumes the + // socket - unless the pipelining read gate paused it (the gate's release + // resumes it instead). req._read = function (_size) { - if (socket.readable) socket.resume(); + if (!socket._paused && socket.readable) socket.resume(); }; const res = new ServerResponseClass(req); @@ -345,6 +353,10 @@ function connectionListenerHTTP1(server, socket, options) { advanceResponsePipeline(server, socket); }); + // Node's parserOnIncoming read gate: stop reading once the connection's + // outgoing side is backed up, so pipelined requests cannot flood it. + maybePauseFallbackReads(socket); + // Node's parserOnIncoming Expect routing (the native dispatcher applies the // same at _http_server.ts's DISPATCH_HAS_EXPECT branch). const expect = req.headers.expect; @@ -412,6 +424,7 @@ function connectionListenerHTTP1(server, socket, options) { socket.removeListener("data", onHttp1SocketData); socket.removeListener("error", onHttp1SocketErrorListener); socket.removeListener("end", onHttp1SocketEnd); + socket.removeListener("drain", onHttp1SocketDrain); connections.delete(socket); try { parser.close(); @@ -450,9 +463,15 @@ function connectionListenerHTTP1(server, socket, options) { socket.end(); } } + // Node's socketOnDrain: a transport-backpressure pause lifts when the + // socket drains (a queued-bytes pause lifts from the pipeline advance). + function onHttp1SocketDrain() { + resumeFallbackReadsOnDrain(socket); + } socket.on("data", onHttp1SocketData); socket.on("error", onHttp1SocketErrorListener); socket.once("end", onHttp1SocketEnd); + socket.on("drain", onHttp1SocketDrain); socket.once("close", () => { connections.delete(socket); // Like the native socket's close path: abort responses (and requests) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 2d8da853965a..a906594c5c51 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -2508,6 +2508,28 @@ function pausePipelineReads(socket) { response.pauseReads(); } +// Node's parserOnIncoming read gate, for fallback connections (the native +// sibling is the kOutgoingData check before pausePipelineReads at the +// dispatcher): stop reading when the transport or the bytes buffered on +// queued pipelined responses are backed up, so a pipelining client cannot +// flood the connection's memory. +function maybePauseFallbackReads(socket) { + if (socket._paused) return; + if (socket._writableState?.needDrain || (socket[kOutgoingData] ?? 0) >= socket.writableHighWaterMark) { + socket._paused = true; + socket.pause(); + } +} + +// Node's socketOnDrain: the transport drained, so resume reads unless queued +// response bytes still hold the gate. +function resumeFallbackReadsOnDrain(socket) { + if (socket._paused && (socket[kOutgoingData] ?? 0) <= socket.writableHighWaterMark) { + socket._paused = false; + socket.resume(); + } +} + function addPipelineOutgoingData(queued, bytes) { const socket = queued.socket; socket[kOutgoingData] = (socket[kOutgoingData] ?? 0) + bytes; @@ -2521,7 +2543,14 @@ function releasePipelineOutgoingData(socket, bytes) { socket[kOutgoingData] = outgoing > 0 ? outgoing : 0; if (socket._paused && outgoing <= socket.writableHighWaterMark) { socket._paused = false; - socket[kHandle]?.response?.resume(); + const response = socket[kHandle]?.response; + if (response) { + response.resume(); + } else if (!(socket instanceof NodeHTTPServerSocket)) { + // Fallback duplex paused by maybePauseFallbackReads: plain stream flow + // control is the only way to restart it. + socket.resume(); + } } } @@ -2605,6 +2634,9 @@ function advanceResponsePipeline(server, socket) { if (res.destroyed || !handle) { // The queued response was destroyed before it could be sent; the // connection cannot produce a response for this slot, so it is unusable. + // Deliberate divergence from Node v26, which assigns the destroyed + // message and wedges the connection until requestTimeout: an HTTP/1.1 + // connection cannot skip a response slot, so reset it instead. if (!socket.destroyed) { socket.destroy(); } @@ -4047,6 +4079,8 @@ function ensureReadableStreamController(run) { http1ServerPipeline.queuePipelinedResponse = queuePipelinedResponse; http1ServerPipeline.advanceResponsePipeline = advanceResponsePipeline; http1ServerPipeline.abortQueuedPipelinedResponses = abortQueuedPipelinedResponses; +http1ServerPipeline.maybePauseFallbackReads = maybePauseFallbackReads; +http1ServerPipeline.resumeFallbackReadsOnDrain = resumeFallbackReadsOnDrain; export default { Server, diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 47f66c80ab34..ae9b8b6575ad 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4245,3 +4245,87 @@ it("connectionListener aborts queued pipelined responses when the connection die expect(closedEvents.sort()).toEqual(["reqB", "resB"]); clientSide.destroy(); }); + +it("connectionListener resets the connection when a queued pipelined response is destroyed", async () => { + // Deliberate divergence from Node v26, which assigns the destroyed message + // and wedges the connection until requestTimeout: an HTTP/1.1 connection + // cannot skip a response slot, so Bun resets it (same as the native path), + // aborting the requests queued behind the destroyed slot. + const closedEvents: string[] = []; + const { promise: cAborted, resolve: onCAborted } = Promise.withResolvers(); + let cClosesPending = 2; + let releaseFirst!: () => void; + const firstGate = new Promise(resolve => (releaseFirst = resolve)); + const server = createServer((req, res) => { + if (req.url === "/a") { + firstGate.then(() => res.end("first")); + } else if (req.url === "/b") { + // Queued behind /a: like Node, a queued response has no socket yet. + expect(res.socket).toBe(null); + res.destroy(); + releaseFirst(); + } else { + req.on("close", () => { + closedEvents.push("reqC"); + if (--cClosesPending === 0) onCAborted(); + }); + res.on("close", () => { + closedEvents.push("resC"); + if (--cClosesPending === 0) onCAborted(); + }); + } + }); + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + let received = ""; + clientSide.on("data", d => (received += d)); + clientSide.write( + "GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\nGET /c HTTP/1.1\r\nHost: x\r\n\r\n", + ); + await cAborted; + expect(closedEvents.sort()).toEqual(["reqC", "resC"]); + // The in-flight response still reached the client before the reset. + expect(received).toContain("first"); + expect(serverSide.destroyed).toBe(true); + clientSide.destroy(); +}); + +it("connectionListener pauses reads when queued pipelined responses back up", async () => { + // Node's parserOnIncoming read gate: once the bytes buffered on queued + // responses pass the socket's high water mark, stop reading so a pipelining + // client cannot flood the connection, and resume as the pipeline drains. + const [clientSide, serverSide] = duplexPair(); + const big = Buffer.alloc(Math.ceil(serverSide.writableHighWaterMark / 2), "x").toString(); + const N = 10; + let dispatched = 0; + let releaseFirst!: () => void; + const firstGate = new Promise(resolve => (releaseFirst = resolve)); + const server = createServer((req, res) => { + dispatched++; + if (req.url === "/0") { + firstGate.then(() => res.end("first")); + } else { + res.end(big); + } + }); + server.emit("connection", serverSide); + let received = ""; + clientSide.on("data", d => (received += d)); + // One write per request so the gate (checked per headers-complete) takes + // effect between data events. + for (let i = 0; i < N; i++) { + clientSide.write(`GET /${i} HTTP/1.1\r\nHost: x\r\n\r\n`); + await new Promise(resolve => setImmediate(resolve)); + } + // Reads paused with requests still unparsed. + expect(serverSide.isPaused()).toBe(true); + expect(dispatched).toBeLessThan(N); + // Draining the pipeline releases the gate and everything is served. + releaseFirst(); + while ((received.match(/HTTP\/1\.1 200 /g) || []).length < N) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(dispatched).toBe(N); + clientSide.destroy(); + serverSide.destroy(); +}); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index aa4900ab329e..4e9c7c1bbd4a 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3896,6 +3896,68 @@ it("http2 allowHTTP1 fallback writes a close-delimited body raw and ends the con } }); +it("http2 allowHTTP1 fallback serves pipelined requests in order", async () => { + // Both requests arrive in one TLS record, so the second one's headers + // complete while the first response is still assigned: it must queue (an + // unconditional assignSocket throws ERR_HTTP_SOCKET_ASSIGNED and kills the + // connection) and its output must follow the first response. + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + setImmediate(() => res.end("ok:" + req.url)); + }); + await new Promise(resolve => server.listen(0, resolve)); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect( + { host: "localhost", port: server.address().port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] }, + () => socket.write("GET /a HTTP/1.1\r\nHost: localhost\r\n\r\nGET /b HTTP/1.1\r\nHost: localhost\r\n\r\n"), + ); + let buf = ""; + socket.on("error", reject); + socket.on("data", chunk => { + buf += chunk; + if (buf.includes("ok:/a") && buf.includes("ok:/b")) resolve(buf); + }); + socket.on("close", () => reject(new Error("closed before both responses: " + buf))); + const raw = await promise; + expect(raw.indexOf("ok:/a")).toBeLessThan(raw.indexOf("ok:/b")); + expect(raw.match(/HTTP\/1\.1 200/g)).toHaveLength(2); + socket.destroy(); + } finally { + server.close(); + } +}); + +it("http2 allowHTTP1 fallback aborts a queued pipelined response when the connection dies", async () => { + const closedEvents = []; + const { promise: aborted, resolve: onAborted } = Promise.withResolvers(); + let closesPending = 2; + const onQueuedClose = tag => { + closedEvents.push(tag); + if (--closesPending === 0) onAborted(); + }; + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + // /a never responds, so its response keeps the socket and /b stays queued. + if (req.url !== "/b") return; + req.on("close", () => onQueuedClose("reqB")); + res.on("close", () => onQueuedClose("resB")); + // Kill the connection while /b is queued behind /a. + socket.destroy(); + }); + 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("GET /a HTTP/1.1\r\nHost: localhost\r\n\r\nGET /b HTTP/1.1\r\nHost: localhost\r\n\r\n"), + ); + socket.on("error", () => {}); + try { + await aborted; + expect(closedEvents.sort()).toEqual(["reqB", "resB"]); + } finally { + socket.destroy(); + server.close(); + } +}); + it("http2 allowHTTP1 fallback writes no terminating chunk after a keep-alive HEAD with a user-set Transfer-Encoding: chunked", async () => { const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { if (req.method === "HEAD") { From 493b1271bc0b52d90a5eb7ffa6bee658dafc07bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:16:11 +0000 Subject: [PATCH 5/8] test: wire failure events to rejection in the new pipelining tests --- test/js/node/http/node-http.test.ts | 12 ++++++++---- test/js/node/http2/node-http2.test.js | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index ae9b8b6575ad..54d53e7dbcc9 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4309,8 +4309,14 @@ it("connectionListener pauses reads when queued pipelined responses back up", as } }); server.emit("connection", serverSide); + const { promise: allServed, resolve: onAllServed, reject: onClientFailure } = Promise.withResolvers(); let received = ""; - clientSide.on("data", d => (received += d)); + clientSide.on("data", d => { + received += d; + if ((received.match(/HTTP\/1\.1 200 /g) || []).length >= N) onAllServed(); + }); + clientSide.on("error", onClientFailure); + clientSide.on("close", () => onClientFailure(new Error("closed before all responses: " + received))); // One write per request so the gate (checked per headers-complete) takes // effect between data events. for (let i = 0; i < N; i++) { @@ -4322,9 +4328,7 @@ it("connectionListener pauses reads when queued pipelined responses back up", as expect(dispatched).toBeLessThan(N); // Draining the pipeline releases the gate and everything is served. releaseFirst(); - while ((received.match(/HTTP\/1\.1 200 /g) || []).length < N) { - await new Promise(resolve => setImmediate(resolve)); - } + await allServed; expect(dispatched).toBe(N); clientSide.destroy(); serverSide.destroy(); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 4e9c7c1bbd4a..fab6519c1a72 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3929,7 +3929,7 @@ it("http2 allowHTTP1 fallback serves pipelined requests in order", async () => { it("http2 allowHTTP1 fallback aborts a queued pipelined response when the connection dies", async () => { const closedEvents = []; - const { promise: aborted, resolve: onAborted } = Promise.withResolvers(); + const { promise: aborted, resolve: onAborted, reject: onSocketError } = Promise.withResolvers(); let closesPending = 2; const onQueuedClose = tag => { closedEvents.push(tag); @@ -3948,7 +3948,9 @@ it("http2 allowHTTP1 fallback aborts a queued pipelined response when the connec { host: "localhost", port: server.address().port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] }, () => socket.write("GET /a HTTP/1.1\r\nHost: localhost\r\n\r\nGET /b HTTP/1.1\r\nHost: localhost\r\n\r\n"), ); - socket.on("error", () => {}); + // The server handler destroys this socket without an error, so any 'error' + // here is a real failure (e.g. the TLS handshake), not the expected close. + socket.on("error", onSocketError); try { await aborted; expect(closedEvents.sort()).toEqual(["reqB", "resB"]); From 0a8ca322057995d7fbf22816046162ee9e7ff42c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:44:33 +0000 Subject: [PATCH 6/8] test: pin the proxy fixture to 127.0.0.1 so listen and connect agree on address family A bare localhost can bind ::1 while the client resolves 127.0.0.1 first; neither Node nor Bun falls back across families, so the test failed with ECONNREFUSED on dual-stack hosts where the two resolutions diverge. --- test/js/node/http/node-http-proxy.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/js/node/http/node-http-proxy.js b/test/js/node/http/node-http-proxy.js index 8b82678ae9e4..eed91d15c5b2 100644 --- a/test/js/node/http/node-http-proxy.js +++ b/test/js/node/http/node-http-proxy.js @@ -32,12 +32,15 @@ export async function run() { req.pipe(proxyRequest); // Use pipe instead of manual data handling }); - proxyServer.listen(0, "localhost", async () => { + // Pin one address family: a bare "localhost" can bind ::1 while the client + // resolves 127.0.0.1 first (neither Node nor Bun falls back across + // families), which fails with ECONNREFUSED on dual-stack hosts. + proxyServer.listen(0, "127.0.0.1", async () => { const address = proxyServer.address(); const options = { protocol: "http:", - hostname: "localhost", + hostname: "127.0.0.1", port: address.port, path: "/", // Change path to / headers: { From 44cef049d18cb1ec04e34e783fb5844d5c1417c5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:15:06 +0000 Subject: [PATCH 7/8] node:http: abort the in-flight request when a fallback connection dies Mirror the native socket close path and Node's socketOnClose abortIncoming: destroy socket._httpMessage's request (ConnResetException when it has an error listener) before aborting the queued pipelined responses, so req 'close' fires for the in-flight request too. --- src/js/internal/http1_server_fallback.ts | 16 ++++++++++++++-- test/js/node/http/node-http.test.ts | 15 +++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 910ca23c5787..765af387ff09 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -235,6 +235,7 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim function connectionListenerHTTP1(server, socket, options) { const http = require("node:http"); const { HTTPParser, prepareError, calculateLenientFlags, continueExpression } = require("node:_http_common"); + const { ConnResetException } = require("internal/shared"); const { kHandle: kHttp1ResponseHandle, http1ServerPipeline } = require("internal/http"); // Populated by node:_http_server, which the require("node:http") above loads. const { @@ -474,8 +475,19 @@ function connectionListenerHTTP1(server, socket, options) { socket.on("drain", onHttp1SocketDrain); socket.once("close", () => { connections.delete(socket); - // Like the native socket's close path: abort responses (and requests) - // still queued behind the in-flight one so they emit 'close'. + // Like the native socket's close path (Node's socketOnClose -> + // abortIncoming): abort the in-flight request, then the responses (and + // requests) still queued behind it, so they all emit 'close'. The + // in-flight response's own 'close' comes from onServerResponseClose, + // installed by assignSocket. + const inflightReq = socket._httpMessage?.req; + if (inflightReq && !inflightReq.destroyed) { + if (inflightReq.listenerCount("error") > 0) { + inflightReq.destroy(new ConnResetException("aborted")); + } else { + inflightReq.destroy(); + } + } abortQueuedPipelinedResponses(socket); try { parser.close(); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 54d53e7dbcc9..f11cc5d6a211 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4220,19 +4220,22 @@ it("connectionListener queues pipelined responses like Node", async () => { it("connectionListener aborts queued pipelined responses when the connection dies", async () => { // Like Node's socketOnClose (abortIncoming) and the native socket's close - // path: a response still queued behind the in-flight one when the socket - // closes is destroyed together with its request, so both emit 'close' + // path: the in-flight request and a response still queued behind it (with + // its request) are destroyed when the socket closes, so they emit 'close' // instead of hanging forever. const closedEvents: string[] = []; const { promise: aborted, resolve: onAborted } = Promise.withResolvers(); - let closesPending = 2; + let closesPending = 3; const onQueuedClose = (tag: string) => { closedEvents.push(tag); if (--closesPending === 0) onAborted(); }; const server = createServer((req, res) => { - // /a never responds, so its response keeps the socket and /b stays queued. - if (req.url !== "/b") return; + if (req.url === "/a") { + // /a never responds, so its response keeps the socket and /b stays queued. + req.on("close", () => onQueuedClose("reqA")); + return; + } req.on("close", () => onQueuedClose("reqB")); res.on("close", () => onQueuedClose("resB")); // Kill the connection while /b is queued behind /a. @@ -4242,7 +4245,7 @@ it("connectionListener aborts queued pipelined responses when the connection die server.emit("connection", serverSide); clientSide.write("GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n"); await aborted; - expect(closedEvents.sort()).toEqual(["reqB", "resB"]); + expect(closedEvents.sort()).toEqual(["reqA", "reqB", "resB"]); clientSide.destroy(); }); From d02dd2f9b2d0d80cd3e451b1a453544fcd6e6f52 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:10:14 +0000 Subject: [PATCH 8/8] node:http: end a fallback connection after a Connection: close response instead of advancing the pipeline Node's resOnFinish _last branch and the native onResponseFinishHandleSocket end the connection when the finished response advertised Connection: close (kMustCloseConnection); the fallback finish path only advanced, so a queued pipelined response was replayed after the final response (RFC 9112 9.6). Gate the advance on the flag and destroySoon/end the socket; the close path then aborts the queued responses like the native path. --- src/js/internal/http.ts | 1 + src/js/internal/http1_server_fallback.ts | 17 ++++++++-- src/js/node/_http_server.ts | 1 + test/js/node/http/node-http.test.ts | 40 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 74a88e76c1fd..2f7e7198d240 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -111,6 +111,7 @@ const http1ServerPipeline: { abortQueuedPipelinedResponses?: (socket: unknown) => void; maybePauseFallbackReads?: (socket: unknown) => void; resumeFallbackReadsOnDrain?: (socket: unknown) => void; + kMustCloseConnection?: symbol; } = {}; export const enum ClientRequestEmitState { diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 765af387ff09..3a54b9086f79 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -244,6 +244,7 @@ function connectionListenerHTTP1(server, socket, options) { abortQueuedPipelinedResponses, maybePauseFallbackReads, resumeFallbackReadsOnDrain, + kMustCloseConnection, } = http1ServerPipeline; const { allMethods } = process.binding("http_parser"); @@ -346,11 +347,21 @@ function connectionListenerHTTP1(server, socket, options) { } else { res.assignSocket(socket); } - // node's resOnFinish: release the socket once the response completes so - // the next keep-alive request's response can attach, then hand it to the - // next queued pipelined response (replaying whatever it buffered). + // node's resOnFinish: release the socket once the response completes, + // then either end the connection (a response that advertised Connection: + // close must not be followed by another one - the close path aborts the + // queued responses) or hand the socket to the next queued pipelined + // response, replaying whatever it buffered. res.on("finish", function onFallbackResponseFinish() { this.detachSocket(socket); + if (this[kMustCloseConnection]) { + if (typeof socket.destroySoon === "function") { + socket.destroySoon(); + } else if (!socket.writableEnded) { + socket.end(); + } + return; + } advanceResponsePipeline(server, socket); }); diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index a906594c5c51..af2f40ce0639 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -4081,6 +4081,7 @@ http1ServerPipeline.advanceResponsePipeline = advanceResponsePipeline; http1ServerPipeline.abortQueuedPipelinedResponses = abortQueuedPipelinedResponses; http1ServerPipeline.maybePauseFallbackReads = maybePauseFallbackReads; http1ServerPipeline.resumeFallbackReadsOnDrain = resumeFallbackReadsOnDrain; +http1ServerPipeline.kMustCloseConnection = kMustCloseConnection; export default { Server, diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index f11cc5d6a211..1ff0fe99ea35 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -4249,6 +4249,46 @@ it("connectionListener aborts queued pipelined responses when the connection die clientSide.destroy(); }); +it("connectionListener ends the connection after a Connection: close response instead of advancing", async () => { + // Node's resOnFinish _last branch: a response that advertised Connection: + // close must be the connection's final response (RFC 9112 9.6); the queued + // pipelined response behind it is aborted by the close path, never sent. + const closedEvents: string[] = []; + const { promise: done, resolve: onDone } = Promise.withResolvers(); + let pending = 3; + const tick = (tag: string) => { + closedEvents.push(tag); + if (--pending === 0) onDone(); + }; + const server = createServer((req, res) => { + if (req.url === "/a") { + res.setHeader("Connection", "close"); + res.end("closing"); + return; + } + req.on("close", () => tick("reqB")); + res.on("close", () => tick("resB")); + res.end("should-never-be-sent"); + }); + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + let received = ""; + clientSide.on("data", d => (received += d)); + clientSide.on("end", () => { + tick("clientEnd"); + // A well-behaved client answers the FIN, fully closing the connection. + clientSide.end(); + }); + clientSide.write("GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n"); + await done; + expect(received).toContain("Connection: close"); + expect(received).toContain("closing"); + expect(received).not.toContain("should-never-be-sent"); + expect(closedEvents.sort()).toEqual(["clientEnd", "reqB", "resB"]); + clientSide.destroy(); + serverSide.destroy(); +}); + it("connectionListener resets the connection when a queued pipelined response is destroyed", async () => { // Deliberate divergence from Node v26, which assigns the destroyed message // and wedges the connection until requestTimeout: an HTTP/1.1 connection