Commit 20daf9a4c73 for nodejs

commit 20daf9a4c73e32950d17ca837ae668f03184361b
Author: Yuya Inoue <65857152+inoway46@users.noreply.github.com>
Date:   Thu Nov 5 02:43:00 2026 +0900

    http2: exclude queued data from response memory checks

    Allow clients to receive responses when queued request data exceeds
    maxSessionMemory, while preserving limits on received headers.

    Fixes: https://github.com/nodejs/node/issues/66739
    Refs: https://github.com/nodejs/node/pull/67113
    Assisted-by: Codex
    Signed-off-by: inoway46 <inoueyuya416@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/67114
    Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
    Reviewed-By: Tim Perry <pimterry@gmail.com>

diff --git a/doc/api/http2.md b/doc/api/http2.md
index 4ee7696cfa1..ac97dd6dbfa 100644
--- a/doc/api/http2.md
+++ b/doc/api/http2.md
@@ -3354,7 +3354,11 @@ changes:
     the current memory use of the header compression tables, header blocks
     retained by open streams, current data queued to be sent, and
     unacknowledged `PING` and `SETTINGS` frames are all counted towards the
-    current limit. **Default:** `10`.
+    current limit. For received headers on locally initiated streams, including
+    informational responses and trailers, queued outbound data across the session
+    is excluded from the memory check. Retained headers still count towards the
+    limit. This exception does not apply to server push streams.
+    **Default:** `10`.
   * `maxHeaderListPairs` {number} Sets the maximum number of header entries.
     This is similar to [`server.maxHeadersCount`][] or
     [`request.maxHeadersCount`][] in the `node:http` module. The minimum value
diff --git a/src/node_http2.cc b/src/node_http2.cc
index dda33643788..a6779610ddb 100644
--- a/src/node_http2.cc
+++ b/src/node_http2.cc
@@ -2782,9 +2782,11 @@ bool Http2Stream::AddHeader(nghttp2_rcbuf* name,

   Http2Header header(env(), name, value, flags);
   size_t length = header.length() + 32;
-  // A header can only be added if we have not exceeded the maximum number
-  // of headers and the session has memory available for it.
-  if (!session_->has_available_session_memory(length) ||
+  // Queued request bodies must not block response headers on local streams.
+  // Push streams have even IDs and retain the full session memory check.
+  const bool exclude_outbound =
+      session_->type() == NGHTTP2_SESSION_CLIENT && (id_ & 1) != 0;
+  if (!session_->has_available_session_memory(length, exclude_outbound) ||
       current_headers_.size() == max_header_pairs_ ||
       current_headers_length_ + length > max_header_length_) {
     return false;
@@ -2895,12 +2897,13 @@ ssize_t Http2Stream::Provider::Stream::OnRead(nghttp2_session* handle,

 void Http2Stream::IncrementAvailableOutboundLength(size_t amount) {
   available_outbound_length_ += amount;
-  session_->IncrementCurrentSessionMemory(amount);
+  session_->IncrementQueuedOutboundMemory(amount);
 }

 void Http2Stream::DecrementAvailableOutboundLength(size_t amount) {
+  DCHECK_LE(amount, available_outbound_length_);
   available_outbound_length_ -= amount;
-  session_->DecrementCurrentSessionMemory(amount);
+  session_->DecrementQueuedOutboundMemory(amount);
 }


diff --git a/src/node_http2.h b/src/node_http2.h
index f3e77d0a2fa..4576d5678f0 100644
--- a/src/node_http2.h
+++ b/src/node_http2.h
@@ -811,6 +811,17 @@ class Http2Session : public AsyncWrap,
   void UpdateLocalCustomSettings(size_t count_,
                                  nghttp2_settings_entry* entries_);

+  void IncrementQueuedOutboundMemory(uint64_t amount) {
+    queued_outbound_memory_ += amount;
+    IncrementCurrentSessionMemory(amount);
+  }
+
+  void DecrementQueuedOutboundMemory(uint64_t amount) {
+    DCHECK_LE(amount, queued_outbound_memory_);
+    queued_outbound_memory_ -= amount;
+    DecrementCurrentSessionMemory(amount);
+  }
+
   // Tell our custom memory allocator that this rcbuf is independent of
   // this session now, and may outlive it.
   void StopTrackingRcbuf(nghttp2_rcbuf* buf);
@@ -824,9 +835,17 @@ class Http2Session : public AsyncWrap,
     return total;
   }

-  // Return true if current_session_memory + amount is less than the max
-  bool has_available_session_memory(uint64_t amount) const {
-    return current_session_memory() + amount <= max_session_memory_;
+  // Headers on local client streams may exclude queued outbound DATA, but
+  // retained headers and all other session memory still count towards the
+  // limit.
+  bool has_available_session_memory(uint64_t amount,
+                                    bool exclude_outbound = false) const {
+    uint64_t memory = current_session_memory();
+    if (exclude_outbound) {
+      DCHECK_LE(queued_outbound_memory_, memory);
+      memory -= queued_outbound_memory_;
+    }
+    return memory + amount <= max_session_memory_;
   }

   struct Statistics {
@@ -966,6 +985,8 @@ class Http2Session : public AsyncWrap,
   // The maximum amount of memory allocated for this session
   uint64_t max_session_memory_ = kDefaultMaxSessionMemory;
   uint64_t current_session_memory_ = 0;
+  // Part of current_session_memory_: DATA not yet consumed by nghttp2.
+  uint64_t queued_outbound_memory_ = 0;
   // The amount of memory allocated by nghttp2 internals
   uint64_t current_nghttp2_memory_ = 0;

diff --git a/test/parallel/test-http2-max-session-memory-cancel.js b/test/parallel/test-http2-max-session-memory-cancel.js
index a333587dc3c..6a659df37c9 100644
--- a/test/parallel/test-http2-max-session-memory-cancel.js
+++ b/test/parallel/test-http2-max-session-memory-cancel.js
@@ -11,9 +11,14 @@ const { once } = require('events');
 (async () => {
   // Keep the entire upload queued until it is canceled.
   const server = http2.createServer({ settings: { initialWindowSize: 0 } });
+  let serverPush;
   server.on('stream', common.mustCall((stream, headers) => {
     stream.on('error', () => {});
     if (headers[':path'] === '/cancel') return;
+    stream.pushStream({ ':path': '/push' }, common.mustSucceed((push) => {
+      serverPush = push;
+      push.on('error', () => {});
+    }));
     stream.respond({ ':status': 200 });
     stream.end();
   }, 2));
@@ -35,7 +40,9 @@ const { once } = require('events');
   // Native write queue cleanup runs on an immediate after stream destruction.
   await new Promise(setImmediate);

-  // The canceled upload must no longer count towards maxSessionMemory.
+  // Push admission uses the full memory check, so leaked outbound accounting
+  // cannot be hidden by the DATA exception on local streams.
+  const promised = once(client, 'stream');
   const req = client.request({ ':path': '/check' });
   const response = once(req, 'response');
   req.end();
@@ -44,6 +51,14 @@ const { once } = require('events');
   const ended = once(req, 'end');
   req.resume();
   await ended;
+  const [push] = await promised;
+  const pushedResponse = once(push, 'push');
+  const pushedEnd = once(push, 'end');
+  push.resume();
+  serverPush.respond({ ':status': 200 });
+  serverPush.end();
+  const [[pushedHeaders]] = await Promise.all([pushedResponse, pushedEnd]);
+  assert.strictEqual(pushedHeaders[':status'], 200);
   client.close();
   server.close();
 })().then(common.mustCall());
diff --git a/test/parallel/test-http2-max-session-memory-response-limits.js b/test/parallel/test-http2-max-session-memory-response-limits.js
new file mode 100644
index 00000000000..ca4458725d0
--- /dev/null
+++ b/test/parallel/test-http2-max-session-memory-response-limits.js
@@ -0,0 +1,75 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const http2 = require('http2');
+const { once } = require('events');
+
+// Excluding queued DATA must preserve the cumulative header budget and
+// the independent header count and size limits.
+async function test(kind, bodySize = 4 * 1024 * 1024) {
+  const sessions = new Set();
+  const server = http2.createServer({ settings: { initialWindowSize: 0 } });
+  server.on('session', (session) => sessions.add(session));
+  server.on('stream', (stream) => {
+    stream.on('error', () => {});
+    switch (kind) {
+      case 'repeated-informational':
+        for (let i = 0; i < 80; i++) {
+          stream.additionalHeaders({ ':status': 103,
+                                     'x-hint': 'x'.repeat(16 * 1024) });
+        }
+        break;
+      case 'pairs':
+        stream.respond({ ':status': 413, 'a': '1', 'b': '2', 'c': '3', 'd': '4' });
+        stream.end();
+        return;
+      case 'size':
+        stream.respond({ ':status': 413, 'x-large': 'x'.repeat(2048) });
+        stream.end();
+        return;
+    }
+    stream.respond({ ':status': 413 });
+    stream.end();
+  });
+  server.listen(0);
+  await once(server, 'listening');
+  const client = http2.connect(`http://localhost:${server.address().port}`, {
+    maxSessionMemory: 1,
+    maxHeaderListPairs: kind === 'pairs' ? 4 : 128,
+    settings: { maxHeaderListSize: kind === 'size' ? 1024 : 65535 },
+  });
+  await Promise.all([
+    once(client, 'remoteSettings'),
+    once(client, 'localSettings'),
+  ]);
+  const req = client.request({ ':method': 'POST' });
+  const failure = once(req, 'error');
+  let hints = 0;
+  if (kind === 'repeated-informational') {
+    req.on('headers', () => hints++);
+  } else {
+    req.on('headers', common.mustNotCall());
+  }
+  req.on('response', common.mustNotCall());
+  req.on('trailers', common.mustNotCall());
+  req.end(Buffer.alloc(bodySize));
+  const [err] = await failure;
+  assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ERROR');
+  if (kind === 'repeated-informational') {
+    assert(hints > 0 && hints < 80);
+  }
+  client.destroy();
+  for (const session of sessions) session.destroy();
+  server.close();
+}
+
+(async () => {
+  await test('pairs');
+  await test('size');
+  await test('repeated-informational', 0);
+  await test('repeated-informational');
+})().then(common.mustCall());
diff --git a/test/parallel/test-http2-max-session-memory-response-push.js b/test/parallel/test-http2-max-session-memory-response-push.js
new file mode 100644
index 00000000000..13df182f551
--- /dev/null
+++ b/test/parallel/test-http2-max-session-memory-response-push.js
@@ -0,0 +1,57 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const http2 = require('http2');
+const { once } = require('events');
+
+(async () => {
+  const sessions = new Set();
+  let serverRequest;
+  let serverPush;
+  const server = http2.createServer({ settings: { initialWindowSize: 0 } });
+  server.on('session', (session) => sessions.add(session));
+  server.on('stream', (stream) => {
+    serverRequest = stream;
+    stream.on('error', () => {});
+    stream.pushStream({ ':path': '/push' }, common.mustSucceed((push) => {
+      serverPush = push;
+      push.on('error', () => {});
+    }));
+  });
+  server.listen(0);
+  await once(server, 'listening');
+  const client = http2.connect(`http://localhost:${server.address().port}`, {
+    maxSessionMemory: 1,
+  });
+  await Promise.all([
+    once(client, 'remoteSettings'),
+    once(client, 'localSettings'),
+  ]);
+  const promised = once(client, 'stream');
+  const req = client.request({ ':method': 'POST' });
+  const response = once(req, 'response');
+  const [push] = await promised;
+  push.on('push', common.mustNotCall());
+  const failure = once(push, 'error');
+
+  // Accept the PUSH_PROMISE first, then exhaust the budget. Even an existing
+  // pushed stream must not exclude queued DATA when receiving its response.
+  req.end(Buffer.alloc(4 * 1024 * 1024));
+  serverPush.respond({ ':status': 200 });
+  serverPush.end();
+  const [err] = await failure;
+  assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ERROR');
+
+  serverRequest.respond({ ':status': 413 });
+  serverRequest.end();
+  const [headers] = await response;
+  assert.strictEqual(headers[':status'], 413);
+  req.close();
+  client.destroy();
+  for (const session of sessions) session.destroy();
+  server.close();
+})().then(common.mustCall());
diff --git a/test/parallel/test-http2-max-session-memory-response.js b/test/parallel/test-http2-max-session-memory-response.js
new file mode 100644
index 00000000000..a7753594c2d
--- /dev/null
+++ b/test/parallel/test-http2-max-session-memory-response.js
@@ -0,0 +1,87 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const http2 = require('http2');
+const { once } = require('events');
+
+// Queued request bodies must not prevent responses on local streams.
+async function test(bodySize, {
+  informational = 0,
+  blocked = false,
+  trailers = false,
+} = {}) {
+  const server = http2.createServer({
+    settings: { initialWindowSize: blocked ? 0 : 65535 },
+  });
+  const sessions = new Set();
+  server.on('session', (session) => sessions.add(session));
+  server.on('stream', (stream, headers) => {
+    stream.on('error', () => {});
+    if (headers[':path'] === '/reuse') {
+      stream.additionalHeaders({ ':status': 103 });
+      stream.respond({ ':status': 200 });
+      stream.end();
+    } else {
+      if (informational) stream.additionalHeaders({ ':status': informational });
+      stream.respond({ ':status': 413 }, { waitForTrailers: trailers });
+      if (trailers) {
+        stream.on('wantTrailers', () => stream.sendTrailers({ 'x-trailer': 'x' }));
+      }
+      stream.end();
+    }
+  });
+  server.listen(0);
+  await once(server, 'listening');
+
+  const client = http2.connect(`http://localhost:${server.address().port}`, {
+    maxSessionMemory: 1,
+  });
+  await Promise.all([
+    once(client, 'remoteSettings'),
+    once(client, 'localSettings'),
+  ]);
+  const req = client.request({ ':method': 'POST' });
+  const response = once(req, 'response');
+  const ended = once(req, 'end');
+  const closed = once(req, 'close');
+  req.on('headers', informational ? common.mustCall((headers) => {
+    assert.strictEqual(headers[':status'], informational);
+  }) : common.mustNotCall());
+  req.on('trailers', trailers ? common.mustCall((headers) => {
+    assert.strictEqual(headers['x-trailer'], 'x');
+  }) : common.mustNotCall());
+  req.resume();
+  // Fill the write queue before any response headers can arrive.
+  req.end(Buffer.alloc(bodySize));
+  const [[headers]] = await Promise.all([response, ended]);
+  assert.strictEqual(headers[':status'], 413);
+  req.destroy();
+  await closed;
+  // The session must remain usable after canceling the upload.
+  const reuse = client.request({ ':path': '/reuse' });
+  reuse.on('headers', common.mustCall());
+  const reusedResponse = once(reuse, 'response');
+  reuse.end();
+  const [reusedHeaders] = await reusedResponse;
+  assert.strictEqual(reusedHeaders[':status'], 200);
+  const reusedClose = once(reuse, 'close');
+  reuse.close();
+  await reusedClose;
+  client.destroy();
+  for (const session of sessions) session.destroy();
+  server.close();
+}
+
+(async () => {
+  const bodySize = 4 * 1024 * 1024;
+  await test(bodySize);
+  await test(bodySize, { blocked: true });
+  await test(bodySize, { informational: 100, blocked: true });
+  await test(bodySize, { informational: 103, blocked: true });
+  await test(bodySize, { trailers: true, blocked: true });
+  await test(1);
+})().then(common.mustCall());
diff --git a/test/parallel/test-http2-max-session-memory-retained-responses.js b/test/parallel/test-http2-max-session-memory-retained-responses.js
new file mode 100644
index 00000000000..e8d60fd0a93
--- /dev/null
+++ b/test/parallel/test-http2-max-session-memory-retained-responses.js
@@ -0,0 +1,66 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const http2 = require('http2');
+const { once } = require('events');
+
+// Final responses must still share a session-wide memory budget, including
+// when another local stream has a large request body waiting to be sent.
+async function test(queuedBody) {
+  const sessions = new Set();
+  const server = http2.createServer({ settings: { initialWindowSize: 0 } });
+  server.on('session', (session) => sessions.add(session));
+  server.on('stream', (stream, headers) => {
+    stream.on('error', () => {});
+    if (headers[':path'] !== '/upload') {
+      stream.respond({ ':status': 200, 'x-padding': 'x'.repeat(16 * 1024) });
+      // Leave responses open so their headers remain charged to the session.
+    }
+  });
+  server.listen(0);
+  await once(server, 'listening');
+  const client = http2.connect(`http://localhost:${server.address().port}`, {
+    maxSessionMemory: 1,
+  });
+  await Promise.all([
+    once(client, 'remoteSettings'),
+    once(client, 'localSettings'),
+  ]);
+  if (queuedBody) {
+    const upload = client.request({ ':method': 'POST', ':path': '/upload' });
+    upload.on('error', common.mustNotCall());
+    upload.end(Buffer.alloc(4 * 1024 * 1024));
+  }
+
+  let rejected = false;
+  const retainedHeaders = [];
+  for (let i = 0; i < 128; i++) {
+    const req = client.request();
+    const result = once(req, 'response');
+    req.end();
+    try {
+      const [headers] = await result;
+      retainedHeaders.push(headers);
+    } catch (err) {
+      assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ERROR');
+      rejected = true;
+      break;
+    }
+  }
+  const accepted = retainedHeaders.length;
+  assert(accepted > 0, 'queued bodies must not consume the response budget');
+  assert(rejected, 'retained final response headers must exhaust the budget');
+  assert(accepted * 16 * 1024 < 1024 * 1024);
+  client.destroy();
+  for (const session of sessions) session.destroy();
+  server.close();
+}
+
+(async () => {
+  await test(false);
+  await test(true);
+})().then(common.mustCall());