Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/runtime/http/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ Bun.serve({
});
```

`idleTimeout` is also how long Bun keeps an idle keep-alive connection open between requests. Behind a load balancer or a reverse proxy, set `idleTimeout` above the keep-alive time the proxy uses for its upstream connections, or set it to `0`. [`server.timeout(req, seconds)`](#server-timeout-request-seconds) lasts until the response to that request is complete. After that, the connection is under `idleTimeout` again.

<Note>
**Streaming & Server-Sent Events** — The idle timer applies while a response is being streamed. If your stream goes
quiet for longer than `idleTimeout`, Bun closes the connection mid-response. For long-lived streams, disable the
Expand Down
6 changes: 6 additions & 0 deletions packages/bun-types/serve.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,9 @@ declare module "bun" {
* Sets the number of seconds to wait before timing out a connection
* due to inactivity.
*
* This is also how long an idle keep-alive connection stays open
* between requests. `0` disables the timeout. The maximum is `255`.
Comment thread
robobun marked this conversation as resolved.
Resolved
*
* @default 10
*/
idleTimeout?: number;
Expand Down Expand Up @@ -1223,6 +1226,9 @@ declare module "bun" {
/**
* Reset the idle timeout of the given Request to the given number of seconds. `0` means no timeout.
*
* The value lasts until the response to that request is complete. After
* that, the connection is under `idleTimeout` again.
*
* @example
* ```js
* export default {
Expand Down
11 changes: 11 additions & 0 deletions packages/bun-usockets/src/eventing/libuv.c
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,17 @@ struct us_loop_t *us_timer_loop(struct us_timer_t *t) {
return internal_cb->loop;
}

void us_internal_sweep_soon(struct us_loop_t *loop) {
struct us_internal_callback_t *internal_cb =
(struct us_internal_callback_t *)loop->data.sweep_timer;

/* Not us_timer_set(): it does not restart a sweep timer that runs. */
if (internal_cb->has_added_timer_to_event_loop) {
uv_timer_start((uv_timer_t *)(internal_cb + 1), timer_cb, 1,
LIBUS_TIMEOUT_GRANULARITY * 1000);
}
}

// async (internal only)
struct us_internal_async *us_internal_create_async(struct us_loop_t *loop,
int fallthrough,
Expand Down
3 changes: 3 additions & 0 deletions packages/bun-usockets/src/internal/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in
void us_internal_timer_sweep(us_loop_r loop);
void us_internal_enable_sweep_timer(struct us_loop_t *loop);
void us_internal_disable_sweep_timer(struct us_loop_t *loop);
/* Testing only: the next loop iteration runs the sweep, as if
* LIBUS_TIMEOUT_GRANULARITY seconds had passed. */
void us_internal_sweep_soon(struct us_loop_t *loop);
#ifndef LIBUS_USE_LIBUV
/* CLOCK_MONOTONIC in ns. The clock every deadline on the loop is measured
* against, so anything comparing against one must read it and not another. */
Expand Down
6 changes: 6 additions & 0 deletions packages/bun-usockets/src/loop.c
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) {
us_internal_timer_sweep(loop);
}

void us_internal_sweep_soon(struct us_loop_t *loop) {
if (loop->data.sweep_next_tick_ns >= 0) {
loop->data.sweep_next_tick_ns = 0;
}
}

#endif


Expand Down
5 changes: 5 additions & 0 deletions packages/bun-uws/src/App.h
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,11 @@ struct TemplatedApp {
return std::move(*this);
}

TemplatedApp &&setIdleTimeout(uint8_t seconds) {
httpContext->getSocketContextData()->idleTimeout = seconds;
return std::move(*this);
}

TemplatedApp &&setMaxHeadersCount(uint32_t maxHeadersCount) {
httpContext->getSocketContextData()->maxHeadersCount = maxHeadersCount;
return std::move(*this);
Expand Down
7 changes: 5 additions & 2 deletions packages/bun-uws/src/HttpContext.h
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ struct HttpContext {
* response's per-request framing bits (204/304, close-delimited,
* trailers), which writeHead only ever sets: a stale one would
* strip the next response's body framing. */
httpResponseData->resetResponseState();
httpResponseData->resetResponseState(httpContextData->idleTimeout);

/* An ancient (HTTP/1.0) request gets no keep-alive and no chunked
* framing; so does an explicit `Connection: close`. */
Expand Down Expand Up @@ -681,7 +681,7 @@ struct HttpContext {
/* We still have some more data coming in later, so reset timeout */
/* Only reset timeout if we got enough bytes (16kb/sec) since last time we reset here */
httpResponseData->received_bytes_per_timeout += (unsigned int) data.length();
if (httpResponseData->received_bytes_per_timeout >= HTTP_RECEIVE_THROUGHPUT_BYTES * httpResponseData->idleTimeout) {
if (httpResponseData->received_bytes_per_timeout >= HTTP_RECEIVE_THROUGHPUT_BYTES * httpResponseData->idleTimeoutInForce) {
((HttpResponse<SSL> *) user)->resetTimeout();
httpResponseData->received_bytes_per_timeout = 0;
}
Expand Down Expand Up @@ -973,6 +973,9 @@ struct HttpContext {
/* Drain any socket buffer, this might empty our backpressure and thus finish the request */
asyncSocket->flush();

/* The tail of a response that ended under backpressure may be out now. */
reinterpret_cast<HttpResponse<SSL> *>(s)->endTimeoutOverrideIfDrained();

/* node:http compat: reads were paused while pipelined responses were
* queued and stayed paused because the socket still had outgoing
* backpressure when the queue drained; now that it has flushed, read
Expand Down
4 changes: 4 additions & 0 deletions packages/bun-uws/src/HttpContextData.h
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ struct alignas(16) HttpContextData {
public:

HttpFlags flags;
/* The server's idle timeout in seconds, 0 = never. Every response starts
* under it (resetResponseState); HttpResponse::setTimeout() replaces it
* for one response. */
uint8_t idleTimeout = 10;

bool isParsing(struct us_socket_t *s) const { return flags.isParsingHttp && parsingSocket == s; }
};
Expand Down
36 changes: 31 additions & 5 deletions packages/bun-uws/src/HttpResponse.h
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,40 @@ struct HttpResponse : public AsyncSocket<SSL> {
return (HttpResponseData<SSL> *) Super::getAsyncSocketData();
}

/* Replaces the server's idle timeout (HttpContextData::idleTimeout) for
* the response in flight, and arms it. */
void setTimeout(uint8_t seconds) {
auto* data = getHttpResponseData();
data->idleTimeout = seconds;
Super::timeout(data->idleTimeout);
if (!(data->state & HttpResponseData<SSL>::HTTP_RESPONSE_PENDING)) {
return;
}
data->state |= HttpResponseData<SSL>::HTTP_TIMEOUT_OVERRIDDEN;
data->idleTimeoutInForce = seconds;
Super::timeout(data->idleTimeoutInForce);
}

/* A setTimeout() override ends when its response is complete and
* hasFullyDrained(): a response that ended under backpressure keeps it
* until onWritable has drained the tail. Arms the server's value when it
* ends the override, so an end path's own arm may come before or after. */
void endTimeoutOverrideIfDrained() {
auto* data = getHttpResponseData();
if ((data->state & (HttpResponseData<SSL>::HTTP_TIMEOUT_OVERRIDDEN | HttpResponseData<SSL>::HTTP_RESPONSE_PENDING)) == HttpResponseData<SSL>::HTTP_TIMEOUT_OVERRIDDEN) [[unlikely]] {
/* The cork buffer can hold the end of the response. Send it
* first: a tail the socket does not take is backpressure too. */
Super::sendCorked();
if (Super::hasFullyDrained()) {
data->state &= ~HttpResponseData<SSL>::HTTP_TIMEOUT_OVERRIDDEN;
data->idleTimeoutInForce = HttpContext<SSL>::getSocketContextDataS((us_socket_t *) this)->idleTimeout;
Super::timeout(data->idleTimeoutInForce);
}
}
}

void resetTimeout() {
auto* data = getHttpResponseData();

Super::timeout(data->idleTimeout);
Super::timeout(data->idleTimeoutInForce);
}
/* The chunk-size line of a chunk. Returns its length. */
static constexpr size_t CHUNK_HEAD_MAX = 10;
Expand Down Expand Up @@ -486,9 +510,11 @@ struct HttpResponse : public AsyncSocket<SSL> {
* connection close gate does not apply (Connection: close, HTTP/1.0
* and close-when-idle describe the HTTP connection, not the WebSocket
* that takes over the socket), and the cork stays so the handshake
* batches with the first frames written from open(). */
* batches with the first frames written from open(). A setTimeout()
* override is dropped for that reason too: its hand-back in
* markDone() sends the cork, and the WebSocket arms its own timeout. */
auto* responseData = getHttpResponseData();
responseData->state |= HttpResponseData<SSL>::HTTP_END_CALLED;
responseData->state = (responseData->state | HttpResponseData<SSL>::HTTP_END_CALLED) & ~HttpResponseData<SSL>::HTTP_TIMEOUT_OVERRIDDEN;
responseData->markDone(this);

/* Grab the httpContext from res */
Expand Down
17 changes: 14 additions & 3 deletions packages/bun-uws/src/HttpResponseData.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {

/* We are done with this request */
this->state &= ~HttpResponseData<SSL>::HTTP_RESPONSE_PENDING;
uwsRes->endTimeoutOverrideIfDrained();
Comment thread
robobun marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved

HttpResponseData<SSL> *httpResponseData = uwsRes->getHttpResponseData();
/* A queued pipelined response (node:http) still owes output on this
Expand Down Expand Up @@ -171,6 +172,10 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {
HTTP_NODE_PEER_ENDED = 1 << 22,
/* node:http socket.end() or destroySoon() ended the write side. Until the socket closes, the request in flight still gets its body. */
HTTP_NODE_HALF_CLOSED = 1 << 23,
/* HttpResponse::setTimeout() replaced the server's idle timeout in
* idleTimeoutInForce for the response in flight. Not derivable from the
* byte: before the first request it is 10, whatever the server's value. */
HTTP_TIMEOUT_OVERRIDDEN = 1 << 27,

/* Bits that describe the connection rather than the response in flight.
* There is one HttpResponseData per socket, reused by every request on a
Expand All @@ -188,9 +193,13 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {
/* Begin a new response on this connection. Clearing the word in one go is
* what keeps a 204/304 (HTTP_NO_BODY_STATUS), a close-delimited body or a
* previous response's trailers from leaking into the next request on a
* keep-alive socket; only the connection-scoped bits are carried over. */
void resetResponseState() {
* keep-alive socket; only the connection-scoped bits are carried over.
* The idle timeout starts over as well: serverIdleTimeout is
* HttpContextData::idleTimeout, so a setTimeout() override that its own
* response could not hand back yet ends here. */
void resetResponseState(uint8_t serverIdleTimeout) {
state = (state & HTTP_CONNECTION_SCOPED) | HTTP_RESPONSE_PENDING;
idleTimeoutInForce = serverIdleTimeout;
/* A response is in flight again (a new request dispatched, or a queued
* pipelined response activated), so the connection is not idle. */
this->isIdle = false;
Expand Down Expand Up @@ -229,7 +238,9 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {

/* Current state (content-length sent, status sent, write called, etc */
uint32_t state = 0;
uint8_t idleTimeout = 10; // default HTTP_TIMEOUT 10 seconds
/* Seconds resetTimeout() arms: 10 until the first request, then the
* server's value (resetResponseState) or a setTimeout() override. */
uint8_t idleTimeoutInForce = 10;
/* The parser writes this through a bool& (getHeaders / consumePostPadded),
* so it cannot live in `state`. */
bool isConnectRequest = false;
Expand Down
3 changes: 3 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,9 @@ export const socketFaultInjection = {
clear: $bindgen("socket_body.bind.ts", "clearSocketFaults"),
};

// Runs the 4 s usockets timeout sweep of this thread in its next loop iteration: every socket moves one step closer to its timeout, so not beside tests that need real idle time.
export const runSocketTimeoutSweepSoon: () => void = $bindgen("socket_body.bind.ts", "runSocketTimeoutSweepSoon");

export const namedPipeInternals = {
/**
* Live native contexts behind sockets over Windows named pipes: one per
Expand Down
2 changes: 1 addition & 1 deletion src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ static void startPipelinedResponseImpl(us_socket_t* socket, bool isAncient, bool
httpResponseData->offset = 0;
// Clears the finished response's framing bits and keeps the connection-scoped
// ones (notably HTTP_NODE_READS_PAUSED, read again below).
httpResponseData->resetResponseState();
httpResponseData->resetResponseState(uWS::HttpContext<SSL>::getSocketContextDataS(socket)->idleTimeout);
if (connectionClose) {
httpResponseData->state |= uWS::HttpResponseData<SSL>::HTTP_CONNECTION_CLOSE;
}
Expand Down
3 changes: 1 addition & 2 deletions src/runtime/server/DirectoryRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ impl DirectoryRoute {
};
if let Some(server) = this.server.get() {
server.on_pending_request();
resp.timeout(server.config().idle_timeout);
resp.start_timeout();
}

let mut path_buf = bun_paths::path_buffer_pool::get();
Expand Down Expand Up @@ -248,7 +248,6 @@ impl DirectoryRoute {
pollable: false,
offset: body_offset,
length: Some(body_len),
idle_timeout: server.config().idle_timeout,
owner: StreamOwner::DirectoryRoute(guard.into_route()),
});
}
Expand Down
8 changes: 2 additions & 6 deletions src/runtime/server/FileResponseStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ pub(crate) struct FileResponseStream {
event_loop_handle: Cell<EventLoopHandle>,
fd: Cell<Fd>,
auto_close: Cell<bool>,
idle_timeout: Cell<u8>,

/// Taken by whichever of complete / abort / error fires first.
owner: Cell<Option<StreamOwner>>,
Expand Down Expand Up @@ -109,7 +108,6 @@ pub(crate) struct StartOptions {
/// Maximum bytes to send; `None` reads to EOF. For regular files this
/// should be `stat.size - offset` (after Range/slice clamping).
pub length: Option<u64>,
pub idle_timeout: u8,
pub owner: StreamOwner,
}

Expand Down Expand Up @@ -171,7 +169,6 @@ impl FileResponseStream {
)),
fd: Cell::new(opts.fd),
auto_close: Cell::new(opts.auto_close),
idle_timeout: Cell::new(opts.idle_timeout),
owner: Cell::new(Some(opts.owner)),
mode: Cell::new(if use_sendfile {
Mode::Sendfile
Expand All @@ -189,7 +186,8 @@ impl FileResponseStream {
let this_ref = unsafe { &*this };

let resp = this_ref.resp.get();
resp.timeout(opts.idle_timeout);
// Arm only: the value is the response's (`server.timeout()` may have set it). The timer can be off here: uWS clears it at the last request-body chunk, the WebSocket-upgrade fetch entry never arms it, and sendfile(2) makes no uWS write that would arm it.
resp.reset_timeout();
resp.on_aborted(
|p: *mut FileResponseStream, r| {
// SAFETY: uWS hands back the userdata pointer set below; the
Expand Down Expand Up @@ -313,7 +311,6 @@ impl FileResponseStream {
}

let resp = self.resp.get();
resp.timeout(self.idle_timeout.get());

if state == ReadState::Eof {
self.insert_state(State::RESPONSE_DONE);
Expand Down Expand Up @@ -390,7 +387,6 @@ impl FileResponseStream {
self.finish();
return true;
}
self.resp.get().timeout(self.idle_timeout.get());
self.hold_read_ref();
// A paused POSIX reader ignores `read()`.
self.reader_mut().unpause();
Expand Down
3 changes: 1 addition & 2 deletions src/runtime/server/FileRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ impl FileRoute {
let route = RefPtr::from_this(this);
if let Some(server) = route.server.get() {
server.on_pending_request();
resp.timeout(server.config().idle_timeout);
resp.start_timeout();
}
let store = route.blob.store().unwrap().clone();
let Some(path) = store.get_path() else {
Expand Down Expand Up @@ -322,7 +322,6 @@ impl FileRoute {
pollable,
offset,
length,
idle_timeout: server.config().idle_timeout,
owner: StreamOwner::FileRoute(route),
});
}
Expand Down
1 change: 0 additions & 1 deletion src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2057,7 +2057,6 @@ where
} else {
None
},
idle_timeout: server.config().idle_timeout,
owner: file_response_stream::StreamOwner::Ctx {
ctx: self.as_ctx_ptr().cast::<c_void>(),
on_complete: Self::on_file_stream_complete,
Expand Down
10 changes: 3 additions & 7 deletions src/runtime/server/StaticRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ impl StaticRoute {
Self::retain_for_response(this);
if let Some(server) = this.server.get() {
server.on_pending_request();
resp.timeout(server.config().idle_timeout);
resp.start_timeout();
}
resp.corked(|| this.render_metadata_and_end(resp));
Self::on_response_complete(this, resp);
Expand Down Expand Up @@ -327,7 +327,7 @@ impl StaticRoute {
Self::retain_for_response(this);
if let Some(server) = this.server.get() {
server.on_pending_request();
resp.timeout(server.config().idle_timeout);
resp.start_timeout();
}
let mut finished = false;
this.do_render_blob(resp, &mut finished);
Expand Down Expand Up @@ -407,10 +407,6 @@ impl StaticRoute {
}

fn on_writable(this: ThisPtr<Self>, write_offset: u64, resp: AnyResponse) -> bool {
if let Some(server) = this.server.get() {
resp.timeout(server.config().idle_timeout);
}

if !this.on_writable_bytes(write_offset, resp) {
return false;
}
Expand Down Expand Up @@ -558,7 +554,7 @@ impl StaticRoute {
Self::retain_for_response(this);
if let Some(server) = this.server.get() {
server.on_pending_request();
resp.timeout(server.config().idle_timeout);
resp.start_timeout();
}
this.do_write_status(status, resp);
this.do_write_headers(resp);
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -838,7 +838,7 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
self.on_pending_request();

req.set_yield(false);
resp_ref.timeout(self.config.get().idle_timeout);
resp_ref.reset_timeout();

// Since we do timeouts by default, we should tell the user when
// this happens - but limit it to only warn once.
Expand Down Expand Up @@ -1292,7 +1292,7 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
// The listener and what it starts continue the script that made the server.
let _context = vm.enter_context(self.context.get());
req.set_yield(false);
resp.timeout(self.config.get().idle_timeout);
resp.reset_timeout();

let global = self.global_this();
let this_object = self.js_value.try_get().unwrap_or(JSValue::UNDEFINED);
Expand Down Expand Up @@ -2934,6 +2934,7 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {

// S012: `NewApp<SSL>` is a ZST opaque — safe `*mut → &mut` deref.
bun_opaque::opaque_deref_mut(app).filter(Self::on_connection_filter, this.cast::<c_void>());
bun_opaque::opaque_deref_mut(app).set_idle_timeout(this_ref.config.get().idle_timeout);

if this_ref.config.get().is_node_http_server {
this_ref.set_using_custom_expect_handler(true);
Expand Down
Loading
Loading