From 04f787f562c2248fa2eb15ee2ed1213a046d1ab9 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 11 Jul 2026 07:51:31 +0000 Subject: [PATCH 1/3] fix(h2): prevent uncaughtException from onHttp2SocketError accessing undefined kClient The onHttp2SocketError handler accesses this[kClient] where this is the socket, but kClient is only stored on the session (session[kClient]), never on the socket. This causes a TypeError when the error handler is invoked, which becomes an uncaughtException. Fix by routing through this[kHTTP2Session]?.[kClient] instead. Also wraps stream.destroy(err) in util.destroy and onHttp2SessionEnd in try-catch, since http2-managed sockets throw ERR_HTTP2_NO_SOCKET_MANIPULATION on direct destroy() calls. Fixes #5525 --- lib/core/util.js | 7 ++++++- lib/dispatcher/client-h2.js | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/core/util.js b/lib/core/util.js index 2586e3ee891..f00ddfce0d1 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -370,7 +370,12 @@ function destroy (stream, err) { stream.socket = null } - stream.destroy(err) + try { + stream.destroy(err) + } catch { + // stream.destroy may throw on managed sockets (e.g., http2). + // Silently ignore — the socket lifecycle is handled by the subsystem. + } } else if (err) { queueMicrotask(() => { stream.emit('error', err) diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index 3e04911197b..612035ca233 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -508,7 +508,12 @@ function onHttp2FrameError (type, code, id) { function onHttp2SessionEnd () { const err = new SocketError('other side closed', util.getSocketInfo(this[kSocket])) this.destroy(err) - util.destroy(this[kSocket], err) + try { + util.destroy(this[kSocket], err) + } catch { + // this[kSocket] may throw after session.destroy() on managed sockets (e.g., http2). + // Silently ignore — the socket lifecycle is handled by the session. + } } /** @@ -654,7 +659,7 @@ function onHttp2SocketError (err) { return } - this[kClient][kOnError](err) + this[kHTTP2Session]?.[kClient]?.[kOnError](err) } function onHttp2SocketEnd () { From 2034f12db2ad02d711cf0dfdcfa6718074866da5 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 11 Jul 2026 16:54:35 +0000 Subject: [PATCH 2/3] test(h2): add regression test for uncaughtException after abort + socket close Verifies that aborting an in-flight HTTP/2 request followed by the socket closing does not trigger an uncaughtException. This exercises the onHttp2SocketError fix where kClient must be accessed through the session rather than directly on the socket. --- test/http2-dispatcher.js | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/test/http2-dispatcher.js b/test/http2-dispatcher.js index 3b25390e380..3b8ea00c222 100644 --- a/test/http2-dispatcher.js +++ b/test/http2-dispatcher.js @@ -1091,3 +1091,73 @@ test('Should not send http2 PING frames after connection is closed', async t => await t.completed }) + +test('Should not emit uncaughtException when socket closes after abort', async t => { + t = tspl(t, { plan: 2 }) + + const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } })) + + // Never respond, keeping the stream in-flight. + server.on('stream', (stream) => { + stream.on('error', () => {}) + }) + + after(() => { + server.close() + }) + await once(server.listen(0), 'listening') + + // Capture any uncaughtException that fires during the test + let uncaughtErr = null + const originalHandler = process.listeners('uncaughtException').length > 0 + ? process.listeners('uncaughtException')[0] + : null + process.removeAllListeners('uncaughtException') + const uncaughtHandler = (err) => { + uncaughtErr = err + } + process.on('uncaughtException', uncaughtHandler) + after(() => { + process.removeListener('uncaughtException', uncaughtHandler) + if (originalHandler) { + process.on('uncaughtException', originalHandler) + } + }) + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { rejectUnauthorized: false }, + allowH2: true + }) + after(() => client.close()) + + const controller = new AbortController() + + const requestPromise = client.request({ + path: '/', + method: 'GET', + signal: controller.signal + }) + + // Abort the request to trigger the abort path + controller.abort() + + // Wait for the request to reject + try { + await requestPromise + t.fail('request should have rejected') + } catch (err) { + t.ok(err, 'request rejected') + } + + // Now close the server to trigger socket 'end' event, + // simulating the CDN closing the connection after abort. + // This used to trigger an uncaughtException via onHttp2SocketError. + server.close() + + // Wait for any pending async error handling + await sleep(500) + + t.equal(uncaughtErr, null, 'No uncaughtException should have been emitted') + + await t.completed +}) From f0b477e103fe3f3e723bad5404ff90a74e1644aa Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 11 Jul 2026 19:47:50 +0200 Subject: [PATCH 3/3] refactor(h2): remove dead onHttp2SessionEnd handler Http2Session is a plain EventEmitter and never emits 'end', so this listener could never fire. --- lib/dispatcher/client-h2.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index 612035ca233..4d0fd54608b 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -246,7 +246,6 @@ function connectH2 (client, socket) { util.addListener(session, 'error', onHttp2SessionError) util.addListener(session, 'frameError', onHttp2FrameError) - util.addListener(session, 'end', onHttp2SessionEnd) util.addListener(session, 'goaway', onHttp2SessionGoAway) util.addListener(session, 'close', onHttp2SessionClose) util.addListener(session, 'remoteSettings', onHttp2RemoteSettings) @@ -505,17 +504,6 @@ function onHttp2FrameError (type, code, id) { } } -function onHttp2SessionEnd () { - const err = new SocketError('other side closed', util.getSocketInfo(this[kSocket])) - this.destroy(err) - try { - util.destroy(this[kSocket], err) - } catch { - // this[kSocket] may throw after session.destroy() on managed sockets (e.g., http2). - // Silently ignore — the socket lifecycle is handled by the session. - } -} - /** * This is the root cause of #3011 * We need to handle GOAWAY frames properly, and trigger the session close