Skip to content

HTTP/2 sessions are never idle-reaped: keepAliveTimeout has no effect when h2 is negotiated (default since v8) #5381

Description

@ai-signeddotcom

When a connection negotiates HTTP/2 (allowH2: true, the default since
v8.0.0), idle sessions are never reapedkeepAliveTimeout /
keepAliveMaxTimeout have no effect. The socket stays ESTABLISHED
indefinitely after the response body has been fully consumed, with the client
idle in the pool (agent.stats reports connected: 1, free: 1, running: 0, size: 0).

The h1 path destroys an idle connection after keepAliveTimeout via the
parser's TIMEOUT_KEEP_ALIVE timer (lib/dispatcher/client-h1.js). The h2
path has no equivalent: lib/dispatcher/client-h2.js only arms per-stream
headersTimeout/bodyTimeout timers (the only timer references in the file)
keepAliveTimeout is never consulted, and there is no session-idle timer at
all.

In long-running processes that contact many distinct origins (crawlers,
scanners, webhook dispatchers), this accumulates one pinned socket + TLS/h2
session state per h2-capable origin, unbounded. Real-world data point: a
scanner of ours accumulated 3,600 pinned ESTABLISHED sockets (+1.7 GiB RSS)
in 17 hours after migrating from Node's built-in fetch (bundled undici 6, h2
off) to npm undici 8 — the migration, not any version bump within 8.x, exposed
it.

Version bisect

Same test (tuned Agent, GET, body fully consumed, h2-capable origin), socket
state checked well past keepAliveTimeout:

undici idle socket reaped?
7.27.2 ✅ yes (~keepAliveTimeout)
8.0.3 ❌ never
8.1.0 ❌ never
8.2.0 ❌ never
8.3.0 ❌ never
8.4.0 ❌ never

i.e. exactly where allowH2 flipped to default-on (v8.0.0). On 8.x with
explicit allowH2: false, reaping works correctly — including for unconsumed
bodies and HEAD. Affects both fetch() and request(), Agent and bare
Client.

Reproducible By

Self-contained (local h2 server, self-signed cert, server-side connection
tracking — no /proc or external origins):

// Repro: undici >=8 never idle-reaps HTTP/2 sessions — keepAliveTimeout has
no effect on h2.
// Run: npm i undici@8.4.0 && node undici-h2-repro.mjs
// (self-contained: local h2 server, self-signed cert, server-side connection
tracking)
import { createSecureServer } from "node:http2";
import { Agent, fetch } from "undici";

const key = `<PASTE: contents of a self-signed key.pem — see note below>`;
const cert = `<PASTE: contents of the matching cert.pem>`;
// generate with: openssl req -x509 -newkey ec -pkeyopt
ec_paramgen_curve:prime256v1 \
//   -nodes -keyout key.pem -out cert.pem -subj "/CN=localhost" -days 365

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function probe(allowH2) {
  const server = createSecureServer({ key, cert, allowHTTP1: true });
  let open = 0;
  server.on("secureConnection", (sock) => { open++; sock.on("close", () =>
open--); });
  server.on("request", (req, res) => res.end("hello"));
  await new Promise((r) => server.listen(0, "127.0.0.1", r));
  const port = server.address().port;

  const agent = new Agent({
    allowH2,
    keepAliveTimeout: 500,
    keepAliveMaxTimeout: 2000,
    connect: { rejectUnauthorized: false },
  });

  const res = await fetch(`https://127.0.0.1:${port}/`, { dispatcher: agent
});
  await res.text(); // body fully consumed
  console.log(`allowH2=${allowH2}: response consumed, server sees ${open} open
connection(s)`);
  for (const t of [1000, 2000, 3000, 5000]) {
    await sleep(t === 1000 ? 1000 : 1000 + (t === 5000 ? 1000 : 0));
    console.log(`  t+${t}ms: open connections = ${open}${t === 5000 ? (open ?
"   <-- NEVER REAPED (leak)" : "   <-- reaped OK") : ""}`);
  }
  await agent.close();
  server.close();
}

console.log("node", process.version, "| npm undici", (await
import("undici/package.json", { with: { type: "json" } })).default.version);
await probe(false); // h1: reaped at ~keepAliveTimeout
await probe(true);  // h2 (the v8 default): never reaped

Output (Node v22.22.3, undici 8.4.0, Linux x64):

node v22.22.3 | npm undici 8.4.0
allowH2=false: response consumed, server sees 1 open connection(s)
  t+1000ms: open connections = 0
  t+2000ms: open connections = 0
  t+3000ms: open connections = 0
  t+5000ms: open connections = 0   <-- reaped OK
allowH2=true: response consumed, server sees 1 open connection(s)
  t+1000ms: open connections = 1
  t+2000ms: open connections = 1
  t+3000ms: open connections = 1
  t+5000ms: open connections = 1   <-- NEVER REAPED (leak)

Note when testing against public origins: the origin must actually negotiate
h2 (e.g. example.com does; www.iana.org ends up on http/1.1 and reaps fine) —
easy to false-negative a repro.

Expected Behavior

Idle h2 sessions are closed after keepAliveTimeout (or a dedicated
session-idle timeout), matching h1 pool semantics — or, at minimum, the
divergence is documented and bounded (today the only mitigation is allowH2: false or manual agent.close()).

Environment

  • undici 8.0.3–8.4.0 (bisected; 7.27.2 unaffected)
  • Node.js v22.22.3, Linux x64 (Debian, also reproduced in docker node:22
    container)

Happy to provide more data or test a patch.

Discovered today on https://www.ai-signed.com.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions