Skip to content

fix(core): Evict stale async TurboModule tracker frames - #6824

Closed
antonis wants to merge 1 commit into
mainfrom
antonis/turbo-module-stale-frame-guard
Closed

antonis wants to merge 1 commit into
mainfrom
antonis/turbo-module-stale-frame-guard

Conversation

@antonis

@antonis antonis commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

Hardens the TurboModule crash-attribution tracker against never-settling promises.

wrapTurboModule pops an async call's frame only when the returned promise settles (wrapTurboModule.ts — the isThenable branch pops in both .then handlers). A native method that accepts a Promise but never settles it therefore pins its frame forever, and popTurboModuleCall re-syncs the native scope onto that leaked frame after every later pop — so every subsequent native crash is mis-attributed to it.

This is the class-level cause behind #6821 (where Android's initNativeReactNavigationNewFrameTracking dropped its promise). The native fix for that specific method is #6823; this PR prevents the same shape from recurring via a future SDK method or a user module passed to turboModuleContextIntegration({ modules }).

The guard is an opportunistic, budgeted age sweep on pushTurboModuleCall:

  • Async frames older than MAX_ASYNC_FRAME_AGE_MS (60s) are evicted on the next push, reusing popTurboModuleCall so the scope re-sync / clear logic stays in one place.
  • No timers — the sweep runs on the existing hot path, reusing the new call's start timestamp as the cutoff (same approach as evictStalePendingCallbackCalls). This deliberately avoids the import-time/lingering-timer class fixed in fix(tracing): Don't start AsyncExpiringMap cleanup interval at import time #6811.
  • Bounded by ASYNC_FRAME_SWEEP_BUDGET (8) per push to keep it amortised O(1).
  • Only async frames expire; sync and callback-style frames are always popped synchronously by the wrapper, so they're left untouched.

The 60s bound and the sweep mechanism mirror the existing CALLBACK_MAX_AGE_MS / evictStalePendingCallbackCalls guard on the callback path (added in #6542) — a never-settling call leaks both a frame and a pending-callback entry, and both now age out at the same bound.

💡 Motivation and Context

Follow-up to #6821 / #6823. The native fix closes the only current instance; this closes the class, so a dangling promise (ours or a user module's) can't pin crash attribution for the process lifetime. Turns "wrong attribution forever" into "attribution expires ~60s after the call started".

💚 How did you test it?

Added 5 tracker tests (fake timers) covering: eviction of a leaked async frame on the next push, full scope clearing afterwards, no eviction within the age bound, sync frames never evicted, and budget-bounded multi-frame eviction. Full turbomodule + turboModuleContextIntegration suites pass (113 tests); yarn build:sdk, oxlint and circularDepCheck clean.

📝 Checklist

  • I added tests to verify changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.
  • No breaking changes.

🔮 Next steps

The bound is an internal constant for now. If a use case needs it tunable, it can later be surfaced as an option on turboModuleContextIntegration (public-API change — would need an API-report regen + review).

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Semver Impact of This PR

⚪ None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • fix(core): Evict stale async TurboModule tracker frames by antonis in #6824
  • chore(core): resolve stale TODO comments by antonis in #6819
  • fix(profiling): Populate Hermes runtime version on JS profiles by antonis in #6817

🤖 This preview updates automatically when you update the PR.

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor
Fails
🚫 Pull request is not ready for merge, please add the "ready-to-merge" label to the pull request
🚫 Please consider adding a changelog entry for the next release.

Instructions and example for changelog

Please add an entry to CHANGELOG.md to the "Unreleased" section. Make sure the entry includes this PR's number.

Example:

## Unreleased

### Fixes

- Evict stale async TurboModule tracker frames ([#6824](https://github.com/getsentry/sentry-react-native/pull/6824))

If none of the above apply, you can opt out of this check by adding #skip-changelog to the PR description or adding a skip-changelog label.

Generated by 🚫 dangerJS against 80f5785

`wrapTurboModule` pops an async frame only when its promise settles. A
native method that accepts a promise but never settles it would pin its
frame — and, via the scope sync, the native crash scope — for the whole
process lifetime, so every later crash is mis-attributed to that call
(the Android shape of #6821, but also any custom module passed to
`turboModuleContextIntegration({ modules })`).

Add an opportunistic, budgeted age sweep on push that drops async frames
older than MAX_ASYNC_FRAME_AGE_MS (60s, matching CALLBACK_MAX_AGE_MS),
reusing popTurboModuleCall for the scope re-sync. No timers. Bounds the
damage to "attribution expires ~60s after the call started" instead of
"wrong forever". Sync and callback frames are untouched — the wrapper
already pops those synchronously.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@antonis
antonis force-pushed the antonis/turbo-module-stale-frame-guard branch from 4a9a0a7 to 80f5785 Compare October 2, 2026 09:15
@antonis

antonis commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

@cursor review

@antonis

antonis commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

@sentry review

Comment on lines +266 to +271
* 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) {

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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 80f5785. Configure here.

@antonis

antonis commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Closing for now since the case covered is hypothetical at this point.

@antonis antonis closed this Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant