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())); + })); +}