From 011c6f6ac8a572910c4ee56daf5bdbef7a8105a4 Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Fri, 26 Jun 2026 12:36:17 -0400 Subject: [PATCH] fix(h2): requeue request on GOAWAY'd session instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a request is dispatched onto an HTTP/2 session that has already received a GOAWAY frame, ClientHttp2Session.request() throws ERR_HTTP2_GOAWAY_SESSION. requestStream caught it and fell through to session.destroy(err) + util.destroy(socket, err) + abort(err); the destroy-with-error on a socket whose 'error' listener is already detached re-emits 'error' and crashes the process via uncaughtException (observed in production on a high-volume h2 client to servers that routinely GOAWAY to rotate connections). A GOAWAY'd session is the same situation as ERR_HTTP2_INVALID_SESSION — the peer won't accept new streams — so route it through the existing graceful branch (resetHttp2Session + requeueUnsentRequest) so the request retries on a fresh connection. Adds a regression test (stubs session.request to throw ERR_HTTP2_GOAWAY_SESSION); it fails on main and passes with the fix. Signed-off-by: Scott Taylor Assisted-By: devx/86ca8ae7-2b51-4dd3-8382-dc6116301425 --- lib/dispatcher/client-h2.js | 5 ++- test/http2-goaway.js | 71 ++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index e22234de8d6..d4d5f01880c 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -862,7 +862,10 @@ function openStream (client, request, session, abort, headers, options) { try { return session.request(headers, options) } catch (err) { - if (err?.code === 'ERR_HTTP2_INVALID_SESSION') { + // A GOAWAY'd session rejects new streams, same as an invalid session: + // reset and requeue on a fresh connection rather than the destroy + abort + // below, whose destroy(socket, err) can crash via an unhandled 'error'. + if (err?.code === 'ERR_HTTP2_INVALID_SESSION' || err?.code === 'ERR_HTTP2_GOAWAY_SESSION') { const wrappedErr = new SocketError(err.message, util.getSocketInfo(session[kSocket])) wrappedErr.cause = err session[kError] = wrappedErr diff --git a/test/http2-goaway.js b/test/http2-goaway.js index dc9c4266349..9aa8562b9b6 100644 --- a/test/http2-goaway.js +++ b/test/http2-goaway.js @@ -3,7 +3,7 @@ const { tspl } = require('@matteo.collina/tspl') const { test, after } = require('node:test') const { constants, createSecureServer } = require('node:http2') -const { once } = require('node:events') +const { once, EventEmitter } = require('node:events') const pem = require('@metcoder95/https-pem') @@ -290,3 +290,72 @@ test('#5089 - Handle GOAWAY Gracefully', async (t) => { await t.completed }) + +test('#5453 - request onto a GOAWAY-rejected session is requeued on a fresh session', async (t) => { + t = tspl(t, { plan: 5 }) + + const http2 = require('node:http2') + const originalConnect = http2.connect + + // A session that has received GOAWAY rejects new streams synchronously. + class FakeSession extends EventEmitter { + constructor () { + super() + this.closed = false + this.destroyed = false + } + + request () { + const err = new Error('New streams cannot be created after receiving a GOAWAY') + err.code = 'ERR_HTTP2_GOAWAY_SESSION' + throw err + } + + destroy () { + if (this.destroyed) return + this.destroyed = true + this.emit('close') + } + + ref () {} + unref () {} + } + + const session = new FakeSession() + let connectCalls = 0 + let streams = 0 + + http2.connect = function connectStub (...args) { + connectCalls++ + return connectCalls === 1 ? session : originalConnect.apply(this, args) + } + after(() => { http2.connect = originalConnect }) + + const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } })) + server.on('stream', (stream) => { + streams++ + stream.respond({ ':status': 200 }) + stream.end('ok') + }) + after(() => server.close()) + await once(server.listen(0), 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + allowH2: true, + connect: { rejectUnauthorized: false } + }) + after(() => client.close()) + + const response = await client.request({ path: '/', method: 'GET' }) + const chunks = [] + response.body.on('data', chunk => chunks.push(chunk)) + await once(response.body, 'end') + + t.strictEqual(response.statusCode, 200) + t.strictEqual(Buffer.concat(chunks).toString(), 'ok') + t.strictEqual(streams, 1) + t.strictEqual(connectCalls, 2) + t.strictEqual(session.destroyed, true) + + await t.completed +})