Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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 @@ -5374,8 +5378,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
22 changes: 0 additions & 22 deletions src/runtime/bake/DevServer/HmrSocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,6 @@ impl HmrSocket {
response[1..].copy_from_slice(&rbi.get().to_ne_bytes());

let _ = ws.send(&response, Opcode::Binary, false, true);
self.notify_inspector_client_navigation(pattern, Some(rbi));
}
x if x == IncomingMessageId::TestingBatchEvents as u8 => {
// SAFETY: JS-thread only; sole `&mut DevServer` for this scope.
Expand Down Expand Up @@ -385,25 +384,4 @@ impl HmrSocket {
// owner reclaiming it. Matches `s.dev.arena().destroy(s)`.
drop(unsafe { bun_core::heap::take(s) });
}

fn notify_inspector_client_navigation(
&self,
pattern: &[u8],
rbi: super::route_bundle::IndexOptional,
) {
if self.inspector_connection_id > -1 {
// SAFETY: JS-thread only; sole `&mut DevServer` for this scope.
let dev = unsafe { self.dev() };
if let Some(agent) = dev.inspector() {
let mut pattern_str = bun_core::String::init(pattern);
// `defer pattern_str.deref()` → Drop on bun_core::String
agent.notify_client_navigated(
dev.inspector_server_id,
self.inspector_connection_id,
&mut pattern_str,
rbi.map(|i| i.get() as i32).unwrap_or(-1),
);
}
}
}
}
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.
19 changes: 19 additions & 0 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ bitflags::bitflags! {
const DEINIT_SCHEDULED = 1 << 0;
const TERMINATED = 1 << 1;
const HAS_HANDLED_ALL_CLOSED_PROMISE = 1 << 2;
const HANDLERS_RELEASED = 1 << 3;
}
}

Expand Down Expand Up @@ -1667,6 +1668,24 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
if let Some(ws) = self.config.websocket.as_mut() {
ws.handler.app = None;
}
// 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.
if !self.flags.contains(ServerFlags::HANDLERS_RELEASED) {
self.flags.insert(ServerFlags::HANDLERS_RELEASED);
self.config.on_request = None;
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() {
Comment thread
robobun marked this conversation as resolved.
Outdated
ws.handler.unprotect();
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}
}
self.unref();

// Detach DevServer. This is needed because there are aggressive
Expand Down
11 changes: 10 additions & 1 deletion src/runtime/server/server_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2191,10 +2191,19 @@ where
.set(super::web_socket_server_context::HandlerFlags::SSL, SSL);
if !ws.handler.on_message.is_empty() || !ws.handler.on_open.is_empty() {
if let Some(old_ws) = self.config.websocket.as_ref() {
old_ws.unprotect();
// Skip when an idle `deinit_if_we_can` already released the
// old context's protections (stopped server being
// reloaded); a second unprotect would unbalance the
// per-value protect counts.
if !self.flags.contains(ServerFlags::HANDLERS_RELEASED) {
old_ws.unprotect();
}
}
ws.global_object = bun_ptr::BackRef::new(global);
self.config.websocket = Some(ws);
// The newly installed context holds fresh protections; let the
// next idle pass release them.
self.flags.remove(ServerFlags::HANDLERS_RELEASED);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} else {
// Not adopting it: release the protections taken in
// `WebSocketServerContext::on_create` so the handlers don't leak.
Expand Down
4 changes: 3 additions & 1 deletion test/bake/deinitialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ test("dev server deinitializes itself", () => {
});
expect(result.signalCode).toBeUndefined();
expect(result.exitCode).toBe(0);
});
// The child runs a whole `bun test` suite (nine GC-heavy cases plus leak
// reporting at exit), which takes longer than the 5s default under ASAN.
}, 60_000);
50 changes: 47 additions & 3 deletions test/bake/fixtures/deinitialization/test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getDevServerDeinitCount } from "bun:internal-for-testing";
import html from "./index.html";
import { expect, test } from "bun:test";
import { fullGC } from "bun:jsc";
import { afterAll, expect, test } from "bun:test";
import { fullGC, heapStats } from "bun:jsc";

expect(process.cwd()).toBe(import.meta.dir);

Expand Down Expand Up @@ -71,7 +71,15 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web
expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("Unable to connect");
}

await main();
try {
await main();
} finally {
// The closure assigned to `globalThis.callback` inside `main()` captures
// `server`; left in place it roots the JS Server wrapper through every GC
// below, so the wrapper never finalizes and the native NewServer box (and
// everything its config owns) is still live at process exit.
globalThis.callback = undefined;
}

if (closeActiveConnections) {
await promise;
Expand Down Expand Up @@ -109,6 +117,42 @@ const cases = [
{ closeActiveConnections: true, sendAnyRequests: false, websocket: 8 },
];

function liveServerWrappers() {
const c = heapStats().objectTypeCounts;
return (c.HTTPServer ?? 0) + (c.DebugHTTPServer ?? 0) + (c.HTTPSServer ?? 0) + (c.DebugHTTPSServer ?? 0);
}
// `objectTypeCounts` includes the (lazily created) prototype object once the
// first server has been constructed. Create-and-stop one trivial server here
// so the prototype is materialized but the instance is freed; the afterAll
// check then asserts every dev-server case returns to this baseline (i.e. zero
// live wrapper instances and the native boxes were actually freed).
let serverWrapperBaseline: number;
test("baseline: stopped server wrapper collects", async () => {
await (async () => {
const server = Bun.serve({ port: 0, fetch: () => new Response("ok") });
server.stop(true);
})();
for (let i = 0; i < 10 && liveServerWrappers() > 1; i++) {
Comment thread
robobun marked this conversation as resolved.
Outdated
Bun.gc(true);
fullGC();
await new Promise(resolve => setTimeout(resolve, 100));
}
serverWrapperBaseline = liveServerWrappers();
expect(serverWrapperBaseline).toBeLessThanOrEqual(1);
});

afterAll(async () => {
// Drain any deferred deinit task scheduled during the final case's GC, then
// assert every JS Server wrapper has actually been collected — i.e. the
// native NewServer boxes are freed, not just the embedded dev servers.
for (let i = 0; i < 10 && liveServerWrappers() > serverWrapperBaseline; i++) {
Comment thread
robobun marked this conversation as resolved.
Outdated
Bun.gc(true);
fullGC();
await new Promise(resolve => setTimeout(resolve, 100));
}
expect(liveServerWrappers()).toBe(serverWrapperBaseline);
});

for (const { closeActiveConnections, sendAnyRequests, websocket } of cases) {
test(
"flags: " +
Expand Down
39 changes: 39 additions & 0 deletions test/cli/inspect/BunFrontendDevServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,45 @@ describe.if(isPosix)("BunFrontendDevServer inspector protocol", () => {
ws.close();
});

test("sends exactly one clientNavigated event per SetUrl message", async () => {
// https://github.com/oven-sh/bun/issues/32080
// Request "/" first so its route bundle takes index 0 and "/second" below
// deterministically gets index 1, even when this test runs in isolation.
await fetch(serverUrl.href).then(r => r.blob());

const connectedEventPromise = session.waitForEvent("BunFrontendDevServer.clientConnected");
const ws = await createHMRClient();
try {
const { connectionId } = await connectedEventPromise;

const navigated: any[] = [];
const { promise: sawSentinelNavigation, resolve, reject } = Promise.withResolvers<void>();
session.addEventListener("BunFrontendDevServer.clientNavigated", (params: any) => {
if (params.connectionId !== connectionId) return;
navigated.push(params);
if (params.url === "/does-not-exist") resolve();
});
ws.addEventListener("close", e => reject(new Error(`HMR socket closed unexpectedly: ${e.code}`)));
ws.addEventListener("error", () => reject(new Error("HMR socket error")));

// Navigate to a matched route, then to an unknown one. The server handles
// the messages in order and emits inspector events synchronously, so once
// the event for the unknown route arrives, every event for "/second" has
// been delivered.
ws.send("n" + "/second");
ws.send("n" + "/does-not-exist");
await sawSentinelNavigation;

expect(navigated).toEqual([
{ serverId: expect.any(Number), connectionId, url: "/second", routeBundleId: 1 },
// Failed lookups report a single event with no routeBundleId.
{ serverId: expect.any(Number), connectionId, url: "/does-not-exist" },
]);
} finally {
ws.close();
}
});

test("should notify on consoleLog events", async () => {
await fetch(serverUrl.href).then(r => r.blob());

Expand Down
111 changes: 111 additions & 0 deletions test/js/bun/websocket/websocket-server-reload-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,114 @@ test("server.reload() with websocket config lacking open/message does not leak p
expect(after - before).toBeLessThan(iters);
expect(exitCode).toBe(0);
});

// A handler defined in a scope that closes over the JS Server value forms a
// native↔JS cycle (server box → protected handler → closure environment →
// Server wrapper → box) that the GC cannot see through. Once the server is
// idle (stopped, no in-flight requests, no live websockets), deinitIfWeCan
// releases the config's handler references, including the gcProtects taken on
// the websocket handlers by WebSocketServerContext.onCreate. Async handlers
// are used so the probe can count them separately from every other protected
// plain Function.
test("stopping an idle server releases its websocket handler protections", async () => {
const script = /* js */ `
const { heapStats } = require("bun:jsc");
const protectedAsyncFns = () => heapStats().protectedObjectTypeCounts.AsyncFunction ?? 0;

const base = protectedAsyncFns();
const server = Bun.serve({
port: 0,
fetch() { return new Response("ok"); },
websocket: { async open(ws) {}, async message(ws, m) {} },
});
const afterServe = protectedAsyncFns();
// stop(true) with nothing in flight goes idle synchronously and runs the
// handler release in the same call.
server.stop(true);
const afterStop = protectedAsyncFns();
console.log(JSON.stringify({ base, afterServe, afterStop }));
`;

await using proc = Bun.spawn({
Comment thread
robobun marked this conversation as resolved.
Outdated
cmd: [bunExe(), "-e", script],
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 { base, afterServe, afterStop } = JSON.parse(stdout.trim());
expect({ afterServe, afterStop }).toEqual({ afterServe: base + 2, afterStop: base });
expect(exitCode).toBe(0);
});

// gcProtect is counted per value. When two servers share the same handler
// functions, each onCreate protects them once. The idle release of a stopped
// server drops that server's count; a later reload() of the stopped server
// swaps in a new websocket context and must not unprotect the old one a
// second time, or it strips the other server's protection of the same values.
test("reloading a stopped server does not release another server's shared websocket handler protections", async () => {
const script = /* js */ `
const { heapStats } = require("bun:jsc");
const protectedAsyncFns = () => heapStats().protectedObjectTypeCounts.AsyncFunction ?? 0;

const base = protectedAsyncFns();
// Both servers protect the same two async handler function values.
const shared = {
async open(ws) {},
async message(ws, m) { ws.send("pong:" + m); },
};
const keeper = Bun.serve({
port: 0,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("keeper");
},
websocket: shared,
});
const stopped = Bun.serve({
port: 0,
fetch() { return new Response("stopped"); },
websocket: shared,
});

// Goes idle synchronously; releases the stopped server's handler refs.
stopped.stop(true);
// The reload's new websocket context uses plain functions so it cannot
// affect the AsyncFunction count either way.
stopped.reload({
fetch() { return new Response("reloaded"); },
websocket: { open(ws) {}, message(ws, m) {} },
});
const afterReload = protectedAsyncFns();

// The keeper must still serve websockets with the shared handlers.
const { promise, resolve, reject } = Promise.withResolvers();
const ws = new WebSocket(keeper.url.href.replace("http", "ws"));
ws.onmessage = e => resolve(e.data);
ws.onerror = () => reject(new Error("keeper websocket errored"));
ws.onclose = e => reject(new Error("keeper websocket closed early: " + e.code));
ws.onopen = () => ws.send("hi");
const echoed = await promise;
ws.onclose = null;
ws.close();
keeper.stop(true);
console.log(JSON.stringify({ base, afterReload, echoed }));
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
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 { base, afterReload, echoed } = JSON.parse(stdout.trim());
expect({ afterReload, echoed }).toEqual({ afterReload: base + 2, echoed: "pong:hi" });
expect(exitCode).toBe(0);
});
Loading