Repository navigation
HTTP/2 idle reaper crashes the process: InformationalError: socket idle timeout is emitted on a ClientHttp2Stream with no 'error' listener #5936
Description
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:
undici/lib/dispatcher/client-h2.js
Lines 524 to 538 in 7789ae1
| 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):
socketOnErrorcallssession.destroy(error)(core.js#L3261-L3271).closeSessioncallsstate.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-traceloaded
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:
- [Bug]: a2a-server が未処理errorイベントでプロセス全体がクラッシュする kuju63/code-review-agents#296 (includes the
ClientHttp2Streamline) - E2E exits 1 on an unhandled "socket idle timeout" while all 74 tests pass zkproofport/proofport-ai#18 (one connection idles while a long test runs)
- wizard exits mid-run with "InformationalError: socket idle timeout" PostHog/wizard#1198
- Daemon becomes unresponsive in native HTTP/2 outgoing-buffer code on Node 26.7.0 OriginTrail/dkg#2828
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:
- 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. - 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). - Optionally, check Node's state as well as
kOpenStreamsbefore 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-slimDocker image, Linux x64 - Built-in
fetchwith Node's default global dispatcher; noallowH2option 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
fix(http2): prevent uncaught InformationalError crash during idle sessi…
fix(http2): prevent uncaught InformationalError crash during idle sessi…
noy-solvin commentedon Oct 8, 2026
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!
style(test): fix neostandard lint failure in test/issue-5936.js