Repository navigation
node:http: enforce server headersTimeout, requestTimeout, setTimeout() and keepAliveTimeout #32942
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,7 +27,6 @@ | |
| kRealListen, | ||
| tlsSymbol, | ||
| optionsSymbol, | ||
| kDeferredTimeouts, | ||
| kDeprecatedReplySymbol, | ||
| headerStateSymbol, | ||
| NodeHTTPHeaderState, | ||
|
|
@@ -51,7 +50,6 @@ | |
| eofInProgress, | ||
| runSymbol, | ||
| drainMicrotasks, | ||
| setServerIdleTimeout, | ||
| setServerCustomOptions, | ||
| getMaxHTTPHeaderSize, | ||
| fakeSocketSymbol, | ||
|
|
@@ -88,6 +86,7 @@ | |
| const ObjectKeys = Object.keys; | ||
| const MathMin = Math.min; | ||
| const MathFloor = Math.floor; | ||
| const DateNow = Date.now; | ||
|
|
||
| let cluster; | ||
|
|
||
|
|
@@ -241,6 +240,7 @@ | |
| this._unref = false; | ||
| this.maxRequestsPerSocket = 0; | ||
| this.maxHeadersCount = null; | ||
| this.timeout = 0; | ||
| this[kInternalSocketData] = undefined; | ||
| this[kTrackedConnections] = new Set(); | ||
| this[tlsSymbol] = null; | ||
|
|
@@ -324,13 +324,12 @@ | |
| // Like Node.js's setupConnectionsTracking: each 'listening' event replaces | ||
| // the connections-checking interval timer (used by the headers/request | ||
| // timeout machinery) and destroys the previous one. | ||
| function noopConnectionsCheck() {} | ||
| function setupConnectionsTracking(this: any) { | ||
| if (this[kConnectionsCheckingInterval]) { | ||
| clearInterval(this[kConnectionsCheckingInterval]); | ||
| } | ||
| const delay = this.connectionsCheckingInterval || 30_000; | ||
| this[kConnectionsCheckingInterval] = setInterval(noopConnectionsCheck, delay); | ||
| this[kConnectionsCheckingInterval] = setInterval(checkConnections.bind(this), delay); | ||
| this[kConnectionsCheckingInterval].unref(); | ||
| } | ||
|
|
||
|
|
@@ -593,6 +592,17 @@ | |
| if (!socket) { | ||
| socket = new NodeHTTPServerSocket(server, socketHandle, !!tls); | ||
| } | ||
| // A parsed request head is activity: headers just completed, the | ||
| // request-start anchor advances for a keep-alive reuse, and the | ||
| // inactivity timer switches back from keepAliveTimeout to | ||
| // server.timeout (like Node.js's parserOnIncoming). | ||
| socket[kHeadersCompleted] = true; | ||
| if (socket[kRequestStart] === 0) socket[kRequestStart] = DateNow(); | ||
| if (socket.timeout !== server.timeout) { | ||
|
Check failure on line 601 in src/js/node/_http_server.ts
|
||
| socket.setTimeout(server.timeout || 0); | ||
| } else { | ||
| socket._unrefTimer(); | ||
| } | ||
|
Comment on lines
+599
to
+605
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The per-socket inactivity timer is armed once at the request head (line 604) but never reset as request body chunks arrive, so Extended reasoning...What the bug is
In this PR the timer is armed exactly once per request, at the head: // _http_server.ts onNodeHTTPRequest, lines 599-605
socket[kHeadersCompleted] = true;
if (socket[kRequestStart] === 0) socket[kRequestStart] = DateNow();
if (socket.timeout !== server.timeout) {
socket.setTimeout(server.timeout || 0);
} else {
socket._unrefTimer();
}After that, request body chunks flow through Why existing code doesn't prevent it
if (response && response.writableLength > 0) {
this._unrefTimer();
return;
}It does not check whether incoming reads are in progress, so an upload with an empty response buffer falls straight through to Bun's own Step-by-step proof
ImpactAny FixCall function onDataIncomingMessage(chunk, isLast, aborted) {
if (aborted === NodeHTTPResponseAbortEvent.abort) { this.destroy(); return; }
this.socket?._unrefTimer();
if (chunk && !this._dumped) this.push(chunk);
if (isLast) emitEOFIncomingMessage(this);
}(or equivalently in the |
||
|
|
||
| const http_req = new RequestClass(kHandle, url, method, headersObject, headersArray, handle, hasBody, socket); | ||
| if (isAncientHTTP) { | ||
|
|
@@ -646,7 +656,7 @@ | |
| if (!requestShouldKeepAlive(http_req)) { | ||
| http_res[kMustCloseConnection] = true; | ||
| } | ||
| http_res.once("finish", endSocketOnFinishIfNeeded.bind(undefined, socket, http_res)); | ||
| http_res.once("finish", resOnFinish.bind(undefined, server, socket, http_res)); | ||
|
|
||
| if (hasObserver("http")) { | ||
| startPerf(http_res, kServerResponseStatistics, { | ||
|
|
@@ -665,6 +675,14 @@ | |
|
|
||
| setIsNextIncomingMessageHTTPS(prevIsNextIncomingMessageHTTPS); | ||
| handle.onabort = onServerRequestEvent.bind(socket); | ||
| // Like Node.js, the requestTimeout clock runs until the incoming | ||
| // request (not the response) is complete: a body-less request is | ||
| // complete at the head, and a body stops the clock at its 'end'. | ||
| if (!hasBody) { | ||
| socket[kRequestStart] = 0; | ||
| } else { | ||
| http_req.once("end", clearIncomingRequestTimer.bind(undefined, socket)); | ||
| } | ||
| // start buffering data if any, the user will need to resume() or .on("data") to read it | ||
| if (hasBody) { | ||
| handle.pause(); | ||
|
|
@@ -692,7 +710,8 @@ | |
| } | ||
| } | ||
|
|
||
| if (isSocketNew && !reachedRequestsLimit) { | ||
| if (!socket[kConnectionEmitted] && !reachedRequestsLimit) { | ||
| socket[kConnectionEmitted] = true; | ||
| server.emit("connection", socket); | ||
| } | ||
|
|
||
|
|
@@ -892,6 +911,7 @@ | |
| true, | ||
| typeof this.maxHeaderSize !== "undefined" ? this.maxHeaderSize : getMaxHTTPHeaderSize(), | ||
| onServerClientError.bind(this), | ||
| onServerSocketOpen.bind(this), | ||
| ); | ||
|
|
||
| if (this?._unref) { | ||
|
|
@@ -902,25 +922,13 @@ | |
| this.once("listening", onListen); | ||
| } | ||
|
|
||
| if (this[kDeferredTimeouts]) { | ||
| for (const { msecs, callback } of this[kDeferredTimeouts]) { | ||
| this.setTimeout(msecs, callback); | ||
| } | ||
| delete this[kDeferredTimeouts]; | ||
| } | ||
|
|
||
| setTimeout(emitListeningNextTick, 1, this, this[serverSymbol]?.hostname, this[serverSymbol]?.port); | ||
| } | ||
| }; | ||
|
|
||
| Server.prototype.setTimeout = function (msecs, callback) { | ||
| const server = this[serverSymbol]; | ||
| if (server) { | ||
| setServerIdleTimeout(server, Math.ceil(msecs / 1000)); | ||
| if (typeof callback === "function") this.once("timeout", callback); | ||
| } else { | ||
| (this[kDeferredTimeouts] ??= []).push({ msecs, callback }); | ||
| } | ||
| this.timeout = msecs; | ||
| if ($isCallable(callback)) this.on("timeout", callback); | ||
| return this; | ||
| }; | ||
|
|
||
|
|
@@ -953,6 +961,67 @@ | |
| HTTP_PARSER_ERROR_INVALID_METHOD = 9, | ||
| HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN = 10, | ||
| } | ||
| // Fired by the native side for every accepted connection (after the TLS | ||
| // handshake for https), before any bytes are parsed. This is what lets the | ||
| // headers/request/inactivity timeouts cover sockets that never send a full | ||
| // request, and it makes 'connection' fire on accept like Node.js. | ||
| function onServerSocketOpen(this: Server, ssl: boolean, handle: unknown) { | ||
| const self = this; | ||
| // The native side returns the existing wrapper for a handle it has seen | ||
| // before; a second Duplex would strand the first in kTrackedConnections. | ||
| const existingDuplex = (handle as any).duplex; | ||
| const socket = existingDuplex ?? new NodeHTTPServerSocket(self, handle, ssl); | ||
|
Comment on lines
+968
to
+973
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The comment on Extended reasoning...What the issue is
// Connections are tracked from the first parsed request on each socket
// (the native server does not surface raw accepts to JS yet).This PR directly invalidates both factual claims in that sentence, but leaves the comment untouched. The code path that invalidates it
So after this PR: connections are tracked from accept (not from the first parsed request), and the native server does surface raw accepts to JS. Both halves of the comment are now factually wrong. Step-by-step proof
Why nothing prevents itThe PR did not touch ImpactDocumentation accuracy only — no runtime effect. But it is a specific factual claim (not a vague description) that this PR's central change directly contradicts, and per repo convention comments invalidated by a PR should be updated in the same PR. A future reader debugging How to fixUpdate the comment to reflect the new behavior, e.g.: // Connections are tracked from accept (onServerSocketOpen adds the
// NodeHTTPServerSocket wrapper to kTrackedConnections before any bytes
// are parsed).Or simply drop the comment if it no longer adds value. |
||
| // Anchor the headers/request timeout clock at accept. Node.js anchors at | ||
| // the first parsed byte, which we cannot observe; accept is never later. | ||
| socket[kRequestStart] = DateNow(); | ||
| socket[kHeadersCompleted] = false; | ||
| const serverTimeout = self.timeout; | ||
| if (serverTimeout) socket.setTimeout(serverTimeout); | ||
| if (!socket[kConnectionEmitted]) { | ||
| socket[kConnectionEmitted] = true; | ||
| self.emit("connection", socket); | ||
| } | ||
| } | ||
|
|
||
| function clearIncomingRequestTimer(socket) { | ||
| socket[kRequestStart] = 0; | ||
| } | ||
|
|
||
| const requestTimeoutResponse = Buffer.from("HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n", "ascii"); | ||
| // Mirrors Node.js's default 'clientError' handling of ERR_HTTP_REQUEST_TIMEOUT: | ||
| // user listeners take over; otherwise answer 408 (only if nothing was | ||
| // written yet) and destroy the socket. | ||
| function onRequestTimeout(server: Server, socket) { | ||
| socket[kRequestStart] = 0; | ||
| const err = $ERR_HTTP_REQUEST_TIMEOUT("Request timeout"); | ||
| if (!server.emit("clientError", err, socket)) { | ||
| if (socket.writable && (socket.bytesWritten | 0) === 0) { | ||
| socket.write(requestTimeoutResponse); | ||
| } | ||
| socket.destroy(err); | ||
| } | ||
| } | ||
|
|
||
| // Node.js's checkConnections sweep: every connectionsCheckingInterval ms, | ||
| // close connections whose request head (headersTimeout) or entire request | ||
| // (requestTimeout) has not arrived in time. | ||
| function checkConnections(this: Server) { | ||
| const headersTimeout = this.headersTimeout; | ||
| const requestTimeout = this.requestTimeout; | ||
| if (headersTimeout === 0 && requestTimeout === 0) return; | ||
| const now = DateNow(); | ||
| for (const socket of this[kTrackedConnections]) { | ||
| const start = socket[kRequestStart]; | ||
| if (!start) continue; | ||
| const elapsed = now - start; | ||
| if (headersTimeout > 0 && !socket[kHeadersCompleted] && elapsed > headersTimeout) { | ||
| onRequestTimeout(this, socket); | ||
| } else if (requestTimeout > 0 && elapsed > requestTimeout) { | ||
| onRequestTimeout(this, socket); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, rawPacket: ArrayBuffer) { | ||
| const self = this as Server; | ||
| let err; | ||
|
|
@@ -989,7 +1058,8 @@ | |
| // connections - the existing duplex already had its 'connection' event. | ||
| const existingDuplex = (socket as any).duplex; | ||
| const nodeSocket = existingDuplex ?? new NodeHTTPServerSocket(self, socket, ssl); | ||
| if (!existingDuplex) { | ||
| if (!nodeSocket[kConnectionEmitted]) { | ||
| nodeSocket[kConnectionEmitted] = true; | ||
| self.emit("connection", nodeSocket); | ||
| } | ||
| self.emit("clientError", err, nodeSocket); | ||
|
|
@@ -1000,6 +1070,15 @@ | |
|
|
||
| const kBytesWritten = Symbol("kBytesWritten"); | ||
| const kEnableStreaming = Symbol("kEnableStreaming"); | ||
| const kTimeoutHandle = Symbol("kTimeoutHandle"); | ||
| const kRequestStart = Symbol("kRequestStart"); | ||
| const kHeadersCompleted = Symbol("kHeadersCompleted"); | ||
| const kConnectionEmitted = Symbol("kConnectionEmitted"); | ||
|
|
||
| function socketOnTimeoutFire(socket) { | ||
| socket[kTimeoutHandle] = null; | ||
| socket._onTimeout(); | ||
| } | ||
| function onServerSocketError(this: any, _err) { | ||
| // Default 'error' listener so socket-level errors (e.g. res.destroy(err) | ||
| // forwarding the error to the socket) do not crash the process as | ||
|
|
@@ -1021,6 +1100,10 @@ | |
| server: Server; | ||
| _httpMessage; | ||
| _secureEstablished = false; | ||
| [kTimeoutHandle] = null; | ||
| [kRequestStart] = 0; | ||
| [kHeadersCompleted] = false; | ||
| [kConnectionEmitted] = false; | ||
| #pendingCallback = null; | ||
| constructor(server: Server, handle, encrypted) { | ||
| super(); | ||
|
|
@@ -1098,6 +1181,11 @@ | |
| } | ||
| #onClose() { | ||
| this[kHandle] = null; | ||
| const timeoutHandle = this[kTimeoutHandle]; | ||
| if (timeoutHandle !== null) { | ||
| clearTimeout(timeoutHandle); | ||
| this[kTimeoutHandle] = null; | ||
| } | ||
| this.server?.[kTrackedConnections]?.delete(this); | ||
|
|
||
| // Node.js's `socketOnClose` → `abortIncoming()` only destroys requests | ||
|
|
@@ -1152,12 +1240,20 @@ | |
| // If there is a response, and it has pending data, | ||
| // we suppress the timeout because a write is in progress. | ||
| if (response && response.writableLength > 0) { | ||
| this._unrefTimer(); | ||
| return; | ||
| } | ||
| this.emit("timeout"); | ||
| } | ||
| _unrefTimer() { | ||
| // for compatibility | ||
| const msecs = this.timeout; | ||
| const handle = this[kTimeoutHandle]; | ||
| if (handle !== null) clearTimeout(handle); | ||
| if (msecs > 0) { | ||
| this[kTimeoutHandle] = setTimeout(socketOnTimeoutFire, msecs, this).unref(); | ||
| } else { | ||
| this[kTimeoutHandle] = null; | ||
| } | ||
| } | ||
|
|
||
| address() { | ||
|
|
@@ -1293,7 +1389,10 @@ | |
| return this; | ||
| } | ||
|
|
||
| setTimeout(_timeout, _callback) { | ||
| setTimeout(msecs, callback) { | ||
| this.timeout = msecs | 0; | ||
| if ($isCallable(callback)) this.once("timeout", callback); | ||
| this._unrefTimer(); | ||
|
Comment on lines
+1392
to
+1395
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit: Extended reasoning...What the bug is. The specific code path. There are three entry points that reach this coercion: (1) a user calling Why nothing prevents it. Node.js's Step-by-step proof.
Impact. The behavioral divergence is real but the practical impact is minimal. 2^31 ms ≈ 24.8 days; no realistic socket inactivity, keep-alive, or server timeout approaches that (defaults are 0 / 5 s; generous deployments use minutes). The functional difference between "no timer" and "timer fires after 24.8 days" is essentially nil — the peer, OS keepalive, or process restart will close the connection long before either fires. Node itself treats this range as out-of-bounds and only warns. This is why the refuting verifier deems it non-actionable; we agree it does not block the PR, but it is still a correctness/parity gap worth noting. Fix. Match |
||
| return this; | ||
| } | ||
|
|
||
|
|
@@ -1653,9 +1752,22 @@ | |
| } | ||
| } | ||
|
|
||
| function endSocketOnFinishIfNeeded(socket, res) { | ||
| function resOnFinish(server, socket, res) { | ||
| if (res[kMustCloseConnection]) { | ||
| socket?.end(); | ||
| return; | ||
| } | ||
| if (!socket || socket.destroyed) return; | ||
| // Keep-alive idle between requests: the headers/request timeouts only | ||
| // apply while a request is in flight; the keep-alive inactivity timer | ||
| // takes over until the next request head arrives (Node.js's resOnFinish). | ||
| socket[kRequestStart] = 0; | ||
| socket[kHeadersCompleted] = false; | ||
| const keepAliveTimeout = server.keepAliveTimeout; | ||
| if (keepAliveTimeout) { | ||
| socket.setTimeout(keepAliveTimeout); | ||
| } else { | ||
| socket._unrefTimer(); | ||
| } | ||
|
Comment on lines
+1760
to
1771
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 When two HTTP/1.1 requests are pipelined in one packet and request #1's handler calls Extended reasoning...What the bug is
socket[kRequestStart] = 0;
socket[kHeadersCompleted] = false;
const keepAliveTimeout = server.keepAliveTimeout;
if (keepAliveTimeout) socket.setTimeout(keepAliveTimeout);
else socket._unrefTimer();But Code pathuWS explicitly supports sync pipelining (HttpContext.h
Why nothing prevents itNode.js's ImpactFor request #2 on the pipelined socket:
(Note: The trigger — HTTP/1.1 pipelining with a synchronously-handled first request — is uncommon in browsers but reachable from raw TCP clients, proxies, and load-test tools, and the Step-by-step proof
FixSkip the keep-alive idle bookkeeping when another response is already attached to the socket: function resOnFinish(server, socket, res) {
if (res[kMustCloseConnection]) { socket?.end(); return; }
if (!socket || socket.destroyed) return;
// The next pipelined request may already be attached; do not clobber its state.
if (socket._httpMessage) return;
socket[kRequestStart] = 0;
socket[kHeadersCompleted] = false;
...
}
|
||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 This PR removes the only consumers of
kDeferredTimeouts(the import here and the deferred-replay loop inkRealListen/setTimeout), but the symbol is still declared atsrc/js/internal/http.ts:92and exported at:547with no remaining importers. ThesetServerIdleTimeoutre-export ininternal/http.ts(:14/:33/:590) is similarly orphaned now that_http_server.tsno longer imports it. Per the rootCLAUDE.md("Delete dead code in the same PR that makes it dead"), both should be removed in this PR.Extended reasoning...
What
Before this PR,
src/js/node/_http_server.tsimportedkDeferredTimeoutsfrominternal/httpand used it in two places:Server.prototype[kRealListen]replayed any{msecs, callback}entries queued inthis[kDeferredTimeouts]once the native server existed, andServer.prototype.setTimeoutpushed onto that array when called beforelisten(). This PR rewritessetTimeoutto simply storethis.timeout = msecs(which is read per-socket at accept time), so the deferred queue is no longer needed and both the import and the replay loop are deleted.That deletion was the only consumer of
kDeferredTimeouts. After this PR, a repo-wide grep shows exactly two remaining hits, both insrc/js/internal/http.ts:No file imports it. The symbol is now an unused module-level
constplus an unused named export.The same applies to
setServerIdleTimeout: this PR removes its only JS-side import (_http_server.tspreviously called it fromServer.prototype.setTimeout). After the PR, the only remaining JS references are the destructure from the native binding atinternal/http.ts:14, the type annotation at:33, and the re-export at:590— none of which has a downstream importer. (The native binding entry inNodeHTTP.cppand the underlyingServer__setIdleTimeoutmay have reasons to stay forBun.serve, but theinternal/http.tsre-export is dead.)Step-by-step proof
rg kDeferredTimeouts→ 4 hits: declaration + export ininternal/http.ts, import + two uses in_http_server.ts._http_server.tsimport (line 30 of the old file) and both use sites (theif (this[kDeferredTimeouts])replay block inkRealListenand the(this[kDeferredTimeouts] ??= []).push(...)branch insetTimeout).rg kDeferredTimeouts→ onlyinternal/http.ts:92and:547. Nothing reads either.rg setServerIdleTimeoutpost-PR →internal/http.ts:14/33/590and the nativeNodeHTTP.cppbinding registration; no JS importer of theinternal/httpexport remains.Why existing code doesn't prevent it
internal/http.tsis bundled into the binary as an internal module, so an unused export is not flagged by any tooling — it just ships dead. Public/exported items escape dead-code lints, which is exactly why the project guideline atCLAUDE.md:237calls this out:Impact
None at runtime — an unused
Symbol()const and an unused export entry have negligible cost. This is purely a code-cleanliness / project-convention issue, hence nit severity. The cost of not cleaning it up is that the next reader ofinternal/http.tshas to grep to discover the symbol is dead, and a future PR may accidentally resurrect the old (broken) deferral pattern by finding the symbol still exported.Fix
In
src/js/internal/http.ts:const kDeferredTimeouts = Symbol("deferredTimeouts");).kDeferredTimeouts,entry from the export list at line 547.setServerIdleTimeoutfrom the destructure at line 14, the type at line 33, and the export list at line 590.No test changes needed.