Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/bun-types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2080,6 +2080,30 @@ interface BunFetchRequestInit extends RequestInit {
* ```
*/
maxRedirects?: number;

/**
* Control the socket idle timeout for this request. The timer is reset on
* every byte sent or received; if the connection stays idle for longer than
* this, the request fails with a timeout error.
*
* - A finite positive number sets the idle deadline in milliseconds,
* overriding the `BUN_CONFIG_HTTP_IDLE_TIMEOUT` default (5 minutes).
* - `0`, `false`, or a non-finite number disables the idle timer for this
* request.
Comment thread
robobun marked this conversation as resolved.
* - `true` or an omitted value uses the default.
*
* This is not a whole-request deadline; use `AbortSignal.timeout(ms)` for
* that. Not part of the Fetch API specification.
*
* @example
* ```js
* // Allow a slow streaming response to stay idle for up to an hour
* const response = await fetch("https://example.com/llm", {
* timeout: 60 * 60 * 1000,
* });
* ```
*/
timeout?: number | boolean;
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ fn make_client<'a>(
prev_redirect: Vec::new(),
progress_node: None,
flags: Flags::default(),
idle_timeout_seconds: None,
state: InternalState::default(),
tls_props: None,
custom_ssl_ctx: None,
Expand Down Expand Up @@ -267,6 +268,9 @@ pub struct Options<'a> {
pub signals: Option<Signals>,
pub unix_socket_path: Option<ZigStringSlice>,
pub disable_timeout: Option<bool>,
/// Per-request idle timeout override in seconds; see
/// `HTTPClient::idle_timeout_seconds`.
pub idle_timeout_seconds: Option<core::ffi::c_uint>,
pub verbose: Option<HTTPVerboseLevel>,
pub disable_keepalive: Option<bool>,
pub disable_decompression: Option<bool>,
Expand Down Expand Up @@ -516,6 +520,10 @@ impl<'a> AsyncHTTP<'a> {
if let Some(val) = options.disable_timeout {
this.client.flags.disable_timeout = val;
}
if let Some(val) = options.idle_timeout_seconds {
this.client.idle_timeout_seconds =
Some(crate::normalize_idle_timeout_seconds(val.into()));
}
if let Some(val) = options.verbose {
this.client.verbose = val;
}
Expand Down
27 changes: 9 additions & 18 deletions src/http/HTTPThread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1257,25 +1257,16 @@ mod _event_loop_draft {
pub(super) fn on_start(opts: InitOpts) {
Output::Source::configure_named_thread(bun_core::zstr!("HTTP Client"));

// uSockets' long-timeout counter is `% 240` minutes (see
// `us_socket_long_timeout` in packages/bun-usockets/src/socket.c), so
// values above 239 min wrap around and fire early. Clamp here — it's the
// only assignment — so the underlying timer can't wrap, and round values
// above 240s up to a whole minute so `socket.set_timeout`'s floor-to-
// minute long-timer path never yields a timeout *shorter* than requested.
// Normalising once here keeps the h1 (`HTTPClient::set_timeout`) and h2
// (`ClientSession::rearm_timeout`) paths identical without duplicating the
// math at each call site.
let raw: u64 = bun_core::env_var::BUN_CONFIG_HTTP_IDLE_TIMEOUT
.get()
.unwrap_or(300)
.min(239 * 60);
// Normalising once here (see `normalize_idle_timeout_seconds`) keeps
// the h1 (`HTTPClient::set_timeout`) and h2
// (`ClientSession::rearm_timeout`) paths identical without duplicating
// the math at each call site.
crate::IDLE_TIMEOUT_SECONDS.store(
(if raw > 240 {
raw.div_ceil(60) * 60
} else {
raw
}) as core::ffi::c_uint,
crate::normalize_idle_timeout_seconds(
bun_core::env_var::BUN_CONFIG_HTTP_IDLE_TIMEOUT
.get()
.unwrap_or(300),
),
core::sync::atomic::Ordering::Relaxed,
);

Expand Down
47 changes: 28 additions & 19 deletions src/http/h2_client/ClientSession.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,26 +518,35 @@ impl ClientSession {
/// sibling re-arming, or strip the safety net from one that wants it),
/// so the session disarms only when *every* attached client opted out.
fn rearm_timeout(&mut self) {
let want = 'blk: {
for &s in self.streams.values() {
if let Some(c) = stream_ref(s).client_ref() {
if !c.flags.disable_timeout {
break 'blk true;
}
}
}
for &c in &self.pending_attach {
if !pending_client_mut(c).flags.disable_timeout {
break 'blk true;
}
}
false
// The socket is shared by every stream on the session, so arm the
// longest effective idle timeout among them (0 = every client's
// effective deadline is "none", or no clients are attached).
let mut want: core::ffi::c_uint = 0;
let mut any_unbounded = false;
let mut fold = |eff: core::ffi::c_uint| {
any_unbounded |= eff == 0;
want = want.max(eff);
};
self.socket.set_timeout(if want {
crate::idle_timeout_seconds()
} else {
0
});
for &s in self.streams.values() {
if let Some(c) = stream_ref(s).client_ref() {
fold(c.effective_idle_timeout_seconds());
}
}
for &c in &self.pending_attach {
fold(pending_client_mut(c).effective_idle_timeout_seconds());
}
// A client whose effective deadline is 0 ("no timeout": explicit
// `{timeout:false}`, or no override under global=0) contributes 0 to
// the max, so a sibling's short explicit override would arm the
// shared socket and kill both. Restore the pre-per-request-override
// lower bound: floor at the global default, or disarm entirely when
// the global is 0. When every client is unbounded `want` is already 0
// and the timer stays disarmed.
if any_unbounded && want != 0 {
let global = crate::idle_timeout_seconds();
want = if global == 0 { 0 } else { want.max(global) };
}
self.socket.set_timeout(want);
}
Comment thread
robobun marked this conversation as resolved.

/// HTTP-thread wake-up from `scheduleResponseBodyDrain`: JS just enabled
Expand Down
49 changes: 37 additions & 12 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,20 @@ pub fn idle_timeout_seconds() -> c_uint {
IDLE_TIMEOUT_SECONDS.load(Ordering::Relaxed)
}

/// Normalise an idle timeout (seconds) for uSockets' timers: the long-timeout
/// counter wraps `% 240` minutes, so clamp to 239 min, and values above 240s
/// are served by the minute-granularity long timer, so round them up to a
/// whole minute so the floor-to-minute path never fires *earlier* than asked.
#[inline]
pub fn normalize_idle_timeout_seconds(raw: u64) -> c_uint {
Comment thread
cirospaciari marked this conversation as resolved.
let raw = raw.min(239 * 60);
(if raw > 240 {
raw.div_ceil(60) * 60
} else {
raw
}) as c_uint
}

pub const END_OF_CHUNKED_HTTP1_1_ENCODING_RESPONSE_BODY: &[u8] = b"0\r\n\r\n";

/// HTTP-thread-only scratch buffer for building NUL-terminated hostnames.
Expand Down Expand Up @@ -623,6 +637,11 @@ pub struct HTTPClient<'a> {

pub flags: Flags,

/// Per-request override of the global [`IDLE_TIMEOUT_SECONDS`], set from
/// `fetch(url, { timeout: <ms> })`. Already normalised (see
/// [`normalize_idle_timeout_seconds`]). `None` = use the global default.
pub idle_timeout_seconds: Option<c_uint>,

pub state: InternalState<'a>,
pub tls_props: Option<ssl_config::SharedPtr>,
/// The custom SSL context used for this request (None = default context).
Expand Down Expand Up @@ -3893,19 +3912,25 @@ impl<'a> HTTPClient<'a> {
}
}

pub fn set_timeout<S: SocketTimeout>(&self, socket: &S) {
// Duration comes from `IDLE_TIMEOUT_SECONDS` (tunable via
// `BUN_CONFIG_HTTP_IDLE_TIMEOUT`, set low in tests) and is normalised once
// in `HTTPThread::on_start` — clamped to the uSockets long-timer bound and
// rounded up to a whole minute above 240s — so this is a plain
// pass-through. `socket.set_timeout` picks the short-tick timer for values
// ≤ 240s and the minute-granularity long timer above that, so the default
// 300s maps to the same 5-minute long timer as before.
if self.flags.disable_timeout || idle_timeout_seconds() == 0 {
socket.set_timeout(0);
return;
/// The idle timeout to arm for this request, in seconds (0 = disabled):
/// the per-request `fetch({ timeout })` override when present, otherwise
/// the global `BUN_CONFIG_HTTP_IDLE_TIMEOUT` default. Both are already
/// normalised (see [`normalize_idle_timeout_seconds`]).
#[inline]
pub fn effective_idle_timeout_seconds(&self) -> c_uint {
if self.flags.disable_timeout {
return 0;
}
socket.set_timeout(idle_timeout_seconds());
self.idle_timeout_seconds
.unwrap_or_else(idle_timeout_seconds)
}

pub fn set_timeout<S: SocketTimeout>(&self, socket: &S) {
// Values are pre-normalised (global: `HTTPThread::on_start`;
// per-request: `AsyncHTTP::init`) so this is a plain pass-through.
// `socket.set_timeout` picks the short-tick timer for values ≤ 240s
// and the minute-granularity long timer above that.
socket.set_timeout(self.effective_idle_timeout_seconds());
}

fn maybe_pause_receive<const IS_SSL: bool>(&mut self, socket: HttpSocket<IS_SSL>) {
Expand Down
19 changes: 18 additions & 1 deletion src/runtime/webcore/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ fn fetch_impl<const ALLOW_GET_BODY: bool>(
let first_arg = args.next_eat().unwrap();

let mut disable_timeout = false;
let mut idle_timeout_seconds: Option<core::ffi::c_uint> = None;
let mut disable_keepalive = false;
let mut disable_decompression = false;
let mut compress: Option<compress_body::CompressOption> = None;
Expand Down Expand Up @@ -860,7 +861,22 @@ fn fetch_impl<const ALLOW_GET_BODY: bool>(
if timeout_value.is_boolean() {
break 'extract_disable_timeout !timeout_value.as_boolean();
} else if timeout_value.is_number() {
break 'extract_disable_timeout timeout_value.to_int32() == 0;
// A finite positive `timeout` (in ms) also governs the
// socket idle deadline, overriding the global
// `BUN_CONFIG_HTTP_IDLE_TIMEOUT` default.
let ms = timeout_value.as_number();
if ms.is_finite() && ms > 0.0 {
idle_timeout_seconds =
Some((ms / 1000.0).ceil().min(core::ffi::c_uint::MAX as f64)
as core::ffi::c_uint);
}
// `to_int32()` saturates ±Infinity (JSC's
// `coerceJSValueDoubleTruncatingT`, not spec ToInt32),
// so gate on `is_finite()` too so `{timeout: Infinity}`
// disables the timer instead of silently falling back
// to the global default.
break 'extract_disable_timeout !ms.is_finite()
|| timeout_value.to_int32() == 0;
}
}

Expand Down Expand Up @@ -2021,6 +2037,7 @@ fn fetch_impl<const ALLOW_GET_BODY: bool>(
body,
disable_keepalive,
disable_timeout,
idle_timeout_seconds,
disable_decompression,
max_redirects,
reject_unauthorized,
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/webcore/fetch/FetchTasklet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1996,6 +1996,7 @@ impl FetchTasklet {
signals: Some(fetch_tasklet.signals),
unix_socket_path: Some(fetch_options.unix_socket_path),
disable_timeout: Some(fetch_options.disable_timeout),
idle_timeout_seconds: fetch_options.idle_timeout_seconds,
disable_keepalive: Some(fetch_options.disable_keepalive),
disable_decompression: Some(fetch_options.disable_decompression),
max_redirects: fetch_options.max_redirects,
Expand Down Expand Up @@ -2517,6 +2518,8 @@ pub struct FetchOptions {
pub headers: Headers,
pub body: HTTPRequestBody,
pub disable_timeout: bool,
/// Per-request idle-timeout override, from `fetch(url, { timeout: <ms> })`.
pub idle_timeout_seconds: Option<core::ffi::c_uint>,
pub disable_keepalive: bool,
pub disable_decompression: bool,
pub max_redirects: Option<u8>,
Expand Down Expand Up @@ -2553,6 +2556,7 @@ impl Default for FetchOptions {
headers: Headers::default(),
body: HTTPRequestBody::default(),
disable_timeout: false,
idle_timeout_seconds: None,
disable_keepalive: false,
disable_decompression: false,
max_redirects: None,
Expand Down
109 changes: 109 additions & 0 deletions test/js/web/fetch/fetch-http2-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1959,3 +1959,112 @@ test("await fetch() over HTTP/2 resolves on headers, before a content-length bod
server.close();
}
});

// https://github.com/oven-sh/bun/issues/16682 (h2 aggregate path): the
// session's shared socket timer is the max over every attached client's
// effective idle deadline.
test("h2: per-request `timeout` extends the session idle deadline, and {timeout:false} is not killed by a sibling's shorter explicit timeout", async () => {
const HOLD_MS = 10_000;
const holdTimers = new Set<ReturnType<typeof setTimeout>>();
const server = makeH2Server({}, (_req, res) => {
// Hold every request idle past uSockets' worst-case firing window for a
// 1s short-tick timer (~5s), then respond.
const timer = setTimeout(() => {
holdTimers.delete(timer);
try {
res.end("hello");
} catch {}
}, HOLD_MS);
holdTimers.add(timer);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
server.listen(0);
await once(server, "listening");
const { port } = server.address() as import("node:net").AddressInfo;
try {
const run = async (idleDefault: string, body: string) => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"--no-warnings",
"-e",
/* js */ `
const url = "https://localhost:${port}";
const get = init => fetch(url, { tls: { rejectUnauthorized: false }, ...init })
.then(r => r.text(), e => "ERR:" + (e?.code ?? e?.name ?? e));
${body}
`,
],
env: {
...bunEnv,
BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT: "1",
BUN_CONFIG_HTTP_IDLE_TIMEOUT: idleDefault,
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
};
const [extendsDefault, floorsSibling, disarmsOnGlobalZero] = await Promise.all([
// Global idle default = 1s. `{timeout:60000}` must extend the shared
// socket's deadline past the 10s hold; the `{timeout:false}` sibling
// coalesces onto the same session and rides along.
run(
"1",
/* js */ `
const [longTimeout, noTimeout] = await Promise.all([
get({ timeout: 60_000 }),
get({ timeout: false }),
]);
console.log(JSON.stringify({ longTimeout, noTimeout }));
`,
),
// Global idle default = 20s. `{timeout:false}` contributes 0 to the
// session max and the `{timeout:1000}` sibling contributes 1s; the
// session must floor at the 20s global default so the no-timeout
// stream is not killed by the sibling's short explicit deadline.
run(
"20",
/* js */ `
const [noTimeout, shortTimeout] = await Promise.all([
get({ timeout: false }),
get({ timeout: 1000 }),
]);
console.log(JSON.stringify({ noTimeout, shortTimeout }));
`,
),
// Global idle default = 0 (disabled). A plain fetch with no `timeout`
// option inherits effective deadline 0 without setting the
// `disable_timeout` flag; the session must still disarm rather than
// letting the `{timeout:1000}` sibling arm the shared socket.
run(
"0",
/* js */ `
const [plain, shortTimeout] = await Promise.all([
get(undefined),
get({ timeout: 1000 }),
]);
console.log(JSON.stringify({ plain, shortTimeout }));
`,
),
]);
expect(extendsDefault).toEqual({
stdout: JSON.stringify({ longTimeout: "hello", noTimeout: "hello" }),
stderr: "",
exitCode: 0,
});
expect(floorsSibling).toEqual({
stdout: JSON.stringify({ noTimeout: "hello", shortTimeout: "hello" }),
stderr: "",
exitCode: 0,
});
expect(disarmsOnGlobalZero).toEqual({
stdout: JSON.stringify({ plain: "hello", shortTimeout: "hello" }),
stderr: "",
exitCode: 0,
});
} finally {
for (const timer of holdTimers) clearTimeout(timer);
server.close();
}
}, 60_000);
Loading
Loading