diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index 9d749031aa..d739198390 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -40,6 +40,34 @@ const CONTEXT_KEY = 'turbo_module'; const TAG_NAME = 'turbo_module.name'; const TAG_METHOD = 'turbo_module.method'; +/** + * An `async` frame still on the stack after this long is treated as leaked and + * evicted on the next push. + * + * {@link wrapTurboModule} pops an async frame only when its returned promise + * settles. A native method that accepts a promise but never settles it (the + * Android bug in #6821, or a custom module passed to + * `turboModuleContextIntegration({ modules })`) would otherwise pin its frame — + * and, via {@link syncToScope}, the native crash scope — for the entire process + * lifetime, so every later crash is mis-attributed to that call. The sweep + * bounds the damage to "attribution expires ~{@link MAX_ASYNC_FRAME_AGE_MS} + * after the call started" instead of "wrong forever". + * + * Only `async` frames expire: `sync` and callback-style frames are popped + * synchronously by the wrapper (see `wrapTurboModule.ts`), so they can never + * outlive their call. Kept equal to `CALLBACK_MAX_AGE_MS` in + * `turboModuleCallbacks.ts` on purpose — a never-settling call leaks both a + * frame and (if callback-style) a pending-callback entry, and both should age + * out at the same bound. + */ +export const MAX_ASYNC_FRAME_AGE_MS = 60_000; + +/** + * How many stale frames a single push may evict. Keeps the sweep amortised O(1) + * on the wrap hot path, mirroring `CALLBACK_SWEEP_BUDGET`. + */ +const ASYNC_FRAME_SWEEP_BUDGET = 8; + let nextCallId = 0; /** @@ -98,11 +126,24 @@ export function pushTurboModuleCall(args: { kind: 'sync' | 'async'; scope?: Scope; }): number { + const startedAtMs = Date.now(); + + // Opportunistically drop frames from earlier calls whose promise never + // settled, before this call's attribution is written. `startedAtMs` is taken + // microseconds ago, so reusing it as the cutoff saves a `Date.now()` at the + // cost of an imperceptibly conservative bound (same trick as the callback + // sweep). Isolated so an eviction failure can never block the real push. + try { + evictStaleAsyncFrames(startedAtMs); + } catch { + // ignore — the push below must still happen. + } + const call: InternalCall = { name: args.name, method: args.method, kind: args.kind, - startedAtMs: Date.now(), + startedAtMs, callId: nextCallId++, // Default to the isolation scope: it's the one wired up to // `enableSyncToNative`, so writes here propagate to the native SDKs and @@ -212,6 +253,38 @@ export function popTurboModuleCall(callId: number): void { } } +/** + * Evicts `async` frames older than {@link MAX_ASYNC_FRAME_AGE_MS} — calls whose + * promise never settled, so {@link wrapTurboModule} never popped them. Bounded + * by {@link ASYNC_FRAME_SWEEP_BUDGET} per call to keep the push hot path + * amortised O(1). + * + * Reuses {@link popTurboModuleCall} for each eviction so the scope re-sync / + * clear logic (including the cross-scope native re-sync) lives in exactly one + * place. `callId`s are snapshotted first because `popTurboModuleCall` mutates + * the stack. Only `async` frames are considered: `sync` and callback-style + * frames are always popped synchronously by the wrapper. + */ +function evictStaleAsyncFrames(nowMs: number): void { + let staleIds: number[] | undefined; + let budget = ASYNC_FRAME_SWEEP_BUDGET; + for (const frame of stack) { + if (budget <= 0) { + break; + } + if (frame.kind === 'async' && nowMs - frame.startedAtMs > MAX_ASYNC_FRAME_AGE_MS) { + (staleIds ??= []).push(frame.callId); + budget--; + } + } + if (!staleIds) { + return; + } + for (const callId of staleIds) { + popTurboModuleCall(callId); + } +} + function syncToScope(call: InternalCall): void { call.scope.setContext(CONTEXT_KEY, { name: call.name, diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index 67a800d10b..fdb382aeb6 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -4,6 +4,7 @@ import { _resetTurboModuleTracker, getActiveTurboModuleCall, getTurboModuleCallStack, + MAX_ASYNC_FRAME_AGE_MS, popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind, @@ -264,4 +265,87 @@ describe('turboModuleTracker', () => { expect(b).toBe(a + 1); expect(c).toBe(b + 1); }); + + describe('stale async frame eviction', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(0); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('evicts an async frame whose promise never settled on the next push', () => { + // Simulates the #6821 shape: an async call pushed at startup that the + // wrapper never pops because the native promise never settles. + pushTurboModuleCall({ + name: 'RNSentry', + method: 'initNativeReactNavigationNewFrameTracking', + kind: 'async', + scope, + }); + expect(getTurboModuleCallStack()).toHaveLength(1); + + jest.setSystemTime(MAX_ASYNC_FRAME_AGE_MS + 1); + const freshId = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', scope }); + + // The leaked frame is gone; only the fresh call remains. + const remaining = getTurboModuleCallStack(); + expect(remaining).toHaveLength(1); + expect(remaining[0]).toMatchObject({ method: 'captureEnvelope', callId: freshId }); + + // The scope now attributes the fresh call, not the leaked one. + expect(scope.getScopeData().contexts.turbo_module).toMatchObject({ method: 'captureEnvelope' }); + }); + + it('fully clears the scope once a leaked frame is evicted and its replacement pops', () => { + pushTurboModuleCall({ name: 'RNSentry', method: 'leaky', kind: 'async', scope }); + + jest.setSystemTime(MAX_ASYNC_FRAME_AGE_MS + 1); + const freshId = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', scope }); + popTurboModuleCall(freshId); + + // If the leaked frame were still lurking, the scope would be pinned to it. + expect(getTurboModuleCallStack()).toEqual([]); + expect(scope.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(scope.getScopeData().tags['turbo_module.method']).toBe(''); + }); + + it('does not evict a frame that is within the age bound', () => { + pushTurboModuleCall({ name: 'RNSentry', method: 'inFlight', kind: 'async', scope }); + + jest.setSystemTime(MAX_ASYNC_FRAME_AGE_MS - 1); + pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', scope }); + + expect(getTurboModuleCallStack()).toHaveLength(2); + }); + + it('only evicts async frames, never sync frames', () => { + // Sync frames are always popped synchronously by the wrapper, so the age + // sweep must leave them alone even if one is somehow older than the bound. + pushTurboModuleCall({ name: 'RNSentry', method: 'syncButOld', kind: 'sync', scope }); + + jest.setSystemTime(MAX_ASYNC_FRAME_AGE_MS + 1); + pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', scope }); + + const methods = getTurboModuleCallStack().map(c => c.method); + expect(methods).toEqual(['syncButOld', 'captureEnvelope']); + }); + + it('evicts multiple leaked frames in one push, up to the sweep budget', () => { + // 10 leaked async frames, sweep budget is 8. + for (let i = 0; i < 10; i++) { + pushTurboModuleCall({ name: 'RNSentry', method: `leak${i}`, kind: 'async', scope }); + } + expect(getTurboModuleCallStack()).toHaveLength(10); + + jest.setSystemTime(MAX_ASYNC_FRAME_AGE_MS + 1); + pushTurboModuleCall({ name: 'RNSentry', method: 'fresh', kind: 'async', scope }); + + // 10 leaked - 8 swept + 1 fresh = 3 remaining. The budget keeps the sweep + // amortised O(1); the leftover frames age out on subsequent pushes. + expect(getTurboModuleCallStack()).toHaveLength(3); + }); + }); });