Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
12 changes: 11 additions & 1 deletion src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4132,7 +4132,17 @@ extern "C" void Zig__GlobalObject__destructOnExit(Zig::GlobalObject* globalObjec
gcUnprotect(globalObject);
globalObject = nullptr;
vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full);
vm.derefSuppressingSaferCPPChecking();
// The two refs that exist when this runs at event-loop top level are
// Zig__GlobalObject__create's manual ref and the boot-scope JSLockHolder.
// When process.exit() is called from inside a JS callback, every nested
// JSLockHolder still on the native stack (e.g. JSEventListener::handleEvent)
// holds a RefPtr<VM>, so a fixed two derefs leave the count > 0 and ~VM
// (and with it Heap::lastChanceToFinalize, which clears all marks and
// sweeps every cell) is skipped. Those holders never destruct because this
// path never returns, so release on their behalf.
for (uint32_t n = vm.refCount(); n > 1; --n)
vm.derefSuppressingSaferCPPChecking();
// refCount 1 -> 0 runs ~VM; `vm` is dead past this line.
vm.derefSuppressingSaferCPPChecking();
runLoop->threadWillExit();
}
Expand Down
46 changes: 46 additions & 0 deletions src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1596,6 +1596,19 @@ pub(crate) fn serve(global_object: &JSGlobalObject, callframe: &CallFrame) -> Js
break 'brk config;
};

// `init()` below `mem::take`s `config` into a heap-boxed `NewServer`, so
// past that point the raw-`JSValue` handler shadows have no GC root until
// `wrap_handler_slot` writes them into the wrapper's WriteBarrier slots.
// For a data-property options object the user's `{ fetch: fn }` on this
// stack still retains them, but a Proxy- or accessor-backed options
// object returns a fresh fn that nothing else holds. `compute_id`,
// `listen()`'s `set_routes`, and the `ptr_to_js` wrapper allocation can
// all trigger a GC in that window, so gcProtect each handler for its
// duration. `Protected`'s `Drop` unprotects on every exit path (including
// a thrown `listen()` and the hot-reload early return).
let _handler_pins: [bun_jsc::js_value::Protected; 10] =
crate::server::protect_handler_shadows(&config);

// SAFETY: same VM pointer; re-borrow after `args` is dropped.
let vm = global_object.bun_vm().as_mut();

Expand Down Expand Up @@ -1658,7 +1671,40 @@ 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.
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);
// Slots are rooted; release the scoped gcProtects and run the
// "server just started" GC nudge split out of `listen()`.
drop(_handler_pins);
server_ref.gc_hint_after_listen();

if global_object.bun_vm().test_isolation_enabled {
if let Some(handles) = crate::jsc_hooks::isolation_handles() {
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
Loading
Loading