From 8aed10d5bb62280e6caf4a96ed1f5fe1ff53d707 Mon Sep 17 00:00:00 2001 From: Robert Nagy Date: Sat, 26 Sep 2026 10:46:35 +0200 Subject: [PATCH] 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 --- lib/internal/http2/compat.js | 16 +++- ...-http2-compat-serverresponse-close-head.js | 76 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 test/parallel/test-http2-compat-serverresponse-close-head.js diff --git a/lib/internal/http2/compat.js b/lib/internal/http2/compat.js index d9c123ee846b..17cb76046ee0 100644 --- a/lib/internal/http2/compat.js +++ b/lib/internal/http2/compat.js @@ -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'); } diff --git a/test/parallel/test-http2-compat-serverresponse-close-head.js b/test/parallel/test-http2-compat-serverresponse-close-head.js new file mode 100644 index 000000000000..14bc6be9791e --- /dev/null +++ b/test/parallel/test-http2-compat-serverresponse-close-head.js @@ -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())); + })); +}