Skip to content

fix(ext/node): wire ConnectionsList hooks so headersTimeout doesn't spuriously fire#34356

Merged
bartlomieju merged 4 commits into
mainfrom
fix/http-connections-list-timeout
Jun 6, 2026
Merged

fix(ext/node): wire ConnectionsList hooks so headersTimeout doesn't spuriously fire#34356
bartlomieju merged 4 commits into
mainfrom
fix/http-connections-list-timeout

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

The JS-based ConnectionsList introduced in #33208 (the node:http
rewrite) defines markHeadersCompleted() and popActive() methods to
mirror Node's native C++ binding, but only the initial pushActive()
was ever called from connectionListenerInternal — the other two were
defined and never invoked. Node's C++ wires these into the HTTP parser
callbacks (on_message_begin / on_headers_complete /
on_message_complete); the JS port forgot to do the same.

As a result every server connection sat in _active with
headersCompleted: false and a fixed startTime, so the 30s
checkConnections watchdog fired a spurious ERR_HTTP_REQUEST_TIMEOUT
about 60 seconds into any long-running request. Fastify's default
clientError handler converts that error into HTTP 400, which is the
user-visible regression. The fix mirrors Node's behavior in JS:
parser[kOnMessageBegin] does popActive + pushActive (also covers the
keepalive case so a second request on the same socket gets a fresh
window), parserOnIncoming calls markHeadersCompleted once headers
are parsed, and resOnFinish calls popActive when the response is
done.

Fixes #34297

…puriously fire

The JS-based `ConnectionsList` introduced by #33208 defines
`markHeadersCompleted()` and `popActive()` but they were never called,
so every server connection sat in `_active` with `headersCompleted: false`
and a fixed `startTime`. The 30s `checkConnections` watchdog then fired
a spurious `ERR_HTTP_REQUEST_TIMEOUT` ~60s into any long-lived request,
which Fastify (and similar frameworks) convert into HTTP 400 via the
`clientError` event handler.

Mirror Node's native `on_message_begin` / `on_headers_complete` /
`on_message_complete` behavior in JS:

- `parser[kOnMessageBegin]` → popActive + pushActive (resets the
  per-request timer; also covers keepalive: the 2nd request on a
  socket gets a fresh window).
- `parserOnIncoming` → markHeadersCompleted (silences `headersTimeout`
  once headers are parsed; `requestTimeout` continues to count).
- `resOnFinish` → popActive (stop tracking once the request is done).

Fixes #34297

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. connections.popActive(socket) in resOnFinish can drop timeout tracking for a later pipelined request. parser[kOnMessageBegin] resets the single per-socket entry when request 2 is parsed, but finishing response 1 then removes that same entry, so requestTimeout/headers timeout checks stop covering request 2. Please keep the active entry while state.incoming or queued responses still contain a parsed request, or track timeout state per request instead of only per socket.

CI is still running/red, so this is a request-changes review for the pipelining timeout hole.

The initial fix called `popActive(socket)` from `resOnFinish`, but for
pipelined requests the parser has already fired `kOnMessageBegin` for
the next request before the previous response finishes — so popping the
entry wrongly removed the tracking state of the in-flight next request,
and `headersTimeout`/`requestTimeout` would never fire for it.

Replace `popActive` with `popActiveIfCompleted`, which only clears the
entry when its `headersCompleted` flag is still true (the just-completed
request); the parser's `kOnMessageBegin` callback resets the flag back
to false when a new message starts, so a pipelined entry survives the
prior request's resOnFinish.

Also stop refreshing the socket timeout from `onParserExecute` while
the connection is in keep-alive idle mode (`state.keepAliveTimeoutSet`
is true). A stray `\r\n` from a client between requests must not extend
keepAliveTimeout — the timer is reset explicitly by `resetSocketTimeout`
when a new request actually begins.

Adds a Deno unit test reproducing the original bug (long-running
handler with a small `headersTimeout` must not get a spurious
`ERR_HTTP_REQUEST_TIMEOUT`), and ignores
`test-http-keep-alive-empty-line` — that test was previously passing
only because the buggy ConnectionsList was closing sockets early; making
it pass for the right reason requires a separate fix to keepAliveTimeout
firing on consumed sockets.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. popActiveIfCompleted(socket) still clears the timeout entry for a later pipelined request once that later request has completed headers. In a pipeline where request 2 is parsed before response 1 finishes, parserOnIncoming marks request 2 headersCompleted=true; then resOnFinish for request 1 deletes that same socket entry, so requestTimeout no longer covers request 2. Please track the active entry by request identity/generation, or add a pipelined regression test that keeps request 2 open past requestTimeout while response 1 finishes.

CI is still running, but this timeout-tracking hole is independent of CI.

popActiveIfCompleted only inspected headersCompleted, so finishing the
first response in a pipeline deleted the active entry that already
belonged to the next request (parser fires kOnMessageBegin while res1
is still in flight, replacing the entry). After that, headersTimeout /
requestTimeout silently stopped covering the pipelined successor.

markHeadersCompleted now stores the req on the entry, and resOnFinish
calls popActiveIfReq(socket, req) which only deletes when the entry
still tracks the request whose response just finished.

Adds a pipelined regression test that sends two HTTP/1.1 requests on
one socket and verifies both responses succeed without a spurious
clientError.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current head fixes my prior blocker: popActiveIfReq(socket, req) only clears the active entry when it still belongs to the response that just finished, while parser[kOnMessageBegin] has already installed a fresh entry for a pipelined successor. The new pipelined test exercises that exact ordering with request 2 staying open after response 1 completes. Holding approval until CI is green.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is red on node_compat::parallel::test-dns-resolver-max-timeout.js (timeout1: 503, timeout2: 503), which is unrelated to the node:http ConnectionsList timeout tracking changed here. CI red; flake-watcher will triage.

…list-timeout

# Conflicts:
#	ext/node/polyfills/_http_server.js
@bartlomieju
bartlomieju merged commit ea45508 into main Jun 6, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the fix/http-connections-list-timeout branch June 6, 2026 16:22
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.

Spurious clientError in http.Server on Deno >= 2.7.13

2 participants