From 18a1c4011858d80d3ea6ea31dae6a6031a364e34 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 10:30:54 +0530 Subject: [PATCH] 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 --- packages/dev-server/src/index.ts | 40 ++++++++++++ packages/dev-server/src/pipeline.ts | 27 ++++++-- packages/dev-server/src/recycle.ts | 69 ++++++++++++++++++++ packages/dev-server/test/recycle.test.ts | 81 ++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 packages/dev-server/src/recycle.ts create mode 100644 packages/dev-server/test/recycle.test.ts diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 2a06ee6b..71c7bc78 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -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 { 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({ port, hostname, @@ -595,12 +627,19 @@ export async function startServer(opts: ServeOptions): Promise { 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 { for (const file of files) { invalidateModule(isAbsolute(file) ? file : resolve(appDir, file)); + recycle?.recordRebuild(); } if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss(); diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index 829a2818..67889bf5 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -58,9 +58,28 @@ export function runMiddleware( */ const moduleCache = new Map>>(); const moduleVersions = new Map(); +/* + * Artifact URLs carry a content hash, so a rebuild registers a new key and the + * previous one is never requested again -- left alone these grow for the life + * of the dev server. Bounded rather than cleared on rebuild because a page + * already mid-load may still ask for the URL it was served. + */ +const ARTIFACT_PATH_LIMIT = 512; const browserArtifactPaths = new Map(); const islandArtifactPaths = new Map(); +function rememberArtifact(paths: Map, pathname: string, artifact: string): void { + // Re-insert so a key still in use is treated as recent. + paths.delete(pathname); + paths.set(pathname, artifact); + + while (paths.size > ARTIFACT_PATH_LIMIT) { + const oldest = paths.keys().next(); + if (oldest.done) break; + paths.delete(oldest.value); + } +} + type ImportMode = "legacy" | "compatible" | "explicit"; interface CompileImportOptions { mode: ImportMode; @@ -622,7 +641,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise asyncCompileInProgress.delete(key)); @@ -673,7 +692,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa .map(([, path]) => path); if (requiredArtifacts.every((path) => statSync(path).isFile())) { compileMetrics.hits++; - browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser); + rememberArtifact(browserArtifactPaths, `/__wrnexus/client/${stem}.mjs`, artifacts.browser); // A cached .wrn still needs its island bundles: the .tsx may have changed // since, and after a restart with a warm cache nothing else would build them. let cachedIslands: Array<{ name: string; sourcePath: string }> = []; @@ -715,7 +734,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa rewriteArtifactImports(targets.browser, result.ast, file, "browser"), "utf8", ); - browserArtifactPaths.set(browserPath, artifacts.browser); + rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser); writeFileSync( artifacts.server, rewriteArtifactImports(targets.server, result.ast, file, "server"), @@ -775,7 +794,7 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null { /** Registers a built island asset for serving under `/__wrnexus/island/`. */ export function registerIslandArtifact(pathname: string, artifact: string): void { - islandArtifactPaths.set(pathname, artifact); + rememberArtifact(islandArtifactPaths, pathname, artifact); } /** Serves a built island bundle, chunk, or the island mount runtime. */ diff --git a/packages/dev-server/src/recycle.ts b/packages/dev-server/src/recycle.ts new file mode 100644 index 00000000..d3f290a7 --- /dev/null +++ b/packages/dev-server/src/recycle.ts @@ -0,0 +1,69 @@ +/** + * Recycle the dev server once hot rebuilds have piled up. + * + * Every rebuild of a `.wrn` file has to be given a new module identity, + * because Bun caches modules by path and would otherwise serve the old one. + * Bun has no API to unload a module, so each rebuild retains its predecessor + * for the life of the process -- measured at roughly 0.66 MB per rebuild, + * while edits that mint no new module (CSS) cost nothing. Over a long session + * that is the difference between a fast dev server and a stuck one. + * + * The process therefore recycles itself: the child exits with + * RESTART_EXIT_CODE and the CLI supervisor respawns it. Browsers reconnect on + * their own because the HMR client already retries. + * + * Recycling is deferred until the server has been idle for a moment, so it + * never interrupts a request in flight. The cost is that in-memory state + * (realtime rooms, warmed caches) resets at that point, which is why the + * threshold is high enough that an ordinary editing session never reaches it. + */ + +export interface RecycleMonitorOptions { + /** Retained rebuilds tolerated before a recycle is armed. */ + threshold: number; + /** Quiet period required before recycling, in milliseconds. */ + idleMs: number; + onRecycle: (reason: string) => void; +} + +export interface RecycleMonitor { + /** Count one rebuild that retained a module version. */ + recordRebuild(): void; + /** Note that a request was served, at `now`. */ + recordRequest(now: number): void; + /** Recycle if the threshold is passed and the server has gone quiet. */ + tick(now: number): void; + retained(): number; +} + +export function createRecycleMonitor(options: RecycleMonitorOptions): RecycleMonitor { + let rebuilds = 0; + let lastRequestAt: number | null = null; + let recycled = false; + + return { + recordRebuild() { + rebuilds++; + }, + + recordRequest(now: number) { + lastRequestAt = now; + }, + + tick(now: number) { + if (recycled) return; + if (rebuilds < options.threshold) return; + // A server that has served nothing is idle by definition. + if (lastRequestAt !== null && now - lastRequestAt < options.idleMs) return; + + recycled = true; + options.onRecycle( + `${rebuilds} hot rebuilds retained; restarting to release the memory they hold`, + ); + }, + + retained() { + return rebuilds; + }, + }; +} diff --git a/packages/dev-server/test/recycle.test.ts b/packages/dev-server/test/recycle.test.ts new file mode 100644 index 00000000..a5799ae1 --- /dev/null +++ b/packages/dev-server/test/recycle.test.ts @@ -0,0 +1,81 @@ +import { test, expect } from "bun:test"; +import { createRecycleMonitor } from "../src/recycle.ts"; + +/** Fresh monitor with a small threshold so tests stay readable. */ +function monitor(overrides: Partial[0]> = {}) { + const recycled: string[] = []; + const control = createRecycleMonitor({ + threshold: 3, + idleMs: 1000, + onRecycle: (reason) => recycled.push(reason), + ...overrides, + }); + return { control, recycled }; +} + +test("stays quiet below the rebuild threshold", () => { + const { control, recycled } = monitor(); + + control.recordRebuild(); + control.recordRebuild(); + control.tick(10_000); + + expect(recycled).toEqual([]); +}); + +test("recycles once rebuilds pass the threshold and the server goes idle", () => { + const { control, recycled } = monitor(); + + for (let i = 0; i < 3; i++) control.recordRebuild(); + control.recordRequest(0); + control.tick(1_500); + + expect(recycled.length).toBe(1); +}); + +test("waits for the idle gap rather than cutting off active work", () => { + // Recycling mid-request would drop it. The gap is the whole point. + const { control, recycled } = monitor(); + + for (let i = 0; i < 3; i++) control.recordRebuild(); + control.recordRequest(0); + control.tick(500); + expect(recycled).toEqual([]); + + control.recordRequest(900); + control.tick(1_400); + expect(recycled).toEqual([]); + + control.tick(2_000); + expect(recycled.length).toBe(1); +}); + +test("recycles only once even if it keeps being ticked", () => { + const { control, recycled } = monitor(); + + for (let i = 0; i < 5; i++) control.recordRebuild(); + control.recordRequest(0); + control.tick(5_000); + control.tick(6_000); + control.tick(7_000); + + expect(recycled.length).toBe(1); +}); + +test("a server that never served a request can still recycle", () => { + const { control, recycled } = monitor(); + + for (let i = 0; i < 3; i++) control.recordRebuild(); + control.tick(9_999); + + expect(recycled.length).toBe(1); +}); + +test("reports how many rebuilds are being retained", () => { + const { control } = monitor(); + + control.recordRebuild(); + control.recordRebuild(); + + expect(control.retained()).toBe(2); +});