diff --git a/.nx/version-plans/fix-promise-tracker-leak.md b/.nx/version-plans/fix-promise-tracker-leak.md new file mode 100644 index 0000000..0308c73 --- /dev/null +++ b/.nx/version-plans/fix-promise-tracker-leak.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Fixes an unbounded memory leak in the runtime promise tracker that could exhaust the JS heap (`JavaScript heap out of memory`) during long runs. Apps that keep producing work every frame (animations, `requestAnimationFrame` loops, polling, async data-binding) create a stream of promises that never settle; these were retained forever along with a captured stack, growing memory until the run crashed. Harness now releases a promise's tracking record as soon as the promise is garbage-collected and caps the number of retained records, so memory stays bounded regardless of how busy the app under test is. diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index b1e785f..382d770 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -20,10 +20,25 @@ type PromiseExecutor = ( reject: PromiseReject ) => void; +// Backstop against unbounded growth if GC can't keep up: never-settling +// promises created in a hot loop would otherwise OOM the heap. Diagnostics only +// ever show a handful, so evicting the oldest (Map keeps insertion order) is safe. +export const MAX_TRACKED_PROMISES = 10_000; + const pendingPromises = new Map(); const promiseIds = new WeakMap(); const promiseContexts = new WeakMap(); +// A garbage-collected promise can never settle or hold anything pending, so drop +// its record (and captured stack) instead of leaking it. Primary defense; the +// size cap above covers the window before GC runs. +const promiseFinalization = + typeof FinalizationRegistry === 'undefined' + ? null + : new FinalizationRegistry((id) => { + pendingPromises.delete(id); + }); + let originalPromise: PromiseConstructor | null = null; let nextPromiseId = 1; let currentTestContext: PromiseTrackerTestContext | undefined; @@ -64,6 +79,15 @@ const registerPromise = (): test, }); + while (pendingPromises.size > MAX_TRACKED_PROMISES) { + const oldest = pendingPromises.keys().next().value; + if (oldest === undefined) { + break; + } + + pendingPromises.delete(oldest); + } + return { id, test }; }; @@ -161,6 +185,7 @@ const createTrackedPromiseConstructor = (): PromiseConstructor => { if (registration.id !== null) { promiseIds.set(this, registration.id); + promiseFinalization?.register(this, registration.id); } if (registration.test) {