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); +});