Skip to content
Closed
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
75 changes: 74 additions & 1 deletion packages/core/src/js/turbomodule/turboModuleTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Comment on lines +266 to +271

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The evictStaleAsyncFrames function performs an O(n) scan of the entire async frame stack on every call push. The stack size is unbounded, creating a potential performance bottleneck.
Severity: LOW

Suggested Fix

To mitigate the O(n) scan, introduce a hard cap on the stack size, similar to MAX_PENDING_CALLBACK_CALLS used for callbacks. This would prevent the stack from growing indefinitely and limit the worst-case performance of the scan. Alternatively, if the stack can be chronologically ordered, modify the scan to break early when encountering the first non-stale frame.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/core/src/js/turbomodule/turboModuleTracker.ts#L266-L271

Potential issue: The `evictStaleAsyncFrames` function, called on every
`pushTurboModuleCall`, iterates through the entire `stack` array. Unlike the callback
sweep mechanism, this scan does not break early when encountering non-stale entries.
Furthermore, the async frame `stack` has no hard size limit, allowing it to grow
unbounded. In a scenario with many concurrent, unresolved async TurboModule calls, the
stack can become very large. This results in an O(n) scan on a hot path, where `n` is
the potentially large stack size. This can cause performance degradation, such as
latency or frame drops, under heavy load.

Did we get this right? ๐Ÿ‘ / ๐Ÿ‘Ž to inform future reviews.

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,
Expand Down
84 changes: 84 additions & 0 deletions packages/core/test/turbomodule/turboModuleTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
_resetTurboModuleTracker,
getActiveTurboModuleCall,
getTurboModuleCallStack,
MAX_ASYNC_FRAME_AGE_MS,
popTurboModuleCall,
pushTurboModuleCall,
relabelTurboModuleCallKind,
Expand Down Expand Up @@ -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);
});
});
});
Loading