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>
82 lines
2.1 KiB
TypeScript
82 lines
2.1 KiB
TypeScript
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<Parameters<typeof createRecycleMonitor>[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);
|
|
});
|