Skip to content

http2: a destroyed Http2Session keeps its idle timer armed, so every closed session stays in memory until the server timeout fires #66885

Open

Description

@ccamensuli

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

  1. added a commit that references this issue on Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions