From 386b212464e4f27d5eadbf03e7d82c36dd8db461 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 12 Jun 2026 12:15:11 -0700 Subject: [PATCH 1/9] serve: defer js_value downgrade until no dispatch can fire Per-route handlers are stored as WriteBarriers reachable only via the Server JS wrapper. stop() previously downgraded js_value immediately, so a late keep-alive request after stop+drop+GC would hit js_value_assert_alive() with a Finalized ref and panic. Downgrade inside the deinit_if_we_can idle predicate instead so the wrapper stays rooted until pending_requests/listener/active websockets are all clear. The websocket close path does not yet call deinit_if_we_can; the next commit threads an AnyServer backref through Handler so the last close can trigger it. --- src/runtime/server/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 9a5e5f90b6f..7127b19a3c5 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1583,9 +1583,6 @@ impl NewServer { } pub fn stop(&mut self, abrupt: bool) { - if self.js_value.is_not_empty() { - self.js_value.downgrade(); - } if self.config.allow_hot && !self.config.id.is_empty() { // `hot_map()` is reached via the thread-local VM singleton (raw ptr // deref) and does not borrow `self`, so it cannot overlap with the @@ -1664,6 +1661,9 @@ impl NewServer { ); } if self.pending_requests == 0 && !self.has_listener() && !self.has_active_web_sockets() { + // Wrapper-rooted handlers need the wrapper to outlive every + // dispatch; downgrade only once nothing can call them. + self.js_value.downgrade(); if let Some(ws) = self.config.websocket.as_mut() { ws.handler.app = None; } From 65c76a2dfc7e7879739903a964915a7def30ce9b Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 12 Jun 2026 12:17:49 -0700 Subject: [PATCH 2/9] serve: run deinit_if_we_can when the last websocket closes The previous commit moved the JsRef downgrade into deinit_if_we_can's idle predicate, but nothing calls that when the last websocket closes after a graceful stop. Thread an AnyServer backref through Handler so on_close can trigger it; move the live-socket count onto NewServer (where reload's context swap can no longer reset it). on_close also copies the close handler to a stack local before sig.signal() so a GC between the test and the call cannot collect it. --- src/runtime/server/ServerWebSocket.rs | 38 ++++++++++++---- src/runtime/server/WebSocketServerContext.rs | 24 +++------- src/runtime/server/mod.rs | 47 ++++++++++++++++++-- 3 files changed, 79 insertions(+), 30 deletions(-) diff --git a/src/runtime/server/ServerWebSocket.rs b/src/runtime/server/ServerWebSocket.rs index 5fb0c0e297b..5056568580a 100644 --- a/src/runtime/server/ServerWebSocket.rs +++ b/src/runtime/server/ServerWebSocket.rs @@ -388,8 +388,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() { @@ -426,17 +430,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(); + } + } } } @@ -633,11 +646,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(); @@ -673,7 +693,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(); @@ -702,9 +725,8 @@ 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); diff --git a/src/runtime/server/WebSocketServerContext.rs b/src/runtime/server/WebSocketServerContext.rs index 2548f670af8..c643d5ce6c8 100644 --- a/src/runtime/server/WebSocketServerContext.rs +++ b/src/runtime/server/WebSocketServerContext.rs @@ -34,10 +34,11 @@ pub struct Handler { // LIFETIMES.tsv = STATIC (vm) / JSC_BORROW (global_object) — both outlive the handler. pub vm: bun_ptr::BackRef, pub global_object: bun_ptr::BackRef, - /// 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, + /// 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, /// used by publish() pub flags: HandlerFlags, @@ -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, @@ -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(), }; diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 7127b19a3c5..e5f29454a00 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -265,6 +265,11 @@ pub struct NewServer { 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, pub request_pool: *mut request_context::RequestContextStackAllocator, /// Null until the H3 listen path runs (`HAS_H3 && config.http3`); never /// allocated when `!SSL`. Kept as a raw nullable pointer rather than a @@ -1462,10 +1467,19 @@ impl NewServer { } 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 { @@ -1905,6 +1919,7 @@ impl NewServer { h3_alt_svc: Box::<[u8]>::default(), js_value: jsc::JsRef::empty(), pending_requests: 0, + active_websocket_count: core::cell::Cell::new(0), request_pool: >::request_pool(), // Plain HTTP servers never allocate the ~816 KB H3 pool; defer to // the H3-listen path (`listen()` below) so HTTPS servers that @@ -2047,6 +2062,7 @@ impl NewServer { websocket.global_object = bun_ptr::BackRef::new(bun_opaque::opaque_deref(self.global_this)); websocket.handler.app = Some(std::ptr::from_mut(app).cast::()); + websocket.handler.server = Some(any_server); websocket .handler .flags @@ -3453,6 +3469,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 `JsRef` downgrade + /// (and deferred deinit) that was held back by the open sockets fires. + /// + /// Skipped while still listening (the idle pass would no-op) and while + /// TERMINATED: the abrupt-stop path drains every socket synchronously + /// from inside `stop_listening` (which holds `&mut self`), and `stop()` + /// runs `deinit_if_we_can` itself right after. + pub(crate) fn on_websocket_closed(&self) { + let drained = any_server_dispatch!(self, |s| { + s.note_websocket_closed() + && !s.has_listener() + && !s.flags.contains(ServerFlags::TERMINATED) + }); + if drained { + any_server_dispatch_mut!(self, |s| s.deinit_if_we_can()); + } + } + pub fn set_inspector_server_id(&mut self, id: jsc::DebuggerId) { any_server_dispatch_mut!(self, |s| { s.inspector_server_id = id; From 0ed8dfdab108bec1a733100ff250da9cafcb1d95 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 12 Jun 2026 12:22:27 -0700 Subject: [PATCH 3/9] serve: answer 503 instead of panicking when a late request races wrapper finalize Idle keep-alive sockets are not counted in pending_requests, so the wrapper can downgrade and be collected while one such socket can still deliver another request. js_value_assert_alive() panics on Finalized; the dispatch entry points now check first and close the connection with 503 instead. --- src/runtime/server/mod.rs | 28 ++++++++++++++++++++++++++++ src/runtime/server/server_body.rs | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index e5f29454a00..db43a22208f 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -570,6 +570,16 @@ impl NewServer { self.js_value.try_get().expect("js_value alive") } + /// Returns the wrapper, or None if it has been finalized (server fully + /// stopped and the JS reference dropped). Dispatch trampolines should + /// answer 503+close on None. + pub fn js_value_for_dispatch(&self) -> Option { + match &self.js_value { + jsc::JsRef::Finalized => None, + _ => self.js_value.try_get(), + } + } + /// Per-monomorphization static. /// Rust statics cannot be const-generic; routed through a /// `&'static AtomicBool` so the four (SSL,DEBUG) instantiations share one @@ -1031,6 +1041,16 @@ impl NewServer { req: &mut uws_sys::Request, resp: *mut uws_sys::NewAppResponse, ) { + // Idle keep-alive sockets aren't counted in pending_requests, so the + // wrapper can have been collected before this fires. Refuse and close + // rather than panicking in js_value_assert_alive() below. + // SAFETY: `this` is the live server backref for this request. + if unsafe { &*this }.js_value_for_dispatch().is_none() { + let resp_ref = bun_opaque::opaque_deref_mut(resp); + resp_ref.write_status(b"503 Service Unavailable"); + resp_ref.end_without_body(true); + return; + } let should_deinit_context = core::cell::Cell::new(false); let Some(prepared) = Self::prepare_js_request_context( this, @@ -1082,6 +1102,14 @@ impl NewServer { let server = user_route.server.cast_mut(); let index = user_route.id; + // SAFETY: `server` is the live backref stored in `user_route`. + if unsafe { &*server }.js_value_for_dispatch().is_none() { + let resp_ref = bun_opaque::opaque_deref_mut(resp); + resp_ref.write_status(b"503 Service Unavailable"); + resp_ref.end_without_body(true); + return; + } + let should_deinit_context = core::cell::Cell::new(false); let Some(mut prepared) = Self::prepare_js_request_context( server, diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 6182510d5cd..f67a5e3970c 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -2806,6 +2806,12 @@ where let server = unsafe { &mut *server_ptr }; let index = user_route.id; + if server.js_value_for_dispatch().is_none() { + RespLike::write_status(resp, b"503 Service Unavailable"); + RespLike::end_without_body(resp, true); + return; + } + let should_deinit_context = core::cell::Cell::new(false); let Some(mut prepared) = server.prepare_js_request_context_for::( req, @@ -2890,6 +2896,11 @@ where req: &mut Ctx::Req, resp: &mut Ctx::Resp, ) { + if self.js_value_for_dispatch().is_none() { + RespLike::write_status(resp, b"503 Service Unavailable"); + RespLike::end_without_body(resp, true); + return; + } let self_ptr: *mut Self = self; let should_deinit_context = core::cell::Cell::new(false); let Some(prepared) = self.prepare_js_request_context_for::( @@ -3189,6 +3200,12 @@ where let server_ptr = server_ref.as_ptr(); let index = this.id; + if server_ref.js_value_for_dispatch().is_none() { + resp.write_status(b"503 Service Unavailable"); + resp.end_without_body(true); + return; + } + let should_deinit_context = core::cell::Cell::new(false); // SAFETY: `server_ptr` is the live heap server registered for this route; // `req`/`resp` are the live uWS handles passed to the route handler. @@ -3265,6 +3282,11 @@ where // (mod.rs `app.ws("/*", self_ptr, 0, ..)`); live for the request's // duration. let this = unsafe { &mut *self_ptr }; + if this.js_value_for_dispatch().is_none() { + resp.write_status(b"503 Service Unavailable"); + resp.end_without_body(true); + return; + } if this.config.on_node_http_request.is_some() { // NOTE: receiver is `*mut Self` (mod.rs) — the callee re-enters // JS, so a long-lived `&mut self` here would alias on callback. From 9b3903d2f794837dbde1877c67c9b9492f6d7436 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 12 Jun 2026 12:24:18 -0700 Subject: [PATCH 4/9] test: late keep-alive request to a route after stop+GC does not crash --- test/js/bun/http/bun-server.test.ts | 98 +++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 26b8d80e2bd..3937df78b39 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -613,6 +613,104 @@ test("should be able to await server.stop(true) with keep alive", async () => { expect(async () => await fetch(server.url)).toThrow(); }); +test("late keep-alive request to a route after stop()+GC does not crash", async () => { + // Per-route handlers live in ServerRouteList, which is reachable from JS only + // through the Server wrapper. After a graceful stop() the user may drop the + // wrapper, and a subsequent GC may finalize it before an idle keep-alive + // connection sends one more request. Run in a subprocess so the previous + // js_value_assert_alive() panic on that path surfaces as a non-zero exit + // instead of taking down the test runner. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { fullGC } = require("bun:jsc"); + + let received = ""; + let waiter = Promise.withResolvers(); + const nextResponse = () => { + // Wait until a complete HTTP/1.1 response (headers + 2-byte body or + // an empty body for 503) has arrived, then consume it. + return (async () => { + while (true) { + const headerEnd = received.indexOf("\\r\\n\\r\\n"); + if (headerEnd !== -1) { + const head = received.slice(0, headerEnd); + const m = /content-length: (\\d+)/i.exec(head); + const bodyLen = m ? Number(m[1]) : 0; + const total = headerEnd + 4 + bodyLen; + if (received.length >= total) { + const status = head.split("\\r\\n")[0]; + received = received.slice(total); + return status; + } + } + await waiter.promise; + waiter = Promise.withResolvers(); + } + })(); + }; + + const port = await (async () => { + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { "/r": () => new Response("ok") }, + }); + const port = server.port; + + globalThis.sock = await Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { + data(socket, data) { + received += data.toString("latin1"); + waiter.resolve(); + }, + close() { waiter.resolve(); }, + error() { waiter.resolve(); }, + }, + }); + + sock.write("GET /r HTTP/1.1\\r\\nHost: x\\r\\nConnection: keep-alive\\r\\n\\r\\n"); + const first = await nextResponse(); + if (!first.includes("200")) throw new Error("first request failed: " + first); + + // Graceful stop: existing connections stay open, listener closes. + server.stop(); + return port; + })(); + // The only \`server\` binding is now out of scope. + + for (let i = 0; i < 10; i++) { + Bun.gc(true); + fullGC(); + await Bun.sleep(10); + } + + // Reuse the same keep-alive socket. Previously this would dispatch into + // a finalized route list and panic; now it must answer (200 if the + // wrapper happened to survive, 503 if it was collected). + sock.write("GET /r HTTP/1.1\\r\\nHost: x\\r\\nConnection: close\\r\\n\\r\\n"); + const second = await nextResponse(); + console.log(second); + + sock.end(); + process.exit(0); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toMatch(/HTTP\/1\.1 (200|503)/); + expect(exitCode).toBe(0); +}); + test("should be able to async upgrade using custom protocol", async () => { const { promise, resolve } = Promise.withResolvers<{ code: number; reason: string } | boolean>(); using server = Bun.serve({ From 9e8171a857ef82ce5f93234676422a64f27d9cc3 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 12 Jun 2026 12:37:51 -0700 Subject: [PATCH 5/9] serve: close idle keep-alive sockets in graceful stop_listening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JsRef::Weak holds a raw JSValue, not a JSC::Weak: try_get() can return the address of a dead-but-unswept cell, so the Finalized→503 check alone leaves a window where dispatch reads an unrooted handler shadow. Closing idle connections at stop() removes the late-request source; in-flight requests are not idle and drain normally. The on_open error-path websocket-close accounting already runs after run_error_callback as of 65c76a2dfc, so the live-socket count stays nonzero across that read; no further change needed there. --- src/runtime/server/mod.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index db43a22208f..bd55d694246 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1614,6 +1614,15 @@ impl NewServer { if !abrupt { // S012: `app::ListenSocket` is a ZST opaque — safe deref. bun_opaque::opaque_deref_mut(listener).close(); + // Shut idle keep-alive sockets so a late request can't dispatch + // after the JS wrapper drops its strong root. `JsRef::Weak` holds + // a raw JSValue (not a JSC::Weak), so `try_get` can hand back a + // dead-but-unswept cell and the Finalized→503 guard alone leaves a + // window. In-flight requests aren't idle and drain normally. + if let Some(app) = self.app { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app).close_idle_connections(); + } } else if !self.flags.contains(ServerFlags::TERMINATED) { if let Some(ws) = self.config.websocket.as_mut() { ws.handler.app = None; From 41464801e283f031c297ae7e4db9c38c15deb0d1 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 12 Jun 2026 12:38:20 -0700 Subject: [PATCH 6/9] test: server wrapper survives GC while a websocket is connected after stop --- test/js/bun/http/bun-server.test.ts | 88 +++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 3937df78b39..69751d75c2f 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -711,6 +711,94 @@ test("late keep-alive request to a route after stop()+GC does not crash", async expect(exitCode).toBe(0); }); +test("server wrapper survives GC while a websocket is connected after stop()", async () => { + // The previous test exercises the one-tick HTTP keep-alive race; this one + // covers the steadier websocket case. After a graceful stop() with a live + // websocket, the user may drop their `server` binding. The native struct + // stays alive (active_websockets > 0), but stop() previously downgraded + // js_value immediately, so GC could finalize the JS wrapper — and with it + // m_routeList — while the connection was still in use. With the downgrade + // deferred into deinit_if_we_can's idle predicate, the wrapper must outlive + // the websocket and become collectable only after the last close. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { fullGC, heapStats } = require("bun:jsc"); + + const serverCount = () => { + const c = heapStats().objectTypeCounts; + return (c.DebugHTTPServer ?? 0) + (c.HTTPServer ?? 0); + }; + + const baseline = serverCount(); + + const ws = await (async () => { + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { "/r": () => new Response("ok") }, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("nope", { status: 404 }); + }, + websocket: { open() {}, message() {}, close() {} }, + }); + + const opened = Promise.withResolvers(); + const ws = new WebSocket("ws://127.0.0.1:" + server.port); + ws.onopen = () => opened.resolve(); + ws.onerror = e => opened.reject(e); + await opened.promise; + + // Graceful stop: listener closes, the live websocket stays open. + server.stop(); + return ws; + })(); + // The only \`server\` binding is now out of scope; only the live + // websocket keeps the native side around. + + for (let i = 0; i < 10; i++) { + Bun.gc(true); + fullGC(); + await Bun.sleep(10); + } + const afterStopGC = serverCount(); + + const closed = Promise.withResolvers(); + ws.onclose = () => closed.resolve(); + ws.close(); + await closed.promise; + + for (let i = 0; i < 10; i++) { + Bun.gc(true); + fullGC(); + await Bun.sleep(10); + } + const afterCloseGC = serverCount(); + + console.log(JSON.stringify({ baseline, afterStopGC, afterCloseGC })); + process.exit(0); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { baseline, afterStopGC, afterCloseGC } = JSON.parse(stdout.trim()); + // js_value stays Strong while a websocket is connected → GC must not + // collect the wrapper. + expect(afterStopGC).toBeGreaterThan(baseline); + // Last websocket closing triggers deinit_if_we_can → downgrade → wrapper + // becomes collectable again (no leak). + expect(afterCloseGC).toBeLessThanOrEqual(baseline); + expect(exitCode).toBe(0); +}); + test("should be able to async upgrade using custom protocol", async () => { const { promise, resolve } = Promise.withResolvers<{ code: number; reason: string } | boolean>(); using server = Bun.serve({ From 0929f8b2e5cb482eef4f643abe0b2a8d4325466d Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 12 Jun 2026 12:57:32 -0700 Subject: [PATCH 7/9] serve: gate dispatch on js_value being strong, not just non-finalized JsRef::Weak holds a raw JSValue: try_get() on Weak returns the address even when the cell is dead-but-unswept. Gating on Strong means trampolines refuse the moment the server goes idle (downgrade) rather than only after the wrapper destructor has run. --- src/runtime/server/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index bd55d694246..764663cb8d9 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -570,13 +570,14 @@ impl NewServer { self.js_value.try_get().expect("js_value alive") } - /// Returns the wrapper, or None if it has been finalized (server fully - /// stopped and the JS reference dropped). Dispatch trampolines should - /// answer 503+close on None. + /// Returns the wrapper while it is strongly rooted, or None once the + /// server has gone idle and downgraded. Dispatch trampolines should + /// answer 503+close on None. Checking strong (not just Finalized) closes + /// the dead-but-unswept window where `JsRef::Weak` holds a stale address. pub fn js_value_for_dispatch(&self) -> Option { match &self.js_value { - jsc::JsRef::Finalized => None, - _ => self.js_value.try_get(), + jsc::JsRef::Strong(_) => self.js_value.try_get(), + _ => None, } } From 868f7aeec3bdbc45553f16fd49878e536c8a78a4 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 12 Jun 2026 13:06:53 -0700 Subject: [PATCH 8/9] test: account for prototype in heapStats baseline; handle closed-socket second request --- test/js/bun/http/bun-server.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 69751d75c2f..f67cd68d05e 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -628,6 +628,7 @@ test("late keep-alive request to a route after stop()+GC does not crash", async const { fullGC } = require("bun:jsc"); let received = ""; + let sockClosed = false; let waiter = Promise.withResolvers(); const nextResponse = () => { // Wait until a complete HTTP/1.1 response (headers + 2-byte body or @@ -646,6 +647,7 @@ test("late keep-alive request to a route after stop()+GC does not crash", async return status; } } + if (sockClosed) return ""; await waiter.promise; waiter = Promise.withResolvers(); } @@ -668,8 +670,8 @@ test("late keep-alive request to a route after stop()+GC does not crash", async received += data.toString("latin1"); waiter.resolve(); }, - close() { waiter.resolve(); }, - error() { waiter.resolve(); }, + close() { sockClosed = true; waiter.resolve(); }, + error() { sockClosed = true; waiter.resolve(); }, }, }); @@ -707,7 +709,11 @@ test("late keep-alive request to a route after stop()+GC does not crash", async const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toMatch(/HTTP\/1\.1 (200|503)/); + // The second request either dispatches (wrapper survived → 200), is refused + // (wrapper downgraded → 503), or never arrives because graceful stop closed + // the idle keep-alive socket (empty). Any of these is correct; the point is + // the process didn't crash. + expect(stdout.trim()).toMatch(/^(|HTTP\/1\.1 (200|503).*)$/s); expect(exitCode).toBe(0); }); @@ -791,11 +797,12 @@ test("server wrapper survives GC while a websocket is connected after stop()", a expect(stderr).toBe(""); const { baseline, afterStopGC, afterCloseGC } = JSON.parse(stdout.trim()); // js_value stays Strong while a websocket is connected → GC must not - // collect the wrapper. - expect(afterStopGC).toBeGreaterThan(baseline); + // collect the wrapper. objectTypeCounts includes the prototype (1) once the + // first server has been created, so the live instance shows as baseline+2. + expect(afterStopGC).toBeGreaterThan(baseline + 1); // Last websocket closing triggers deinit_if_we_can → downgrade → wrapper // becomes collectable again (no leak). - expect(afterCloseGC).toBeLessThanOrEqual(baseline); + expect(afterCloseGC).toBeLessThanOrEqual(baseline + 1); expect(exitCode).toBe(0); }); From 0564a7b9d90bf3f411f1fa393fae6fb6c84236af Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:43:44 +0000 Subject: [PATCH 9/9] [autofix.ci] apply automated fixes --- src/runtime/server/ServerWebSocket.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/server/ServerWebSocket.rs b/src/runtime/server/ServerWebSocket.rs index 5056568580a..238501f5223 100644 --- a/src/runtime/server/ServerWebSocket.rs +++ b/src/runtime/server/ServerWebSocket.rs @@ -725,9 +725,7 @@ impl ServerWebSocket { }; let call_args = [cached_this, JSValue::js_number(code as f64), message_js]; - if let Err(e) = - on_close_handler.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);