diff --git a/tests/memory-leak.test.ts b/tests/memory-leak.test.ts index 04e3310..d945199 100644 --- a/tests/memory-leak.test.ts +++ b/tests/memory-leak.test.ts @@ -100,34 +100,21 @@ const ALL_KEYS: ComponentKey[] = [ // ── Helpers ───────────────────────────────────────────────────────────────── /** - * Force garbage collection with multiple passes. When `earlyExitRef` is - * supplied the loop bails as soon as the object is confirmed collected, - * reducing test time in the common (no-leak) case while still applying - * sufficient pressure when the object lingers. - * - * ## WeakRef liveness hazard - * - * Per the ECMAScript WeakRef specification, calling `deref()` and getting a - * live value pins the referent alive for the rest of the **current macrotask**. - * If the deref() check and the next `global.gc()` call are in the same - * macrotask, GC can never collect the object — an infinite liveness cycle. - * - * Fix: after a non-undefined deref() result, yield to a new macrotask via - * `setTimeout(0)` before the next gc() call. The liveness guarantee from the - * previous macrotask has then expired and gc() is free to collect the object. + * Force garbage collection with multiple passes. When `earlyExitRefs` is supplied + * the loop bails as soon as every referenced object is confirmed collected. The + * setTimeout(0) yield after a live deref() avoids the WeakRef macrotask-liveness pin. + * Without early-exit refs the loop always runs all passes, which on a slow `gc()` + * can exceed the Vitest testTimeout — always pass refs when you have them. */ -async function forceGC(earlyExitRef?: WeakRef): Promise { +async function forceGC(earlyExitRefs?: WeakRef | readonly WeakRef[]): Promise { + const refs = earlyExitRefs === undefined ? [] : Array.isArray(earlyExitRefs) ? earlyExitRefs : [earlyExitRefs]; for (let i = 0; i < 15; i++) { globalThis.gc?.(); - // Yield to a new macrotask so the GC has processed any pending finalization. await new Promise((r) => setTimeout(r, 50)); - if (earlyExitRef !== undefined && earlyExitRef.deref() === undefined) { - return; // Object collected — skip remaining passes. + if (refs.length > 0 && refs.every((ref) => ref.deref() === undefined)) { + return; } - if (earlyExitRef !== undefined) { - // The deref() above returned a live value, pinning the referent for the - // rest of this macrotask. Yield to a fresh macrotask so that liveness - // guarantee expires *before* the next gc() call at the top of the loop. + if (refs.length > 0) { await new Promise((r) => setTimeout(r, 0)); } } @@ -376,7 +363,7 @@ describe("Memory leak regression", () => { for (let i = 0; i < 10; i++) { refs.push(createModelOnly(key)); } - await forceGC(); + await forceGC(refs); const survivors = refs.filter((r) => r.deref() !== undefined).length; expect(survivors).toBe(0); }); @@ -504,7 +491,7 @@ describe("Memory leak regression", () => { refs.push({ key, ...createWithView(key, mvt) }); } - await forceGC(); + await forceGC(refs.flatMap((r) => (r.viewRef ? [r.elementRef, r.viewRef] : [r.elementRef]))); const leaks = refs.filter((r) => r.elementRef.deref() !== undefined || r.viewRef?.deref() !== undefined); if (leaks.length > 0) { @@ -532,7 +519,7 @@ describe("Memory leak regression", () => { scene.dispose(); })(); - await forceGC(); + await forceGC([...elementRefs.map((e) => e.ref), sceneRef]); const leakedElements = elementRefs.filter((e) => e.ref.deref() !== undefined); if (leakedElements.length > 0) { @@ -568,7 +555,7 @@ describe("Memory leak regression", () => { scene.dispose(); })(); - await forceGC(); + await forceGC(elementRefs.map((e) => e.ref)); const leaked = elementRefs.filter((e) => e.ref.deref() !== undefined); if (leaked.length > 0) { @@ -602,7 +589,7 @@ describe("Memory leak regression", () => { scene.dispose(); })(); - await forceGC(); + await forceGC([...elementRefs.map((e) => e.ref), sceneRef]); const leakedElements = elementRefs.filter((e) => e.ref.deref() !== undefined); if (leakedElements.length > 0) { @@ -659,7 +646,7 @@ describe("Memory leak regression", () => { } })(); - await forceGC(); + await forceGC([...allRefs.map((e) => e.ref), ...sceneRefs]); const leakedScenes = sceneRefs.filter((r) => r.deref() !== undefined).length; const leakedElements = allRefs.filter((e) => e.ref.deref() !== undefined); @@ -708,7 +695,7 @@ describe("Memory leak regression", () => { // Switch to "preset 2" — round-1 elements are now superseded. loadPreset(null); - await forceGC(); + await forceGC(round1Refs.map((e) => e.ref)); const leaked = round1Refs.filter((e) => e.ref.deref() !== undefined); scene.dispose();