Skip to content

fix(h1): do not reuse idle sockets whose keep-alive timeout expired - #6171

Open
mcollina wants to merge 1 commit into
mainfrom
fix/h1-expired-idle-socket-reuse
Open

mcollina wants to merge 1 commit into
mainfrom
fix/h1-expired-idle-socket-reuse

Conversation

@mcollina

@mcollina mcollina commented Nov 6, 2026

Copy link
Copy Markdown
Member

This is the undici counterpart of nodejs/node#67292.

Problem

The keep-alive timer of an idle HTTP/1.1 socket cannot fire while the event loop is blocked. Before reusing an idle socket, undici yields one setImmediate (scheduleIdleSocketValidation, added for GHSA-35p6-xmwp-9g52), so that a FIN or RST that has already been received is processed first. That only works if a poll phase runs between the dispatch and the check phase.

With a server in a separate process (keepAliveTimeout = 5000), one request, then a 6s synchronous block, then a second request:

Second request dispatched from Processed before the validation completes Result on main
poll phase (I/O callback, server request handler, await after I/O) nothing: the check phase comes next ECONNRESET: the dead socket is reused
timers phase the poll phase reads the FIN reconnects
check phase (setImmediate) the keep-alive timer fires reconnects

The poll-phase case is the common one, for example a proxy that does synchronous work in its request handler before calling upstream. A shorter version of the same problem: a block longer than the client's keep-alive timeout but shorter than the server's uses up the keepAliveTimeoutThreshold margin, and the request is sent just before the server closes the connection.

Fix

  • When resumeH1 arms the keep-alive timer, record performance.now() on the socket.
  • Before reusing an idle socket, destroy it with InformationalError('socket idle timeout') if its keep-alive timeout has passed. Nothing is running on the socket, so the pending requests are sent on a new connection, the same as when the timer fires.

The time is recorded when the timer is armed, so the check never expires a socket before the timer would have. It uses the global performance.now(), so it follows @sinonjs/fake-timers together with setTimeout. The cost is one performance.now() call when a socket becomes idle and one when it is reused.

The check does not depend on kSocketUsed, so it also covers the experimental Milo parser. parser-h1.js never sets that symbol, which is private to client-h1.js. As a result, the existing idle socket validation does not run with Milo. That is a separate issue and is left for a follow-up.

Not covered

As in the Node.js change:

  • a server close that is still in flight when the request is sent (the general keep-alive race; the retry interceptor handles this for idempotent requests)
  • time spent blocked before the socket becomes idle

HTTP/2 is not changed.

Testing

  • test/client-keep-alive-blocked-loop.js blocks inside an fs callback (poll phase) and checks that an expired socket is replaced and that a socket that has not expired is reused. The expiry case fails on main and passes with this change, with both llhttp and UNDICI_USE_MILO=1.
  • The out-of-process reproduction above passes for all three phases, and a 1s block still reuses the socket.
  • test/*.js: 1664 passed, 0 failed, 3 skipped. test/node-test/**: 245 passed, 0 failed, 5 skipped.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sfj4nEWQ4ovycNDjbvXcRs

The keep-alive timer of an idle socket cannot fire while the event loop
is blocked. When the next request is dispatched from the poll phase (an
I/O callback, a server request handler, or a continuation after awaiting
I/O), the idle socket validation added for GHSA-35p6-xmwp-9g52 completes
in the check phase of the same loop iteration, before that timer fires
and before a FIN received during the block is read. The request is then
written to a socket that the server may already have closed, and fails
with ECONNRESET.

Record when the keep-alive timer is armed, and destroy the socket with
an InformationalError instead of reusing it once its keep-alive timeout
has passed. Nothing is running on the socket, so the pending requests
are sent on a new connection.

The check does not depend on kSocketUsed, so it also covers the
experimental Milo parser, which never sets that symbol.

Refs: nodejs/node#67292

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sfj4nEWQ4ovycNDjbvXcRs
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@codecov-commenter

codecov-commenter commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.68%. Comparing base (76f0b3e) to head (8f1768a).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #6171   +/-   ##
=======================================
  Coverage   91.67%   91.68%           
=======================================
  Files         113      113           
  Lines       44419    44467   +48     
=======================================
+ Hits        40721    40768   +47     
- Misses       3698     3699    +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mcollina
mcollina requested review from Uzlopak and ronag and removed request for ronag November 6, 2026 10:18

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants