diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index a44142705651..ad10d623439a 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -5,19 +5,16 @@ const { setHeader, Headers, assignHeaders: assignHeadersFast, - setRequestTimeout, headersTuple, webRequestOrResponseHasBodyValue, setServerCustomOptions, getCompleteWebRequestOrResponseBodyValueAsArrayBuffer, drainMicrotasks, - setServerIdleTimeout, } = $cpp("NodeHTTP.cpp", "createNodeHTTPInternalBinding") as { getHeader: (headers: Headers, name: string) => string | undefined; setHeader: (headers: Headers, name: string, value: string) => void; Headers: (typeof globalThis)["Headers"]; assignHeaders: (object: any, req: Request, headersTuple: any) => boolean; - setRequestTimeout: (req: Request, timeout: number) => boolean; headersTuple: any; webRequestOrResponseHasBodyValue: (arg: any) => boolean; setServerCustomOptions: ( @@ -29,7 +26,6 @@ const { ) => void; getCompleteWebRequestOrResponseBodyValueAsArrayBuffer: (arg: any) => ArrayBuffer | undefined; drainMicrotasks: () => void; - setServerIdleTimeout: (server: any, timeout: number) => void; }; const getRawKeys = $newCppFunction("JSFetchHeaders.cpp", "jsFetchHeaders_getRawKeys", 0); @@ -88,7 +84,6 @@ const serverSymbol = Symbol.for("::bunternal::"); const kPendingCallbacks = Symbol("pendingCallbacks"); const kRequest = Symbol("request"); const kCloseCallback = Symbol("closeCallback"); -const kDeferredTimeouts = Symbol("deferredTimeouts"); const kEmptyObject = Object.freeze(Object.create(null)); @@ -543,7 +538,6 @@ export { kBodyChunks, kClearTimeout, kCloseCallback, - kDeferredTimeouts, kDeprecatedReplySymbol, kEmitState, kEmptyObject, @@ -584,9 +578,7 @@ export { setHeader, setIsNextIncomingMessageHTTPS, setMaxHTTPHeaderSize, - setRequestTimeout, setServerCustomOptions, - setServerIdleTimeout, statusCodeSymbol, statusMessageSymbol, timeoutTimerSymbol, diff --git a/src/js/node/_http_incoming.ts b/src/js/node/_http_incoming.ts index 95afd5d0b475..9415d07d761c 100644 --- a/src/js/node/_http_incoming.ts +++ b/src/js/node/_http_incoming.ts @@ -19,7 +19,6 @@ const { NodeHTTPBodyReadState, emitEOFIncomingMessage, NodeHTTPResponseAbortEvent, - setRequestTimeout, kAbortController, } = require("internal/http"); @@ -55,6 +54,7 @@ function onIncomingMessageResumeNodeHTTPResponse(this: IncomingMessage) { if (handle && !this.destroyed) { const resumed = handle.resume(); if (resumed && resumed !== true) { + this.socket?._unrefTimer?.(); const bodyReadState = handle.hasBody; if ((bodyReadState & NodeHTTPBodyReadState.done) !== 0) { emitEOFIncomingMessage(this); @@ -302,13 +302,7 @@ ObjectDefineProperty(IncomingMessage.prototype, "signal", { IncomingMessage.prototype.setTimeout = function setTimeout(msecs, callback) { if (callback) this.on("timeout", callback); - - const handle = this[kHandle]; - if (handle) { - setRequestTimeout(handle, Math.ceil(msecs / 1000)); - } else { - this.socket?.setTimeout(msecs); - } + this.socket?.setTimeout(msecs); return this; }; @@ -357,6 +351,7 @@ IncomingMessage.prototype._read = function _read(_n) { if ((bodyReadState & NodeHTTPBodyReadState.hasBufferedDataDuringPause) !== 0) { const drained = handle.drainRequestBody(); if (drained && !this._dumped) { + socket?._unrefTimer?.(); this.push(drained); } } @@ -378,6 +373,8 @@ function onDataIncomingMessage( return; } + this.socket?._unrefTimer?.(); + if (chunk && !this._dumped) this.push(chunk); if (isLast) { diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 7b7893d31cfb..a8ea1418169c 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -15,6 +15,7 @@ const { validateInteger, validateFunction, } = require("internal/validators"); +const { getTimerDuration } = require("internal/timers"); const { ConnResetException, hasObserver, startPerf, stopPerf } = require("internal/shared"); const kServerResponseStatistics = Symbol("ServerResponseStatistics"); @@ -27,7 +28,6 @@ const { kRealListen, tlsSymbol, optionsSymbol, - kDeferredTimeouts, kDeprecatedReplySymbol, headerStateSymbol, NodeHTTPHeaderState, @@ -51,7 +51,6 @@ const { eofInProgress, runSymbol, drainMicrotasks, - setServerIdleTimeout, setServerCustomOptions, getMaxHTTPHeaderSize, fakeSocketSymbol, @@ -254,6 +253,7 @@ function Server(options, callback): void { this.listening = false; this._unref = false; + this.timeout = 0; this.maxRequestsPerSocket = 0; this.maxHeadersCount = null; this[kInternalSocketData] = undefined; @@ -609,6 +609,18 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort socket = new NodeHTTPServerSocket(server, socketHandle, !!tls); } + // Like Node.js's connectionListenerInternal: the server timeout is + // applied once per connection (before any request routing, so it also + // covers CONNECT and upgrade tunnels) and before 'connection' fires so + // a user listener can override it; subsequent keep-alive requests only + // refresh the existing timer. + if (isSocketNew) { + const serverTimeoutMs = server.timeout; + if (serverTimeoutMs) socket.setTimeout(serverTimeoutMs); + } else { + socket._unrefTimer(); + } + const http_req = new RequestClass(kHandle, url, method, headersObject, headersArray, handle, hasBody, socket); if (isAncientHTTP) { http_req.httpVersion = "1.0"; @@ -920,25 +932,16 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort 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); } }; +// Like Node.js: the server records the value and applies it to each new +// connection via socket.setTimeout(); the socket emits 'timeout' and a +// listener on any of req/res/server vetoes the default destroy. 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 (typeof callback === "function") this.on("timeout", callback); return this; }; @@ -1042,6 +1045,9 @@ const NodeHTTPServerSocket = class Socket extends Duplex { _httpMessage; _secureEstablished = false; #pendingCallback = null; + #timeoutTimer = null; + #boundOnTimeout = null; + #lastBufferedAmount = 0; constructor(server: Server, handle, encrypted) { super(); this.server = server; @@ -1086,6 +1092,7 @@ const NodeHTTPServerSocket = class Socket extends Duplex { } } #onDrain() { + this._unrefTimer(); const handle = this[kHandle]; this[kBytesWritten] = handle ? (handle.response?.getBytesWritten?.() ?? handle.bytesWritten ?? 0) : 0; const callback = this.#pendingCallback; @@ -1096,6 +1103,7 @@ const NodeHTTPServerSocket = class Socket extends Duplex { this.emit("drain"); } #onData(chunk, last) { + this._unrefTimer(); if (chunk) { this.push(chunk); } @@ -1122,6 +1130,11 @@ const NodeHTTPServerSocket = class Socket extends Duplex { } #onClose() { this[kHandle] = null; + const timer = this.#timeoutTimer; + if (timer) { + clearTimeout(timer); + this.#timeoutTimer = null; + } this.server?.[kTrackedConnections]?.delete(this); // Node.js's `socketOnClose` → `abortIncoming()` only destroys requests @@ -1180,15 +1193,20 @@ const NodeHTTPServerSocket = class Socket extends Duplex { _onTimeout() { const handle = this[kHandle]; const response = handle?.response; - // 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) { + // Like Node.js's net.Socket._onTimeout: only suppress when the buffered + // amount has changed since the last check (progress), so a stalled write + // still emits 'timeout' instead of looping forever. + const buffered = response ? response.bufferedAmount : 0; + if (buffered > 0 && buffered !== this.#lastBufferedAmount) { + this.#lastBufferedAmount = buffered; + this._unrefTimer(); return; } + this.#lastBufferedAmount = buffered; this.emit("timeout"); } _unrefTimer() { - // for compatibility + this.#timeoutTimer?.refresh(); } address() { @@ -1324,7 +1342,26 @@ const NodeHTTPServerSocket = class Socket extends Duplex { return this; } - setTimeout(_timeout, _callback) { + setTimeout(msecs, callback) { + if (this.destroyed) return this; + this.timeout = msecs; + msecs = getTimerDuration(msecs, "msecs"); + const existing = this.#timeoutTimer; + if (existing) clearTimeout(existing); + if (msecs === 0) { + this.#timeoutTimer = null; + if (callback !== undefined) { + validateFunction(callback, "callback"); + this.removeListener("timeout", callback); + } + } else { + const bound = (this.#boundOnTimeout ??= this._onTimeout.bind(this)); + this.#timeoutTimer = setTimeout(bound, msecs).unref(); + if (callback !== undefined) { + validateFunction(callback, "callback"); + this.once("timeout", callback); + } + } return this; } @@ -1339,6 +1376,7 @@ const NodeHTTPServerSocket = class Socket extends Duplex { } _write(_chunk, _encoding, _callback) { + this._unrefTimer(); const handle = this[kHandle]; let err; try { @@ -1687,6 +1725,8 @@ function stopServerResponsePerf(this: any) { function endSocketOnFinishIfNeeded(socket, res) { if (res[kMustCloseConnection]) { socket?.end(); + } else { + socket?._unrefTimer(); } } @@ -2189,6 +2229,8 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { result = handle.write(chunk, encoding, allowWritesToContinue.bind(this), strictContentLength(this)); } + this[fakeSocketSymbol]?._unrefTimer(); + if (result < 0) { if (callback) { // The write was buffered due to backpressure. @@ -2352,6 +2394,7 @@ ServerResponse.prototype._send = function (data, encoding, callback, _byteLength } else { handle.write(data, encoding, callback, strictContentLength(this)); } + this[fakeSocketSymbol]?._unrefTimer(); }; const kSnapshotStatusCode = Symbol("kSnapshotStatusCode"); @@ -2501,6 +2544,7 @@ function callWriteHeadIfObservable(self, headerState) { } function allowWritesToContinue() { + this[fakeSocketSymbol]?._unrefTimer(); this._callPendingCallbacks(); this.emit("drain"); } diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 05c7e88408a5..a7428f89d242 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -35,9 +35,6 @@ BUN_DECLARE_HOST_FUNCTION(jsFunctionRequestOrResponseHasBodyValue); BUN_DECLARE_HOST_FUNCTION(jsFunctionGetCompleteRequestOrResponseBodyValueAsArrayBuffer); extern "C" uWS::HttpRequest* Request__getUWSRequest(void*); extern "C" void Request__setInternalEventCallback(void*, EncodedJSValue, JSC::JSGlobalObject*); -extern "C" void Request__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); -extern "C" bool NodeHTTPResponse__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); -extern "C" void Server__setIdleTimeout(EncodedJSValue, EncodedJSValue, JSC::JSGlobalObject*); 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); @@ -972,43 +969,6 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPAssignEventCallback, (JSGlobalObject * globalObje return JSValue::encode(jsNull()); } -JSC_DEFINE_HOST_FUNCTION(jsHTTPSetTimeout, (JSGlobalObject * globalObject, CallFrame* callFrame)) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - - // This is an internal binding. - JSValue requestValue = callFrame->uncheckedArgument(0); - JSValue seconds = callFrame->uncheckedArgument(1); - - ASSERT(callFrame->argumentCount() == 2); - - if (auto* jsRequest = dynamicDowncast(requestValue)) { - Request__setTimeout(jsRequest->wrapped(), JSValue::encode(seconds), globalObject); - } - - if (auto* nodeHttpResponse = dynamicDowncast(requestValue)) { - NodeHTTPResponse__setTimeout(nodeHttpResponse->wrapped(), JSValue::encode(seconds), globalObject); - } - - return JSValue::encode(jsUndefined()); -} -JSC_DEFINE_HOST_FUNCTION(jsHTTPSetServerIdleTimeout, (JSGlobalObject * globalObject, CallFrame* callFrame)) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - - // This is an internal binding. - JSValue serverValue = callFrame->uncheckedArgument(0); - JSValue seconds = callFrame->uncheckedArgument(1); - - ASSERT(callFrame->argumentCount() == 2); - - Server__setIdleTimeout(JSValue::encode(serverValue), JSValue::encode(seconds), globalObject); - - return JSValue::encode(jsUndefined()); -} - JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); @@ -1180,14 +1140,6 @@ JSValue createNodeHTTPInternalBinding(Zig::GlobalObject* globalObject) vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "assignEventCallback"_s)), JSC::JSFunction::create(vm, globalObject, 2, "assignEventCallback"_s, jsHTTPAssignEventCallback, ImplementationVisibility::Public), 0); - obj->putDirect( - vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setRequestTimeout"_s)), - JSC::JSFunction::create(vm, globalObject, 2, "setRequestTimeout"_s, jsHTTPSetTimeout, ImplementationVisibility::Public), 0); - - obj->putDirect( - vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerIdleTimeout"_s)), - JSC::JSFunction::create(vm, globalObject, 2, "setServerIdleTimeout"_s, jsHTTPSetServerIdleTimeout, ImplementationVisibility::Public), 0); - obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerCustomOptions"_s)), JSC::JSFunction::create(vm, globalObject, 2, "setServerCustomOptions"_s, jsHTTPSetCustomOptions, ImplementationVisibility::Public), 0); diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 44467ad0e80d..1940bc56c3cf 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -1,5 +1,5 @@ use core::cell::Cell; -use core::ffi::{c_uint, c_void}; +use core::ffi::c_void; use core::ptr; use bitflags::bitflags; @@ -2126,31 +2126,3 @@ pub unsafe extern "C" fn NodeHTTPResponse__createForJS( unsafe { *node_response_ptr = response }; js_this } - -impl NodeHTTPResponse { - #[uws::uws_callback(export = "NodeHTTPResponse__setTimeout")] - pub(crate) fn ffi_set_timeout(&self, seconds: JSValue, global_this: &JSGlobalObject) -> bool { - if !seconds.is_number() { - let _: jsc::JsError = - global_this.throw_invalid_argument_type_value(b"timeout", b"number", seconds); - return false; - } - - let flags = self.flags.get(); - let Some(raw) = self.raw_response.get() else { - return false; - }; - if flags.contains(Flags::REQUEST_HAS_COMPLETED) - || flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) - { - return false; - } - - // ECMAScript ToUint32 — same bit pattern as - // ToInt32 reinterpreted as unsigned (negative inputs wrap, e.g. -1 → u32::MAX). - let secs = (seconds.to_int32() as c_uint).min(255) as u8; - raw.timeout(secs); - true - } -} diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 1b0f1fb48c43..779cc323d380 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1456,10 +1456,6 @@ impl NewServer { self.listener.is_some() || (Self::HAS_H3 && self.h3_listener.is_some()) } - pub fn set_idle_timeout(&mut self, seconds: core::ffi::c_uint) { - self.config.idle_timeout = seconds.min(255) as u8; - } - pub fn set_flags(&mut self, require_host_header: bool, use_strict_method_validation: bool) { if let Some(app) = self.app { // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index adcbbdf1ee19..ed45ed1b7c8d 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3567,58 +3567,6 @@ pub enum AnyUserRouteList<'a> { } // ─── Exported fns ──────────────────────────────────────────────────────────── -#[unsafe(no_mangle)] -pub(super) extern "C" fn Server__setIdleTimeout( - server: JSValue, - seconds: JSValue, - global: &JSGlobalObject, -) { - match server_set_idle_timeout_(server, seconds, global) { - Ok(()) => {} - Err(JsError::Thrown) => {} - Err(JsError::OutOfMemory) => { - let _ = global.throw_out_of_memory_value(); - } - Err(JsError::Terminated) => {} - } -} - -pub(super) fn server_set_idle_timeout_( - server: JSValue, - seconds: JSValue, - global: &JSGlobalObject, -) -> JsResult<()> { - if !server.is_object() { - return Err(global.throw(format_args!( - "Failed to set timeout: The 'this' value is not a Server." - ))); - } - - if !seconds.is_number() { - return Err(global.throw(format_args!( - "Failed to set timeout: The provided value is not of type 'number'." - ))); - } - let value = seconds.to_u32(); - if let Some(this) = server.as_::() { - // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. - unsafe { &mut *this }.set_idle_timeout(value); - } else if let Some(this) = server.as_::() { - // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. - unsafe { &mut *this }.set_idle_timeout(value); - } else if let Some(this) = server.as_::() { - // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. - unsafe { &mut *this }.set_idle_timeout(value); - } else if let Some(this) = server.as_::() { - // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. - unsafe { &mut *this }.set_idle_timeout(value); - } else { - return Err(global.throw(format_args!( - "Failed to set timeout: The 'this' value is not a Server." - ))); - } - Ok(()) -} pub(super) fn server_set_on_client_error_( global: &JSGlobalObject, diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index 1c7ceaf67f37..94e747fbf1df 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1,7 +1,6 @@ //! https://developer.mozilla.org/en-US/docs/Web/API/Request use core::cell::Cell; -use core::ffi::c_uint; use core::ptr::NonNull; use std::borrow::Cow; @@ -398,20 +397,6 @@ impl Request { self.request_context.enable_timeout_events(); } - #[bun_uws::uws_callback(export = "Request__setTimeout")] - pub fn ffi_set_timeout(&self, seconds: JSValue, global_this: &JSGlobalObject) { - if !seconds.is_number() { - let _ = global_this.throw(format_args!( - "Failed to set timeout: The provided value is not of type 'number'." - )); - return; - } - - // `JSValue.toU32` clamps via JS ToUint32 rules, - // not signed wrap-then-reinterpret like `to_int32() as c_uint` would do. - self.set_timeout(seconds.to_u32() as c_uint); - } - /// `BunRequest.prototype.clone` (the `Bun.serve` `routes:` subclass) goes /// through `JSBunRequest::clone` -> here, not through [`Self::do_clone`], /// so it needs the same fetch-spec step-1 usability check. @@ -1678,10 +1663,6 @@ impl Request { self.clone_into(&mut req, global_this, false)?; Ok(req) } - - pub fn set_timeout(&self, seconds: c_uint) { - let _ = self.request_context.set_timeout(seconds); - } } #[derive(Default)] diff --git a/test/js/bun/test/parallel/test-http-should-emit-timeout-event-when-using-server-setTimeout.ts b/test/js/bun/test/parallel/test-http-should-emit-timeout-event-when-using-server-setTimeout.ts index cf053bd8dbf1..086590b2bfc7 100644 --- a/test/js/bun/test/parallel/test-http-should-emit-timeout-event-when-using-server-setTimeout.ts +++ b/test/js/bun/test/parallel/test-http-should-emit-timeout-event-when-using-server-setTimeout.ts @@ -6,9 +6,13 @@ const { expect } = createTest(import.meta.path); await using server = http.createServer().listen(0); await once(server, "listening"); let callBackCalled = false; -server.setTimeout(100, () => { +// Node.js: once a 'timeout' listener is installed the runtime does not +// destroy the socket; the listener decides. Destroy it here so the fetch +// below completes and the server can close. +server.setTimeout(100, socket => { callBackCalled = true; console.log("Called timeout"); + socket.destroy(); }); fetch(`http://localhost:${server.address().port}`, { verbose: true }) diff --git a/test/js/bun/test/parallel/test-http-should-emit-timeout-event.ts b/test/js/bun/test/parallel/test-http-should-emit-timeout-event.ts index d7dda335a19e..0cbb5a731978 100644 --- a/test/js/bun/test/parallel/test-http-should-emit-timeout-event.ts +++ b/test/js/bun/test/parallel/test-http-should-emit-timeout-event.ts @@ -12,8 +12,11 @@ fetch(`http://localhost:${server.address().port}`) const [req, res] = await once(server, "request"); expect(req.complete).toBe(false); let callBackCalled = false; +// Node.js: a 'timeout' listener vetoes the default socket destroy, so the +// callback must destroy the socket itself for the server to close cleanly. req.setTimeout(100, () => { callBackCalled = true; + req.socket.destroy(); }); await once(req, "timeout"); expect(callBackCalled).toBe(true); diff --git a/test/js/node/http/node-http-server-setTimeout.test.ts b/test/js/node/http/node-http-server-setTimeout.test.ts new file mode 100644 index 000000000000..c0a088bb1285 --- /dev/null +++ b/test/js/node/http/node-http-server-setTimeout.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from "bun:test"; +import { once } from "node:events"; +import http from "node:http"; +import net from "node:net"; + +// https://nodejs.org/api/http.html#serversettimeoutmsecs-callback +// Once a 'timeout' listener is installed on the server (or on req/res), the +// runtime must NOT destroy the socket on timeout; the listener decides. + +async function connectAndWrite(port: number, wire: string) { + const s = net.connect(port, "127.0.0.1"); + s.setNoDelay(true); + s.on("error", () => {}); + s.resume(); + await once(s, "connect"); + s.write(wire); + return s; +} + +async function listen(handler: http.RequestListener) { + const srv = http.createServer(handler); + srv.listen(0, "127.0.0.1"); + await once(srv, "listening"); + return srv; +} + +function cleanup(srv: http.Server, client: net.Socket) { + try { + client.destroy(); + } catch {} + try { + srv.close(); + srv.closeAllConnections?.(); + } catch {} +} + +describe("http.Server.prototype.setTimeout", () => { + test("sets server.timeout and returns this", () => { + const srv = http.createServer(); + expect(srv.timeout).toBe(0); + const ret = srv.setTimeout(1234); + expect(ret).toBe(srv); + expect(srv.timeout).toBe(1234); + }); + + test("callback is registered as a 'timeout' listener", () => { + const srv = http.createServer(); + const cb = () => {}; + srv.setTimeout(1234, cb); + expect(srv.listeners("timeout")).toContain(cb); + }); + + test.concurrent("a server 'timeout' listener vetoes the default destroy (stalled body)", async () => { + const srv = await listen((req, _res) => void req.resume()); + srv.setTimeout(200); + + const { promise, resolve } = Promise.withResolvers<{ event: string; socket?: any }>(); + srv.on("timeout", socket => resolve({ event: "timeout", socket })); + + const client = await connectAndWrite( + (srv.address() as net.AddressInfo).port, + "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 900\r\n\r\nab", + ); + client.on("close", () => resolve({ event: "close" })); + + try { + const { event, socket } = await promise; + // The 'timeout' event must fire before the connection is torn down. + expect(event).toBe("timeout"); + expect(socket.destroyed).toBe(false); + // And the connection must still be open afterwards: the listener did + // not destroy, so the runtime must not either. Race a fresh 'timeout' + // (listener returning without destroying leaves the socket alive; the + // timer is one-shot until refreshed, but the old native path closed + // within a few ms of the event) against 'close'. + const winner = await Promise.race([ + once(client, "close").then(() => "close"), + Bun.sleep(250).then(() => "alive"), + ]); + expect(winner).toBe("alive"); + expect(socket.destroyed).toBe(false); + } finally { + cleanup(srv, client); + } + }); + + test.concurrent("a server 'timeout' listener vetoes the default destroy (idle keep-alive)", async () => { + const srv = await listen((req, res) => { + req.resume(); + req.on("end", () => res.end("ok")); + }); + srv.setTimeout(200); + + const { promise, resolve } = Promise.withResolvers<{ event: string; socket?: any }>(); + srv.on("timeout", socket => resolve({ event: "timeout", socket })); + + const client = await connectAndWrite((srv.address() as net.AddressInfo).port, "GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + client.on("close", () => resolve({ event: "close" })); + + try { + const { event, socket } = await promise; + // An idle keep-alive connection reaped by the server timeout must + // emit 'timeout' (not close silently). + expect(event).toBe("timeout"); + expect(socket.destroyed).toBe(false); + const winner = await Promise.race([ + once(client, "close").then(() => "close"), + Bun.sleep(250).then(() => "alive"), + ]); + expect(winner).toBe("alive"); + expect(socket.destroyed).toBe(false); + } finally { + cleanup(srv, client); + } + }); + + test.concurrent("with no listener, the socket is destroyed on timeout", async () => { + const srv = await listen((req, _res) => void req.resume()); + srv.setTimeout(200); + + const client = await connectAndWrite( + (srv.address() as net.AddressInfo).port, + "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 900\r\n\r\nab", + ); + try { + await once(client, "close"); + } finally { + cleanup(srv, client); + } + }); + + test.concurrent("a request 'timeout' listener also vetoes the default destroy", async () => { + const { promise, resolve } = Promise.withResolvers<{ event: string; socket?: any }>(); + const srv = await listen((req, _res) => { + req.resume(); + req.on("timeout", () => resolve({ event: "timeout", socket: req.socket })); + }); + srv.setTimeout(200); + + const client = await connectAndWrite( + (srv.address() as net.AddressInfo).port, + "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 900\r\n\r\nab", + ); + client.on("close", () => resolve({ event: "close" })); + + try { + const { event, socket } = await promise; + expect(event).toBe("timeout"); + expect(socket.destroyed).toBe(false); + const winner = await Promise.race([ + once(client, "close").then(() => "close"), + Bun.sleep(250).then(() => "alive"), + ]); + expect(winner).toBe("alive"); + } finally { + cleanup(srv, client); + } + }); + + test.concurrent("an actively-writing response is not destroyed by server.setTimeout()", async () => { + // Response body writes refresh the socket's inactivity timer, like + // Node.js's net.Socket._writeGeneric -> _unrefTimer(). + const { promise, resolve } = Promise.withResolvers(); + const srv = await listen((req, res) => { + req.resume(); + res.writeHead(200, { "Transfer-Encoding": "chunked" }); + let n = 0; + const i = setInterval(() => { + if (!res.write(".")) res.once("drain", () => {}); + if (++n === 8) { + clearInterval(i); + res.end(); + } + }, 100); + req.socket.on("close", () => clearInterval(i)); + }); + // Shorter than the interval between writes: if writes did not refresh the + // timer, the default-destroy would fire between the first two chunks. + srv.setTimeout(250); + srv.on("timeout", () => resolve("timeout")); + + const client = await connectAndWrite((srv.address() as net.AddressInfo).port, "GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + let body = ""; + client.on("data", d => { + body += d.toString("latin1"); + if (body.includes("\r\n0\r\n\r\n")) resolve("finished"); + }); + client.on("close", () => resolve("close")); + + try { + expect(await promise).toBe("finished"); + // Eight body bytes, one per chunk (after the header block). + const chunks = body.slice(body.indexOf("\r\n\r\n") + 4); + expect(chunks.match(/\./g)?.length).toBe(8); + } finally { + cleanup(srv, client); + } + }); + + test.concurrent("req.setTimeout(0) clears the server-armed socket timer", async () => { + // All four entry points (server/socket/req/res.setTimeout) share one timer. + const { promise, resolve } = Promise.withResolvers(); + const srv = await listen((req, _res) => { + req.resume(); + req.setTimeout(0); + }); + srv.setTimeout(200); + srv.on("timeout", () => resolve("timeout")); + + const client = await connectAndWrite( + (srv.address() as net.AddressInfo).port, + "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 900\r\n\r\nab", + ); + client.on("close", () => resolve("close")); + + try { + const winner = await Promise.race([promise, Bun.sleep(600).then(() => "alive")]); + expect(winner).toBe("alive"); + } finally { + cleanup(srv, client); + } + }); +});