From bef0c747932d56eb7a8d68749042f66f7d8cea4d Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 9 Jul 2026 18:26:00 +0000
Subject: [PATCH 1/2] fetch: add connectTimeout, socketTimeout, and a
whole-request timeout
Squashed and rebased onto #33647, which landed the per-request
idle_timeout_seconds plumbing (HTTPClient/AsyncHTTP/FetchOptions,
effective_idle_timeout_seconds, normalize_idle_timeout_seconds, and the h2
rearm_timeout aggregation) along with the #16682 fix that a numeric timeout
extends the socket-idle deadline.
This PR keeps what is net-new on top of that:
timeout whole-request wall-clock deadline (EventLoopTimer)
connectTimeout DNS + TCP + TLS
socketTimeout socket inactivity in either direction
plus the supporting pieces: Signals.connected and mark_connected() across
h1/h2/h3 so connectTimeout knows when dialling ended; the two embedded
EventLoopTimers in FetchTasklet with dispatch, arm, cancel and on-fire handling;
CommonAbortReason::ConnectionTimeout so connectTimeout reports a distinct
message; the cancel_request_body() helper shared by every abort path so a
streaming request body is cancelled with the reason; docs, types, and a 21-test
suite.
Conflict resolution against #33647: took main for every shared hunk
(normalize/effective helpers, h2 rearm_timeout, AsyncHTTP::init normalisation
site). Two adjustments followed:
- timeout_ms_arg now treats Infinity as "no deadline", matching the semantic
#33647 established and tests for.
- The h2 idle-aggregation test's "short explicit deadline" sub-cases switched
{timeout: 1000} -> {socketTimeout: 1000}, since timeout now arms a
per-request wall-clock deadline that is independent of the shared socket
timer the test is exercising.
---
docs/runtime/networking/fetch.mdx | 76 +++-
packages/bun-types/globals.d.ts | 92 +++++
src/event_loop/EventLoopTimer.rs | 2 +
src/http/HTTPContext.rs | 4 +
src/http/Signals.rs | 9 +
src/http/h2_client/ClientSession.rs | 16 +-
src/http/h3_client/ClientSession.rs | 11 +
src/http/h3_client/callbacks.rs | 1 +
src/http/lib.rs | 13 +
src/http_types/FetchRedirect.rs | 1 +
src/jsc/AbortSignal.rs | 7 +-
src/jsc/bindings/ErrorCode.cpp | 3 +
src/jsc/bindings/webcore/AbortSignal.h | 1 +
src/runtime/dispatch.rs | 13 +
src/runtime/webcore/fetch.rs | 149 ++++++--
src/runtime/webcore/fetch/FetchTasklet.rs | 164 ++++++++-
test/js/web/fetch/fetch-http2-client.test.ts | 14 +-
.../web/fetch/fetch-timeout-options.test.ts | 327 ++++++++++++++++++
18 files changed, 843 insertions(+), 60 deletions(-)
create mode 100644 test/js/web/fetch/fetch-timeout-options.test.ts
diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx
index afe904b75e09..46a991ebbb5f 100644
--- a/docs/runtime/networking/fetch.mdx
+++ b/docs/runtime/networking/fetch.mdx
@@ -166,14 +166,84 @@ When using streams with S3:
### Fetching a URL with a timeout
-To fetch a URL with a timeout, use `AbortSignal.timeout`:
+The simplest deadline is `timeout`, a number of milliseconds: the whole request
+must finish within it, or it rejects.
```ts
-const response = await fetch("http://example.com", {
- signal: AbortSignal.timeout(1000),
+// Fails if the request isn't completely done within 5 seconds.
+await fetch("http://example.com", { timeout: 5_000 });
+```
+
+One deadline for everything is a blunt instrument, though. A large download
+legitimately takes minutes, but nothing should spend minutes opening a socket.
+Two more options bound the individual phases.
+
+```ts
+const response = await fetch("http://example.com/large-file", {
+ // Give up if the connection isn't established within 5 seconds.
+ connectTimeout: 5_000,
+ // ...but let the download take as long as it needs, as long as bytes keep
+ // arriving at least every 2 minutes.
+ socketTimeout: 120_000,
});
```
+| Option | Covers | Default |
+| ---------------- | ---------------------------------------------------------------------- | ----------- |
+| `timeout` | The whole request, from `fetch()` until the body finishes. | No deadline |
+| `connectTimeout` | DNS resolution, the TCP handshake, and the TLS handshake for `https:`. | No deadline |
+| `socketTimeout` | Any stretch where no bytes move in either direction, in any phase. | 5 minutes |
+
+All three are in milliseconds and all three reject with a `TimeoutError`.
+`connectTimeout` reports `The connection timed out.`, so a retry policy can tell
+"the network is down" from "the server is slow"; the other two report
+`The operation timed out.`
+
+`socketTimeout` and `timeout` answer different questions, and you often want
+both:
+
+- `socketTimeout` fires when the connection goes quiet. It is re-armed on every
+ byte in either direction, so a response that trickles in steadily never trips
+ it.
+- `timeout` fires regardless of activity. It is the one that catches a server
+ that is technically alive but far too slow.
+
+```ts
+// Bail if the server goes quiet for 30s, AND never spend more than 2 minutes.
+await fetch("http://example.com/", { socketTimeout: 30_000, timeout: 120_000 });
+```
+
+`timeout: false` (or `0`) disables _every_ timeout for the request, which is what
+you want for long-polling and server-sent events:
+
+```ts
+await fetch("http://example.com/events", { timeout: false });
+```
+
+
+
+`connectTimeout` applies to the initial connection, not to one reopened while
+following a redirect. It and `timeout` are both measured from the moment
+`fetch()` is called, so time spent queued behind the [simultaneous connection
+limit](#simultaneous-connection-limit) counts against them.
+
+`socketTimeout` is rounded up to whole seconds (and to whole minutes above 240
+seconds) because that is the resolution of the underlying socket timer, which may
+also fire up to one tick early, and values above 239 minutes are clamped to that
+ceiling. Treat it as a coarse backstop rather than a precise deadline; `timeout`
+and `connectTimeout` have millisecond precision. Its default can be changed
+process-wide with the `BUN_CONFIG_HTTP_IDLE_TIMEOUT` environment variable, in
+seconds.
+
+A numeric `timeout` also raises the effective `socketTimeout` to match unless
+`socketTimeout` is set explicitly, so the socket-idle default can never preempt
+the deadline you wrote. `{ timeout: 900_000, socketTimeout: 30_000 }` keeps both
+bounds; `{ timeout: 900_000 }` alone relaxes the idle backstop to 15 minutes.
+Setting `socketTimeout` explicitly outranks `timeout: false`, so
+`{ timeout: false, socketTimeout: 30_000 }` keeps the socket timer you asked for.
+
+
+
#### Canceling a request
To cancel a request, use an `AbortController`:
diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts
index d88036b35841..bb14b3e082a2 100644
--- a/packages/bun-types/globals.d.ts
+++ b/packages/bun-types/globals.d.ts
@@ -1927,6 +1927,98 @@ interface BunFetchRequestInit extends RequestInit {
*/
tls?: BunFetchRequestInitTLS;
+ /**
+ * Milliseconds the whole request may take, measured from the moment `fetch()`
+ * is called until the response body has finished. A hard wall-clock deadline:
+ * it fires even while bytes are actively arriving, so a server that trickles
+ * data forever is still cut off. Rejects with a
+ * `TimeoutError: The operation timed out.`
+ *
+ * The equivalent of `Client.Timeout` in Go, `timeout` in reqwest and
+ * `libcurl`, `callTimeout` in OkHttp, and `timeoutIntervalForResource` in
+ * `URLSession`.
+ *
+ * Defaults to `true`, which means "use the other timeout defaults with no
+ * whole-request deadline": {@link socketTimeout} is still on, but there is no
+ * overall wall-clock limit unless you pass a number here.
+ *
+ * A number also raises the effective {@link socketTimeout} to at least that
+ * value unless `socketTimeout` is set explicitly, so the socket-idle default
+ * can never fire ahead of the deadline you wrote.
+ *
+ * `false` (or `0`) disables *every* timeout for this request, including
+ * {@link socketTimeout}.
+ *
+ * Not part of the Fetch API specification.
+ *
+ * @default true
+ *
+ * @example
+ * ```js
+ * // The whole request must finish within 5 seconds.
+ * const response = await fetch("https://example.com/", { timeout: 5_000 });
+ * ```
+ *
+ * @example
+ * ```js
+ * // Never time out: useful for long-polling or server-sent events.
+ * const response = await fetch("https://example.com/events", { timeout: false });
+ * ```
+ */
+ timeout?: boolean | number;
+
+ /**
+ * Milliseconds to spend establishing the connection (DNS resolution, the TCP
+ * handshake, and the TLS handshake for `https:`) before the request rejects
+ * with a `TimeoutError: The connection timed out.` The distinct message lets a
+ * retry policy tell "the network is down" from "the server is slow".
+ *
+ * Applies to the initial connection, not to one reopened while following a
+ * redirect. Measured from the moment `fetch()` is called, so time spent queued
+ * behind `BUN_CONFIG_MAX_HTTP_REQUESTS` in-flight requests counts against it.
+ *
+ * `0` or `false` means no connect deadline.
+ *
+ * Not part of the Fetch API specification.
+ *
+ * @default false
+ *
+ * @example
+ * ```js
+ * // Fail fast on a dead network, but allow a slow response body.
+ * await fetch("https://example.com/large", {
+ * connectTimeout: 5_000,
+ * socketTimeout: 120_000,
+ * });
+ * ```
+ */
+ connectTimeout?: number | false;
+
+ /**
+ * Milliseconds the socket may sit with no bytes moving in either direction
+ * before the request rejects with a `TimeoutError: The operation timed out.`
+ * The timer is re-armed on every read and every write, so a response that
+ * trickles in steadily never trips it. Use {@link timeout} when you need a
+ * deadline that fires regardless of activity.
+ *
+ * This is `socketTimeoutMillis` in Ktor and `socket` in `got`. It is the
+ * equivalent of `read`/`bodyTimeout` elsewhere, except that it also covers a
+ * stalled upload.
+ *
+ * Rounded up to whole seconds (and to whole minutes above 240 seconds)
+ * because that is the resolution of the underlying socket timer, which may
+ * also fire up to one tick early. It is a coarse backstop, not a precise
+ * deadline.
+ *
+ * `0` or `false` means no socket deadline. Setting it explicitly outranks
+ * `timeout: false`.
+ *
+ * Not part of the Fetch API specification.
+ *
+ * @default 300_000 (overridable process-wide with `BUN_CONFIG_HTTP_IDLE_TIMEOUT`, in seconds)
+ */
+ socketTimeout?: number | false;
+
/**
* Log the raw HTTP request and response to stdout, as a debugging aid.
* This API may be removed in a future version of Bun without notice.
diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs
index c2397060c68b..8705fb3c01b8 100644
--- a/src/event_loop/EventLoopTimer.rs
+++ b/src/event_loop/EventLoopTimer.rs
@@ -199,6 +199,8 @@ pub enum Tag {
DevServerSweepSourceMaps,
DevServerMemoryVisualizerTick,
AbortSignalTimeout,
+ FetchConnectTimeout,
+ FetchTotalTimeout,
DateHeaderTimer,
BunTest,
EventLoopDelayMonitor,
diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs
index d5db29f77305..6829a2be53c2 100644
--- a/src/http/HTTPContext.rs
+++ b/src/http/HTTPContext.rs
@@ -1011,6 +1011,10 @@ impl HTTPContext {
// the centralised [`proxy_tunnel::raw_as_mut`] backref upgrade.
crate::proxy_tunnel::raw_as_mut(raw).adopt::(client, sock);
client.on_open::(sock)?;
+ // With `SSL` this branch never reaches `first_call` (the tunnel
+ // is established and `on_open` only calls it for plain TCP), so
+ // mark the connect phase done here. Redundant when `!SSL`.
+ client.mark_connected();
client.on_writable::(sock);
} else {
client.on_open::(sock)?;
diff --git a/src/http/Signals.rs b/src/http/Signals.rs
index e8a01a85ff96..55641254d154 100644
--- a/src/http/Signals.rs
+++ b/src/http/Signals.rs
@@ -10,6 +10,10 @@ pub struct Signals {
pub aborted: Option>,
pub cert_errors: Option>,
pub upgraded: Option>,
+ /// Set once the transport is established (h1 socket open, h2 session adopted,
+ /// h3 handshake done). Read from the JS thread by the `connectTimeout`
+ /// deadline to tell "still dialing" from "connected".
+ pub connected: Option>,
pub body_receive_mode: Option>,
}
@@ -59,6 +63,7 @@ impl Signals {
Field::Aborted => self.aborted,
Field::CertErrors => self.cert_errors,
Field::Upgraded => self.upgraded,
+ Field::Connected => self.connected,
}?;
Some(bun_ptr::BackRef::from(ptr))
}
@@ -89,6 +94,7 @@ pub struct Store {
pub aborted: AtomicBool,
pub cert_errors: AtomicBool,
pub upgraded: AtomicBool,
+ pub connected: AtomicBool,
pub body_receive_mode: AtomicU8,
}
@@ -100,6 +106,7 @@ impl Default for Store {
aborted: AtomicBool::new(false),
cert_errors: AtomicBool::new(false),
upgraded: AtomicBool::new(false),
+ connected: AtomicBool::new(false),
body_receive_mode: AtomicU8::new(BodyReceiveMode::AutoPause as u8),
}
}
@@ -113,6 +120,7 @@ impl Store {
aborted: Some(NonNull::from(&self.aborted)),
cert_errors: Some(NonNull::from(&self.cert_errors)),
upgraded: Some(NonNull::from(&self.upgraded)),
+ connected: Some(NonNull::from(&self.connected)),
body_receive_mode: None,
}
}
@@ -156,4 +164,5 @@ pub enum Field {
Aborted,
CertErrors,
Upgraded,
+ Connected,
}
diff --git a/src/http/h2_client/ClientSession.rs b/src/http/h2_client/ClientSession.rs
index d9696a577cf4..976b7d3bc62b 100644
--- a/src/http/h2_client/ClientSession.rs
+++ b/src/http/h2_client/ClientSession.rs
@@ -299,6 +299,9 @@ impl ClientSession {
pub fn adopt(&mut self, client: &mut HTTPClient) {
client.h2_register_abort_tracker(self.socket);
+ // The session's socket is already established, so this request is past
+ // the connect phase even while it waits for SETTINGS in `pending_attach`.
+ client.mark_connected();
// Park instead of attaching when (a) we're inside onData's deliver
// loop — attach() mustn't mutate `streams` under iteration — or (b)
// the server's first SETTINGS hasn't arrived yet, so the real
@@ -332,6 +335,7 @@ impl ClientSession {
/// is routed via the session socket so `abortByHttpId` can find it.
pub fn enqueue(&mut self, client: &mut HTTPClient<'_>) {
client.h2_register_abort_tracker(self.socket);
+ client.mark_connected();
self.pending_attach.push(client.as_erased_ptr().as_ptr());
self.rearm_timeout();
}
@@ -397,6 +401,7 @@ impl ClientSession {
/// DATA, and flush.
pub fn attach(&mut self, client: &mut HTTPClient) {
debug_assert!(self.has_headroom());
+ client.mark_connected();
let send_window = i32::try_from(self.remote_initial_window_size.min(wire::MAX_WINDOW_SIZE))
.expect("int cast");
@@ -511,12 +516,11 @@ impl ClientSession {
}
}
- /// Re-arm the shared socket's idle timer based on the aggregate of every
- /// attached client. With multiplexed streams the per-request
- /// `disable_timeout` flag can't drive the socket directly (last writer
- /// would win and a `{timeout:false}` long-poll could be killed by a
- /// sibling re-arming, or strip the safety net from one that wants it),
- /// so the session disarms only when *every* attached client opted out.
+ /// Re-arm the shared socket's idle timer at the longest any attached client
+ /// asked for — treating `0` ("disarm") as unbounded, not as a minimum. The
+ /// fire handler kills the whole session, so a shorter value would cut off a
+ /// longer-timeout or `timeout: false` sibling; this only delays the short
+ /// one. Per-stream idle tracking would be the exact answer.
fn rearm_timeout(&mut self) {
// The socket is shared by every stream on the session, so arm the
// longest effective idle timeout among them (0 = every client's
diff --git a/src/http/h3_client/ClientSession.rs b/src/http/h3_client/ClientSession.rs
index d4b0c6e8513d..4d9fb1e73534 100644
--- a/src/http/h3_client/ClientSession.rs
+++ b/src/http/h3_client/ClientSession.rs
@@ -115,11 +115,22 @@ impl ClientSession {
self.ref_();
if self.handshake_done {
+ client.mark_connected();
// handshake_done implies qsocket is Some and valid.
self.qsocket_mut().unwrap().make_stream();
}
}
+ /// The QUIC handshake landed: every request parked on this connection is
+ /// past the connect phase, so `connectTimeout` must stop applying to them.
+ pub fn mark_pending_connected(&mut self) {
+ for &stream_ptr in self.pending.iter() {
+ if let Some(client) = stream_mut(stream_ptr).client {
+ client_mut(client).mark_connected();
+ }
+ }
+ }
+
pub fn stream_body_by_http_id(&mut self, async_http_id: u32, ended: bool) {
for &stream_ptr in self.pending.iter() {
let stream = stream_mut(stream_ptr);
diff --git a/src/http/h3_client/callbacks.rs b/src/http/h3_client/callbacks.rs
index f9d8b23bc419..3421c417a773 100644
--- a/src/http/h3_client/callbacks.rs
+++ b/src/http/h3_client/callbacks.rs
@@ -101,6 +101,7 @@ extern "C" fn on_hsk_done(qs: *mut quic::Socket, ok: c_int) {
return;
}
session.handshake_done = true;
+ session.mark_pending_connected();
for _ in 0..session.pending.len() {
qs.make_stream();
}
diff --git a/src/http/lib.rs b/src/http/lib.rs
index 3c12b4afdba6..df9ce34cdfc7 100644
--- a/src/http/lib.rs
+++ b/src/http/lib.rs
@@ -1963,6 +1963,10 @@ impl<'a> HTTPClient<'a> {
}
pub fn first_call(&mut self, socket: HttpSocket) {
+ // Reached from `on_open` for plain TCP and from `on_handshake` for TLS,
+ // so `connectTimeout` covers DNS + TCP + the TLS handshake.
+ self.mark_connected();
+
if FeatureFlags::IS_FETCH_PRECONNECT_SUPPORTED {
if self.flags.is_preconnect_only {
self.on_preconnect::(socket);
@@ -4115,6 +4119,15 @@ impl<'a> HTTPClient<'a> {
socket.set_timeout(self.effective_idle_timeout_seconds());
}
+ /// Transport usable (TCP connected, TLS handshaken, or pooled): where
+ /// `connectTimeout` stops applying. Must be reached on *every* path that can
+ /// send a request; write-once, so only the first connection is bounded.
+ #[inline]
+ pub fn mark_connected(&self) {
+ self.signals
+ .store(signals::Field::Connected, true, Ordering::Release);
+ }
+
fn maybe_pause_receive(&mut self, socket: HttpSocket) {
if self.state.flags.receive_paused
|| self.proxy_tunnel.is_some()
diff --git a/src/http_types/FetchRedirect.rs b/src/http_types/FetchRedirect.rs
index b05e7b418df3..4b9199e3f4f4 100644
--- a/src/http_types/FetchRedirect.rs
+++ b/src/http_types/FetchRedirect.rs
@@ -34,4 +34,5 @@ pub enum CommonAbortReason {
Timeout = 1,
UserAbort = 2,
ConnectionClosed = 3,
+ ConnectionTimeout = 4,
}
diff --git a/src/jsc/AbortSignal.rs b/src/jsc/AbortSignal.rs
index 1c32228b8fae..dce56ffd4bd0 100644
--- a/src/jsc/AbortSignal.rs
+++ b/src/jsc/AbortSignal.rs
@@ -135,7 +135,12 @@ impl AbortSignal {
return Some(AbortReason::Common(match reason {
1 => CommonAbortReason::Timeout,
2 => CommonAbortReason::UserAbort,
- _ => CommonAbortReason::ConnectionClosed,
+ 3 => CommonAbortReason::ConnectionClosed,
+ 4 => CommonAbortReason::ConnectionTimeout,
+ _ => {
+ debug_assert!(false, "unknown CommonAbortReason discriminant {reason}");
+ CommonAbortReason::ConnectionClosed
+ }
}));
}
if js_reason.is_empty() {
diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp
index abe3a18f09b0..bafec052ca2b 100644
--- a/src/jsc/bindings/ErrorCode.cpp
+++ b/src/jsc/bindings/ErrorCode.cpp
@@ -1782,6 +1782,9 @@ JSC::JSValue WebCore::toJS(JSC::JSGlobalObject* globalObject, CommonAbortReason
case CommonAbortReason::ConnectionClosed: {
return createDOMException(globalObject, ExceptionCode::AbortError, "The connection was closed."_s);
}
+ case CommonAbortReason::ConnectionTimeout: {
+ return createDOMException(globalObject, ExceptionCode::TimeoutError, "The connection timed out."_s);
+ }
default: {
break;
}
diff --git a/src/jsc/bindings/webcore/AbortSignal.h b/src/jsc/bindings/webcore/AbortSignal.h
index a41aeebac944..0ed266292c55 100644
--- a/src/jsc/bindings/webcore/AbortSignal.h
+++ b/src/jsc/bindings/webcore/AbortSignal.h
@@ -54,6 +54,7 @@ enum class CommonAbortReason : uint8_t {
Timeout,
UserAbort,
ConnectionClosed,
+ ConnectionTimeout,
};
JSC::JSValue toJS(JSC::JSGlobalObject*, CommonAbortReason);
diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs
index 9ddfc8c0454b..41a5a4304871 100644
--- a/src/runtime/dispatch.rs
+++ b/src/runtime/dispatch.rs
@@ -1048,6 +1048,19 @@ pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, v
timer_arm!(Subprocess<'_>, event_loop_timer, |c, _now, _vm| (*c)
.timeout_callback())
}
+ EventLoopTimerTag::FetchConnectTimeout => {
+ // SAFETY: §Dispatch — tag set together with the container in
+ // `FetchTasklet::get`; `t` is the tasklet's `connect_timeout_timer`.
+ let container = unsafe { FetchTasklet::from_connect_timeout_timer_ptr(t) };
+ // SAFETY: per fn contract.
+ unsafe { (*container).on_connect_timeout() };
+ }
+ EventLoopTimerTag::FetchTotalTimeout => {
+ // SAFETY: §Dispatch — `t` is the tasklet's `total_timeout_timer`.
+ let container = unsafe { FetchTasklet::from_total_timeout_timer_ptr(t) };
+ // SAFETY: per fn contract.
+ unsafe { (*container).on_total_timeout() };
+ }
EventLoopTimerTag::DevServerSweepSourceMaps => {
// `sweep_weak_refs` takes the raw `*EventLoopTimer` and recovers
// the store inside.
diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs
index 7bda0212bc0f..f73256faf404 100644
--- a/src/runtime/webcore/fetch.rs
+++ b/src/runtime/webcore/fetch.rs
@@ -180,6 +180,66 @@ impl HTTPRequestBodyExt for HTTPRequestBody {
}
}
+// ──────────────────────────────────────────────────────────────────────────
+// timeout
+// ──────────────────────────────────────────────────────────────────────────
+
+/// A `fetch({ timeout })` duration in milliseconds. `0` and `false` both mean
+/// "no deadline". `name` is the user-facing option (`timeout`, `connectTimeout`,
+/// ...) so the error echoes back exactly what they wrote.
+fn timeout_ms_arg(global_this: &JSGlobalObject, value: JSValue, name: &str) -> JsResult {
+ if value.is_boolean() && !value.as_boolean() {
+ return Ok(0);
+ }
+ if value.is_number() {
+ let n = value.as_number();
+ // `Infinity` means "no deadline", same as `false`/`0`.
+ if n == f64::INFINITY {
+ return Ok(0);
+ }
+ if !n.is_nan() && n >= 0.0 && n.fract() == 0.0 {
+ // Above u32::MAX a timeout is indistinguishable from "never", and
+ // `Timespec::ms_from_now` takes an i64 — clamp instead of wrapping.
+ return Ok(n.min(f64::from(u32::MAX)) as u32);
+ }
+ }
+ Err(global_this.throw_invalid_arguments(format_args!(
+ "fetch: '{name}' must be a non-negative integer number of milliseconds, or false"
+ )))
+}
+
+/// Read a `number | false` millisecond option off the first init object that
+/// carries it. `None` means the caller never set it.
+fn optional_timeout_ms(
+ global_this: &JSGlobalObject,
+ objects: &[JSValue],
+ name: &str,
+) -> JsResult