Repository navigation
http2: a destroyed Http2Session keeps its idle timer armed, so every closed session stays in memory until the server timeout fires #66885
Description
Version
v22.23.1, v24.21.0, v26.11.1 (and main: the code path below is unchanged)
Platform
Darwin 24.6.0 arm64 (macOS); the faulty logic is in lib/, not platform-specific
Subsystem
http2
What steps will reproduce the bug?
// Run with: node --expose-gc repro.mjs
import http2 from "node:http2";
const TIMEOUT = 30_000;
const server = http2.createServer();
server.setTimeout(TIMEOUT, () => {});
server.on("request", (_req, res) => res.end("ok"));
let last;
server.on("session", (s) => (last = s));
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const url = `http://127.0.0.1:${server.address().port}`;
async function oneSession() {
const client = http2.connect(url);
await new Promise((r) => client.request({ ":path": "/" }).resume().on("end", r).end());
await new Promise((r) => client.close(r));
await new Promise((r) => setImmediate(r));
}
// 1. The session is destroyed, but its timeout was never cleared.
await oneSession();
console.log("destroyed:", last.destroyed, "| timeout still armed:", last.timeout); // true | 30000 (expected 0)
// 2. Each closed session stays reachable until the timer fires.
const heap = () => (globalThis.gc(), globalThis.gc(), process.memoryUsage().heapUsed);
for (let i = 0; i < 500; i++) await oneSession(); // warm up
const before = heap();
for (let i = 0; i < 2000; i++) await oneSession();
console.log("retained per closed session:", ((heap() - before) / 2000 / 1024).toFixed(2), "KiB");
server.close();How often does it reproduce? Is there a required condition?
Always, as soon as the server has a session timeout (server.setTimeout(ms), or timeout in the server options). Same result over TLS (createSecureServer).
What is the expected behavior? Why is that the expected behavior?
destroyed: true | timeout still armed: 0
retained per closed session: ~0.00 KiB
closeSession() explicitly tries to do this — // Clear timeout and remove timeout listeners. — so a destroyed session should no longer be referenced by a timer.
What do you see instead?
v26.11.1 destroyed: true | timeout still armed: 30000 retained per closed session: 1.77 KiB
v24.21.0 destroyed: true | timeout still armed: 30000 retained per closed session: 1.80 KiB
v22.23.1 destroyed: true | timeout still armed: 30000 retained per closed session: 2.47 KiB
The retention is bounded by the timeout: with a 1 s timeout and a 1.5 s wait before measuring, it drops to 0. But under a steady flow of new connections it amounts to new sessions/s × timeout × ~2 KiB held at all times (e.g. 1,000 sessions/s with a 30 s timeout ≈ 60 MB).
Additional information
The cause is an ordering issue in closeSession() (lib/internal/http2/core.js):
function closeSession(session, code, error) {
...
state.flags |= SESSION_FLAGS_DESTROYED; // (1) the session is now `destroyed`
state.destroyCode = code;
// Clear timeout and remove timeout listeners.
session.setTimeout(0); // (2) no-op
session.removeAllListeners('timeout');Http2Session.prototype.setTimeout is setStreamTimeout (lib/internal/stream_base_commons.js), whose first statement is:
function setStreamTimeout(msecs, callback) {
if (this.destroyed)
return this;So (2) returns immediately: clearTimeout(this[kTimeout]) is never reached, and the Timeout (whose callback is bound to the session) keeps the whole session — socket, handle, buffers — reachable until it fires. sessionOnTimeout then returns early on this.destroyed, so the only visible effect is the memory.
Http2Stream is not affected: after a stream closes, stream.timeout is 0 as expected.
A possible fix is to clear the timer before setting SESSION_FLAGS_DESTROYED (or to call clearTimeout(session[kTimeout]) directly in closeSession).
Userland workaround: clear the timer while the session is still alive, e.g. on GOAWAY:
server.on("session", (session) => {
session.once("goaway", () => session.setTimeout(0));
});It covers graceful closes (clients send GOAWAY on close() and destroy()), but not a TCP connection dropped without GOAWAY.
Activity
docs(http): référencer nodejs/node#66885 sur le contournement du minu…
http2: clear the session timeout before marking it destroyed