Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 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
8 changes: 6 additions & 2 deletions src/runtime/bake/DevServer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,10 @@ impl Drop for DevServer {
// SAFETY: stored ref from `init_from_any_blob`; no live borrow.
unsafe { StaticRoute::deref_(cached.as_ptr()) };
}
// SAFETY: paired with the `ref_` taken in
// `get_or_put_route_bundle` when this bundle was created; the
// raw `html_bundle` field has no Drop, so release it here.
unsafe { bun_ptr::RefCount::<HTMLBundleRoute>::deref(html.html_bundle) };
}
}

Expand Down Expand Up @@ -5376,8 +5380,8 @@ impl DevServer {
let file = &mut self.client_graph.bundled_files.values_mut()
[incremental_graph_index.get() as usize];
file.html_route_bundle_index = Some(bundle_index);
// Bump the intrusive refcount; matched by
// `RouteBundle::deinit`'s deref of `html_bundle`.
// Bump the intrusive refcount; matched by the
// `html_bundle` deref in `DevServer`'s `Drop`.
// SAFETY: `html` is a live IntrusiveRc-managed allocation.
unsafe { bun_ptr::RefCount::<HTMLBundleRoute>::ref_(html) };
break 'brk route_bundle::Data::Html(route_bundle::Html {
Expand Down
11 changes: 6 additions & 5 deletions src/runtime/bake/dev_server/route_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,9 @@ impl RouteBundle {
}
}

// `deinit` is fully subsumed by Drop:
// - client_bundle / cached_response: Option<Arc<StaticRoute>> drop = .deref()
// - Framework: StrongOptional fields drop = .deinit()
// - Html: bundled_html_text Box<[u8]> drop = allocator.free()
// html_bundle RefPtr drop = .deref()
// Zig `RouteBundle.deinit` equivalent, split across two mechanisms:
// - Drop: `Framework` StrongOptional fields (= .deinit()) and
// `Html.bundled_html_text` Box<[u8]> (= allocator.free()).
// - `DevServer`'s `Drop` (explicit): `client_bundle`, `Html.cached_response`
// (BackRef, no Drop) and `Html.html_bundle` (raw ptr, no Drop) each hold
// an intrusive ref that is deref'd there.
40 changes: 36 additions & 4 deletions src/runtime/server/ServerWebSocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,12 @@

let handler = self.handler();
let vm = handler.vm();
// The handler is shared (&), so mutate via the interior-mutability helper.
handler.active_connections_saturating_add(1);
// Live-socket accounting lives on the server (`Cell`), reached
// through the type-erased backref so the shared `&Handler` suffices.
let server = handler.server;
if let Some(server) = server {
server.on_websocket_opened();
}
let global_object = handler.global_object();
let on_open_handler = handler.on_open;
if vm.is_shutting_down() {
Expand Down Expand Up @@ -426,17 +430,26 @@
if let Some(err_value) = result.to_error() {
bun_output::scoped_log!(WebSocketServer, "onOpen exception");

let mut closed_here = false;
if !self.flags.get().closed() {
self.update_flags(|f| f.set_closed(true));
// we un-gracefully close the connection if there was an exception
// we don't want any event handlers to fire after this for anything other than error()
// https://github.com/oven-sh/bun/issues/1480
// (`close()` re-enters `on_close`, which skips its own
// accounting because the closed flag is already set.)
self.websocket().close();
handler.active_connections_saturating_sub(1);
closed_here = true;
this_value.unprotect();
}

handler.run_error_callback(vm, global_object, err_value);
if closed_here {
if let Some(server) = server {
// May run the idle pass; no `&Handler` borrow is live here.
server.on_websocket_closed();
}
}
}
}

Expand Down Expand Up @@ -633,11 +646,18 @@
bun_output::scoped_log!(WebSocketServer, "onClose");
// TODO: Can this called inside finalize?
let handler = self.handler();
// Copy the erased server handle out now: the guard below runs after
// every `handler` borrow has expired, and `on_websocket_closed` may
// form `&mut NewServer` (which owns the handler storage) to run the
// idle pass when this was the last live socket.
let server = handler.server;
let was_closed = self.is_closed();
self.update_flags(|f| f.set_closed(true));
scopeguard::defer! {
if !was_closed {
handler.active_connections_saturating_sub(1);
if let Some(server) = server {
server.on_websocket_closed();
}
}
}
Comment thread
robobun marked this conversation as resolved.
let signal = self.signal.take();
Expand Down Expand Up @@ -1349,8 +1369,14 @@
break 'brk args.ptr[1].to_slice_or_null(global_this)?;
};

let server = self.handler().server;
self.update_flags(|f| f.set_closed(true));
// `end()` re-enters `on_close`, which skips its own accounting
// because the closed flag is already set; balance it here.
self.websocket().end(code, message_value.slice());
if let Some(server) = server {
server.on_websocket_closed();
}

Check failure on line 1379 in src/runtime/server/ServerWebSocket.rs

View check run for this annotation

Claude / Claude Code Review

ws.close()/terminate() inside a message handler can run the idle unprotect before the enclosing dispatch's run_error_callback reads on_error

Calling `ws.close()`/`ws.terminate()` from inside a `message` (or `drain`/`ping`/`pong`) handler on a gracefully-stopped server's last socket runs `on_websocket_closed()` → `deinit_if_we_can()` → `ws.handler.unprotect()` *before* the enclosing dispatch's tail `run_error_callback` reads `self.on_error`. `unprotect()` doesn't zero the fields, so if the user's handler then allocates (GC collects the now-unrooted `on_error`) and throws, `run_error_callback` calls a freed JSValue. `on_open`'s error p
Comment thread
robobun marked this conversation as resolved.
Ok(JSValue::UNDEFINED)
}

Expand All @@ -1368,8 +1394,14 @@
return Ok(JSValue::UNDEFINED);
}

let server = self.handler().server;
self.update_flags(|f| f.set_closed(true));
// `close()` re-enters `on_close`, which skips its own accounting
// because the closed flag is already set; balance it here.
self.websocket().close();
if let Some(server) = server {
server.on_websocket_closed();
}

Ok(JSValue::UNDEFINED)
}
Expand Down
24 changes: 6 additions & 18 deletions src/runtime/server/WebSocketServerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ pub struct Handler {
// LIFETIMES.tsv = STATIC (vm) / JSC_BORROW (global_object) — both outlive the handler.
pub vm: bun_ptr::BackRef<VirtualMachine>,
pub global_object: bun_ptr::BackRef<JSGlobalObject>,
/// Mutated through `&Handler` (the field is owned by
/// `ServerConfig.websocket` and only ever touched on the JS thread), so
/// it's a `Cell`.
pub active_connections: core::cell::Cell<usize>,
/// Type-erased backref to the owning `NewServer`, set alongside `app`
/// in `set_routes` (so it is in place before any socket can upgrade and
/// refreshed whenever a reload installs a new context). `ServerWebSocket`
/// open/close events route the live-socket accounting through it.
pub server: Option<super::AnyServer>,

/// used by publish()
pub flags: HandlerFlags,
Expand Down Expand Up @@ -68,19 +69,6 @@ impl Handler {
self.vm.get()
}

#[inline]
pub fn active_connections_saturating_add(&self, n: usize) {
self.active_connections
.set(self.active_connections.get().saturating_add(n));
}

/// See `active_connections_saturating_add`.
#[inline]
pub fn active_connections_saturating_sub(&self, n: usize) {
self.active_connections
.set(self.active_connections.get().saturating_sub(n));
}

pub fn run_error_callback(
&self,
vm: &VirtualMachine,
Expand Down Expand Up @@ -119,7 +107,7 @@ impl Handler {
app: None,
vm: bun_ptr::BackRef::new(VirtualMachine::get()),
global_object: bun_ptr::BackRef::new(global_object),
active_connections: core::cell::Cell::new(0),
server: None,
flags: HandlerFlags::empty(),
};

Expand Down
129 changes: 112 additions & 17 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ bitflags::bitflags! {
const DEINIT_SCHEDULED = 1 << 0;
const TERMINATED = 1 << 1;
const HAS_HANDLED_ALL_CLOSED_PROMISE = 1 << 2;
const HANDLERS_RELEASED = 1 << 3;
/// Transient: set only while the abrupt-stop `app.close()` drains
/// websockets synchronously under `stop_listening`'s `&mut self`.
const WEBSOCKETS_DRAINING = 1 << 4;
}
}

Expand Down Expand Up @@ -265,6 +269,11 @@ pub struct NewServer<const SSL: bool, const DEBUG: bool> {
pub base_url_string_for_joining: Box<[u8]>,
pub config: ServerConfig,
pub pending_requests: usize,
/// Live `ServerWebSocket` count. Lives on the server (not the websocket
/// context) so a reload's context swap cannot reset it, and sits in a
/// `Cell` because the open/close accounting arrives through shared
/// `AnyServer` handles on the JS thread.
pub active_websocket_count: core::cell::Cell<u32>,
pub request_pool: *mut request_context::RequestContextStackAllocator<Self, SSL, DEBUG, false>,
/// Null until the H3 listen path runs (`HAS_H3 && config.http3`); never
/// allocated when `!SSL`. Kept as a raw nullable pointer rather than a
Expand Down Expand Up @@ -1026,6 +1035,20 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
req: &mut uws_sys::Request,
resp: *mut uws_sys::NewAppResponse<SSL>,
) {
// `deinit_if_we_can` releases the handler Strongs once the server is
// fully idle after a graceful `stop()`. A surviving keep-alive
// connection can still deliver one more request after that; there is
// nothing left to dispatch it to, so 503 and close the connection
// instead of calling a released handler. Every uws dispatch
// trampoline carries this same guard.
// SAFETY: `this` is the live server backref registered as the uws
// userdata; no other borrow derived from it is alive here.
if unsafe { (*this).config.on_request.is_none() } {
Comment thread
robobun marked this conversation as resolved.
// S012: `NewAppResponse<SSL>` is a ZST opaque — safe deref.
server_body::reject_stopped(bun_opaque::opaque_deref_mut(resp));
return;
}

let should_deinit_context = core::cell::Cell::new(false);
let Some(prepared) = Self::prepare_js_request_context(
this,
Expand All @@ -1040,13 +1063,8 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {

// SAFETY: `this` is the live server backref for this request.
let server = unsafe { &*this };
let on_request = server
.config
.on_request
.as_ref()
.map(|s| s.get())
.unwrap_or(JSValue::ZERO);
debug_assert!(!on_request.is_empty());
// `Some` is guaranteed by the early return above.
let on_request = server.config.on_request.as_ref().unwrap().get();

let global = server.global_this();
let js_value = server.js_value_assert_alive();
Expand Down Expand Up @@ -1077,6 +1095,15 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
let server = user_route.server.cast_mut();
let index = user_route.id;

// Same late-503 path as `on_request` (see the comment there); the JS
// wrapper that owns the route list may already be collected.
// SAFETY: `server` is the live backref stored in `user_route`.
if unsafe { (*server).flags.contains(ServerFlags::HANDLERS_RELEASED) } {
// S012: `NewAppResponse<SSL>` is a ZST opaque — safe deref.
server_body::reject_stopped(bun_opaque::opaque_deref_mut(resp));
return;
}

let should_deinit_context = core::cell::Cell::new(false);
let Some(mut prepared) = Self::prepare_js_request_context(
server,
Expand Down Expand Up @@ -1160,6 +1187,14 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
use bun_http_jsc::method_jsc::MethodJsc as _;
use node_http_response::Flags as NhrFlags;

// Same late-503 path as `on_request` (see the comment there).
// SAFETY: `this` is the live server backref registered as the uws
// userdata; no other borrow derived from it is alive here.
if unsafe { (*this).config.on_node_http_request.is_none() } {
Comment thread
robobun marked this conversation as resolved.
server_body::reject_stopped(resp);
return;
}

// SAFETY: `this` is the live server backref registered as the uws
// userdata; only one borrow derived from it is alive at a time.
unsafe { (*this).on_pending_request() };
Expand Down Expand Up @@ -1205,12 +1240,8 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
},
None => JSValue::UNDEFINED,
};
let callback = this_ref
.config
.on_node_http_request
.as_ref()
.map(|s| s.get())
.unwrap_or(JSValue::ZERO);
// `Some` is guaranteed by the early return above.
let callback = this_ref.config.on_node_http_request.as_ref().unwrap().get();
// C++ forwards `any_server` to `NodeHTTPResponse::create`, which
// unpacks it via `any_server_from_packed` (bits 49..64 = variant tag);
// a raw `*mut Self` would zero those bits and trip the dispatch
Expand Down Expand Up @@ -1462,10 +1493,19 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
}

pub fn active_sockets_count(&self) -> u32 {
self.config
.websocket
.as_ref()
.map_or(0, |ws| ws.handler.active_connections.get() as u32)
self.active_websocket_count.get()
}

pub(crate) fn note_websocket_opened(&self) {
self.active_websocket_count
.set(self.active_websocket_count.get().saturating_add(1));
}

/// Returns true when this close drained the last live websocket.
pub(crate) fn note_websocket_closed(&self) -> bool {
let remaining = self.active_websocket_count.get().saturating_sub(1);
self.active_websocket_count.set(remaining);
remaining == 0
}

pub fn has_active_web_sockets(&self) -> bool {
Expand Down Expand Up @@ -1577,8 +1617,14 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
ws.handler.app = None;
}
self.flags.insert(ServerFlags::TERMINATED);
// `app.close()` dispatches every websocket's `on_close`
// synchronously while this frame holds `&mut self`; the flag keeps
// `on_websocket_closed` from re-entering `deinit_if_we_can` under
// that borrow (`stop()` runs the idle pass right after anyway).
self.flags.insert(ServerFlags::WEBSOCKETS_DRAINING);
// S012: `NewApp<SSL>` is a ZST opaque — safe `*mut → &mut` deref.
bun_opaque::opaque_deref_mut(self.app.unwrap()).close();
self.flags.remove(ServerFlags::WEBSOCKETS_DRAINING);
}
}

Expand Down Expand Up @@ -1664,9 +1710,33 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
);
}
if self.pending_requests == 0 && !self.has_listener() && !self.has_active_web_sockets() {
// No request or upgrade can reach the user's handler callbacks once
// the listener is gone, the in-flight count is zero, and there are
// no live websockets. Release their `Strong` handles now: a handler
// defined in a scope that closes over the JS `Server` value
// otherwise forms a native↔JS cycle (box → Strong(handler) →
// lexical environment → Server wrapper → m_ctx → box) that the GC
// cannot see through, so the wrapper never finalizes and the box is
// never freed.
//
// The `Option<Strong>` / `StrongOptional` releases are idempotent
// and run on every idle pass. The websocket unprotect is counted
// per value, so HANDLERS_RELEASED gates it to exactly once. The
// flag is terminal: a released server can never dispatch again
// (every uws trampoline rejects on the flag or the cleared
// handler, and `on_reload_from_zig` short-circuits before
// installing anything new).
self.config.on_request = None;
Comment thread
robobun marked this conversation as resolved.
self.config.on_node_http_request = None;
self.config.on_error = None;
self.on_clienterror.deinit();
if let Some(ws) = self.config.websocket.as_mut() {
ws.handler.app = None;
if !self.flags.contains(ServerFlags::HANDLERS_RELEASED) {
ws.handler.unprotect();
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}
}
self.flags.insert(ServerFlags::HANDLERS_RELEASED);
self.unref();

// Detach DevServer. This is needed because there are aggressive
Expand Down Expand Up @@ -1905,6 +1975,7 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
h3_alt_svc: Box::<[u8]>::default(),
js_value: jsc::JsRef::empty(),
pending_requests: 0,
active_websocket_count: core::cell::Cell::new(0),
request_pool: <Self as ServerPools<SSL, DEBUG>>::request_pool(),
// Plain HTTP servers never allocate the ~816 KB H3 pool; defer to
// the H3-listen path (`listen()` below) so HTTPS servers that
Expand Down Expand Up @@ -2047,6 +2118,7 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
websocket.global_object =
bun_ptr::BackRef::new(bun_opaque::opaque_deref(self.global_this));
websocket.handler.app = Some(std::ptr::from_mut(app).cast::<c_void>());
websocket.handler.server = Some(any_server);
websocket
.handler
.flags
Expand Down Expand Up @@ -3453,6 +3525,29 @@ impl AnyServer {
any_server_dispatch!(self, |s| s.inspector_server_id)
}

pub(crate) fn on_websocket_opened(&self) {
any_server_dispatch!(self, |s| s.note_websocket_opened());
}

/// Decrement the live-socket count and, when the last socket drained on
/// an already-stopped server, run the idle pass so the handler release
/// (and deferred deinit) that was held back by the open sockets fires.
///
/// Skipped while WEBSOCKETS_DRAINING (transient): the abrupt-stop path
/// drains sockets synchronously from inside `stop_listening`, which holds
/// `&mut self`, and `stop()` runs `deinit_if_we_can` itself right after.
/// A socket whose `on_close` is the frame that *called* `stop(true)`
/// decrements only after `stop()` returns, with the flag already cleared,
/// so its drain still triggers the pass.
pub(crate) fn on_websocket_closed(&self) {
let drained = any_server_dispatch!(self, |s| {
s.note_websocket_closed() && !s.flags.contains(ServerFlags::WEBSOCKETS_DRAINING)
});
if drained {
any_server_dispatch_mut!(self, |s| s.deinit_if_we_can());
}
}
Comment thread
claude[bot] marked this conversation as resolved.

pub fn set_inspector_server_id(&mut self, id: jsc::DebuggerId) {
any_server_dispatch_mut!(self, |s| {
s.inspector_server_id = id;
Expand Down
Loading
Loading