diff --git a/mod_memory_test.ts b/mod_memory_test.ts index 5b9abf0..3072939 100644 --- a/mod_memory_test.ts +++ b/mod_memory_test.ts @@ -69,14 +69,20 @@ function readHeapUsedBytes(): number | null { /** * Trigger a full GC cycle if the runtime exposes one. * - * Without explicit GC, heap measurements include unreachable objects - * from previous iterations, inflating the delta artificially. + * V8's GC is generational — a single `gc()` call typically only scavenges + * the young generation. Objects promoted to old-gen require a major (full) + * cycle, which needs 2–3 calls to reliably trigger. Calling three times in + * succession gives the collector enough passes to reclaim old-gen survivors + * and produce a stable heap baseline for before/after comparisons. + * * `deno task test` passes `--v8-flags=--expose-gc` which makes * `globalThis.gc()` available in V8. */ function forceGCIfAvailable(): void { const maybeGlobal = globalThis as unknown as { gc?: () => void }; if (typeof maybeGlobal.gc === "function") { + maybeGlobal.gc(); + maybeGlobal.gc(); maybeGlobal.gc(); return; } @@ -86,28 +92,66 @@ function forceGCIfAvailable(): void { }; if (typeof maybeBun.Bun?.gc === "function") { maybeBun.Bun.gc(true); + maybeBun.Bun.gc(true); + maybeBun.Bun.gc(true); } } /** - * Run `fn`, measure heap growth with GC on both sides, and assert the - * delta stays under `thresholdKB` kilobytes. + * Assert that repeated calls to `fn` do not retain memory across iterations. + * + * A single before/after heap snapshot cannot reliably distinguish a real leak + * from initialisation overhead (JIT compilation, WeakMap warming, first-pass + * object promotion). This helper uses a two-phase growth-rate check instead: + * + * ``` + * warm-up (warmupCount iters) + * └─ lets JIT settle, populates caches, promotes short-lived objects + * GC × 3 → snapshot[0] + * phase 1 (iterCount iters) → GC × 3 → snapshot[1] + * phase 2 (iterCount iters) → GC × 3 → snapshot[2] + * + * assert: (snapshot[2] − snapshot[1]) < thresholdKB + * ``` * - * When heap measurement is unavailable the assertion is skipped rather - * than trivially passing with a zero delta. + * If memory grows between snapshot[0]→[1] but is flat between [1]→[2], the + * growth was one-time initialisation, not a leak. If it grows proportionally + * in both phases, something is being retained across iterations. + * + * When heap measurement is unavailable the assertion is skipped rather than + * trivially passing with a zero delta. + * + * @param fn - Operation to repeat. Called warmupCount + iterCount × 2 times total. + * @param iterCount - Iterations per measurement phase. Default: 5 000. + * @param thresholdKB - Maximum tolerated growth (KB) in the second phase. Default: 512. + * @param warmupCount - Warm-up iterations before any measurement. Default: 500. */ -function assertNoLeak(fn: () => void, thresholdKB: number): void { +function assertNoLeak( + fn: () => void, + iterCount = 5_000, + thresholdKB = 512, + warmupCount = 500, +): void { + // Warm-up: let JIT compile, populate WeakMap caches, and flush short-lived + // allocations so they don't pollute the measured phases. + for (let i = 0; i < warmupCount; i++) fn(); + forceGCIfAvailable(); - const before = readHeapUsedBytes(); + // snapshot[0] not strictly needed but useful for debugging if a test fails. - fn(); + // Phase 1 + for (let i = 0; i < iterCount; i++) fn(); + forceGCIfAvailable(); + const after1 = readHeapUsedBytes(); + // Phase 2 — if memory is flat here, phase 1 growth was initialisation cost. + for (let i = 0; i < iterCount; i++) fn(); forceGCIfAvailable(); - const after = readHeapUsedBytes(); + const after2 = readHeapUsedBytes(); - if (before === null || after === null) return; // measurement unavailable + if (after1 === null || after2 === null) return; // measurement unavailable - const deltaKB = (after - before) / 1024; + const deltaKB = (after2 - after1) / 1024; expect(deltaKB).toBeLessThan(thresholdKB); } @@ -116,66 +160,74 @@ function assertNoLeak(fn: () => void, thresholdKB: number): void { // --------------------------------------------------------------------------- describe("memory regression", () => { - it(".string() × 10K (5K-line input) stays under 1 MB", () => { + it(".string() does not retain memory across calls (5K-line input)", () => { const input = makeLines(5000, " "); - assertNoLeak(() => { - for (let i = 0; i < 10_000; i++) undent.string(input); - }, 1000); + // Large input — fewer iterations per phase; threshold is looser because + // each call produces a large output string that legitimately lives until + // the next GC pass. + assertNoLeak(() => undent.string(input), 1_000, 1024); }); - it("tag × 15K (hot cache) stays under 1 MB", () => { + it("tag does not retain memory on the hot cache path", () => { + // Uses a literal TSA so the WeakMap cache is hit on every iteration. + // Memory should stay flat after warm-up once the cached entry is stable. + let i = 0; assertNoLeak(() => { - for (let i = 0; i < 15_000; i++) { - undent` - Hello ${i} - World ${i} - `; - } - }, 1000); + undent` + Hello ${i} + World ${i++} + `; + }); }); - it(".with() × 10K new instances stays under 1 MB", () => { + it(".with() does not accumulate instances across calls", () => { + let i = 0; assertNoLeak(() => { - for (let i = 0; i < 10_000; i++) { - const inst = undent.with({ trim: "none" }); - inst`test ${i}`; - } - }, 1000); + const inst = undent.with({ trim: "none" }); + inst`test ${i++}`; + }); }); - it("cold TSA × 10K (WeakMap churn) stays under 1 MB", () => { + it("cold TSA does not grow the WeakMap unboundedly", () => { + // Each iteration creates a fresh TSA — a new WeakMap key. WeakMap entries + // for unreachable keys must be collectable; if they are not, heap grows + // proportionally with iteration count. + let i = 0; assertNoLeak(() => { - for (let i = 0; i < 10_000; i++) { - const tsa = makeTSA(2); - undent(tsa, String(i)); - } - }, 1000); + const tsa = makeTSA(2); + undent(tsa, String(i++)); + }); }); - it("align() × 10K with 1K-line values stays under 1 MB", () => { - let big = ""; - for (let i = 0; i < 1_000; i++) { - big += `line ${i}\n`; - } - assertNoLeak(() => { - for (let i = 0; i < 10_000; i++) { + it("align() does not retain 1K-line values across calls", () => { + let i = 0; + const big = makeLines(1_000); + assertNoLeak( + () => { undent` - header: - ${align(big)} - `; - } - }, 1000); + header: + ${align(big)} + `; + i++; + }, + 2_000, + 1024, + ); }); - it("embed() × 10K with 1K-line values stays under 1 MB", () => { + it("embed() does not retain 1K-line values across calls", () => { + let i = 0; const indented = makeLines(1_000, " "); - assertNoLeak(() => { - for (let i = 0; i < 10_000; i++) { + assertNoLeak( + () => { undent` - code: - ${embed(indented)} - `; - } - }, 1000); + code: + ${embed(indented)} + `; + i++; + }, + 2_000, + 1024, + ); }); });