Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions test/js/node/http/node-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6325,6 +6325,212 @@ it("connectionListener ends the connection after a Connection: close response in
serverSide.destroy();
});

describe("connectionListener closes the connection like Node's resOnFinish", () => {
// A connection is ended after a response when the request forbade reuse, the
// response itself did, or (httpAllowHalfOpen) the peer half-closed while the
// response was in flight: Node's res._last.
type Handler = (req: IncomingMessage, res: ServerResponse) => void;
// `connection` holds the value of every Connection header of the response.
type WireResponse = { connection: string[]; body: string };
type Outcome = { responses: WireResponse[]; ended: boolean; served: string[] };
type Case = {
name: string;
version?: string;
requestHeaders?: string;
handler?: Handler;
// httpAllowHalfOpen server; the client half-closes right after its request.
peerEndsFirst?: boolean;
expected: Outcome;
};

// Splits what the client received into responses. A body has Content-Length bytes and a 204 has
// none. Any other body has no length of its own: it ends where the next response starts.
// `complete` is false while a head or a body is cut short.
function splitResponses(wire: string) {
const responses: WireResponse[] = [];
let complete = true;
while (complete && wire.length > 0) {
const headEnd = wire.indexOf("\r\n\r\n");
if (headEnd === -1) {
complete = false;
break;
}
const head = wire.slice(0, headEnd);
const rest = wire.slice(headEnd + 4);
const contentLength = /^content-length: (\d+)$/im.exec(head)?.[1];
const nextResponse = rest.search(/HTTP\/1\.1 \d{3} /);
const length = head.startsWith("HTTP/1.1 204 ")
? 0
: contentLength !== undefined
? Number(contentLength)
: nextResponse !== -1
? nextResponse
: rest.length;
complete = rest.length >= length;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
responses.push({
connection: Array.from(head.matchAll(/^connection: (.*)$/gim), match => match[1]),
body: rest.slice(0, length),
});
wire = rest.slice(length);
}
return { responses, complete };
}

async function serve({
version = "1.1",
requestHeaders = "",
handler = endBody,
peerEndsFirst = false,
}: Case): Promise<Outcome> {
const requestFor = (path: string) => `GET ${path} HTTP/${version}\r\nHost: x\r\n${requestHeaders}\r\n`;
const served: string[] = [];
const server = createServer((req, res) => {
served.push(req.url!);
handler(req, res);
});
if (peerEndsFirst) server.httpAllowHalfOpen = true;
const [clientSide, serverSide] = duplexPair();
server.emit("connection", serverSide);
try {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let wire = "";
let ended = false;
let firstResponseSeen = false;
clientSide.on("error", reject);
clientSide.on("close", () => reject(new Error("the connection closed before the server ended it")));
Comment thread
robobun marked this conversation as resolved.
Resolved
clientSide.on("end", () => {
ended = true;
resolve();
});
clientSide.on("data", chunk => {
wire += chunk;
const { responses, complete } = splitResponses(wire);
if (!complete) return;
if (responses.length >= 2) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
resolve();
} else if (!firstResponseSeen) {
firstResponseSeen = true;
// A server that ends the connection does so as the first response
// finishes, so its 'end' settles this before the immediate runs. One
// that keeps it open is sent a second request and answers it (or, once
// the client has half-closed, is simply observed to still be open).
setImmediate(() => {
if (ended) return;
if (peerEndsFirst) resolve();
else clientSide.write(requestFor("/2"));
});
}
});
if (peerEndsFirst) clientSide.end(requestFor("/1"));
else clientSide.write(requestFor("/1"));
await promise;
return { responses: splitResponses(wire).responses, ended, served };
} finally {
clientSide.destroy();
serverSide.destroy();
}
}

function endBody(_req: IncomingMessage, res: ServerResponse) {
res.end("served");
}
const answered = (connection: string[], body = "served"): WireResponse => ({ connection, body });
const ends = (response: WireResponse): Outcome => ({ responses: [response], ended: true, served: ["/1"] });
const closes = ends(answered(["close"]));
const closesAdvertisingKeepAlive = ends(answered(["keep-alive"]));
let manyOtherHeaders = "";
for (let i = 0; i < 40; i++) manyOtherHeaders += `X-Filler-${i}: ${i}\r\n`;

const cases: Case[] = [
{
name: "ends it after answering an HTTP/1.0 request that asked for keep-alive",
version: "1.0",
requestHeaders: "Connection: keep-alive\r\n",
expected: closes,
},
{ name: "ends it after answering an HTTP/1.0 request", version: "1.0", expected: closes },
{
name: "ends it after answering an HTTP/1.1 request that sent Connection: close",
requestHeaders: "Connection: close\r\n",
expected: closes,
},
{
// More header fields than the parser delivers in one batch.
name: "ends it after answering a Connection: close request that carried many other headers",
requestHeaders: "Connection: close\r\n" + manyOtherHeaders,
expected: closes,
},
{
name: "ends it after a response the handler gave a Connection: close header",
handler: (_req, res) => {
res.setHeader("Connection", "close");
res.end("served");
},
expected: closes,
},
{
name: "ends it after a response the handler cleared shouldKeepAlive on",
handler: (_req, res) => {
res.shouldKeepAlive = false;
res.end("served");
},
expected: closes,
},
{
// Node's _removedConnection branch: nothing advertised, still the last response.
name: "ends it after a response with shouldKeepAlive cleared and the Connection header removed",
handler: (_req, res) => {
res.removeHeader("Connection");
res.shouldKeepAlive = false;
res.end("served");
},
expected: ends(answered([])),
},
{
name: "ends it after a 204 response carrying a Transfer-Encoding header",
handler: (_req, res) => {
res.writeHead(204, { "Transfer-Encoding": "chunked" });
res.end();
},
expected: ends(answered(["close"], "")),
},
{
// An HTTP/1.0 response without a Content-Length ends where the connection
// does, whatever Connection header the handler writes itself.
name: "ends an HTTP/1.0 connection after a response without Content-Length, even when the handler advertised keep-alive",
version: "1.0",
requestHeaders: "Connection: keep-alive\r\n",
handler: (_req, res) => {
res.setHeader("Connection", "keep-alive");
res.end("served");
},
expected: closesAdvertisingKeepAlive,
},
{
name: "ends it after the response when the peer half-closed before the handler answered (httpAllowHalfOpen)",
peerEndsFirst: true,
handler: (req, res) => req.socket.once("end", () => res.end("served")),
expected: closesAdvertisingKeepAlive,
},
{
name: "ends it after a synchronous response to a request that arrived together with the peer's FIN (httpAllowHalfOpen)",
peerEndsFirst: true,
expected: closesAdvertisingKeepAlive,
},
{
name: "keeps a kept-alive HTTP/1.1 connection open for the next request",
expected: {
responses: [answered(["keep-alive"]), answered(["keep-alive"])],
ended: false,
served: ["/1", "/2"],
},
},
];
it.each(cases)("$name", async testCase => {
expect(await serve(testCase)).toEqual(testCase.expected);
});
});

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
Expand Down
32 changes: 32 additions & 0 deletions test/js/node/http2/node-http2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5504,6 +5504,38 @@ it("http2 allowHTTP1 fallback omits the Connection header on a close-delimited r
}
});

it("http2 allowHTTP1 fallback ends the connection after answering an HTTP/1.0 keep-alive request with Connection: close", async () => {
const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => {
res.end(`served ${req.httpVersion} ${req.url}`);
});
await new Promise(resolve => server.listen(0, resolve));
let socket;
try {
const { promise, resolve, reject } = Promise.withResolvers();
socket = tls.connect(
{ host: "localhost", port: server.address().port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] },
() => socket.write("GET /first HTTP/1.0\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"),
);
const chunks = [];
socket.on("error", reject);
socket.on("close", () => reject(new Error("the connection closed before the server ended it")));
socket.on("data", chunk => chunks.push(chunk));
// Never arrives while the server keeps the connection open.
Comment thread
robobun marked this conversation as resolved.
Resolved
socket.on("end", () => resolve(Buffer.concat(chunks).toString()));
const raw = await promise;
const headEnd = raw.indexOf("\r\n\r\n");
expect({
statusLine: raw.slice(0, raw.indexOf("\r\n")),
connection: Array.from(raw.slice(0, headEnd).matchAll(/^connection: (.*)$/gim), match => match[1]),
body: raw.slice(headEnd + 4),
}).toEqual({ statusLine: "HTTP/1.1 200 OK", connection: ["close"], body: "served 1.0 /first" });
} finally {
// server.close() does not end a connection that is still open.
socket?.destroy();
server.close();
}
});

function connectOverHttp1(port) {
const { promise, resolve, reject } = Promise.withResolvers();
const socket = tls.connect({ host: "localhost", port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] }, () =>
Expand Down
Loading