From 24461fe07da8f6adb0de423b42cb6201a7521298 Mon Sep 17 00:00:00 2001 From: huang <13165998033@163.com> Date: Wed, 21 Oct 2026 12:46:42 +0800 Subject: [PATCH 1/2] http2: clear the session timeout before marking it destroyed closeSession() set SESSION_FLAGS_DESTROYED and then called session.setTimeout(0). setStreamTimeout() returns early when the session is destroyed, so the idle timer stayed armed and retained the session until the server timeout fired. Clear the timeout and remove its listeners before setting the destroyed flag. Fixes: https://github.com/nodejs/node/issues/66885 Signed-off-by: huang <13165998033@163.com> --- lib/internal/http2/core.js | 9 ++++-- ...-http2-session-timeout-cleared-on-close.js | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-http2-session-timeout-cleared-on-close.js diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 011cf22210ff..04acb5d30f63 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -1303,13 +1303,16 @@ function closeSession(session, code, error) { debugSessionObj(session, 'start closing/destroying', error); const state = session[kState]; - state.flags |= SESSION_FLAGS_DESTROYED; - state.destroyCode = code; - // Clear timeout and remove timeout listeners. + // Clear timeout and remove timeout listeners before marking the session + // destroyed: setTimeout() is a no-op once destroyed, so the idle timer + // would otherwise stay armed and retain the session. session.setTimeout(0); session.removeAllListeners('timeout'); + state.flags |= SESSION_FLAGS_DESTROYED; + state.destroyCode = code; + const socket = session[kSocket]; const handle = session[kHandle]; diff --git a/test/parallel/test-http2-session-timeout-cleared-on-close.js b/test/parallel/test-http2-session-timeout-cleared-on-close.js new file mode 100644 index 000000000000..476e60702ab5 --- /dev/null +++ b/test/parallel/test-http2-session-timeout-cleared-on-close.js @@ -0,0 +1,32 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const http2 = require('http2'); + +// A destroyed session must not keep its idle timer armed: setTimeout() is a +// no-op once the session is marked destroyed, so closeSession() has to clear +// the timer first. +const server = http2.createServer(); +server.setTimeout(30_000, common.mustNotCall()); + +server.on('session', common.mustCall((session) => { + session.on('close', common.mustCall(() => { + assert.strictEqual(session.timeout, 0); + server.close(); + })); +})); + +server.on('stream', common.mustCall((stream) => { + stream.respond({ ':status': 200 }); + stream.end('ok'); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + const req = client.request(); + req.resume(); + req.on('end', common.mustCall(() => client.close())); +})); From 50fff4bf7dad7281a41f3114c7b6d0f332fa7880 Mon Sep 17 00:00:00 2001 From: huang <13165998033@163.com> Date: Tue, 27 Oct 2026 19:25:08 +0800 Subject: [PATCH 2/2] test: cover a raw socket close in http2 session timeout The existing test only covers a graceful close, where the client sends GOAWAY. Destroy the client's TCP socket instead, so the server closes the session through socketOnClose() -> closeSession() directly, and no 'goaway' event fires. Signed-off-by: huang <13165998033@163.com> --- ...ion-timeout-cleared-on-close-raw-socket.js | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/parallel/test-http2-session-timeout-cleared-on-close-raw-socket.js diff --git a/test/parallel/test-http2-session-timeout-cleared-on-close-raw-socket.js b/test/parallel/test-http2-session-timeout-cleared-on-close-raw-socket.js new file mode 100644 index 000000000000..1df70ec9b522 --- /dev/null +++ b/test/parallel/test-http2-session-timeout-cleared-on-close-raw-socket.js @@ -0,0 +1,38 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const http2 = require('http2'); +const net = require('net'); + +// Same as test-http2-session-timeout-cleared-on-close.js, but the client's +// TCP socket is destroyed without sending GOAWAY. +const server = http2.createServer(); +server.setTimeout(30_000, common.mustNotCall()); + +server.on('session', common.mustCall((session) => { + session.on('goaway', common.mustNotCall()); + session.on('close', common.mustCall(() => { + assert.strictEqual(session.timeout, 0); + server.close(); + })); +})); + +server.on('stream', common.mustCall((stream) => { + stream.respond({ ':status': 200 }); + stream.end('ok'); +})); + +server.listen(0, common.mustCall(() => { + let socket; + const client = http2.connect(`http://localhost:${server.address().port}`, { + createConnection: (url) => (socket = net.connect(url.port, url.hostname)), + }); + client.on('error', () => {}); + const req = client.request(); + req.on('error', () => {}); + req.resume(); + req.on('end', common.mustCall(() => socket.destroy())); +}));