Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
386b212
serve: defer js_value downgrade until no dispatch can fire
alii Jun 12, 2026
65c76a2
serve: run deinit_if_we_can when the last websocket closes
alii Jun 12, 2026
0ed8dfd
serve: answer 503 instead of panicking when a late request races wrap…
alii Jun 12, 2026
9b3903d
test: late keep-alive request to a route after stop+GC does not crash
alii Jun 12, 2026
9e8171a
serve: close idle keep-alive sockets in graceful stop_listening
alii Jun 12, 2026
4146480
test: server wrapper survives GC while a websocket is connected after…
alii Jun 12, 2026
0929f8b
serve: gate dispatch on js_value being strong, not just non-finalized
alii Jun 12, 2026
868f7ae
test: account for prototype in heapStats baseline; handle closed-sock…
alii Jun 12, 2026
4c08ec8
serve: add WriteBarrier slots for handler callbacks on the wrapper
alii Jun 12, 2026
b8498fd
serve: write fetch/error/nodeHTTP handler values into wrapper slots
alii Jun 12, 2026
efd9e96
serve: use individual ws handler slots, not a wsHandlers object
alii Jun 12, 2026
58ab885
serve: trace JSServerWebSocket → server wrapper via m_server slot
alii Jun 12, 2026
71dac52
serve: drop ServerConfig handler Strongs; read shadows, root via wrapper
alii Jun 12, 2026
d6440ae
serve: write ws handler shadows into wrapper slots; drop gcProtect
alii Jun 12, 2026
44f8414
serve: store on_clienterror in wrapper slot, drop StrongOptional
alii Jun 12, 2026
ce62668
serve: store all_closed_promise in wrapper slot, drop JSPromiseStrong…
alii Jun 12, 2026
0c23046
serve: wrap handlers with async context at slot-write, not at parse
alii Jun 12, 2026
f91ef8b
serve: tests for the GC-traced handler redesign
alii Jun 12, 2026
40c06c1
serve: DevServer on_request check uses !is_empty() not is_some()
alii Jun 12, 2026
ab7020e
serve: dedup Handler.server field after rebase onto ws-liveness branch
alii Jun 12, 2026
7984ef9
bake: deref html_bundle in DevServer Drop
alii Jun 12, 2026
c1310d1
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 12, 2026
33b691a
serve: clear ws slots when reload removes websocket; normalize undefi…
alii Jun 12, 2026
27b7600
serve: extract wrap_handler_slot + respond_stopped_503 helpers; fix g…
alii Jun 12, 2026
2de5aff
serve: respond_stopped_503 honors per-protocol close semantics
alii Jun 12, 2026
425891d
test: cover node:http late-request, reload-drops-ws-handler; fix self…
alii Jun 12, 2026
bc209fb
serve: gate on_client_error_callback + reload_static_routes on Strong…
alii Jun 12, 2026
c502b16
serve: balance websocket count for server-initiated ws.close/terminate
alii Jun 12, 2026
cce3f38
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 12, 2026
831dcfe
test: capture true heapStats baseline; longer drain for libuv platforms
alii Jun 12, 2026
ee84a66
serve: drop slot getter accessors; revert close_idle_connections in g…
alii Jun 12, 2026
560b617
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 12, 2026
ca7a771
serve: re-entrance flag on deinit_if_we_can instead of TERMINATED pro…
alii Jun 12, 2026
2f8be64
serve: drop dead AnyServer::js_value; fix stale close_idle prose afte…
alii Jun 13, 2026
282cf09
Merge branch 'main' into ali/serve-handlers-gc-traced-v2
alii Jun 14, 2026
5034967
serve: review fixups for handler GC-tracing redesign
alii Jun 14, 2026
d9e1d97
serve: clear ws.handler.server after the abrupt-stop drain, not before
alii Jun 14, 2026
0d4accc
test(serve): exercise ws-close auto-downgrade; dedup late keep-alive …
alii Jun 14, 2026
b7bface
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 14, 2026
3385b43
serve: copy handler.server before uws re-entry; reload writes slots v…
alii Jun 14, 2026
beaddea
serve: reuse server_js for routeList slot in reload; trim stale asser…
alii Jun 15, 2026
3263581
Merge branch 'main' into ali/serve-handlers-gc-traced-v2
alii Jun 15, 2026
b7230ce
serve: collapse wrap_handler_slot server_js to plain JSValue
robobun Jun 15, 2026
874e91e
Merge branch 'main' into ali/serve-handlers-gc-traced-v2
robobun Jul 9, 2026
ab24590
ci: retrigger
robobun Jul 9, 2026
35f9dc6
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 9, 2026
3feca20
serve(ws): re-check is_closed() after reason toString() in ServerWebS…
robobun Jul 9, 2026
b9275c7
test(serve): capture re-entrant-close target ws in message(), not by …
robobun Jul 9, 2026
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
31 changes: 31 additions & 0 deletions src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,37 @@ pub(crate) fn serve(global_object: &JSGlobalObject, callframe: &CallFrame) -> Js
// `server_body` until per-type codegen externs land.
<$ServerType>::js_gc_route_list_set(obj, global_object, route_list_object);
}
// Mirror the handler callbacks into the wrapper's WriteBarrier
// slots — the wrapper is the sole GC root for these; `ServerConfig`
// / `Handler` only hold raw `JSValue` shadows for hot-path dispatch.
// The async-context wrap is applied here (not in `from_js`) so the
// freshly-allocated wrapper fn is rooted by the slot immediately;
// the unwrapped fn is held live by the user's options object on the
// `serve()` stack across `init`/`listen` until this point.
crate::server::wrap_handler_slot(
&mut server_ref.config.on_request,
obj,
global_object,
<$ServerType>::js_gc_on_request_set,
);
crate::server::wrap_handler_slot(
&mut server_ref.config.on_error,
obj,
global_object,
<$ServerType>::js_gc_on_error_set,
);
crate::server::wrap_handler_slot(
&mut server_ref.config.on_node_http_request,
obj,
global_object,
<$ServerType>::js_gc_on_node_http_request_set,
);
// Skip the 7-slot write when there's no websocket config: the
// slots default ZERO so `write_ws_handler_slots`'s clear path
// would be 7 wasted FFI calls.
if server_ref.config.websocket.is_some() {
server_ref.write_ws_handler_slots(obj, global_object);
}
server_ref.js_value.set_strong(obj, global_object);
Comment thread
alii marked this conversation as resolved.

if global_object.bun_vm().test_isolation_enabled {
Expand Down
12 changes: 8 additions & 4 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 @@ -5252,13 +5256,13 @@ fn on_request(dev: &mut DevServer, req: &mut Request, mut resp: AnyResponse) {
return;
}

if dev
if !dev
.server
.as_ref()
.expect("infallible: server bound")
.config()
.on_request
.is_some()
.is_empty()
{
dev.server
.as_mut()
Expand Down Expand Up @@ -5389,8 +5393,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.
6 changes: 2 additions & 4 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3499,12 +3499,10 @@ where
if let Some(server) = self.server {
// SAFETY: BACKREF
let server = &*server;
if let Some(on_error) = server.config().on_error.as_ref()
&& !self.flags.has_called_error_handler()
{
let on_error = server.config().on_error;
if !on_error.is_empty() && !self.flags.has_called_error_handler() {
self.flags.set_has_called_error_handler(true);
let result = on_error
.get()
.call(
server.global_this(),
server.js_value().try_get().unwrap_or(JSValue::UNDEFINED),
Expand Down
35 changes: 19 additions & 16 deletions src/runtime/server/ServerConfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ pub struct ServerConfig {
/// If HMR is not enabled, then this field is ignored.
pub enable_chrome_devtools_automatic_workspace_folders: bool,

pub on_error: Option<Strong>,
pub on_request: Option<Strong>,
pub on_node_http_request: Option<Strong>,
/// Raw shadow of the wrapper's `onError`/`onRequest`/`onNodeHTTPRequest`
/// WriteBarrier slots. The wrapper JSCell is the GC root; these are
/// `JSValue::ZERO` when unset and copied for hot-path dispatch reads.
pub on_error: JSValue,
pub on_request: JSValue,
pub on_node_http_request: JSValue,

pub websocket: Option<WebSocketServerContext>,

Expand Down Expand Up @@ -81,9 +84,9 @@ impl Default for ServerConfig {
development: DevelopmentOption::Development,
broadcast_console_log_from_browser_to_server_for_bake: false,
enable_chrome_devtools_automatic_workspace_folders: true,
on_error: None,
on_request: None,
on_node_http_request: None,
on_error: JSValue::ZERO,
on_request: JSValue::ZERO,
on_node_http_request: JSValue::ZERO,
websocket: None,
reuse_port: false,
id: Box::default(),
Expand Down Expand Up @@ -274,9 +277,9 @@ impl ServerConfig {
.broadcast_console_log_from_browser_to_server_for_bake,
enable_chrome_devtools_automatic_workspace_folders: self
.enable_chrome_devtools_automatic_workspace_folders,
on_error: self.on_error.take(),
on_request: self.on_request.take(),
on_node_http_request: self.on_node_http_request.take(),
on_error: self.on_error,
on_request: self.on_request,
on_node_http_request: self.on_node_http_request,
websocket: self.websocket.take(),
reuse_port: self.reuse_port,
id: core::mem::take(&mut self.id),
Expand Down Expand Up @@ -1313,8 +1316,10 @@ impl ServerConfig {
global.throw_invalid_arguments(format_args!("Expected error to be a function"))
);
}
let on_error_snapshot = on_error.with_async_context_if_needed(global);
args.on_error = Some(Strong::create(on_error_snapshot, global));
// Raw value — async-context wrapping is deferred to the slot-write
// site (`serve_with!` / `on_reload_from_zig`) so the wrapped fn is
// rooted by the wrapper's WriteBarrier slot the moment it exists.
args.on_error = on_error;
}
if global.has_exception() {
return Err(JsError::Thrown);
Expand All @@ -1326,19 +1331,17 @@ impl ServerConfig {
"Expected onNodeHTTPRequest to be a function",
)));
}
let on_request = on_request_.with_async_context_if_needed(global);
args.on_node_http_request = Some(Strong::create(on_request, global));
args.on_node_http_request = on_request_;
}

if let Some(on_request_) = arg.get_truthy(global, "fetch")? {
if !on_request_.is_callable() {
return Err(global
.throw_invalid_arguments(format_args!("Expected fetch() to be a function")));
}
let on_request = on_request_.with_async_context_if_needed(global);
args.on_request = Some(Strong::create(on_request, global));
args.on_request = on_request_;
} else if args.bake.is_none()
&& args.on_node_http_request.is_none()
&& args.on_node_http_request.is_empty()
&& ((args.static_routes.len() + args.user_routes_to_build.len()) == 0
&& !opts.has_user_routes)
&& opts.is_fetch_required
Expand Down
75 changes: 62 additions & 13 deletions src/runtime/server/ServerWebSocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,9 @@ impl Flags {
// (`generate-classes.ts` → `${T}__data{Get,Set}Cached`).
#[allow(non_snake_case)]
pub mod js {
// Emits `data_{get,set}_cached`. Getter maps `JSValue::ZERO` → `None`;
// Emits `{data,server}_{get,set}_cached`. Getter maps `JSValue::ZERO` → `None`;
// setter forwards through the JSC `WriteBarrier<Unknown>` slot.
::bun_jsc::codegen_cached_accessors!("ServerWebSocket"; data);
::bun_jsc::codegen_cached_accessors!("ServerWebSocket"; data, server);
}

/// Maps a uWS `SendStatus` to the JS-visible number contract shared by every
Expand Down Expand Up @@ -356,6 +356,12 @@ impl ServerWebSocket {
.this_value
.set(JsRef::init_strong(this_value, global_object));
js::data_set_cached(this_value, global_object, data_value);
// Only mirror the server wrapper while it is strongly rooted —
// `js_value()` would return a weak (potentially dead-but-unswept)
// address once the server has gone idle and downgraded.
if let Some(server_js) = handler.server.and_then(|s| s.js_value_for_dispatch()) {
js::server_set_cached(this_value, global_object, server_js);
}
this
}

Expand Down Expand Up @@ -383,8 +389,12 @@ impl ServerWebSocket {

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 @@ -421,17 +431,26 @@ impl ServerWebSocket {
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 @@ -523,7 +542,8 @@ impl ServerWebSocket {
return;
}

if !handler.on_drain.is_empty() {
let on_drain = handler.on_drain;
if !on_drain.is_empty() {
let global_object = handler.global_object();

let args = [self
Expand All @@ -535,7 +555,7 @@ impl ServerWebSocket {
args: &args,
global_object,
this_value: JSValue::ZERO,
callback: handler.on_drain,
callback: on_drain,
result: JSValue::ZERO,
};
let _loop_guard = vm.enter_event_loop_scope();
Expand Down Expand Up @@ -628,11 +648,18 @@ impl ServerWebSocket {
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();
}
}
}
let signal = self.signal.take();
Comment thread
alii marked this conversation as resolved.
Expand Down Expand Up @@ -668,7 +695,10 @@ impl ServerWebSocket {
return;
}

if !handler.on_close.is_empty_or_undefined_or_null() {
// Copy to a stack local before `sig.signal()` re-enters JS: a GC
// between the test and the `.call(...)` could otherwise collect it.
let on_close_handler = handler.on_close;
if !on_close_handler.is_empty_or_undefined_or_null() {
let global_object = handler.global_object();

let _loop_guard = vm.enter_event_loop_scope();
Expand Down Expand Up @@ -697,10 +727,7 @@ impl ServerWebSocket {
};

let call_args = [cached_this, JSValue::js_number(code as f64), message_js];
if let Err(e) = handler
.on_close
.call(global_object, JSValue::UNDEFINED, &call_args)
{
if let Err(e) = on_close_handler.call(global_object, JSValue::UNDEFINED, &call_args) {
let err = global_object.take_exception(e);
bun_output::scoped_log!(WebSocketServer, "onClose error {}", was_not_empty);
handler.run_error_callback(vm, global_object, err);
Expand Down Expand Up @@ -1344,8 +1371,22 @@ impl ServerWebSocket {
break 'brk args.ptr[1].to_slice_or_null(global_this)?;
};

// `to_slice_or_null` can run user `toString()`, which may re-entrantly
// `ws.close()` and already decrement the count; re-check the guard.
if self.is_closed() {
return Ok(JSValue::UNDEFINED);
}

// Copy the server backref BEFORE end(): on_close re-enters and the
// user's close handler may call stop(true), which clears handler.server.
let server = self.handler().server;
self.update_flags(|f| f.set_closed(true));
self.websocket().end(code, message_value.slice());
// on_close re-entered with was_closed=true so it skipped the
// accounting; balance the count here.
if let Some(server) = server {
server.on_websocket_closed();
}
Comment thread
alii marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
Ok(JSValue::UNDEFINED)
}

Expand All @@ -1363,8 +1404,16 @@ impl ServerWebSocket {
return Ok(JSValue::UNDEFINED);
}

// Copy the server backref BEFORE close(): on_close re-enters and the
// user's close handler may call stop(true), which clears handler.server.
let server = self.handler().server;
self.update_flags(|f| f.set_closed(true));
self.websocket().close();
// on_close re-entered with was_closed=true so it skipped the
// accounting; balance the count here.
if let Some(server) = server {
server.on_websocket_closed();
}

Ok(JSValue::UNDEFINED)
}
Expand Down
Loading
Loading