Skip to content

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

Closed

Description

@ai-signeddotcom

When a connection negotiates HTTP/2 (allowH2: true, the default since
v8.0.0), idle sessions are never reaped — keepAliveTimeout /
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.

Activity

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