From 4c10f546dbaba20acab0b5af2d155ba9d49b3c37 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 25 Jun 2026 15:23:30 +0200 Subject: [PATCH] quic: fix potential crash from unobserved closed Signed-off-by: Tim Perry --- lib/internal/quic/quic.js | 4 +- ...st-quic-session-unobserved-error-close.mjs | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-quic-session-unobserved-error-close.mjs diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 99eeccc3c51d23..3c752f82650210 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -3586,7 +3586,9 @@ class QuicSession { if (error) { // If the session is still waiting to be closed, and error - // is specified, reject the closed promise. + // is specified, reject the closed promise. Mark it handled first + // (as with opened) to silence errors if it's not actually awaited. + markPromiseAsHandled(inner.pendingClose.promise); inner.pendingClose.reject?.(error); } else { inner.pendingClose.resolve?.(); diff --git a/test/parallel/test-quic-session-unobserved-error-close.mjs b/test/parallel/test-quic-session-unobserved-error-close.mjs new file mode 100644 index 00000000000000..c0440766aaaac1 --- /dev/null +++ b/test/parallel/test-quic-session-unobserved-error-close.mjs @@ -0,0 +1,47 @@ +// Flags: --experimental-quic --no-warnings + +// Regression test: destroying a session with an error while its `closed` +// promise is not being observed must NOT surface as an unhandled rejection. + +import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; +import { subscribe, unsubscribe } from 'node:diagnostics_channel'; +import { setImmediate as tick } from 'node:timers/promises'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); + +// Any unhandled rejection - e.g. an unobserved `closed` rejecting - fails. +process.on('unhandledRejection', mustNotCall('unexpected unhandled rejection')); + +// We use the diagnostics channel to observe the error close without actually +// observing session.closed (not listening is the whole point of the test): +const serverErrored = Promise.withResolvers(); +function onSessionError() { + unsubscribe('quic.session.error', onSessionError); + serverErrored.resolve(); +} +subscribe('quic.session.error', onSessionError); + +// The server session callback is left deliberately empty, so no response +// is sent and closed remains unobserved: +const serverEndpoint = await listen(mustCall()); + +const clientSession = await connect(serverEndpoint.address); +await clientSession.opened; + +// Finish handshake before close: +await tick(); + +// Cleanly close from the client with an error code, so the server +// receives a peer error close: +await clientSession.close({ code: 1 }); + +// Wait until the server has processed the error close, plus another tick +// to ensure unobserved promise rejection doesn't fire anywhere: +await serverErrored.promise; +await tick(); + +await serverEndpoint.close();