diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index cccd7263ccd9..0e640acdd0a9 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -778,6 +778,19 @@ struct TemplatedApp { return std::move(*this); } + /* node:http only: enable per-socket headersTimeout/requestTimeout receive + * deadlines (seconds; 0 disables that phase's deadline). */ + TemplatedApp &&setNodeReceiveTimeouts(unsigned int headersTimeoutSeconds, unsigned int requestTimeoutSeconds) { + /* us_socket_timeout's slot ring wraps past 240 4-second ticks; larger + * values would alias to a much earlier expiry. */ + constexpr unsigned int maxTimeoutSeconds = 940; + HttpContextData *data = httpContext->getSocketContextData(); + data->nodeHeadersTimeoutSeconds = headersTimeoutSeconds < maxTimeoutSeconds ? headersTimeoutSeconds : maxTimeoutSeconds; + data->nodeRequestTimeoutSeconds = requestTimeoutSeconds < maxTimeoutSeconds ? requestTimeoutSeconds : maxTimeoutSeconds; + data->flags.hasNodeReceiveTimeouts = true; + return std::move(*this); + } + }; typedef TemplatedApp App; diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 75248bed3f5f..c618007c0760 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -36,6 +36,7 @@ #include #include #include +#include namespace uWS { @@ -124,6 +125,87 @@ struct HttpContext { /* Minimum allowed receive throughput per second (clients uploading less than 16kB/sec get dropped) */ static constexpr int HTTP_RECEIVE_THROUGHPUT_BYTES = 16 * 1024; +public: + /* The configured node:http deadline (whole seconds) governing a receive + * phase, 0 = disabled. requestTimeout spans the whole message including + * its header section, so the headers phase uses the tighter of the two. */ + static unsigned int nodeConfiguredReceiveSeconds(us_socket_t *s, NodeReceivePhase phase) { + HttpContextData *httpContextData = getSocketContextDataS(s); + unsigned int headersSeconds = httpContextData->nodeHeadersTimeoutSeconds; + unsigned int requestSeconds = httpContextData->nodeRequestTimeoutSeconds; + if (phase == NodeReceivePhase::Headers && headersSeconds && (!requestSeconds || headersSeconds < requestSeconds)) { + return headersSeconds; + } + return requestSeconds; + } + + /* See NodeReceivePhase in HttpParser.h. Returns None — meaning the legacy + * idle timeout owns the per-socket timer — both outside the receive phases + * and when the current phase's configured deadline is 0 (disabled). */ + static NodeReceivePhase nodeReceivePhase(us_socket_t *s, HttpResponseData *httpResponseData) { + NodeReceivePhase phase; + /* isConnectRequest is set as soon as the CONNECT request line parses; + * until its header section is complete (partial bytes buffered) it is + * still subject to headersTimeout. Only an established tunnel has no phase. */ + if (httpResponseData->isConnectRequest && !httpResponseData->hasPartialRequest()) { + return NodeReceivePhase::None; + } + /* requestTimeout applies until the message is fully received, even if + * the handler already responded (Node keeps enforcing it on the + * outstanding body, then dumps it). */ + if (httpResponseData->isReceivingHttpBody()) { + phase = NodeReceivePhase::Body; + } else if (httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) { + return NodeReceivePhase::None; + } else if (httpResponseData->hasPartialRequest() || !httpResponseData->hasCompletedResponse) { + phase = NodeReceivePhase::Headers; + } else { + return NodeReceivePhase::None; + } + /* A phase whose deadline is disabled never claims the timer: a user + * req.setTimeout()/server idle timeout must keep working through it. */ + return nodeConfiguredReceiveSeconds(s, phase) ? phase : NodeReceivePhase::None; + } + + static uint64_t nodeNowMs() { + return (uint64_t) std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + } + + /* Seconds left until the current message's deadline, measured from the + * message start like Node: the headers deadline is additionally capped by + * requestTimeout (which spans the whole message including its header + * section), and the body deadline is whatever remains of requestTimeout. + * Only called for the phase nodeReceivePhase() returned, whose configured + * deadline is therefore non-zero. */ + static unsigned int nodeRemainingReceiveSeconds(us_socket_t *s, HttpResponseData *httpResponseData, NodeReceivePhase phase) { + unsigned int total = nodeConfiguredReceiveSeconds(s, phase); + uint64_t elapsedSeconds = (nodeNowMs() - httpResponseData->nodeMessageStartMs) / 1000; + /* Already past the deadline: fire at the next timer sweep */ + return elapsedSeconds >= total ? 1 : (unsigned int) (total - elapsedSeconds); + } + + /* While a receive phase is active its deadline owns the per-socket timer; + * every legacy resetTimeout()/pause() routes back through here so it + * cannot be disarmed early. Returns false outside the receive phases, in + * which case the caller falls back to the legacy idle timeout. */ + static bool tryArmNodeReceiveTimeout(us_socket_t *s, HttpResponseData *httpResponseData) { + NodeReceivePhase phase = nodeReceivePhase(s, httpResponseData); + if (phase == NodeReceivePhase::None) { + return false; + } + /* Like Node (last_message_start_), the clock is the message's first + * received byte (or connection open before any byte arrives). onData + * and the parser own nodeMessageStartMs; this only reads it, so nothing + * a handler reaches synchronously can re-base a message's deadlines. + * Both deadlines are absolute from that start: received bytes never + * extend them, so a client trickling data slowly cannot hold the + * socket past them. */ + us_socket_timeout(s, nodeRemainingReceiveSeconds(s, httpResponseData, phase)); + return true; + } + +private: + /* Not constexpr — the ordinals are linked from `src/uws_sys/SocketKind.rs` * so a reorder there can't silently mis-route us. Only ever read * at runtime (listen/adopt). */ @@ -172,6 +254,13 @@ struct HttpContext { * surfaced separately (rejectUnauthorized above / tls.authorized). */ httpResponseData->isAuthorized = success; + if (httpResponseData->hasNodeReceiveTimeouts) { + /* Like Node ('secureConnection' → parser.initialize → + * last_message_start_), the first message's receive deadlines + * start after the TLS handshake, not at TCP accept. */ + httpResponseData->nodeMessageStartMs = nodeNowMs(); + } + /* Any connected socket should timeout until it has a request */ ((HttpResponse *) s)->resetTimeout(); @@ -184,13 +273,22 @@ struct HttpContext { static us_socket_t *onOpen(us_socket_t *s, int /*is_client*/, char * /*ip*/, int /*ip_length*/) { /* Init socket ext */ - new (us_socket_ext(s)) HttpResponseData; - /* Any connected socket should timeout until it has a request */ + auto *httpResponseData = new (us_socket_ext(s)) HttpResponseData; + HttpContextData *httpContextData = getSocketContextDataS(s); + /* Any connected socket should timeout until it has a request */ + if (httpContextData->flags.hasNodeReceiveTimeouts) { + /* node:http: no implicit idle timeout; the headersTimeout receive + * deadline owns the socket timer until a complete request arrives. */ + httpResponseData->hasNodeReceiveTimeouts = true; + httpResponseData->idleTimeout = 0; + /* The first message's receive deadlines are measured from + * connection open until its first byte arrives and re-bases them. */ + httpResponseData->nodeMessageStartMs = nodeNowMs(); + } ((HttpResponse *) s)->resetTimeout(); if(!SSL) { /* Call filter */ - HttpContextData *httpContextData = getSocketContextDataS(s); for (auto &f : httpContextData->filterHandlers) { f((HttpResponse *) s, 1); } @@ -268,6 +366,13 @@ struct HttpContext { httpContextData->flags.isParsingHttp = true; httpResponseData->isIdle = false; + if (httpResponseData->hasNodeReceiveTimeouts) { + /* Every message whose request line begins in this packet starts its + * receive deadlines here; the parser stamps nodeMessageStartMs with + * this as it reaches each one (see HttpParser). */ + httpResponseData->nodePacketTimestampMs = nodeNowMs(); + } + // clients need to know the cursor after http parse, not servers! // how far did we read then? we need to know to continue with websocket parsing data? or? @@ -365,7 +470,6 @@ struct HttpContext { }, [httpResponseData, httpContextData](void *user, std::string_view data, bool fin) -> void * { - if (httpResponseData->isConnectRequest && httpResponseData->socketData && httpContextData->onSocketData) { httpContextData->onSocketData(httpResponseData->socketData, SSL, (struct us_socket_t *) user, data.data(), data.length(), fin); } @@ -437,6 +541,12 @@ struct HttpContext { ((HttpResponse *) s)->resetTimeout(); } + if (httpContextData->flags.hasNodeReceiveTimeouts) { + /* Receive-phase deadline, or restore the idle timeout (which + * disarms a body deadline once the message is complete). */ + ((HttpResponse *) s)->resetTimeout(); + } + /* We need to check if we should close this socket here now */ if (httpResponseData->state & HttpResponseData::HTTP_CONNECTION_CLOSE) { if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { @@ -514,8 +624,13 @@ struct HttpContext { } /* Ask the developer to write data and return success (true) or failure (false), OR skip sending anything and return success (true). */ if (httpResponseData->onWritable) { - /* We are now writable, so hang timeout again, the user does not have to do anything so we should hang until end or tryEnd rearms timeout */ - us_socket_timeout(s, 0); + /* We are now writable, so hang timeout again, the user does not have to do anything so we should hang until end or tryEnd rearms timeout. + * node:http receive deadlines own the timer while a message is being + * received and tryArmNodeReceiveTimeout assumes it stays armed, so + * never suspend it here. */ + if (!httpResponseData->hasNodeReceiveTimeouts || nodeReceivePhase(s, httpResponseData) == NodeReceivePhase::None) { + us_socket_timeout(s, 0); + } /* We expect the developer to return whether or not write was successful (true). * If write was never called, the developer should still return true so that we may drain. */ @@ -565,6 +680,37 @@ struct HttpContext { AsyncSocket *asyncSocket = reinterpret_cast *>(s); // Node.js by default closes the connection but they emit the timeout event before that HttpResponseData *httpResponseData = reinterpret_cast *>(asyncSocket->getAsyncSocketData()); + HttpContextData *httpContextData = getSocketContextDataS(s); + + /* node:http headersTimeout/requestTimeout expiry: like Node, always + * emit 'clientError' (ERR_HTTP_REQUEST_TIMEOUT) and close; the canned + * 408 is only written when nothing was written for the current message + * (Node: socketOnError's bytesWritten check). No 'timeout' events are + * emitted for these deadlines. The write-state bits are only meaningful + * for the dispatched (Body phase) message; in the Headers phase they + * are leftovers from the previous keep-alive response. + * For SSL, Node's HTTP layer only attaches after the handshake: a stalled + * handshake (isAuthorized still false, set only by onHandshake) must not be + * reported as an HTTP request timeout — it falls through to a plain close, + * still bounded by the deadline onOpen armed. */ + auto nodePhase = httpResponseData->hasNodeReceiveTimeouts && (!SSL || httpResponseData->isAuthorized) + ? nodeReceivePhase(s, httpResponseData) : NodeReceivePhase::None; + if (nodePhase != NodeReceivePhase::None) { + if (httpContextData->onClientError) { + httpContextData->onClientError(SSL, s, HTTP_PARSER_ERROR_REQUEST_TIMEOUT, nullptr, 0); + } + /* The 'clientError' listener may have closed the socket already */ + if (us_socket_is_closed(s)) { + return s; + } + bool wroteSomething = nodePhase == NodeReceivePhase::Body + && (httpResponseData->state & (HttpResponseData::HTTP_STATUS_CALLED | HttpResponseData::HTTP_WRITE_CALLED | HttpResponseData::HTTP_END_CALLED)); + if (!wroteSomething && !us_socket_is_shut_down(s)) { + us_socket_write(s, httpErrorResponses[HTTP_ERROR_408_REQUEST_TIMEOUT].data(), (int) httpErrorResponses[HTTP_ERROR_408_REQUEST_TIMEOUT].length()); + us_socket_shutdown(s); + } + return asyncSocket->close(); + } if (httpResponseData->onTimeout) { httpResponseData->onTimeout((HttpResponse *)s, httpResponseData->userData); diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 538537c92c8f..201ed866b02e 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -34,6 +34,10 @@ struct HttpFlags { bool requireHostHeader: 1 = true; bool isAuthorized: 1 = false; bool useStrictMethodValidation: 1 = false; + /* node:http only: per-socket receive deadlines (headersTimeout / + * requestTimeout) drive the socket timer instead of the legacy 10s + * pre-request idle timeout. Never set for Bun.serve. */ + bool hasNodeReceiveTimeouts: 1 = false; }; template @@ -71,6 +75,11 @@ struct alignas(16) HttpContextData { uint64_t maxHeaderSize = 0; // 0 means no limit + /* node:http receive deadlines in seconds (0 = disabled); only read when + * flags.hasNodeReceiveTimeouts is set. */ + unsigned int nodeHeadersTimeoutSeconds = 0; + unsigned int nodeRequestTimeoutSeconds = 0; + // TODO: SNI void clearRoutes() { this->router = HttpRouter{}; diff --git a/packages/bun-uws/src/HttpErrors.h b/packages/bun-uws/src/HttpErrors.h index f12fbba3b517..06676d65ac89 100644 --- a/packages/bun-uws/src/HttpErrors.h +++ b/packages/bun-uws/src/HttpErrors.h @@ -24,7 +24,8 @@ namespace uWS { enum HttpError { HTTP_ERROR_505_HTTP_VERSION_NOT_SUPPORTED = 1, HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 2, - HTTP_ERROR_400_BAD_REQUEST = 3 + HTTP_ERROR_400_BAD_REQUEST = 3, + HTTP_ERROR_408_REQUEST_TIMEOUT = 4 }; @@ -33,7 +34,8 @@ static const std::string_view httpErrorResponses[] = { "", /* Zeroth place is no error so don't use it */ "HTTP/1.1 505 HTTP Version Not Supported\r\nConnection: close\r\n\r\n", "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n", - "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n" + "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", + "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n" }; diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index fea221668dc1..422cbaf5a670 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -65,8 +65,19 @@ namespace uWS HTTP_PARSER_ERROR_INVALID_EOF = 8, HTTP_PARSER_ERROR_INVALID_METHOD = 9, HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN = 10, + /* Not a parse error: a node:http headersTimeout/requestTimeout receive + * deadline expired before the message was fully received. */ + HTTP_PARSER_ERROR_REQUEST_TIMEOUT = 11, }; + /* node:http receive phase of a socket (only meaningful when + * hasNodeReceiveTimeouts is set): Headers from connection (or the first + * byte of the next keep-alive message) until a message's header section + * is fully parsed, Body until its body is fully received, None while a + * handler/response is in flight or the keep-alive socket is idle after a + * completed exchange. CONNECT tunnels stream forever and have no phase. */ + enum class NodeReceivePhase : unsigned char { None, Headers, Body }; + enum HTTPHeaderParserError: uint8_t { HTTP_HEADER_PARSER_ERROR_NONE = 0, @@ -868,6 +879,15 @@ namespace uWS data[length + 1] = 'a'; /* Anything that is not \n, to trigger "invalid request" */ req->ancientHttp = false; for (;length;) { + /* node:http: a message whose request line begins here arrived in the + * packet onData is delivering, so its receive deadlines are measured + * from that packet's arrival. ConsumeMinimally only ever resumes a + * message buffered from an earlier packet, which keeps its own start. */ + if constexpr (!ConsumeMinimally) { + if (nodePacketTimestampMs) { + nodeMessageStartMs = nodePacketTimestampMs; + } + } auto result = getHeaders(data, data + length, req->headers, reserved, req->ancientHttp, isConnectRequest, useStrictMethodValidation, maxHeaderSize); if(result.isError()) { return result; @@ -1039,6 +1059,26 @@ namespace uWS } public: + /* True while a dispatched message still has body bytes outstanding + * (Content-Length not yet satisfied, or chunked body not terminated). */ + bool isReceivingHttpBody() const { + return remainingStreamingBytes != 0; + } + + /* True when incomplete request bytes are buffered, i.e. a message started + * arriving but its header section has not been fully parsed yet. */ + bool hasPartialRequest() const { + return fallback.length() > 0; + } + + /* node:http receive deadlines are absolute per message, measured from the + * message's first received byte. HttpContext::onData stamps the packet's + * arrival time here before parsing (0 = those deadlines are off), and the + * parser copies it into nodeMessageStartMs as each message's request line + * begins. See HttpContext::tryArmNodeReceiveTimeout. */ + uint64_t nodePacketTimestampMs = 0; + uint64_t nodeMessageStartMs = 0; + HttpParserResult consumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { /* This resets BloomFilter by construction, but later we also reset it again. * Optimize this to skip resetting twice (req could be made global) */ diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index 2d6b725b3ae7..b7cfa1b2b305 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -60,12 +60,28 @@ struct HttpResponse : public AsyncSocket { void setTimeout(uint8_t seconds) { auto* data = getHttpResponseData(); data->idleTimeout = seconds; + + /* node:http receive deadlines (headersTimeout/requestTimeout) own the + * per-socket timer while a message is being received — Node enforces + * them independently of a user socket timeout. The new idle timeout + * takes over once the message has been fully received. */ + if (data->hasNodeReceiveTimeouts && HttpContext::tryArmNodeReceiveTimeout((us_socket_t *) this, data)) { + return; + } + Super::timeout(data->idleTimeout); } void resetTimeout() { auto* data = getHttpResponseData(); + /* node:http receive deadlines (headersTimeout/requestTimeout) own the + * timer while a message is being received; they must not be replaced + * by the generic idle timeout. */ + if (data->hasNodeReceiveTimeouts && HttpContext::tryArmNodeReceiveTimeout((us_socket_t *) this, data)) { + return; + } + Super::timeout(data->idleTimeout); } /* Write an unsigned 32-bit integer in hex */ @@ -212,13 +228,20 @@ struct HttpResponse : public AsyncSocket { /* Success is when we wrote the entire thing without any failures */ bool success = written == data.length() && !failed; - /* Reset the timeout on each tryEnd */ - this->resetTimeout(); /* Remove onAborted function if we reach the end */ - if (httpResponseData->offset == totalSize) { + bool reachedEnd = httpResponseData->offset == totalSize; + if (reachedEnd) { httpResponseData->markDone(this); + } + + /* Reset the timeout on each tryEnd. This runs after markDone() (as + * the chunked path above does) so that completing the response arms + * the receive deadline of a next message whose partial bytes are + * already buffered, instead of seeing a still-pending response. */ + this->resetTimeout(); + if (reachedEnd) { /* We need to check if we should close this socket here now */ if (!Super::isCorked()) { if (httpResponseData->state & HttpResponseData::HTTP_CONNECTION_CLOSE) { @@ -399,6 +422,12 @@ struct HttpResponse : public AsyncSocket { /* Throttle reads and writes */ HttpResponse *pause() { Super::pause(); + /* A paused node:http socket is still subject to its receive deadline: + * the message bytes the peer owes us have not arrived yet. */ + auto* data = getHttpResponseData(); + if (data->hasNodeReceiveTimeouts && HttpContext::tryArmNodeReceiveTimeout((us_socket_t *) this, data)) { + return this; + } Super::timeout(0); return this; } diff --git a/packages/bun-uws/src/HttpResponseData.h b/packages/bun-uws/src/HttpResponseData.h index 4d30ca92009d..a3a9d2f28d03 100644 --- a/packages/bun-uws/src/HttpResponseData.h +++ b/packages/bun-uws/src/HttpResponseData.h @@ -53,6 +53,8 @@ struct HttpResponseData : AsyncSocketData, HttpParser { // Ensure we don't call a timeout callback onTimeout = nullptr; + hasCompletedResponse = true; + /* We are done with this request */ this->state &= ~HttpResponseData::HTTP_RESPONSE_PENDING; @@ -118,6 +120,13 @@ struct HttpResponseData : AsyncSocketData, HttpParser { uint8_t idleTimeout = 10; // default HTTP_TIMEOUT 10 seconds bool fromAncientRequest = false; bool isConnectRequest = false; + /* Mirror of HttpContextData::flags.hasNodeReceiveTimeouts, copied at + * accept time so the hot resetTimeout() path only reads this struct. */ + bool hasNodeReceiveTimeouts = false; + /* Set once the first response on this socket completes: distinguishes a + * connection that never produced a complete request (headersTimeout + * applies) from an idle keep-alive socket between messages (it doesn't). */ + bool hasCompletedResponse = false; /* When set, the response carries no body framing at all: no Content-Length, * no chunked encoding, no terminating chunk. writeStatus() sets it for 1xx * and 204 (RFC 9110 8.6); node:http additionally sets it for 304. */ diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 7b86c3557ed8..6261d3944046 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -663,6 +663,7 @@ declare function $ERR_INVALID_STATE_TypeError(message: string): TypeError; declare function $ERR_INVALID_STATE_RangeError(message: string): RangeError; declare function $ERR_UNESCAPED_CHARACTERS(arg): TypeError; declare function $ERR_HTTP_INVALID_STATUS_CODE(code): RangeError; +declare function $ERR_HTTP_REQUEST_TIMEOUT(message: string): Error; declare function $ERR_UNHANDLED_ERROR(err?): Error; declare function $ERR_BUFFER_OUT_OF_BOUNDS(name?: string): RangeError; declare function $ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(value, expected): TypeError; diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index a44142705651..da926139198b 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -26,6 +26,8 @@ const { useStrictMethodValidation: boolean, maxHeaderSize: number, onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, + headersTimeoutSeconds: number, + requestTimeoutSeconds: number, ) => void; getCompleteWebRequestOrResponseBodyValueAsArrayBuffer: (arg: any) => ArrayBuffer | undefined; drainMicrotasks: () => void; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 6a01d6883869..7bc29ed7b541 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -102,6 +102,8 @@ const GlobalPromise = globalThis.Promise; const kEmptyBuffer = Buffer.alloc(0); const ObjectKeys = Object.keys; const MathMin = Math.min; +const MathMax = Math.max; +const MathCeil = Math.ceil; const MathFloor = Math.floor; let cluster; @@ -910,6 +912,9 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort true, typeof this.maxHeaderSize !== "undefined" ? this.maxHeaderSize : getMaxHTTPHeaderSize(), onServerClientError.bind(this), + // headersTimeout/requestTimeout are enforced natively in whole seconds (0 = disabled). + this.headersTimeout > 0 ? MathMin(940, MathMax(1, MathCeil(this.headersTimeout / 1000))) : 0, + this.requestTimeout > 0 ? MathMin(940, MathMax(1, MathCeil(this.requestTimeout / 1000))) : 0, ); if (this?._unref) { @@ -970,6 +975,7 @@ enum HttpParserError { HTTP_PARSER_ERROR_INVALID_EOF = 8, HTTP_PARSER_ERROR_INVALID_METHOD = 9, HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN = 10, + HTTP_PARSER_ERROR_REQUEST_TIMEOUT = 11, } function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, rawPacket: ArrayBuffer) { const self = this as Server; @@ -995,11 +1001,18 @@ function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, r err = $HPE_HEADER_OVERFLOW("Parse Error: Header overflow"); err.bytesParsed = rawPacket.byteLength; break; + case HttpParserError.HTTP_PARSER_ERROR_REQUEST_TIMEOUT: + // headersTimeout/requestTimeout expired; like Node, the error carries + // neither rawPacket nor bytesParsed. + err = $ERR_HTTP_REQUEST_TIMEOUT("Request timeout"); + break; default: err = $HPE_INTERNAL("Parse Error"); break; } - err.rawPacket = rawPacket; + if (errorCode !== HttpParserError.HTTP_PARSER_ERROR_REQUEST_TIMEOUT) { + err.rawPacket = rawPacket; + } // A prior request on this keep-alive connection may already have wrapped // the native handle (the native side returns the existing handle); a second // wrapper would overwrite its onclose/duplex and strand the first one in diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 7e5d18b0b575..2b21c090dbab 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -717,9 +717,11 @@ impl ErrorCode { pub const TRACE_EVENTS_CATEGORY_REQUIRED: ErrorCode = ErrorCode(329); /// `ERR_TRACE_EVENTS_UNAVAILABLE` (instanceof Error) pub const TRACE_EVENTS_UNAVAILABLE: ErrorCode = ErrorCode(330); + /// `ERR_HTTP_REQUEST_TIMEOUT` (instanceof Error) + pub const HTTP_REQUEST_TIMEOUT: ErrorCode = ErrorCode(331); /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 331; + pub const COUNT: u16 = 332; } // ────────────────────────────────────────────────────────────────────────── @@ -815,6 +817,7 @@ impl ErrorCode { pub const ERR_HTTP_CONTENT_LENGTH_MISMATCH: ErrorCode = ErrorCode::HTTP_CONTENT_LENGTH_MISMATCH; pub const ERR_HTTP_INVALID_HEADER_VALUE: ErrorCode = ErrorCode::HTTP_INVALID_HEADER_VALUE; pub const ERR_HTTP_INVALID_STATUS_CODE: ErrorCode = ErrorCode::HTTP_INVALID_STATUS_CODE; + pub const ERR_HTTP_REQUEST_TIMEOUT: ErrorCode = ErrorCode::HTTP_REQUEST_TIMEOUT; pub const ERR_HTTP_TRAILER_INVALID: ErrorCode = ErrorCode::HTTP_TRAILER_INVALID; pub const ERR_HTTP_SOCKET_ASSIGNED: ErrorCode = ErrorCode::HTTP_SOCKET_ASSIGNED; pub const ERR_HTTP2_ALTSVC_INVALID_ORIGIN: ErrorCode = ErrorCode::HTTP2_ALTSVC_INVALID_ORIGIN; @@ -1442,6 +1445,7 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_INVALID_BUFFER_SIZE", "ERR_TRACE_EVENTS_CATEGORY_REQUIRED", "ERR_TRACE_EVENTS_UNAVAILABLE", + "ERR_HTTP_REQUEST_TIMEOUT", ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index acd959d9bfc3..7b7f6cdf66ae 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -342,5 +342,6 @@ const errors: ErrorCodeMapping = [ ["ERR_INVALID_BUFFER_SIZE", RangeError], ["ERR_TRACE_EVENTS_CATEGORY_REQUIRED", TypeError], ["ERR_TRACE_EVENTS_UNAVAILABLE", Error], + ["ERR_HTTP_REQUEST_TIMEOUT", Error], ]; export default errors; diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 9ad2c767dd11..d9ee61d4140c 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -41,6 +41,7 @@ extern "C" void Server__setIdleTimeout(EncodedJSValue, EncodedJSValue, JSC::JSGl extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation); extern "C" EncodedJSValue Server__setOnClientError(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setMaxHTTPHeaderSize(JSC::JSGlobalObject*, EncodedJSValue, uint64_t); +extern "C" EncodedJSValue Server__setNodeReceiveTimeouts(JSC::JSGlobalObject*, EncodedJSValue, uint32_t, uint32_t); static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject* prototype, JSObject* objectValue, JSC::InternalFieldTuple* tuple, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { @@ -988,17 +989,25 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(callFrame->argumentCount() == 5); + ASSERT(callFrame->argumentCount() == 7); // This is an internal binding. JSValue serverValue = callFrame->uncheckedArgument(0); JSValue requireHostHeader = callFrame->uncheckedArgument(1); JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); JSValue maxHeaderSize = callFrame->uncheckedArgument(3); JSValue callback = callFrame->uncheckedArgument(4); + JSValue headersTimeoutSeconds = callFrame->uncheckedArgument(5); + JSValue requestTimeoutSeconds = callFrame->uncheckedArgument(6); double maxHeaderSizeNumber = maxHeaderSize.toNumber(globalObject); RETURN_IF_EXCEPTION(scope, {}); + uint32_t headersTimeoutSecondsNumber = headersTimeoutSeconds.toUInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + uint32_t requestTimeoutSecondsNumber = requestTimeoutSeconds.toUInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject)); RETURN_IF_EXCEPTION(scope, {}); @@ -1008,6 +1017,9 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, Server__setOnClientError(globalObject, JSValue::encode(serverValue), JSValue::encode(callback)); RETURN_IF_EXCEPTION(scope, {}); + Server__setNodeReceiveTimeouts(globalObject, JSValue::encode(serverValue), headersTimeoutSecondsNumber, requestTimeoutSecondsNumber); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); } diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 12e0defe5c89..07f2d0d616a9 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1474,6 +1474,20 @@ impl NewServer { } } + /// node:http only: enforce `Server.headersTimeout` / `Server.requestTimeout` + /// (seconds, 0 disables) as per-socket receive deadlines in uWS. + pub fn set_node_receive_timeouts( + &mut self, + headers_timeout_seconds: u32, + request_timeout_seconds: u32, + ) { + if let Some(app) = self.app { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app) + .set_node_receive_timeouts(headers_timeout_seconds, request_timeout_seconds); + } + } + pub fn ref_(&mut self) { if self.poll_ref.is_active() { return; diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 3020120f217a..561b0d40aa22 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3731,6 +3731,37 @@ pub(super) fn server_set_max_http_header_size_( Ok(JSValue::UNDEFINED) } +pub(super) fn server_set_node_receive_timeouts_( + global: &JSGlobalObject, + server: JSValue, + headers_timeout_seconds: u32, + request_timeout_seconds: u32, +) -> JsResult { + if !server.is_object() { + return Err(global.throw(format_args!( + "Failed to set headersTimeout: The 'this' value is not a Server." + ))); + } + + macro_rules! handle { + ($T:ty) => { + if let Some(this) = server.as_::<$T>() { + // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. + unsafe { &mut *this } + .set_node_receive_timeouts(headers_timeout_seconds, request_timeout_seconds); + return Ok(JSValue::UNDEFINED); + } + }; + } + handle!(HTTPServer); + handle!(HTTPSServer); + handle!(DebugHTTPServer); + handle!(DebugHTTPSServer); + Err(global.throw(format_args!( + "Failed to set headersTimeout: The 'this' value is not a Server." + ))) +} + // `host_fn.wrap{3,4}` C-ABI shims: each forwards through `to_js_host_call` // (= `host_fn::to_js_host_fn_result`) so a `JsError` becomes `.zero` with the // exception left on the global. Signatures match the C++ callers in @@ -3783,6 +3814,24 @@ extern "C" fn server_set_max_http_header_size_shim( ) } +#[unsafe(export_name = "Server__setNodeReceiveTimeouts")] +extern "C" fn server_set_node_receive_timeouts_shim( + global: &JSGlobalObject, + server: JSValue, + headers_timeout_seconds: u32, + request_timeout_seconds: u32, +) -> JSValue { + host_fn::to_js_host_fn_result( + global, + server_set_node_receive_timeouts_( + global, + server, + headers_timeout_seconds, + request_timeout_seconds, + ), + ) +} + // ─── Externs ───────────────────────────────────────────────────────────────── // C++-implemented (bindings/BunServer.cpp). Declared here (not `bun_jsc`) // because the signatures name `bun_runtime` types (`NodeHTTPResponse`, diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index a1d0af41f224..b08eb57dde98 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -136,6 +136,21 @@ impl App { c::uws_app_set_max_http_header_size(Self::SSL_FLAG, self.as_raw(), max_header_size) } + /// node:http only: enable headersTimeout/requestTimeout receive deadlines + /// (seconds, 0 disables that phase's deadline). + pub fn set_node_receive_timeouts( + &mut self, + headers_timeout_seconds: u32, + request_timeout_seconds: u32, + ) { + c::uws_app_set_node_receive_timeouts( + Self::SSL_FLAG, + self.as_raw(), + headers_timeout_seconds, + request_timeout_seconds, + ) + } + pub fn clear_routes(&mut self) { c::uws_app_clear_routes(Self::SSL_FLAG, self.as_raw()) } @@ -519,6 +534,12 @@ pub mod c { app: &mut uws_app_t, max_header_size: u64, ); + pub(crate) safe fn uws_app_set_node_receive_timeouts( + ssl: i32, + app: &mut uws_app_t, + headers_timeout_seconds: u32, + request_timeout_seconds: u32, + ); pub(crate) fn uws_app_get( ssl: i32, app: *mut uws_app_t, diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index b9a49d00bb6c..756ab9ac790c 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -541,6 +541,15 @@ extern "C" uwsApp->setMaxHTTPHeaderSize(max_header_size); } } + void uws_app_set_node_receive_timeouts(int ssl, uws_app_t *app, unsigned int headers_timeout_seconds, unsigned int request_timeout_seconds) { + if (ssl) { + uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; + uwsApp->setNodeReceiveTimeouts(headers_timeout_seconds, request_timeout_seconds); + } else { + uWS::App *uwsApp = (uWS::App *)app; + uwsApp->setNodeReceiveTimeouts(headers_timeout_seconds, request_timeout_seconds); + } + } void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, bool use_strict_method_validation) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 261e2007baa2..31fb4f2acd66 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -28,6 +28,7 @@ import { connect, createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { PassThrough, Writable } from "node:stream"; +import { connect as tlsConnect } from "node:tls"; import tunnel from "tunnel"; import { run as runHTTPProxyTest } from "./node-http-proxy.js"; const { describe, expect, it, beforeAll, afterAll, createDoneDotAll, mock, test } = createTest(import.meta.path); @@ -3749,3 +3750,969 @@ it("OutgoingMessage outputData is per-instance and _flushOutput is defined", () c.outputData.push({ data: "y", encoding: "utf8", callback: null }); expect(d.outputData.length).toBe(0); }); + +// node:http Server.headersTimeout / Server.requestTimeout enforcement. +// Node answers an expired receive deadline with a canned +// "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n" (only when no +// 'clientError' listener is installed) and emits 'clientError' with +// ERR_HTTP_REQUEST_TIMEOUT. The deadline check granularity is +// connectionsCheckingInterval in Node and ~4s in Bun, so these use small +// configured values and only await events. +describe("server headersTimeout/requestTimeout enforcement", () => { + const REQUEST_TIMEOUT_408_RESPONSE = "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n"; + const timeoutOptions = { + headersTimeout: 500, + requestTimeout: 1000, + connectionsCheckingInterval: 100, + }; + + it.each([["http"], ["https"]] as const)( + "%s: stalled request headers are answered with a 408 and the socket is closed", + async protocol => { + const isHttps = protocol === "https"; + const server = isHttps + ? createHttpsServer({ ...timeoutOptions, key: tlsCert.key, cert: tlsCert.cert }, () => {}) + : createServer(timeoutOptions, () => {}); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = isHttps + ? tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false }) + : connect(port, "127.0.0.1"); + const received: Buffer[] = []; + const closed = new Promise((resolve, reject) => { + socket.on("close", () => resolve()); + socket.on("error", err => reject(err)); + }); + socket.on("data", chunk => received.push(chunk)); + await once(socket, isHttps ? "secureConnect" : "connect"); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + await closed; + expect(Buffer.concat(received).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + } finally { + server.closeAllConnections(); + server.close(); + } + }, + 20_000, + ); + + it("emits 'clientError' with ERR_HTTP_REQUEST_TIMEOUT when the headers stall", async () => { + const server = createServer(timeoutOptions, () => {}); + const { promise: clientError, resolve: onClientError } = Promise.withResolvers(); + server.on("clientError", (err, socket) => { + onClientError(err); + socket.destroy(); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + const err: any = await clientError; + expect(err.code).toBe("ERR_HTTP_REQUEST_TIMEOUT"); + expect(err.message).toBe("Request timeout"); + expect(err.name).toBe("Error"); + await closed; + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("a dispatched request whose body stalls is aborted with a 408", async () => { + const { promise: handlerCalled, resolve: onHandler } = Promise.withResolvers(); + const { promise: aborted, resolve: onAborted } = Promise.withResolvers(); + const server = createServer(timeoutOptions, (req, res) => { + onHandler(); + req.on("aborted", () => onAborted()); + req.on("error", () => {}); + res.on("error", () => {}); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\nab"); + await handlerCalled; + await aborted; + await closed; + expect(Buffer.concat(received).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("stalled headers of a second keep-alive request are answered with a 408", async () => { + const server = createServer(timeoutOptions, (req, res) => { + res.end("first-response"); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + const chunks: Buffer[] = []; + let firstResponse: ((value: void) => void) | undefined; + const receivedFirstResponse = new Promise(resolve => (firstResponse = resolve)); + socket.on("data", chunk => { + chunks.push(chunk); + if (Buffer.concat(chunks).toString().endsWith("first-response")) { + firstResponse!(); + } + }); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await receivedFirstResponse; + expect(Buffer.concat(chunks).toString()).toStartWith("HTTP/1.1 200 OK\r\n"); + chunks.length = 0; + socket.write("GET /second HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + await closed; + expect(Buffer.concat(chunks).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("stalled headers of a pipelined second request behind an async handler are answered with a 408", async () => { + // The next request's partial header bytes are already buffered when the + // handler later completes via a one-shot res.end(); finishing the response + // must still arm the buffered message's headersTimeout. + const server = createServer(timeoutOptions, (req, res) => { + setImmediate(() => res.end("first-response")); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + const chunks: Buffer[] = []; + let firstResponse: ((value: void) => void) | undefined; + const receivedFirstResponse = new Promise(resolve => (firstResponse = resolve)); + socket.on("data", chunk => { + chunks.push(chunk); + if (Buffer.concat(chunks).toString().endsWith("first-response")) { + firstResponse!(); + } + }); + // The complete first request and the partial start of the second share one packet. + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\nGET /second HTTP/1.1\r\nHost: "); + await receivedFirstResponse; + expect(Buffer.concat(chunks).toString()).toStartWith("HTTP/1.1 200 OK\r\n"); + chunks.length = 0; + await closed; + expect(Buffer.concat(chunks).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("requestTimeout does not apply to a fully received request with a slow handler", async () => { + // The receive deadlines stop once the message is fully received. The + // handler holds the response until a sibling stalled connection on the same + // server (opened after the request was fully received) is 408-closed, + // proving a whole deadline window elapsed while the slow handler survived. + const { promise: handlerCalled, resolve: onHandler } = Promise.withResolvers(); + const { promise: release, resolve: releaseHandler } = Promise.withResolvers(); + const server = createServer( + { headersTimeout: 500, requestTimeout: 500, connectionsCheckingInterval: 100 }, + (req, res) => { + onHandler(); + release.then(() => res.end("slow-but-fine")); + }, + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + await handlerCalled; + + const stalled = connect(port, "127.0.0.1"); + stalled.on("error", () => {}); + const stalledChunks: Buffer[] = []; + stalled.on("data", chunk => stalledChunks.push(chunk)); + const stalledClosed = new Promise(resolve => stalled.on("close", () => resolve())); + stalled.write("GET / HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + await stalledClosed; + expect(Buffer.concat(stalledChunks).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + + releaseHandler(); + await closed; + const response = Buffer.concat(received).toString(); + expect(response).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(response).toEndWith("slow-but-fine"); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("requestTimeout still applies after the handler responds before the body is received", async () => { + // Node keeps enforcing requestTimeout on the outstanding request body even + // after the response completed: 'clientError' (ERR_HTTP_REQUEST_TIMEOUT) + // and the socket is destroyed; the 408 is not written (a response was). + const { promise: clientError, resolve: onClientError } = Promise.withResolvers(); + const server = createServer(timeoutOptions, (req, res) => { + req.on("error", () => {}); + res.end("early-response"); + }); + server.on("clientError", (err, socket) => { + onClientError(err); + socket.destroy(); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\nab"); + const err: any = await clientError; + expect(err.code).toBe("ERR_HTTP_REQUEST_TIMEOUT"); + await closed; + const response = Buffer.concat(received).toString(); + expect(response).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(response).toEndWith("early-response"); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("requestTimeout expiry after the response started still emits 'clientError' without a 408", async () => { + // Like Node, only the canned 408 is suppressed once response bytes were + // written; the deadline itself (clientError + destroy) still applies. + const { promise: clientError, resolve: onClientError } = Promise.withResolvers(); + const server = createServer(timeoutOptions, (req, res) => { + req.on("error", () => {}); + res.on("error", () => {}); + res.writeHead(200, { "content-type": "text/plain" }); + res.write("partial-"); + }); + server.on("clientError", (err, socket) => { + onClientError(err); + socket.destroy(); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\nab"); + const err: any = await clientError; + expect(err.code).toBe("ERR_HTTP_REQUEST_TIMEOUT"); + await closed; + const response = Buffer.concat(received).toString(); + expect(response).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(response).not.toContain("408 Request Timeout"); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it.each([["auto"], ["checkContinue"]] as const)( + "an Expect: 100-continue request whose body stalls still gets the canned 408 after the 100 (%s)", + async mode => { + // Node v26 writes the interim "100 Continue" and then, on requestTimeout + // expiry, still appends the canned 408: socketOnError only suppresses it + // once the in-flight response's own header was sent (_headerSent), and an + // interim 1xx does not set it. + const { promise: aborted, resolve: onAborted } = Promise.withResolvers(); + const server = createServer(timeoutOptions, (req, res) => { + req.on("aborted", () => onAborted()); + req.on("error", () => {}); + res.on("error", () => {}); + }); + if (mode === "checkContinue") { + server.on("checkContinue", (req, res) => { + res.writeContinue(); + server.emit("request", req, res); + }); + } + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nExpect: 100-continue\r\nContent-Length: 100\r\n\r\n"); + await aborted; + await closed; + expect(Buffer.concat(received).toString()).toBe(`HTTP/1.1 100 Continue\r\n\r\n${REQUEST_TIMEOUT_408_RESPONSE}`); + } finally { + server.closeAllConnections(); + server.close(); + } + }, + 20_000, + ); + + it("req.setTimeout() does not replace the requestTimeout deadline", async () => { + // In Node the receive deadlines are enforced independently of any user + // socket timeout; arming a much longer one must not disarm requestTimeout. + const { promise: clientError, resolve: onClientError } = Promise.withResolvers(); + const userTimeout = mock(() => {}); + const server = createServer(timeoutOptions, (req, res) => { + req.on("error", () => {}); + res.on("error", () => {}); + req.setTimeout(60_000, userTimeout); + }); + server.on("clientError", (err, socket) => { + onClientError(err); + socket.destroy(); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\nab"); + const err: any = await clientError; + expect(err.code).toBe("ERR_HTTP_REQUEST_TIMEOUT"); + await closed; + expect(userTimeout).not.toHaveBeenCalled(); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("requestTimeout: 0 (disabled) does not swallow a req.setTimeout() armed during a stalled body", async () => { + // With the body deadline disabled there is nothing for the receive phase + // to enforce, so the user idle timeout must keep owning the socket timer; + // a disabled deadline claiming (and disarming) it leaves no timer at all. + const { promise: userTimedOut, resolve: onUserTimeout } = Promise.withResolvers(); + const clientError = mock(() => {}); + const server = createServer({ headersTimeout: 0, requestTimeout: 0, connectionsCheckingInterval: 100 }, req => { + req.on("error", () => {}); + req.resume(); + req.setTimeout(1000, () => onUserTimeout()); + }); + server.on("clientError", clientError); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\nab"); + await userTimedOut; + // The expiry is the user idle timeout, not a receive deadline: no + // canned 408 and no 'clientError' (ERR_HTTP_REQUEST_TIMEOUT). + expect(Buffer.concat(received).toString()).toBe(""); + expect(clientError).not.toHaveBeenCalled(); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("an enabled headersTimeout with requestTimeout: 0 hands the timer to req.setTimeout() for the body", async () => { + // The Headers phase deadline (headersTimeout) is enforced, but once the + // message moves into its (deadline-less) body the user idle timeout owns + // the socket timer again. + const { promise: userTimedOut, resolve: onUserTimeout } = Promise.withResolvers(); + const clientError = mock(() => {}); + const server = createServer({ headersTimeout: 8000, requestTimeout: 0, connectionsCheckingInterval: 100 }, req => { + req.on("error", () => {}); + req.resume(); + req.setTimeout(1000, () => onUserTimeout()); + }); + server.on("clientError", clientError); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\nab"); + await userTimedOut; + expect(Buffer.concat(received).toString()).toBe(""); + expect(clientError).not.toHaveBeenCalled(); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("headersTimeout: 0 with an enabled requestTimeout still bounds a stalled header section", async () => { + // requestTimeout spans the whole message including its header section, so + // disabling only headersTimeout must not leave the Headers phase unbounded. + const server = createServer( + { headersTimeout: 0, requestTimeout: 1000, connectionsCheckingInterval: 100 }, + () => {}, + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + await closed; + expect(Buffer.concat(received).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("the requestTimeout deadline survives a drain event during the body", async () => { + // A response large enough to hit backpressure registers the native + // writable (drain) handler; the deadline on the still-incomplete request + // body must stay armed across it. + const { promise: clientError, resolve: onClientError } = Promise.withResolvers(); + const server = createServer(timeoutOptions, (req, res) => { + req.on("error", () => {}); + res.on("error", () => {}); + res.writeHead(200); + res.write(Buffer.alloc(8 * 1024 * 1024, "x")); + }); + server.on("clientError", (err, socket) => { + onClientError(err); + socket.destroy(); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + // Keep reading so the server-side backpressure drains. + socket.on("data", () => {}); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100000\r\n\r\nab"); + const err: any = await clientError; + expect(err.code).toBe("ERR_HTTP_REQUEST_TIMEOUT"); + await closed; + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("headersTimeout applies to a CONNECT request whose header section stalls", async () => { + // isConnectRequest is set as soon as the request line parses; the header + // section is still subject to headersTimeout (only an established tunnel + // is exempt from the receive deadlines). + const server = createServer(timeoutOptions, () => {}); + server.on("connect", (req, socket) => socket.destroy()); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nX-Partial: "); + await closed; + expect(Buffer.concat(received).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("headersTimeout is an absolute deadline: trickling header bytes does not extend it", async () => { + // A slowloris client sending one header byte at a time keeps the socket + // active forever under an inactivity timer; Node measures headersTimeout + // from the start of the message regardless of activity. The deadline must + // exceed uWS's 4s timer-sweep granularity for re-arming to be observable. + const server = createServer( + { headersTimeout: 5000, requestTimeout: 30000, connectionsCheckingInterval: 100 }, + () => {}, + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nX-Slow: "); + const trickle = setInterval(() => { + if (!socket.destroyed && !socket.writableEnded) socket.write("a"); + }, 500); + try { + await closed; + } finally { + clearInterval(trickle); + } + expect(Buffer.concat(received).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 30_000); + + it("requestTimeout is an absolute deadline: trickling body bytes does not extend it", async () => { + const { promise: handlerCalled, resolve: onHandler } = Promise.withResolvers(); + const server = createServer( + { headersTimeout: 5000, requestTimeout: 6000, connectionsCheckingInterval: 100 }, + (req, res) => { + onHandler(); + // Keep the request flowing so every trickled chunk reaches the server. + req.resume(); + req.on("error", () => {}); + res.on("error", () => {}); + }, + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100000\r\n\r\n"); + await handlerCalled; + const trickle = setInterval(() => { + if (!socket.destroyed && !socket.writableEnded) socket.write("b"); + }, 500); + try { + await closed; + } finally { + clearInterval(trickle); + } + expect(Buffer.concat(received).toString()).toBe(REQUEST_TIMEOUT_408_RESPONSE); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 30_000); + + it("requestTimeout for a chunked request is measured from the message's first byte, not from header completion", async () => { + // Chunked framing is only known after the request is dispatched, so the + // receive phase passes through None inside the handler; the message-start + // time must survive that. The headers take 10s of the 13s requestTimeout, + // so the absolute deadline expires 3s into the stalled body — long before + // the client finishes the body at +16s. A deadline re-based at header + // completion would instead let the request complete with a 200. + const server = createServer( + { headersTimeout: 13000, requestTimeout: 13000, connectionsCheckingInterval: 100 }, + (req, res) => { + req.on("error", () => {}); + res.on("error", () => {}); + req.on("data", () => {}); + req.on("end", () => res.end("chunked-done")); + }, + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + let received = ""; + const { promise: respondedTo, resolve: onRespondedTo } = Promise.withResolvers(); + socket.on("data", chunk => { + received += chunk.toString(); + if (received.includes("chunked-done")) onRespondedTo(); + }); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nX-Slow: "); + // Deliberate fixed waits: they are inputs (when bytes reach the socket relative to the + // per-message deadline, which has no observable event), not waits for a condition. + await Bun.sleep(10_000); + socket.write("a\r\n\r\n5\r\nhello\r\n"); + await Bun.sleep(6_000); + if (!socket.destroyed && !socket.writableEnded) socket.write("0\r\n\r\n"); + await Promise.race([closed, respondedTo]); + expect(received).toBe(REQUEST_TIMEOUT_408_RESPONSE); + await closed; + } finally { + server.closeAllConnections(); + server.close(); + } + }, 40_000); + + it("a second keep-alive request coalesced with the first request's body tail gets its own headersTimeout", async () => { + // The first message's last body bytes and the second message's partial + // headers arrive in one packet, so the receive phase moves straight from + // Body to Headers without passing through None. The second message's + // headersTimeout must restart at its own first byte: a deadline inherited + // from the first message (whose body held the socket for ~5s of its 9s + // headersTimeout) expires within one 4s uWS timer sweep and 408-closes the + // socket long before the second headers complete at +6s. + const { promise: firstResponse, resolve: onFirstResponse } = Promise.withResolvers(); + const { promise: secondResponse, resolve: onSecondResponse } = Promise.withResolvers(); + const server = createServer( + { headersTimeout: 9000, requestTimeout: 30000, connectionsCheckingInterval: 100, keepAliveTimeout: 0 }, + (req, res) => { + req.on("error", () => {}); + res.on("error", () => {}); + // Respond before the request body completes so the connection is still + // in the Body receive phase when the coalesced packet arrives. + res.end(`resp:${req.url};`); + }, + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + let received = ""; + socket.on("data", chunk => { + received += chunk.toString(); + if (received.includes("resp:/first;")) onFirstResponse(); + if (received.includes("resp:/second;")) onSecondResponse(); + }); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + // Message 1: a POST whose 4-byte body is withheld. + socket.write("POST /first HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\n"); + await firstResponse; + // Deliberate fixed waits: they are inputs (when bytes reach the socket relative to the + // per-message deadline, which has no observable event), not waits for a condition. + await Bun.sleep(5200); + received = ""; + // One packet: message 1's body tail + message 2's partial headers. + socket.write("abcdGET /second HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + // Complete the headers after an inherited deadline would have fired, but + // well within this message's own headersTimeout. + await Bun.sleep(6000); + socket.write("1\r\n\r\n"); + await Promise.race([secondResponse, closed]); + expect(received).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(received).toEndWith("resp:/second;"); + socket.end(); + await closed; + } finally { + server.closeAllConnections(); + server.close(); + } + }, 30_000); + + it("a second keep-alive request coalesced with the first request's body tail gets its own requestTimeout", async () => { + // Same coalesced boundary, but the second message's complete headers and a + // partial body arrive in the packet, so the receive phase stays Body across + // the message boundary. The deadline must be re-armed for the new message; + // the stale same-phase state otherwise leaves the second message's stalled + // body with no requestTimeout at all. + const { promise: firstResponse, resolve: onFirstResponse } = Promise.withResolvers(); + const { promise: clientError, resolve: onClientError } = Promise.withResolvers(); + const server = createServer( + { headersTimeout: 5000, requestTimeout: 5000, connectionsCheckingInterval: 100, keepAliveTimeout: 0 }, + (req, res) => { + req.on("error", () => {}); + res.on("error", () => {}); + if (req.url === "/second") { + // Only respond once the (stalled) body completes. + req.on("data", () => {}); + req.on("end", () => res.end("resp:/second;")); + return; + } + // Respond before the first request's body completes. + res.end("resp:/first;"); + }, + ); + server.on("clientError", (err, socket) => { + onClientError(err); + socket.destroy(); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + let received = ""; + socket.on("data", chunk => { + received += chunk.toString(); + if (received.includes("resp:/first;")) onFirstResponse(); + }); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST /first HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\n"); + await firstResponse; + // One packet: message 1's body tail + message 2's complete headers and partial body. + socket.write("abcdPOST /second HTTP/1.1\r\nHost: localhost\r\nContent-Length: 8\r\n\r\nxy"); + const err: any = await clientError; + expect(err.code).toBe("ERR_HTTP_REQUEST_TIMEOUT"); + await closed; + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); + + it("a Content-Length: 0 request whose headers complete on the buffered fallback path does not leak its start time into the next keep-alive request", async () => { + // The parser's buffered (ConsumeMinimally) header path never delivers the + // empty fin body chunk for an explicit Content-Length: 0 request, so the + // message boundary must not depend on it. The second request's + // headersTimeout must restart at its own first byte: a deadline inherited + // from the first message (begun ~7s earlier) expires within one 4s uWS + // timer sweep, long before the second headers complete at +6.5s. + const { promise: firstResponse, resolve: onFirstResponse } = Promise.withResolvers(); + const { promise: secondResponse, resolve: onSecondResponse } = Promise.withResolvers(); + const server = createServer( + { headersTimeout: 9000, requestTimeout: 30000, connectionsCheckingInterval: 100, keepAliveTimeout: 0 }, + (req, res) => { + req.on("error", () => {}); + res.on("error", () => {}); + res.end(`resp:${req.url};`); + }, + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + let received = ""; + socket.on("data", chunk => { + received += chunk.toString(); + if (received.includes("resp:/first;")) onFirstResponse(); + if (received.includes("resp:/second;")) onSecondResponse(); + }); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + // Message 1's headers span two packets so they complete on the parser's + // buffered fallback path. + socket.write("DELETE /first HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nX-Split: "); + // Deliberate fixed waits: they are inputs (when bytes reach the socket relative to the + // per-message deadline, which has no observable event), not waits for a condition. + await Bun.sleep(200); + socket.write("a\r\n\r\n"); + await firstResponse; + await Bun.sleep(7000); + received = ""; + socket.write("GET /second HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + // Complete the headers after an inherited deadline would have fired, but + // well within this message's own headersTimeout. + await Bun.sleep(6500); + socket.write("1\r\n\r\n"); + await Promise.race([secondResponse, closed]); + expect(received).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(received).toEndWith("resp:/second;"); + socket.end(); + await closed; + } finally { + server.closeAllConnections(); + server.close(); + } + }, 30_000); + + it("a synchronous res.write() from a 'data' listener does not re-base the next keep-alive request's deadline", async () => { + // The echo handler's res.write() runs while the parser is still delivering + // the request body's final chunk, so the resetTimeout() it triggers + // observes the Body receive phase across the message boundary; it must not + // restore a message start the boundary already retired. The second + // request's headersTimeout must restart at its own first byte, exactly as + // in the test above. + const { promise: firstResponse, resolve: onFirstResponse } = Promise.withResolvers(); + const { promise: secondResponse, resolve: onSecondResponse } = Promise.withResolvers(); + const server = createServer( + { headersTimeout: 9000, requestTimeout: 30000, connectionsCheckingInterval: 100, keepAliveTimeout: 0 }, + (req, res) => { + req.on("error", () => {}); + res.on("error", () => {}); + if (req.url === "/first") { + req.on("data", chunk => res.write(chunk)); + req.on("end", () => res.end("resp:/first;")); + return; + } + res.end("resp:/second;"); + }, + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + let received = ""; + socket.on("data", chunk => { + received += chunk.toString(); + if (received.includes("resp:/first;")) onFirstResponse(); + if (received.includes("resp:/second;")) onSecondResponse(); + }); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.write("POST /first HTTP/1.1\r\nHost: localhost\r\nContent-Length: 8\r\n\r\nabcd"); + // Deliberate fixed waits: they are inputs (when bytes reach the socket relative to the + // per-message deadline, which has no observable event), not waits for a condition. + await Bun.sleep(200); + // The body's final chunk: the 'data' listener echoes it synchronously. + socket.write("efgh"); + await firstResponse; + await Bun.sleep(7000); + received = ""; + socket.write("GET /second HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + // Complete the headers after an inherited deadline would have fired, but + // well within this message's own headersTimeout. + await Bun.sleep(6500); + socket.write("1\r\n\r\n"); + await Promise.race([secondResponse, closed]); + expect(received).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(received).toEndWith("resp:/second;"); + socket.end(); + await closed; + } finally { + server.closeAllConnections(); + server.close(); + } + }, 30_000); + + it("disabled timeouts (0) do not close a stalled connection", async () => { + // Negative contract bounded by an awaited condition: a sibling server with + // the timeouts enabled answers its own stalled request with a 408 first, + // proving more than enough time elapsed for the disabled one to be reaped. + const disabledServer = createServer( + { headersTimeout: 0, requestTimeout: 0, connectionsCheckingInterval: 100 }, + () => {}, + ); + const enabledServer = createServer(timeoutOptions, () => {}); + try { + disabledServer.listen(0, "127.0.0.1"); + enabledServer.listen(0, "127.0.0.1"); + await Promise.all([once(disabledServer, "listening"), once(enabledServer, "listening")]); + + const disabledSocket = connect((disabledServer.address() as AddressInfo).port, "127.0.0.1"); + disabledSocket.on("error", () => {}); + let disabledClosed = false; + disabledSocket.on("close", () => (disabledClosed = true)); + disabledSocket.write("GET / HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + + const enabledSocket = connect((enabledServer.address() as AddressInfo).port, "127.0.0.1"); + enabledSocket.on("error", () => {}); + const enabledClosed = new Promise(resolve => enabledSocket.on("close", () => resolve())); + enabledSocket.write("GET / HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + + await enabledClosed; + expect(disabledClosed).toBe(false); + expect(disabledSocket.destroyed).toBe(false); + } finally { + disabledServer.closeAllConnections(); + disabledServer.close(); + enabledServer.closeAllConnections(); + enabledServer.close(); + } + }, 20_000); + + it("a headersTimeout of more than 2**32 seconds does not become a short deadline", async () => { + // 4_294_967_297_000 ms is 2**32 + 1 seconds; narrowed naively to a 32-bit + // count of seconds it wraps to a 1-second deadline. Negative contract + // bounded by an awaited condition: a sibling server whose 6s headersTimeout + // 408-closes first proves a wrapped 1s deadline (plus a full 4s timer + // sweep) would already have reaped the huge-timeout connection. + const hugeServer = createServer( + { headersTimeout: 4_294_967_297_000, requestTimeout: 4_294_967_297_000, connectionsCheckingInterval: 100 }, + (req, res) => { + res.end("late-but-fine"); + }, + ); + const siblingServer = createServer( + { headersTimeout: 6000, requestTimeout: 30000, connectionsCheckingInterval: 100 }, + () => {}, + ); + try { + hugeServer.listen(0, "127.0.0.1"); + siblingServer.listen(0, "127.0.0.1"); + await Promise.all([once(hugeServer, "listening"), once(siblingServer, "listening")]); + + const hugeSocket = connect((hugeServer.address() as AddressInfo).port, "127.0.0.1"); + hugeSocket.on("error", () => {}); + let hugeClosed = false; + hugeSocket.on("close", () => (hugeClosed = true)); + let hugeReceived = ""; + hugeSocket.on("data", chunk => (hugeReceived += chunk.toString())); + const hugeClosedPromise = new Promise(resolve => hugeSocket.on("close", () => resolve())); + hugeSocket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Partial: "); + + const siblingSocket = connect((siblingServer.address() as AddressInfo).port, "127.0.0.1"); + siblingSocket.on("error", () => {}); + const siblingClosed = new Promise(resolve => siblingSocket.on("close", () => resolve())); + siblingSocket.write("GET / HTTP/1.1\r\nHost: localhost\r\nX-Partial: "); + + await siblingClosed; + expect(hugeClosed).toBe(false); + expect(hugeReceived).toBe(""); + + // The connection is still alive: finishing the headers gets a normal 200. + hugeSocket.write("1\r\n\r\n"); + await hugeClosedPromise; + expect(hugeReceived).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(hugeReceived).toEndWith("late-but-fine"); + } finally { + hugeServer.closeAllConnections(); + hugeServer.close(); + siblingServer.closeAllConnections(); + siblingServer.close(); + } + }, 30_000); + + it("https: a stalled TLS handshake is not reported as an HTTP request timeout", async () => { + // Node only attaches the HTTP parser (and starts headersTimeout) after the + // TLS handshake completes. A raw TCP client that never sends a ClientHello + // must not get 'clientError' (ERR_HTTP_REQUEST_TIMEOUT) or any 408 bytes; + // the socket is just closed. + const server = createHttpsServer({ ...timeoutOptions, key: tlsCert.key, cert: tlsCert.cert }, () => {}); + const clientError = jest.fn(); + server.on("clientError", (err, socket) => { + clientError(err); + socket.destroy(); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + // Plain TCP: connect and never start the TLS handshake. + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + const received: Buffer[] = []; + socket.on("data", chunk => received.push(chunk)); + const closed = new Promise(resolve => socket.on("close", () => resolve())); + await closed; + expect(Buffer.concat(received).length).toBe(0); + expect(clientError).not.toHaveBeenCalled(); + } finally { + server.closeAllConnections(); + server.close(); + } + }, 20_000); +});