Skip to content

HTTP/2 idle reaper crashes the process: InformationalError: socket idle timeout is emitted on a ClientHttp2Stream with no 'error' listener #5936

Description

@ethanresnick

Bug Description

On Node 26, the built-in fetch sometimes crashes the process with an uncaught InformationalError: socket idle timeout (code UND_ERR_INFO). The stack starts in onHttp2SessionIdleTimeout, the HTTP/2 idle reaper that #5406 added to fix #5381.

The reaper closes the idle session with an error:

function onHttp2SessionIdleTimeout (session) {
const client = session[kClient]
const socket = session[kSocket]
const state = session[kHTTP2SessionState]
state.idleTimeout = null
if (client[kHTTP2Session] !== session || session[kOpenStreams] !== 0 || client[kSize] !== 0 || session.closed || session.destroyed) {
return
}
const err = new InformationalError('socket idle timeout')
socket[kError] = err
util.destroy(socket, err)
}

const err = new InformationalError('socket idle timeout')
socket[kError] = err
util.destroy(socket, err)

Node's HTTP/2 core passes that error on to every stream that the session still tracks (v26.5.0):

  1. socketOnError calls session.destroy(error) (core.js#L3261-L3271).
  2. closeSession calls state.streams.forEach((stream) => stream.destroy(error)) (core.js#L1241-L1246).

When one of those streams has no 'error' listener, the 'error' event throws. One third-party report shows Node's crash output, and it names the emitter: Emitted 'error' event on ClientHttp2Stream instance (kuju63/code-review-agents#296).

So at reap time, Node still tracks at least one stream, and undici's kOpenStreams is 0. The reaper's guard reads only undici's counter. undici also removes its own 'error' listener from a stream in several places: onError (L1310), releaseRequestStream (L1318), and after completion. Its replacement, stream.once('error', noop), is added only when the stream is not yet closed or destroyed, and it handles only one event.

I have not identified which stream disagrees with the counter, or why.

The code already describes this hazard for a different path. The openStream catch block avoids destroy(socket, err) for a GOAWAY'd session because it "can crash via an unhandled 'error'" (L998-L1001). The idle reaper uses that same pattern.

onHttp2SessionIdleTimeout is the same on main (8.11.2) as in 8.7.0, the version that Node 26.5.0 bundles.

Reproducible By

I do not have a deterministic reproduction. Local attempts on Node 26.5.0 (the node:26.5.0-slim image) and on 26.8.2 did not crash. Across 39 runs, these attempts reaped 6,223 idle sessions and left no error unhandled. The attempts covered:

  • bursts of up to 600 concurrent requests
  • TLS handshakes slower than a 3 s AbortSignal.timeout
  • a black-holed TCP proxy
  • server-side GOAWAY and idle closes
  • stream resets
  • event-loop stalls
  • dd-trace loaded

The production trigger that I can observe: a burst of fetch calls to one HTTP/2 origin, most of them aborted by AbortSignal.timeout(3000). The crash follows seconds later. One process had 192 aborted requests to the same origin in the 12 s before it crashed. Processes that make the same kinds of requests, with far fewer aborts, never crashed.

Other projects report the same stack on Node 26.7.0 and 26.8.1:

Expected Behavior

A planned close of an idle connection never reaches uncaughtException. The HTTP/1.1 client shows the intended behavior. Its keep-alive timer destroys the socket with the same message (onParserTimeout, TIMEOUT_KEEP_ALIVE). The socket keeps its 'error' listener until close, and onError ignores UND_ERR_INFO.

Possible fixes:

  1. Close the idle session without an error. For example, call session.close(), or destroy with no error argument. An idle close is not a failure. When an error is passed in, Node sends it to every stream that the session still tracks.
  2. Keep an 'error' listener on every stream until its 'close' event. Node can destroy a stream with a session-level error after undici is done with it. This change also protects the other teardown paths (GOAWAY, socket errors, ping failures).
  3. Optionally, check Node's state as well as kOpenStreams before reaping.

Fix 1 alone probably stops this crash. Fix 2 removes the class of crash.

Logs & Screenshots

Production, Node 26.5.0 (bundled undici 8.7.0):

InformationalError: socket idle timeout
    at Timeout.onHttp2SessionIdleTimeout [as _onTimeout] (node:internal/deps/undici/undici:8676:19)
    at listOnTimeout (node:internal/timers:...)
    at process.processTimers (node:internal/timers:...)
  code: 'UND_ERR_INFO'

From kuju63/code-review-agents#296, Node 26.7.0:

node:events:505
    throw er; // Unhandled 'error' event
    ^
InformationalError: socket idle timeout
    at Timeout.onHttp2SessionIdleTimeout [as _onTimeout] (node:internal/deps/undici/undici:8713:19)
...
Emitted 'error' event on ClientHttp2Stream instance at:
...
  code: 'UND_ERR_INFO'
}
Node.js v26.7.0

Environment

  • Node.js v26.5.0 (bundled undici 8.7.0), in the node:26.5.0-slim Docker image, Linux x64
  • Built-in fetch with Node's default global dispatcher; no allowH2 option set
  • The origin that negotiated HTTP/2 was a public HTTPS API behind a CDN

Additional context

Workaround: rebuild the dispatcher under Symbol.for('undici.globalDispatcher.2') with allowH2: false. With HTTP/1.1 only, the reaper never runs. zkproofport/proofport-ai#18 reports that this stops the crash, and that setting allowH2 back to true brings it back.

In the process that crashed, all 152 fatal exits over 16 days had this error.

Activity

  1. noy-solvin commented on Oct 8, 2026

    @noy-solvin

    Hi @mcollina, @KhafraDev
    I've submitted PR #5972 that fixes this issue.
    Would love a review whenever you get a chance. Happy to adjust anything!

  2. added a commit that references this issue on Oct 8, 2026
    159b0bf
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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions