Commit 41b1e6845a4 for nodejs
commit 41b1e6845a40a2328d6fb208462aac97d89b3ccc
Author: Matteo Collina <hello@matteocollina.com>
Date: Sat Oct 24 12:50:34 2026 -0700
http2: detect streams abandoned by nghttp2
When a peer sends GOAWAY(NO_ERROR), the session starts a graceful close
on its own, which disarmed the detection of nghttp2 internally
terminating the session after a protocol error. Instead, also treat it
as an internal termination when nghttp2 no longer wants to read or
write while we still have open streams.
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66641
Reviewed-By: Tim Perry <pimterry@gmail.com>
diff --git a/src/node_http2.cc b/src/node_http2.cc
index 86b09c1d4ef..6aa389536f1 100644
--- a/src/node_http2.cc
+++ b/src/node_http2.cc
@@ -801,6 +801,18 @@ bool Http2Session::HasPendingData() const {
return true;
}
+// nghttp2 signals that the connection should be dropped by having both
+// want_read and want_write return 0. If that happens while we still track
+// open streams, nghttp2 has terminated the session on its own (for example
+// due to a protocol error from the peer) and those streams will never be
+// completed.
+bool Http2Session::HasStreamsAbandonedByNghttp2() const {
+ if (HasPendingData()) return false;
+ return std::ranges::any_of(streams_, [](const auto& entry) {
+ return !entry.second->is_closed() && !entry.second->is_destroyed();
+ });
+}
+
void Http2Session::EmitStatistics() {
if (!HasHttp2Observer(env())) [[likely]] {
return;
@@ -2069,9 +2081,11 @@ uint8_t Http2Session::SendPendingData() {
MaybeStopReading();
- // If nghttp2 has internally torn down the session (detected in OnFrameSent)
- // during the nghttp2_session_mem_send loop above, at this point we error:
- if (internal_goaway_sent_) {
+ // If nghttp2 has internally torn down the session (detected in OnFrameSent,
+ // or because it no longer wants to read or write while we still have open
+ // streams) during the nghttp2_session_mem_send loop above, at this point we
+ // error:
+ if (internal_goaway_sent_ || HasStreamsAbandonedByNghttp2()) {
internal_goaway_sent_ = false;
if (!is_closing() && !is_destroyed()) {
Isolate* isolate = env()->isolate();
diff --git a/src/node_http2.h b/src/node_http2.h
index c042c46ab59..f3e77d0a2fa 100644
--- a/src/node_http2.h
+++ b/src/node_http2.h
@@ -794,6 +794,7 @@ class Http2Session : public AsyncWrap,
BaseObjectPtr<Http2Ping> PopPing();
bool AddPing(const uint8_t* data, v8::Local<v8::Function> callback);
bool HasPendingData() const;
+ bool HasStreamsAbandonedByNghttp2() const;
BaseObjectPtr<Http2Settings> PopSettings();
bool AddSettings(v8::Local<v8::Function> callback);
diff --git a/test/parallel/test-http2-session-cleanup-on-nghttp2-goaway-after-peer-goaway.js b/test/parallel/test-http2-session-cleanup-on-nghttp2-goaway-after-peer-goaway.js
new file mode 100644
index 00000000000..b249ada325e
--- /dev/null
+++ b/test/parallel/test-http2-session-cleanup-on-nghttp2-goaway-after-peer-goaway.js
@@ -0,0 +1,116 @@
+'use strict';
+
+const common = require('../common');
+
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const http2 = require('http2');
+const net = require('net');
+
+// When a peer sends GOAWAY(NO_ERROR), the session starts a graceful close
+// on its own. If nghttp2 later terminates the session internally because of
+// a protocol error (here, an oversized frame) while a stream is still open,
+// the session must still be torn down and the error surfaced, rather than
+// leaving the stream and connection hanging.
+
+const server = http2.createServer();
+
+server.on('stream', common.mustCall((stream) => {
+ stream.on('error', common.expectsError({
+ code: 'ERR_HTTP2_ERROR',
+ name: 'Error',
+ message: 'Protocol error'
+ }));
+ stream.on('close', common.mustCall());
+}));
+
+server.on('session', common.mustCall((session) => {
+ session.on('goaway', common.mustCall());
+ session.on('error', common.expectsError({
+ code: 'ERR_HTTP2_ERROR',
+ name: 'Error',
+ message: 'Protocol error'
+ }));
+
+ session.on('close', common.mustCall(() => server.close()));
+}));
+
+server.listen(0, common.mustCall(() => {
+ const conn = net.connect({
+ port: server.address().port,
+ allowHalfOpen: true,
+ });
+
+ // HTTP/2 client connection preface.
+ conn.write('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n');
+
+ // Empty SETTINGS frame.
+ const settingsFrame = Buffer.alloc(9);
+ settingsFrame[3] = 0x04; // type: SETTINGS
+ conn.write(settingsFrame);
+
+ let inbuf = Buffer.alloc(0);
+ let state = 'settingsHeader';
+ let settingsFrameLength;
+
+ conn.on('data', (chunk) => {
+ inbuf = Buffer.concat([inbuf, chunk]);
+
+ switch (state) {
+ case 'settingsHeader':
+ if (inbuf.length < 9) return;
+ settingsFrameLength = inbuf.readUIntBE(0, 3);
+ inbuf = inbuf.slice(9);
+ state = 'readingSettings';
+ // Fallthrough
+ case 'readingSettings': {
+ if (inbuf.length < settingsFrameLength) return;
+ inbuf = inbuf.slice(settingsFrameLength);
+ state = 'done';
+
+ // ACK the server SETTINGS.
+ const ack = Buffer.alloc(9);
+ ack[3] = 0x04; // type: SETTINGS
+ ack[4] = 0x01; // flag: ACK
+ conn.write(ack);
+
+ // Open stream 1 without END_STREAM, so it stays open on the server.
+ // HPACK: :method GET (0x82), :scheme http (0x86), :path / (0x84),
+ // :authority localhost (literal with indexed name 1).
+ const block = Buffer.from([
+ 0x82, 0x86, 0x84, 0x41, 0x09,
+ ...Buffer.from('localhost'),
+ ]);
+ const headers = Buffer.alloc(9);
+ headers.writeUIntBE(block.length, 0, 3);
+ headers[3] = 0x01; // type: HEADERS
+ headers[4] = 0x04; // flags: END_HEADERS
+ headers.writeUInt32BE(1, 5); // stream id: 1
+ conn.write(Buffer.concat([headers, block]));
+
+ // GOAWAY(NO_ERROR), last stream id 0. This makes the server
+ // gracefully close its session without any application code.
+ const goaway = Buffer.alloc(17);
+ goaway.writeUIntBE(8, 0, 3); // length: 8
+ goaway[3] = 0x07; // type: GOAWAY
+ goaway.writeUInt32BE(0, 9); // last stream id
+ goaway.writeUInt32BE(0, 13); // error code: NO_ERROR
+ conn.write(goaway);
+
+ // Send a DATA frame header claiming length 16385, which exceeds the
+ // default max_frame_size of 16384, so nghttp2 calls
+ // nghttp2_session_terminate_session(FRAME_SIZE_ERROR) internally.
+ const oversized = Buffer.alloc(9);
+ oversized.writeUIntBE(16385, 0, 3); // length: 16385 (one over max)
+ oversized[3] = 0x00; // type: DATA
+ oversized.writeUInt32BE(1, 5); // stream id: 1
+ conn.write(oversized);
+ }
+ }
+ });
+
+ // The server must close the connection after sending GOAWAY:
+ conn.on('end', common.mustCall(() => conn.end()));
+ conn.on('close', common.mustCall());
+}));