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
19 changes: 18 additions & 1 deletion docs/runtime/http/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,18 @@ await server.stop();
await server.stop(true);
```

By default, `stop()` allows in-flight requests and WebSocket connections to complete. Pass `true` to immediately terminate all connections.
By default, `stop()` allows in-flight requests and WebSocket connections to complete. Idle keep-alive connections are closed immediately, and connections with a request in flight close once their response has been sent. Pass `true` to immediately terminate all connections instead. The returned promise resolves once every connection has closed.

### `server.closeIdleConnections()`

To close keep-alive connections that are not currently serving a request, without stopping the server:

```ts
const closed = server.closeIdleConnections();
console.log(`closed ${closed} idle connections`);
```

It returns the number of connections it closed. Connections with a request in flight and open WebSockets are untouched, and the server keeps accepting new connections. This mirrors `node:http`'s `server.closeIdleConnections()`, which returns nothing.

### `server.ref()` and `server.unref()`

Expand Down Expand Up @@ -558,6 +569,12 @@ interface Server extends Disposable {
*/
stop(closeActiveConnections?: boolean): Promise<void>;

/**
* Close idle keep-alive connections without stopping the server.
* @returns The number of connections closed
*/
closeIdleConnections(): number;

/**
* Update handlers without restarting the server.
* Only fetch and error handlers can be updated.
Expand Down
18 changes: 17 additions & 1 deletion packages/bun-types/serve.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,13 +910,29 @@ declare module "bun" {
/**
* Stop listening to prevent new connections from being accepted.
*
* By default, it does not cancel in-flight requests or websockets. That means it may take some time before all network activity stops.
* By default, it does not cancel in-flight requests or websockets. Idle
* keep-alive connections are closed right away, and connections with a
* request in flight close as soon as their response completes. That means
* it may take some time before all network activity stops.
*
* The returned promise resolves once every connection is closed.
*
* @param closeActiveConnections Immediately terminate in-flight requests, websockets, and stop accepting new connections.
* @default false
*/
stop(closeActiveConnections?: boolean): Promise<void>;

/**
* Close every connection that is not currently sending a request or
* waiting for a response, without stopping the server.
*
* In-flight requests and open WebSockets are untouched, and the server
* keeps accepting new connections.
*
* @returns The number of connections that were closed.
*/
closeIdleConnections(): number;

/**
* Update the `fetch` and `error` handlers without restarting the server.
*
Expand Down
18 changes: 13 additions & 5 deletions packages/bun-uws/src/App.h
Original file line number Diff line number Diff line change
Expand Up @@ -397,20 +397,28 @@ struct TemplatedApp {
return std::move(*this);
}

/** Closes all connections connected to this server which are not sending a request or waiting for a response. Does not close the listen socket. */
TemplatedApp &&closeIdle() {
/** Closes all connections connected to this server which are not sending a request or waiting for a response. Does not close the listen socket.
* With closeWhenIdle set, connections that are busy right now are marked to close as soon as their in-flight work completes (graceful shutdown);
* upgraded WebSockets and CONNECT/Upgrade tunnels never become idle, so they are left alone either way.
* Returns the number of connections closed. */
size_t closeIdle(bool closeWhenIdle = false) {
auto *group = httpContext->getSocketGroup();
struct us_socket_t *s = group->head_sockets;
size_t closed = 0;
while (s) {
// no matter the type of socket will always contain the AsyncSocketData
auto *data = ((AsyncSocket<SSL> *) s)->getAsyncSocketData();
/* The HTTP group only holds HTTP sockets (an upgraded WebSocket is
* adopted into its own group), so the ext block is an HttpResponseData. */
auto *data = (HttpResponseData<SSL> *) ((AsyncSocket<SSL> *) s)->getAsyncSocketData();
struct us_socket_t *next = s->next;
if (data->isIdle) {
us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, 0);
closed++;
} else if (closeWhenIdle) {
data->state |= HttpResponseData<SSL>::HTTP_CLOSE_WHEN_IDLE;
}
s = next;
}
return std::move(*this);
return closed;
}

template <typename UserData>
Expand Down
7 changes: 6 additions & 1 deletion packages/bun-uws/src/HttpContext.h
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,12 @@ struct HttpContext {
/* Cork this socket */
((AsyncSocket<SSL> *) s)->cork();

/* Mark that we are inside the parser now */
/* Mark that we are inside the parser now. Save/restore the parsed
* socket: node:http's read replay can nest a parse inside another
* socket's dispatch. */
httpContextData->flags.isParsingHttp = true;
struct us_socket_t *prevParsingSocket = httpContextData->parsingSocket;
httpContextData->parsingSocket = s;
httpResponseData->isIdle = false;

/* node:http compat: maintain the headers/request timeout window (see
Expand Down Expand Up @@ -581,6 +585,7 @@ struct HttpContext {

/* Mark that we are no longer parsing Http */
httpContextData->flags.isParsingHttp = false;
httpContextData->parsingSocket = prevParsingSocket;
/* If we got fullptr that means the parser wants us to close the socket from error (same as calling the errorHandler) */
if (httpErrorStatusCode) {
/* node:http compat: parse errors surface as the server's 'clientError'
Expand Down
8 changes: 8 additions & 0 deletions packages/bun-uws/src/HttpContextData.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ struct alignas(16) HttpContextData {
/* This is the currently browsed-to router when using SNI */
HttpRouter<RouterData> *currentRouter = &router;

/* The socket onData is currently parsing, nullptr outside a parse. The
* close gates in internalEnd need the per-socket identity: a DIFFERENT
* socket's response can complete inside this window (a microtask drained
* during a request dispatch), and the context-wide isParsingHttp bit
* alone would wrongly defer its close to a post-parse gate that only
* checks the parsed socket. */
struct us_socket_t *parsingSocket = nullptr;

/* This is the default router for default SNI or non-SSL */
HttpRouter<RouterData> router;
void *upgradedWebSocket = nullptr;
Expand Down
64 changes: 44 additions & 20 deletions packages/bun-uws/src/HttpResponse.h
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,24 @@ struct HttpResponse : public AsyncSocket<SSL> {
getHttpResponseData()->state |= HttpResponseData<SSL>::HTTP_WROTE_DATE_HEADER;
}

/* Shutdown+close when the connection is marked to close (Connection:
* close, peer FIN, close-when-idle), the response is complete and every
* outgoing byte has been flushed. Returns true when the socket was closed. */
bool closeIfDoneAndMarked(HttpResponseData<SSL> *httpResponseData) {
if (httpResponseData->shouldCloseConnection()) {
if ((httpResponseData->state & HttpResponseData<SSL>::HTTP_RESPONSE_PENDING) == 0) {
if (((AsyncSocket<SSL> *) this)->hasFullyDrained()) {
((AsyncSocket<SSL> *) this)->shutdown();
/* We need to force close after sending FIN since we want to hinder
* clients from keeping to send their huge data */
((AsyncSocket<SSL> *) this)->close();
return true;
}
}
}
return false;
}

/* Returns true on success, indicating that it might be feasible to write more data.
* Will start timeout if stream reaches totalSize or write failure.
* keepCorked: if true, skip the trailing uncork so the caller can batch
Expand Down Expand Up @@ -189,19 +207,22 @@ struct HttpResponse : public AsyncSocket<SSL> {

/* We need to check if we should close this socket here now */
if (!Super::isCorked()) {
if (httpResponseData->shouldCloseConnection()) {
if ((httpResponseData->state & HttpResponseData<SSL>::HTTP_RESPONSE_PENDING) == 0) {
if (((AsyncSocket<SSL> *) this)->hasFullyDrained()) {
((AsyncSocket<SSL> *) this)->shutdown();
/* We need to force close after sending FIN since we want to hinder
* clients from keeping to send their huge data */
((AsyncSocket<SSL> *) this)->close();
return true;
}
}
if (closeIfDoneAndMarked(httpResponseData)) {
return true;
}
} else if (!keepCorked) {
this->uncork();
/* That uncork released our cork slot, so the cork() wrapper's
* post-uncork close gate will not run. When THIS socket is the
* one being parsed, onData's post-parse gate closes it once
* the buffer is fully consumed; any other socket (an async
* handler completing, possibly inside another socket's parse
* window via a drained microtask) gets no later gate, so close
* here. */
if (HttpContext<SSL>::fromSocket((us_socket_t *) this)->getSocketContextData()->parsingSocket != (us_socket_t *) this
&& closeIfDoneAndMarked(httpResponseData)) {
return true;
}
Comment thread
robobun marked this conversation as resolved.
}

/* tryEnd can never fail when in chunked mode, since we do not have tryWrite (yet), only write */
Expand Down Expand Up @@ -255,18 +276,15 @@ struct HttpResponse : public AsyncSocket<SSL> {

/* We need to check if we should close this socket here now */
if (!Super::isCorked()) {
if (httpResponseData->shouldCloseConnection()) {
if ((httpResponseData->state & HttpResponseData<SSL>::HTTP_RESPONSE_PENDING) == 0) {
if (((AsyncSocket<SSL> *) this)->hasFullyDrained()) {
((AsyncSocket<SSL> *) this)->shutdown();
/* We need to force close after sending FIN since we want to hinder
* clients from keeping to send their huge data */
((AsyncSocket<SSL> *) this)->close();
}
}
}
closeIfDoneAndMarked(httpResponseData);
} else if (!keepCorked) {
this->uncork();
/* Same as the chunked arm above: the cork slot is gone, so
* run the close gate here unless THIS socket is the one
* being parsed (then onData's post-parse gate handles it). */
if (HttpContext<SSL>::fromSocket((us_socket_t *) this)->getSocketContextData()->parsingSocket != (us_socket_t *) this) {
closeIfDoneAndMarked(httpResponseData);
}
}
Comment thread
robobun marked this conversation as resolved.
}

Expand Down Expand Up @@ -591,6 +609,12 @@ struct HttpResponse : public AsyncSocket<SSL> {
* Starts a timeout in some cases. Returns [ok, hasResponded] */
std::pair<bool, bool> tryEnd(std::string_view data, uintmax_t totalSize = 0, bool closeConnection = false) {
bool ok = internalEnd(data, totalSize, true, true, closeConnection);
/* internalEnd's close gate may have closed the socket (destructing the
* ext hasResponded() reads); that only happens once the response has
* completed, so report responded without touching it. */
if (us_socket_is_closed((us_socket_t *) this)) {
return {ok, true};
}
return {ok, hasResponded()};
}

Expand Down
18 changes: 15 additions & 3 deletions packages/bun-uws/src/HttpResponseData.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {
this->state &= ~HttpResponseData<SSL>::HTTP_RESPONSE_PENDING;

HttpResponseData<SSL> *httpResponseData = uwsRes->getHttpResponseData();
httpResponseData->isIdle = true;
/* A queued pipelined response (node:http) still owes output on this
* connection, so it is not idle between the responses. */
httpResponseData->isIdle = httpResponseData->nodeHttpQueuedPipelinedCount == 0;
}

/* Caller of onWritable. It is possible onWritable calls markDone so we need to borrow it. */
Expand Down Expand Up @@ -143,13 +145,19 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {
* into the shared word so the shared response-end path (internalEnd) never
* has to touch the node-only field. */
HTTP_NODE_HAS_RESPONSE_TRAILERS = 1 << 16,
/* Close this connection the next time it is idle (no request being
* received, no response in flight or queued). Set by
* App::closeIdle(true) on connections that were busy during a graceful
* shutdown sweep; the shouldCloseConnection() gates act on it once the
* in-flight work completes. */
HTTP_CLOSE_WHEN_IDLE = 1 << 17,

/* Bits that describe the connection rather than the response in flight.
* There is one HttpResponseData per socket, reused by every request on a
* keep-alive connection, so starting a new response clears the rest of the
* word (resetResponseState) - these have to survive that. */
HTTP_CONNECTION_SCOPED = HTTP_NODE_PARSING_STOPPED | HTTP_NODE_READS_PAUSED
| HTTP_NODE_TUNNEL_AFTER_BODY | HTTP_NODE_RECEIVED_FIN,
| HTTP_NODE_TUNNEL_AFTER_BODY | HTTP_NODE_RECEIVED_FIN | HTTP_CLOSE_WHEN_IDLE,
};

/* Begin a new response on this connection. Clearing the word in one go is
Expand All @@ -158,6 +166,9 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {
* keep-alive socket; only the connection-scoped bits are carried over. */
void resetResponseState() {
state = (state & HTTP_CONNECTION_SCOPED) | HTTP_RESPONSE_PENDING;
/* 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;
}

/* Set or clear a flag from a runtime bool. */
Expand Down Expand Up @@ -214,7 +225,8 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {
* any) has completed and all buffered outgoing data has been flushed. */
bool shouldCloseConnection() const {
return (state & HTTP_CONNECTION_CLOSE)
|| ((state & HTTP_NODE_RECEIVED_FIN) && nodeHttpQueuedPipelinedCount == 0);
|| ((state & HTTP_NODE_RECEIVED_FIN) && nodeHttpQueuedPipelinedCount == 0)
|| ((state & HTTP_CLOSE_WHEN_IDLE) && this->isIdle);
Comment on lines 225 to +229

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 One layer up from the shims fb828c2/8b98bd35/a0337dc3 guarded: after callOnWritable returns, its own if (onWritable) restore (HttpResponseData.h:78-83) and HttpContext::onWritable's tail (HttpContext.h:743-746, 777-800) read httpResponseData->state/offset, hasFullyDrained() (unguarded — reads the destructed BackPressure buffer), shouldCloseConnection(), and resetTimeout() on an ext that ~HttpResponseData() already ran on when the Rust onWritable callback (on_writable_bytes, uncorked) called try_end and internalEnd's uncorked closeIfDoneAndMarked gate closed the socket synchronously. Pre-existing for HTTP_CONNECTION_CLOSE; this line adds the HTTP_CLOSE_WHEN_IDLE && isIdle trigger, widening it to every busy connection during stop(false). Same practically-benign class (ext inline in closed-list us_socket_t swept at tick end; POD reads on mapped memory; flush() at :761 IS guarded so that specific write is safe), so filing under REVIEW.md "fix the whole class — grep for every sibling site": simplest fix is if (us_socket_is_closed((us_socket_t *) s)) return s; right after callOnWritable returns, mirroring the parser's post-dispatch check.

Extended reasoning...

What was left out

Three fix commits (fb828c2, 8b98bd3, a0337dc) hardened callers against closeIfDoneAndMarked destructing the HttpResponseData ext mid-frame — guarding the shims (uws_res_on_aborted, clear_on_writable, on_timeout, on_data, pause, resume), tryEnd's hasResponded(), and uws_res_try_end's inline clearOnWritableAndAborted(). But one layer higher was not touched: HttpResponseData::callOnWritable (HttpResponseData.h:68-85) and HttpContext<SSL>::onWritable (HttpContext.h:737-800) both read httpResponseData fields after the borrowed onWritable callback returns, and that callback can synchronously close the socket via internalEnd's uncorked close gate — which now has this hunk's new HTTP_CLOSE_WHEN_IDLE && isIdle trigger.

The code path

HttpContext::onWritable (:737, does not cork) → callOnWritable → Rust on_writable_bytes / on_writable_complete_response_buffer (RequestContext.rs:1648/1304, uncorked) → resp.try_end(...) → uws_res_try_end → tryEnd → internalEnd, non-chunked arm, offset == totalSize, markDone() sets isIdle = true, then !Super::isCorked() → closeIfDoneAndMarked(httpResponseData). With HTTP_CONNECTION_CLOSE (pre-existing) or the PR's new (HTTP_CLOSE_WHEN_IDLE && isIdle) (this line), and hasFullyDrained() true (the tail fit in the kernel buffer), shutdown()+close() runs → us_socket_close → HttpContext::onClose synchronously runs ~HttpResponseData() on the ext (HttpContext.h:286-289). try_end returns true (a0337dc's guard makes tryEnd and uws_res_try_end themselves safe); the Rust callback calls detach_response() (guarded shims — safe) and returns true.

Then: callOnWritable reads if (onWritable) on the destructed ext (was nulled by markDone before the dtor, so happens to read null). Back in HttpContext::onWritable:

  • :743-746 (the !IsNodeHttp block) reads httpResponseData->state, ->offset, and asyncSocket->hasFullyDrained() — which reads getAsyncSocketData()->buffer.length() on the destructed BackPressure (AsyncSocket.h:197-198, unguarded).
  • :761 asyncSocket->flush() — this one is safe: AsyncSocket.h:247 checks us_socket_is_closed first and returns 0.
  • :777-791 read shouldCloseConnection(), state, and hasFullyDrained() again on the destructed ext, then shutdown()+close() a second time on the already-closed socket (both no-op on the is_closed check).
  • :800 resetTimeout() reads idleTimeout from the destructed ext.

Why nothing else prevents it

None of the shims fb828c2/8b98bd35/a0337dc3 guarded sit between internalEnd and HttpContext::onWritable's tail; there is no us_socket_is_closed check after callOnWritable returns; and on_writable_bytes returns true unconditionally, so the if (!success) return s short-circuit at :752 is skipped.

Step-by-step proof

  1. A Bun.serve response's first try_end hits backpressure (>~kernel-buffer body). on_writable_bytes is armed with the remaining tail.
  2. server.stop(false) sweeps: the connection is busy, so closeIdle(true) sets HTTP_CLOSE_WHEN_IDLE on it (App.h:414). (Or, pre-existing: the client sent Connection: close.)
  3. The socket becomes writable → HttpContext::onWritable → callOnWritable → on_writable_bytes calls resp.try_end(tail, total, ...), uncorked.
  4. The tail fits: internalEnd non-chunked arm, offset == totalSize, markDone() sets isIdle = true, !isCorked() → closeIfDoneAndMarked: shouldCloseConnection() is true via (HTTP_CLOSE_WHEN_IDLE && isIdle), HTTP_RESPONSE_PENDING cleared, hasFullyDrained() true → shutdown()+close() → onClose → ~HttpResponseData().
  5. try_end returns true (a0337dc's is_closed guard); on_writable_bytes calls detach_response() (fb828c2/8b98bd35's guarded shims no-op), returns true.
  6. callOnWritable reads/writes onWritable on the destructed ext; HttpContext::onWritable :743-800 read state/offset/buffer.length()/idleTimeout on the destructed ext.

Impact

Same practically-benign class as the three prior accepted-and-fixed findings on this PR: the ext block is inline in a closed-list us_socket_t swept at loop-tick end, so the memory is still mapped; state/offset/onWritable/idleTimeout are POD and read as-left; BackPressure is a custom struct whose .length() is tail - head (POD size_t fields), not a std::string; flush() at :761 is guarded and no-ops; the second shutdown()/close() no-op on the is_closed check. Formally UB (object lifetime ended), no observable failure.

Pre-existing for Connection: close, but this hunk adds the HTTP_CLOSE_WHEN_IDLE trigger and the PR explicitly undertook this class across three fix commits at 6+ sibling sites, so filing as a nit under REVIEW.md "fix the whole class — grep for every sibling site".

Fix

bool success = httpResponseData->callOnWritable(...);
/* The onWritable callback may have completed the response and closed the
 * socket via a shouldCloseConnection() gate; the ext is destructed. */
if (us_socket_is_closed((us_socket_t *) s)) {
    return s;
}

right after :737, mirroring the parser's post-dispatch check. This also makes the if (onWritable) restore in callOnWritable unreachable-after-close moot in practice (the field was nulled by markDone before the close), or that read can be guarded the same way inside callOnWritable.

}

#ifdef UWS_WITH_PROXY
Expand Down
22 changes: 22 additions & 0 deletions src/runtime/server/FileResponseStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,24 @@ impl FileResponseStream {
let resp = self.resp.get();
resp.end_send_file(self.sendfile.get().offset, resp.should_close_connection());
(self.on_complete.get())(self.ctx.get(), resp);
// `end_send_file` bypasses every shouldCloseConnection() gate: it does
// not go through internalEnd, and the onWritable gate is skipped
// because this frame returns `false` to it. Run the gate here — after
// `on_complete`, which must see a live socket — so Connection: close
// and the graceful-stop close-when-idle mark actually close.
//
// `resp` is still valid here: usockets never frees a socket
// synchronously — us_socket_close only links it onto the loop's
// closed list, freed by us_internal_free_closed_sockets at the end of
// the loop iteration — so the allocation outlives this frame no
// matter what `on_complete` did (the same invariant that makes
// passing `resp` to `on_complete` after the end sound). It is still
// *this* HTTP socket: an upgrade (us_socket_adopt) is only reachable
// from a live in-flight request, and this one just completed. And if
// anything in the frame closed it, the shim's leading
// us_socket_is_closed check returns before touching the destructed
// ext. Only `finish()` runs after this, and it never touches `resp`.
resp.close_if_done_and_marked();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What gurantees resp is still alive and correct here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three separate guarantees, one per failure mode:

Alive (memory): usockets never frees a socket synchronously. us_socket_close only unlinks it and pushes it onto loop->data.closed_head (packages/bun-usockets/src/socket.c:209, 342, 389); the allocation is freed exclusively by us_internal_free_closed_sockets, which the loop runs at the end of the iteration (packages/bun-usockets/src/loop.c:362, called at :449). This whole sequence is one synchronous frame on the loop thread, so the us_socket_t behind resp outlives it regardless of what on_complete did. This is the same invariant end_sendfile already leaned on to pass resp to on_complete after end_send_file, and the one detach_resp's comment documents (uWS may free it on the NEXT loop tick).

Correct (still this HTTP socket): the only thing that re-types a socket mid-tick is us_socket_adopt from a WebSocket upgrade, which is only reachable from a live in-flight request dispatch; this request just completed (end_send_file ran markDone), and do_upgrade rejects a responded request. None of the on_complete callees (RequestContext::on_file_stream_complete, FileRoute/DirectoryRoute::on_response_complete) dispatch an upgrade; they do bookkeeping, and server deinit from on_request_complete is deferred via schedule_deinit, never a synchronous app.close().

Closed-but-not-freed (state): if anything in the frame did close the socket (the end itself, or JS reached through a drained microtask calling stop(true)), HttpContext::onClose has destructed the ext block, and the shim handles exactly that: uws_res_close_if_done_and_marked checks us_socket_is_closed first and returns before touching HttpResponseData (src/uws_sys/libuwsockets.cpp).

Pushed 7913af6 putting this argument in the comment at the call site so it does not live only in the PR.

self.finish();
}

Expand Down Expand Up @@ -543,6 +561,10 @@ impl FileResponseStream {
let resp = self.resp.get();
resp.end_without_body(resp.should_close_connection());
(self.on_complete.get())(self.ctx.get(), resp);
// This end runs uncorked (reader callbacks), so no cork or parser
// gate will run the close check; do it here, after `on_complete`
// like `end_sendfile`, so the callbacks see a live socket.
resp.close_if_done_and_marked();
Comment thread
robobun marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
}

// Release the owner ref from `heap::into_raw` in `start()`. Every entry
Expand Down
6 changes: 6 additions & 0 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,12 @@ where
self.detach_response();
// SAFETY: FFI handle
resp.end_without_body(close_connection);
// This end can run uncorked (e.g. render_production_error from a
// rejection microtask), where no cork or parser gate runs the
// close check for Connection: close or a graceful-stop mark. The
// shim no-ops when the socket is corked (the cork wrapper's own
// gate runs later) or already closed.
resp.close_if_done_and_marked();
// end_request_streaming_and_drain() must run after the last
// `resp` access: its drain_microtasks() can re-enter lsquic (H3)
// and free the stream out from under the local `resp` copy.
Expand Down
20 changes: 20 additions & 0 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,26 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
if !abrupt {
// S012: `app::ListenSocket<SSL>` is a ZST opaque — safe deref.
bun_opaque::opaque_deref_mut(listener).close();
// Close idle keep-alive connections now and mark busy ones to
// close once their in-flight work completes; open websockets are
// untouched and drain on their own. Each close reaches
// `on_connection_filter(-1)` synchronously, so hold the guard so
// that path cannot form a second `&mut self` under this frame —
// `stop()` runs `deinit_if_we_can` right after this returns.
//
// node:http servers are exempt: Node's `close()` sweeps idle
// connections exactly once (the JS layer already called
// `closeIdleConnections()`), and a connection whose response
// completes after `close()` stays keep-alive until its timeout
// reaps it — verified against Node v26.
if self.config.on_node_http_request.is_empty() {
if let Some(app) = self.app {
self.deinit_running.set(true);
// S012: `NewApp<SSL>` is a ZST opaque — safe `*mut → &mut` deref.
let _closed = bun_opaque::opaque_deref_mut(app).close_idle_connections(true);
self.deinit_running.set(false);
}
}
} else if !self.flags.contains(ServerFlags::TERMINATED) {
if let Some(ws) = self.config.websocket.as_mut() {
ws.handler.app = None;
Expand Down
Loading