fix(dev-server): recycle the server once hot rebuilds pile up

The dev server got slower the longer it ran. Measured on the example app:
30 .wrn edits grew RSS from 117 MB to 137 MB and never gave it back, while
30 CSS edits cost nothing -- so the leak is exactly one retained module
identity per rebuild, not caches or file handles.

That is inherent to reloading a module in-process. Bun caches modules by
path, so a rebuild has to be given a new identity to be picked up at all,
and Bun has no API to unload the old one. At roughly 0.66 MB a rebuild, a
long editing session is several hundred megabytes of garbage that cannot
be collected.

The process now recycles itself past a rebuild threshold, exiting with the
RESTART_EXIT_CODE the CLI supervisor already respawns on; browsers
reconnect because the HMR client already retries. It waits for a quiet
period first so a live request is never cut off, and the threshold (300
rebuilds, about 200 MB) sits well above a normal session. Set
WRNEXUS_DEV_RECYCLE_AFTER to tune it, or 0 to switch it off.

Also bounds browserArtifactPaths and islandArtifactPaths, which are keyed
by content hash and so gained an entry per rebuild that was never read
again. Small next to the module leak, but unbounded is unbounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:30:54 +05:30
co-authored by Claude Opus 5
parent a20f143acb
commit 18a1c40118
4 changed files with 213 additions and 4 deletions
+40
View File
@@ -48,6 +48,22 @@ import {
wrnBrowserArtifactUrlAsync,
} from "./pipeline.ts";
import { createRpcHandler } from "@wrnexus/ssr/rpc";
import { createRecycleMonitor } from "./recycle.ts";
import { RESTART_EXIT_CODE } from "./restart.ts";
/*
* Recycle after this many hot rebuilds. Each retains roughly 0.66 MB that Bun
* cannot release, so 300 caps the leak near 200 MB -- far more than a normal
* session reaches, and far less than what makes the server crawl. Set
* WRNEXUS_DEV_RECYCLE_AFTER to tune it, or to 0 to never recycle.
*/
const RECYCLE_REBUILD_THRESHOLD = (() => {
const configured = Number(process.env.WRNEXUS_DEV_RECYCLE_AFTER);
return Number.isFinite(configured) && configured >= 0 ? configured : 300;
})();
/** Quiet period required first, so a recycle never interrupts a live request. */
const RECYCLE_IDLE_MS = 10_000;
const RECYCLE_CHECK_MS = 5_000;
import { createHandlers, type WsData } from "./runtime.ts";
import { createDevAssetServer } from "./assets.ts";
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
@@ -588,6 +604,22 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
validateCsrf: validateRpcCsrf,
});
/*
* Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads
* modules, so only dev needs to recycle.
*/
const recycle =
hmr && mode === "development" && RECYCLE_REBUILD_THRESHOLD > 0
? createRecycleMonitor({
threshold: RECYCLE_REBUILD_THRESHOLD,
idleMs: RECYCLE_IDLE_MS,
onRecycle(reason) {
console.log(`[wrnexus] ${reason}`);
process.exit(RESTART_EXIT_CODE);
},
})
: null;
const server = Bun.serve<WsData>({
port,
hostname,
@@ -595,12 +627,19 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
maxRequestBodySize: 10 * 1024 * 1024,
...(opts.tls ? { tls: opts.tls } : {}),
fetch(request, server) {
recycle?.recordRequest(Date.now());
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
return handlers.fetch(request, server);
},
websocket: handlers.websocket,
});
if (recycle) {
// unref so a pending check never keeps the process alive on its own.
const timer = setInterval(() => recycle.tick(Date.now()), RECYCLE_CHECK_MS);
timer.unref?.();
}
try {
await pluginRunner.hook("configureServer", {
server,
@@ -626,6 +665,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
for (const file of files) {
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
recycle?.recordRebuild();
}
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();