Commit 191a3b2db41 for nodejs

commit 191a3b2db41fe1fe39ee2f88dab0aabf0792a390
Author: Robert Nagy <ronagy@icloud.com>
Date:   Mon Sep 28 12:40:58 2026 +0200

    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>

diff --git a/lib/internal/http2/compat.js b/lib/internal/http2/compat.js
index d9c123ee846..17cb76046ee 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 00000000000..14bc6be9791
--- /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()));
+  }));
+}