Skip to content
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .nx/version-plans/fix-promise-tracker-leak.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions packages/runtime/src/promise-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,25 @@ type PromiseExecutor<T> = (
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<number, TrackedPromiseRecord>();
const promiseIds = new WeakMap<object, number>();
const promiseContexts = new WeakMap<object, PromiseTrackerTestContext>();

// 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<number>((id) => {
pendingPromises.delete(id);
});

let originalPromise: PromiseConstructor | null = null;
let nextPromiseId = 1;
let currentTestContext: PromiseTrackerTestContext | undefined;
Expand Down Expand Up @@ -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 };
};

Expand Down Expand Up @@ -161,6 +185,7 @@ const createTrackedPromiseConstructor = (): PromiseConstructor => {

if (registration.id !== null) {
promiseIds.set(this, registration.id);
promiseFinalization?.register(this, registration.id);
}

if (registration.test) {
Expand Down
Loading