Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fb45958
server: store node:http onClientError/onConnection as cached JS value…
robobun Jul 16, 2026
8d939a4
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 16, 2026
352a558
server: collapse cached-value dispatch into one macro; run GC guard i…
robobun Jul 16, 2026
f4f9a0b
serve: root every server-level callback via the wrapper's WriteBarrie…
robobun Jul 16, 2026
4975d1b
serve(ws): clear m_server in on_close; extend deinit drain to 30 passes
robobun Jul 16, 2026
cdc76c9
test: skip exit-time LeakSan for websocket-syscall-fault.test.ts
robobun Jul 16, 2026
5eb38aa
serve(ws): copy on_error to the stack before dispatching into user JS
robobun Jul 16, 2026
36c4321
test(bun-server): scope server so the on_error regression is load-bea…
robobun Jul 16, 2026
b9d3676
test: replace whole-file leaksan skip with a targeted on_web_socket_u…
robobun Jul 16, 2026
bbde0db
serve(ws): zero the Request JS cell's stack slot after on_request ret…
robobun Jul 17, 2026
5c69128
serve(ws): document the ConservativeScan proof for the args[0] scrub;…
robobun Jul 17, 2026
529e3c4
Merge branch 'main' into claude/farm/ebef6d1a/server-strong-callbacks…
Jarred-Sumner Jul 17, 2026
55790e0
test(bake/deinitialization): yield via setImmediate in drainServerWra…
robobun Jul 17, 2026
f94e6f3
vm: bring the VM refcount to 0 in destructOnExit so ~VM runs from pro…
robobun Jul 17, 2026
d2d09c5
server: protect_handler_shadows in on_reload too; fix misplaced wrap_…
robobun Jul 17, 2026
c1abfeb
test(bun-server): surface stderr/exitCode before parsed-field asserti…
robobun Jul 17, 2026
633ecab
server: js_value_for_dispatch returns the wrapper while Weak too
robobun Jul 18, 2026
6373191
server: refuse server.upgrade() once handler.server is cleared; fix s…
robobun Jul 18, 2026
2690175
serve(ws): fix stale ServerWebSocket::init comment (both callers go t…
robobun Jul 18, 2026
67e51d0
serve: fix last stale is-Strong comment in on_saved_request
robobun Jul 18, 2026
34df4a4
ci: retrigger
robobun Jul 18, 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 @@ -1658,6 +1658,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,
);
Comment thread
robobun marked this conversation as resolved.
// 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);

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 @@ -5249,13 +5253,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 @@ -5388,8 +5392,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 @@ -163,8 +163,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 @@ -3517,12 +3517,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
83 changes: 67 additions & 16 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() dispatches on_close, which owns the accounting decrement.
// (`close()` re-enters `on_close`, which skips its own
// accounting because the closed flag is already set.)
self.websocket().close();
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,12 +648,21 @@ 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));
// uws fires the close callback exactly once per opened socket, so this
// balances on_open's increment even when close()/terminate() already
// set the closed flag (which used to skip it and leak the count).
// Whoever set the closed flag owns the decrement; close()/terminate()
// and on_open's error path each decrement themselves when they flip it.
scopeguard::defer! {
handler.on_connection_closed();
if !was_closed {
if let Some(server) = server {
server.on_websocket_closed();
}
}
}
let signal = self.signal.take();

Expand Down Expand Up @@ -668,7 +697,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 +729,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 +1373,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();
}
Ok(JSValue::UNDEFINED)
Comment thread
robobun marked this conversation as resolved.
}

Expand All @@ -1363,8 +1406,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