fix(ext/node): wire ConnectionsList hooks so headersTimeout doesn't spuriously fire#34356
Conversation
…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
left a comment
There was a problem hiding this comment.
connections.popActive(socket)inresOnFinishcan 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, sorequestTimeout/headers timeout checks stop covering request 2. Please keep the active entry whilestate.incomingor 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
left a comment
There was a problem hiding this comment.
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,parserOnIncomingmarks request 2headersCompleted=true; thenresOnFinishfor request 1 deletes that same socket entry, sorequestTimeoutno longer covers request 2. Please track the active entry by request identity/generation, or add a pipelined regression test that keeps request 2 open pastrequestTimeoutwhile 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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
The JS-based
ConnectionsListintroduced in #33208 (thenode:httprewrite) defines
markHeadersCompleted()andpopActive()methods tomirror Node's native C++ binding, but only the initial
pushActive()was ever called from
connectionListenerInternal— the other two weredefined 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
_activewithheadersCompleted: falseand a fixedstartTime, so the 30scheckConnectionswatchdog fired a spuriousERR_HTTP_REQUEST_TIMEOUTabout 60 seconds into any long-running request. Fastify's default
clientErrorhandler converts that error into HTTP 400, which is theuser-visible regression. The fix mirrors Node's behavior in JS:
parser[kOnMessageBegin]does popActive + pushActive (also covers thekeepalive case so a second request on the same socket gets a fresh
window),
parserOnIncomingcallsmarkHeadersCompletedonce headersare parsed, and
resOnFinishcallspopActivewhen the response isdone.
Fixes #34297