Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 17 additions & 30 deletions tests/memory-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>): Promise<void> {
async function forceGC(earlyExitRefs?: WeakRef<object> | readonly WeakRef<object>[]): Promise<void> {
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<void>((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<void>((r) => setTimeout(r, 0));
}
}
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down