From cbf8fdf6c9baacf567d00d061c5ea8eb0aeba5bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 3 Jul 2026 07:26:55 +0200 Subject: [PATCH 1/4] fix(runtime): bound promise tracker to prevent OOM The promise tracker retained a record (with a captured stack) for every promise created via the global constructor and only released it on settle. Records were never drained between tests/files and had no size bound, so an app that continuously creates promises that are abandoned before they settle (animations, requestAnimationFrame loops, polling, async data-binding) grew the JS heap linearly until 'JavaScript heap out of memory'. Release a promise's record as soon as the promise is garbage-collected (via FinalizationRegistry) and cap the number of retained records as a synchronous backstop, keeping memory bounded regardless of app bridge/render traffic. Fixes #142 --- .nx/version-plans/fix-promise-tracker-leak.md | 5 ++ .../promise-tracker-leak.repro.test.ts | 53 +++++++++++++++++++ .../promise-tracker-oom.repro.test.ts | 44 +++++++++++++++ packages/runtime/src/promise-tracker.ts | 36 +++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 .nx/version-plans/fix-promise-tracker-leak.md create mode 100644 packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts create mode 100644 packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts 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/__tests__/promise-tracker-leak.repro.test.ts b/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts new file mode 100644 index 0000000..8b9935a --- /dev/null +++ b/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + getPendingPromises, + installPromiseTracker, + MAX_TRACKED_PROMISES, + uninstallPromiseTracker, +} from '../promise-tracker.js'; + +afterEach(() => { + uninstallPromiseTracker(); +}); + +/** + * Reproduces the harness node-process OOM brief: + * https://.local/docs/react-native-harness-oom-brief.md + * + * A component that keeps the app busy (auto-playing view / rAF loop / async + * data-binding) creates a stream of promises. Any promise that never settles + * (abandoned / superseded async work, which is extremely common in a per-frame + * render loop) is retained forever by the promise tracker, together with a + * captured stack string. The tracker is a process-global singleton that is + * installed once and NEVER cleared between tests or test files, so the buffer + * grows monotonically for the whole run -> heap exhaustion. + */ +describe('promise-tracker leak repro (OOM brief)', () => { + it('retains every never-settled promise and never drains across test files', () => { + installPromiseTracker(); + + const FILES = 30; + const PROMISES_PER_FILE = 1000; + + for (let file = 0; file < FILES; file++) { + // installPromiseTracker() is called again at the start of every runTests() + // invocation on the device, but it is idempotent and does NOT reset the + // accumulated records. + installPromiseTracker(); + + for (let i = 0; i < PROMISES_PER_FILE; i++) { + // Continuous bridge/async work that is abandoned before it settles. + void new Promise(() => undefined); + } + } + + const created = FILES * PROMISES_PER_FILE; + const pending = getPendingPromises(); + + // Before the fix this retained all `created` records (each with a captured + // stack string) forever -> linear heap growth -> OOM. The tracker must now + // stay bounded regardless of how many abandoned promises the app creates. + expect(created).toBeGreaterThan(pending.length); + expect(pending.length).toBeLessThanOrEqual(MAX_TRACKED_PROMISES); + }); +}); diff --git a/packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts b/packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts new file mode 100644 index 0000000..23310a4 --- /dev/null +++ b/packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { + getPendingPromises, + installPromiseTracker, +} from '../promise-tracker.js'; + +/** + * Opt-in OOM reproduction for the harness node-process OOM brief. + * + * Run with a low heap cap and the opt-in flag to watch the retained + * promise-tracker records exhaust the JS heap, exactly like the brief: + * + * NODE_OPTIONS="--max-old-space-size=256" RN_HARNESS_OOM_REPRO=1 \ + * pnpm exec vitest run --pool=forks \ + * packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts + * + * Without the fix this prints a linear RSS climb and then dies with + * "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of + * memory". With a bounded tracker RSS plateaus and the loop completes. + */ +describe('promise-tracker OOM repro (opt-in)', () => { + it.skipIf(!process.env.RN_HARNESS_OOM_REPRO)( + 'exhausts the heap with retained never-settled promises', + () => { + installPromiseTracker(); + + const rssMb = () => Math.round(process.memoryUsage().rss / 1024 / 1024); + const startRss = rssMb(); + + for (let batch = 0; batch < 500; batch++) { + for (let i = 0; i < 10_000; i++) { + void new Promise(() => undefined); + } + if (batch % 5 === 0) { + process.stderr.write( + `batch=${batch} retained=${getPendingPromises().length} rss=${rssMb()}MB (+${rssMb() - startRss})\n`, + ); + } + } + + expect(getPendingPromises().length).toBeGreaterThan(0); + }, + ); +}); diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index b1e785f..e1f9cf8 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -20,10 +20,36 @@ type PromiseExecutor = ( reject: PromiseReject ) => void; +/** + * Hard upper bound on how many pending-promise records we retain. The tracker + * only exists to report which promises are still pending when a test times out, + * and diagnostics never show more than a handful. Without a bound, an app that + * continuously creates promises that are abandoned before they settle (any + * per-frame render / animation / polling loop) grows this map without limit and + * OOMs the JS heap. When the bound is exceeded we evict the oldest records + * (Map preserves insertion order), keeping the most recent — and most relevant + * for a "what is still pending right now" report. + */ +export const MAX_TRACKED_PROMISES = 10_000; + const pendingPromises = new Map(); const promiseIds = new WeakMap(); const promiseContexts = new WeakMap(); +/** + * Drop a promise's record as soon as the promise itself is garbage-collected. + * A collected promise can no longer settle and cannot be keeping anything + * pending, so retaining its record (and captured stack) is a pure leak. This is + * the primary defense against unbounded growth; the size cap is a synchronous + * backstop for when GC can't keep up under heavy allocation pressure. + */ +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 +90,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 +196,7 @@ const createTrackedPromiseConstructor = (): PromiseConstructor => { if (registration.id !== null) { promiseIds.set(this, registration.id); + promiseFinalization?.register(this, registration.id); } if (registration.test) { From 2bb1b265111b45421f558e42b1f9480f011b4236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 3 Jul 2026 09:04:20 +0200 Subject: [PATCH 2/4] docs(runtime): trim promise-tracker comments to essential rationale --- .../promise-tracker-leak.repro.test.ts | 29 ++++--------------- packages/runtime/src/promise-tracker.ts | 23 ++++----------- 2 files changed, 12 insertions(+), 40 deletions(-) diff --git a/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts b/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts index 8b9935a..6fd09de 100644 --- a/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts +++ b/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts @@ -10,33 +10,19 @@ afterEach(() => { uninstallPromiseTracker(); }); -/** - * Reproduces the harness node-process OOM brief: - * https://.local/docs/react-native-harness-oom-brief.md - * - * A component that keeps the app busy (auto-playing view / rAF loop / async - * data-binding) creates a stream of promises. Any promise that never settles - * (abandoned / superseded async work, which is extremely common in a per-frame - * render loop) is retained forever by the promise tracker, together with a - * captured stack string. The tracker is a process-global singleton that is - * installed once and NEVER cleared between tests or test files, so the buffer - * grows monotonically for the whole run -> heap exhaustion. - */ -describe('promise-tracker leak repro (OOM brief)', () => { - it('retains every never-settled promise and never drains across test files', () => { - installPromiseTracker(); - +// Regression guard for the OOM caused by never-settling promises (e.g. abandoned +// per-frame async work in a busy app) accumulating in the tracker unbounded. +describe('promise-tracker leak repro', () => { + it('stays bounded across test files instead of retaining every promise', () => { const FILES = 30; const PROMISES_PER_FILE = 1000; for (let file = 0; file < FILES; file++) { - // installPromiseTracker() is called again at the start of every runTests() - // invocation on the device, but it is idempotent and does NOT reset the - // accumulated records. + // Idempotent and does NOT reset accumulated records, so records leak across + // files just as they do on the device where it runs once per runTests(). installPromiseTracker(); for (let i = 0; i < PROMISES_PER_FILE; i++) { - // Continuous bridge/async work that is abandoned before it settles. void new Promise(() => undefined); } } @@ -44,9 +30,6 @@ describe('promise-tracker leak repro (OOM brief)', () => { const created = FILES * PROMISES_PER_FILE; const pending = getPendingPromises(); - // Before the fix this retained all `created` records (each with a captured - // stack string) forever -> linear heap growth -> OOM. The tracker must now - // stay bounded regardless of how many abandoned promises the app creates. expect(created).toBeGreaterThan(pending.length); expect(pending.length).toBeLessThanOrEqual(MAX_TRACKED_PROMISES); }); diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index e1f9cf8..382d770 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -20,29 +20,18 @@ type PromiseExecutor = ( reject: PromiseReject ) => void; -/** - * Hard upper bound on how many pending-promise records we retain. The tracker - * only exists to report which promises are still pending when a test times out, - * and diagnostics never show more than a handful. Without a bound, an app that - * continuously creates promises that are abandoned before they settle (any - * per-frame render / animation / polling loop) grows this map without limit and - * OOMs the JS heap. When the bound is exceeded we evict the oldest records - * (Map preserves insertion order), keeping the most recent — and most relevant - * for a "what is still pending right now" report. - */ +// 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(); -/** - * Drop a promise's record as soon as the promise itself is garbage-collected. - * A collected promise can no longer settle and cannot be keeping anything - * pending, so retaining its record (and captured stack) is a pure leak. This is - * the primary defense against unbounded growth; the size cap is a synchronous - * backstop for when GC can't keep up under heavy allocation pressure. - */ +// 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 From 98ac3f78b969ff4f636dbd0aae64a9b9381bb40d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 3 Jul 2026 10:21:00 +0200 Subject: [PATCH 3/4] test: move promise tracker leak coverage to harness Replace runtime reproduction tests with a playground harness regression that verifies abandoned promises are reclaimed after GC. --- .../normal/promise-tracker.harness.ts | 43 ++++++++++++++++++ .../promise-tracker-leak.repro.test.ts | 36 --------------- .../promise-tracker-oom.repro.test.ts | 44 ------------------- 3 files changed, 43 insertions(+), 80 deletions(-) create mode 100644 apps/playground/src/__tests__/normal/promise-tracker.harness.ts delete mode 100644 packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts delete mode 100644 packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts diff --git a/apps/playground/src/__tests__/normal/promise-tracker.harness.ts b/apps/playground/src/__tests__/normal/promise-tracker.harness.ts new file mode 100644 index 0000000..f19bb19 --- /dev/null +++ b/apps/playground/src/__tests__/normal/promise-tracker.harness.ts @@ -0,0 +1,43 @@ +import { + describe, + expect, + getPendingPromises, + test, +} from 'react-native-harness'; + +const PROMISE_COUNT = 50_000; + +const getGc = (): (() => void) | undefined => + (globalThis as typeof globalThis & { gc?: () => void }).gc; + +const waitForFinalizers = async (baseline: number, gc: () => void) => { + for (let attempt = 0; attempt < 10; attempt++) { + if (getPendingPromises().length <= baseline) { + return; + } + + gc(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +}; + +describe('Promise tracker', () => { + test('reclaims abandoned promises after garbage collection', async (context) => { + const gc = getGc(); + + if (!gc) { + context.skip('gc global is not available in this runtime'); + return; + } + + const baseline = getPendingPromises().length; + + for (let i = 0; i < PROMISE_COUNT; i++) { + void new Promise(() => undefined); + } + + await waitForFinalizers(baseline, gc); + + expect(getPendingPromises().length).toBeLessThanOrEqual(baseline); + }); +}); diff --git a/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts b/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts deleted file mode 100644 index 6fd09de..0000000 --- a/packages/runtime/src/__tests__/promise-tracker-leak.repro.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { - getPendingPromises, - installPromiseTracker, - MAX_TRACKED_PROMISES, - uninstallPromiseTracker, -} from '../promise-tracker.js'; - -afterEach(() => { - uninstallPromiseTracker(); -}); - -// Regression guard for the OOM caused by never-settling promises (e.g. abandoned -// per-frame async work in a busy app) accumulating in the tracker unbounded. -describe('promise-tracker leak repro', () => { - it('stays bounded across test files instead of retaining every promise', () => { - const FILES = 30; - const PROMISES_PER_FILE = 1000; - - for (let file = 0; file < FILES; file++) { - // Idempotent and does NOT reset accumulated records, so records leak across - // files just as they do on the device where it runs once per runTests(). - installPromiseTracker(); - - for (let i = 0; i < PROMISES_PER_FILE; i++) { - void new Promise(() => undefined); - } - } - - const created = FILES * PROMISES_PER_FILE; - const pending = getPendingPromises(); - - expect(created).toBeGreaterThan(pending.length); - expect(pending.length).toBeLessThanOrEqual(MAX_TRACKED_PROMISES); - }); -}); diff --git a/packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts b/packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts deleted file mode 100644 index 23310a4..0000000 --- a/packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getPendingPromises, - installPromiseTracker, -} from '../promise-tracker.js'; - -/** - * Opt-in OOM reproduction for the harness node-process OOM brief. - * - * Run with a low heap cap and the opt-in flag to watch the retained - * promise-tracker records exhaust the JS heap, exactly like the brief: - * - * NODE_OPTIONS="--max-old-space-size=256" RN_HARNESS_OOM_REPRO=1 \ - * pnpm exec vitest run --pool=forks \ - * packages/runtime/src/__tests__/promise-tracker-oom.repro.test.ts - * - * Without the fix this prints a linear RSS climb and then dies with - * "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of - * memory". With a bounded tracker RSS plateaus and the loop completes. - */ -describe('promise-tracker OOM repro (opt-in)', () => { - it.skipIf(!process.env.RN_HARNESS_OOM_REPRO)( - 'exhausts the heap with retained never-settled promises', - () => { - installPromiseTracker(); - - const rssMb = () => Math.round(process.memoryUsage().rss / 1024 / 1024); - const startRss = rssMb(); - - for (let batch = 0; batch < 500; batch++) { - for (let i = 0; i < 10_000; i++) { - void new Promise(() => undefined); - } - if (batch % 5 === 0) { - process.stderr.write( - `batch=${batch} retained=${getPendingPromises().length} rss=${rssMb()}MB (+${rssMb() - startRss})\n`, - ); - } - } - - expect(getPendingPromises().length).toBeGreaterThan(0); - }, - ); -}); From 2b1d8f3c5f8e76487d94e289b082557c8e069b22 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 3 Jul 2026 12:15:17 +0200 Subject: [PATCH 4/4] test: remove promise tracker gc harness test The runtime unit tests continue to cover promise tracker behavior, while the playground harness GC assertion depends on non-deterministic FinalizationRegistry scheduling. --- .../normal/promise-tracker.harness.ts | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 apps/playground/src/__tests__/normal/promise-tracker.harness.ts diff --git a/apps/playground/src/__tests__/normal/promise-tracker.harness.ts b/apps/playground/src/__tests__/normal/promise-tracker.harness.ts deleted file mode 100644 index f19bb19..0000000 --- a/apps/playground/src/__tests__/normal/promise-tracker.harness.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - describe, - expect, - getPendingPromises, - test, -} from 'react-native-harness'; - -const PROMISE_COUNT = 50_000; - -const getGc = (): (() => void) | undefined => - (globalThis as typeof globalThis & { gc?: () => void }).gc; - -const waitForFinalizers = async (baseline: number, gc: () => void) => { - for (let attempt = 0; attempt < 10; attempt++) { - if (getPendingPromises().length <= baseline) { - return; - } - - gc(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } -}; - -describe('Promise tracker', () => { - test('reclaims abandoned promises after garbage collection', async (context) => { - const gc = getGc(); - - if (!gc) { - context.skip('gc global is not available in this runtime'); - return; - } - - const baseline = getPendingPromises().length; - - for (let i = 0; i < PROMISE_COUNT; i++) { - void new Promise(() => undefined); - } - - await waitForFinalizers(baseline, gc); - - expect(getPendingPromises().length).toBeLessThanOrEqual(baseline); - }); -});