http2: emit close for aborted HEAD compat responses

The compat response defers 'finish' and 'close' for a HEAD request
until response.end(), because the stream of a headers-only response
closes as soon as the headers are sent. The same deferral also applied
to a HEAD stream that closed before any response was sent, for example
when the client cancelled it or the session was destroyed. Nothing was
left to call end(), so the response never emitted 'close' and the abort
could not be observed on it.

Defer only once the headers were sent, and otherwise close the response
as for any other method. The writable side of a HEAD stream is finished
from the start, so 'finish' is emitted only after the headers were
sent, and an aborted HEAD response does not report success.

Assisted-by: Opus 5.5
Signed-off-by: Robert Nagy <ronagy@icloud.com>
PR-URL: https://github.com/nodejs/node/pull/66310
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
Robert Nagy authored and GitHub committed 2026-09-28 10:40:58 +00:00
1 parent cff7b12df1
commit 191a3b2db4
2 files changed
+88 -4

No files matched your search

+12 -4
View File
@@ -459,8 +459,14 @@ function onStreamCloseResponse() {
const state = res[kState];
if (this.headRequest !== state.headRequest)
return;
if (this.headRequest !== state.headRequest) {
// A headers-only HEAD response closes its stream as soon as the headers
// are sent; defer to response.end() as HTTP/1 does. A stream that closed
// before the response was sent was aborted and closes the response now.
if (this.headersSent)
return;
state.headRequest = this.headRequest;
}
state.closed = true;
@@ -469,8 +475,10 @@ function onStreamCloseResponse() {
this.removeListener('wantTrailers', onStreamTrailersReady);
this[kResponse] = undefined;
// Only emit 'finish' when the underlying writable actually finished
if (this.writableFinished)
// Only emit 'finish' when the underlying writable actually finished. The
// writable side of a HEAD stream is finished from the start, so it counts
// only once the headers were sent.
if (this.writableFinished && this.headersSent)
res.emit('finish');
res.emit('close');
}
@@ -0,0 +1,76 @@
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const h2 = require('http2');
// A HEAD response must receive a close event when its stream closes before
// the response was sent, like a response to any other method. A headers-only
// HEAD response still waits for response.end() before finish and close.
{
// The client cancels the stream before the server responds.
let request;
const server = h2.createServer(common.mustCall((req, res) => {
res.on('finish', common.mustNotCall());
res.on('close', common.mustCall(() => {
// Ending an already closed response still calls back.
res.end(common.mustCall());
server.close();
}));
request.close(h2.constants.NGHTTP2_CANCEL);
}));
server.listen(0, common.mustCall(() => {
const client = h2.connect(`http://localhost:${server.address().port}`);
request = client.request({ ':method': 'HEAD' });
request.on('close', common.mustCall(() => client.close()));
}));
}
{
// The connection is lost before the server responds.
let client;
const server = h2.createServer(common.mustCall((req, res) => {
res.on('finish', common.mustNotCall());
res.on('close', common.mustCall(() => server.close()));
client.destroy();
}));
server.listen(0, common.mustCall(() => {
client = h2.connect(`http://localhost:${server.address().port}`);
client.on('error', () => {});
client.request({ ':method': 'HEAD' }).on('error', () => {});
}));
}
{
// The stream of a headers-only response closes before response.end().
const server = h2.createServer(common.mustCall((req, res) => {
let ended = false;
res.on('finish', common.mustCall(() => assert(ended)));
res.on('close', common.mustCall(() => {
assert(ended);
server.close();
}));
req.stream.on('close', common.mustCall(() => {
setImmediate(() => {
ended = true;
res.end();
});
}));
res.writeHead(200);
}));
server.listen(0, common.mustCall(() => {
const client = h2.connect(`http://localhost:${server.address().port}`);
const request = client.request({ ':method': 'HEAD' });
request.on('response', common.mustCall((headers) => {
assert.strictEqual(headers[':status'], 200);
}));
request.resume();
request.on('close', common.mustCall(() => client.close()));
}));
}