-
-
Notifications
You must be signed in to change notification settings - Fork 365
fix(core): Measure callback-style native module calls until completion #6561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
alwx
wants to merge
4
commits into
main
Choose a base branch
from
alwx/fix/6542
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
9e1c07f
fix(core): Measure callback-style native module calls until completion
alwx 5a45497
chore: point changelog entry to PR
alwx 1a37cb5
fix(core): Do not double-record a thenable call whose callback fired โฆ
alwx 8ef7342
Merge branch 'main' into alwx/fix/6542
alwx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
257 changes: 257 additions & 0 deletions
257
packages/core/src/js/turbomodule/turboModuleCallbacks.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| /** | ||
| * Instrumentation for callback-style native module methods. | ||
| * | ||
| * `wrapTurboModule` can only close a call record on its own for two shapes: a | ||
| * plain sync return, and a thenable return. Bridge methods that report | ||
| * completion through success/failure callbacks return `undefined` โ the | ||
| * dominant async shape on the Old Architecture, and still used by some | ||
| * TurboModules โ so without this layer they collapse to ~0ms sync calls. | ||
| * | ||
| * See https://github.com/getsentry/sentry-react-native/issues/6542. | ||
| */ | ||
|
|
||
| import { debug } from '@sentry/core'; | ||
|
|
||
| import type { TurboModuleCallKind } from './turboModuleTracker'; | ||
|
|
||
| import { recordTurboModuleCall, type TurboModuleArch } from './turboModuleAggregator'; | ||
|
|
||
| /** | ||
| * Cap on callback-style calls awaiting their completion callback. A callback | ||
| * that is never invoked would otherwise pin its state forever, so the oldest | ||
| * entry is closed out without a duration once the cap is reached. | ||
| */ | ||
| export const MAX_PENDING_CALLBACK_CALLS = 1024; | ||
|
|
||
| /** | ||
| * A completion callback that fires later than this is treated as not being a | ||
| * completion callback at all (e.g. a long-lived subscription handler on the New | ||
| * Architecture, where no `(failure, success)` convention is enforced). The call | ||
| * is still counted, but with a zero duration, so a multi-minute "duration" | ||
| * can't poison the aggregate or fire a bogus slow-call breadcrumb. | ||
| */ | ||
| export const CALLBACK_MAX_AGE_MS = 60_000; | ||
|
|
||
| /** | ||
| * How many stale entries a single insert may sweep. Keeps the age sweep | ||
| * amortised O(1) on the wrap hot path. | ||
| */ | ||
| const CALLBACK_SWEEP_BUDGET = 8; | ||
|
|
||
| interface PendingCallbackCall { | ||
| startedAtMs: number; | ||
| /** Closes the call without a trustworthy duration; later callbacks no-op. */ | ||
| expire: () => void; | ||
| } | ||
|
|
||
| /** Insertion-ordered, so the first entry is always the oldest. */ | ||
| let pendingCallbackCalls = new Map<number, PendingCallbackCall>(); | ||
| let nextPendingCallbackId = 0; | ||
|
|
||
| /** Tests only. */ | ||
| export function _resetPendingCallbackCalls(): void { | ||
| pendingCallbackCalls = new Map(); | ||
| nextPendingCallbackId = 0; | ||
| } | ||
|
|
||
| export interface CallbackCallHandle { | ||
| /** Called once the instrumented method returned without a thenable. */ | ||
| markReturned: () => void; | ||
| /** | ||
| * Drops the bookkeeping: later callback invocations become no-ops. Returns | ||
| * `true` if a record was already emitted, so the caller doesn't double-count. | ||
| */ | ||
| abandon: () => boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Records a TurboModule invocation, isolated so a failure inside Sentry only | ||
| * drops the data instead of breaking the user's call. | ||
| */ | ||
| export function safeRecordTurboModuleCall( | ||
| name: string, | ||
| method: string, | ||
| kind: TurboModuleCallKind, | ||
| durationMs: number, | ||
| errored: boolean, | ||
| recordId: number | undefined, | ||
| arch: TurboModuleArch, | ||
| ): void { | ||
| try { | ||
| recordTurboModuleCall({ | ||
| name, | ||
| method, | ||
| kind, | ||
| durationMs, | ||
| errored, | ||
| recordId, | ||
| arch, | ||
| }); | ||
| } catch (e) { | ||
| debug.warn(`[TurboModuleTracker] record failed for ${name}.${method}: ${String(e)}`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Wraps the trailing completion callbacks of `args` in place so the call's | ||
| * record is emitted when the callback fires rather than when the method | ||
| * returns. Returns `undefined` when the method isn't callback-shaped, which is | ||
| * the common case โ nothing is allocated on that path. | ||
| * | ||
| * React Native's bridge fixes the shape: the last argument is the success | ||
| * callback, the second-to-last the failure callback, and a non-function | ||
| * argument may never follow a function one (see `genMethod` in RN's | ||
| * `Libraries/BatchedBridge/NativeModules.js`). `'promise'`-typed methods never | ||
| * receive callbacks and are already covered by the thenable path, so they are | ||
| * skipped outright. | ||
| */ | ||
| export function instrumentTrailingCallbacks( | ||
| args: unknown[], | ||
| originalFn: (...a: unknown[]) => unknown, | ||
| name: string, | ||
| method: string, | ||
| startedAtMs: number, | ||
| recordId: number | undefined, | ||
| arch: TurboModuleArch, | ||
| ): CallbackCallHandle | undefined { | ||
| const methodType = (originalFn as { type?: unknown }).type; | ||
| if (methodType === 'promise') { | ||
| return undefined; | ||
| } | ||
|
|
||
| const lastIndex = args.length - 1; | ||
| if (lastIndex < 0 || typeof args[lastIndex] !== 'function') { | ||
| return undefined; | ||
| } | ||
| const failureIndex = lastIndex > 0 && typeof args[lastIndex - 1] === 'function' ? lastIndex - 1 : -1; | ||
|
|
||
| // Only the Old Architecture bridge guarantees that the second-to-last | ||
| // function is the failure callback. New Architecture TurboModules take | ||
| // arbitrary callbacks with no such convention, so guessing there would | ||
| // corrupt `errorCount` โ close the record without flagging an error instead. | ||
| const failureIsError = failureIndex >= 0 && arch === 'legacy' && typeof methodType === 'string'; | ||
|
|
||
| let settled = false; | ||
| let returned = false; | ||
| let pendingId: number | undefined; | ||
|
|
||
| const settle = (errored: boolean, durationMs: number): void => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
| settled = true; | ||
| if (pendingId !== undefined) { | ||
| pendingCallbackCalls.delete(pendingId); | ||
| pendingId = undefined; | ||
| } | ||
| safeRecordTurboModuleCall( | ||
| name, | ||
| method, | ||
| // A callback that already fired before the method returned means the work | ||
| // was synchronous (RN's `'sync'` method type invokes callbacks inline). | ||
| returned ? 'async' : 'sync', | ||
| durationMs, | ||
| errored, | ||
| recordId, | ||
| arch, | ||
| ); | ||
| }; | ||
|
|
||
| const emit = (errored: boolean): void => { | ||
| const durationMs = Date.now() - startedAtMs; | ||
| // A callback firing this late is not a completion callback (e.g. a | ||
| // long-lived subscription handler), so its "duration" is meaningless. | ||
| // Still record the call itself โ dropping it would hide the method from | ||
| // the aggregate entirely, which is worse than the pre-fix ~0ms. | ||
| settle(errored, returned && durationMs > CALLBACK_MAX_AGE_MS ? 0 : durationMs); | ||
| }; | ||
|
|
||
| args[lastIndex] = instrumentCallback(args[lastIndex] as (...a: unknown[]) => unknown, false, emit); | ||
| if (failureIndex >= 0) { | ||
| args[failureIndex] = instrumentCallback(args[failureIndex] as (...a: unknown[]) => unknown, failureIsError, emit); | ||
| } | ||
|
|
||
| return { | ||
| markReturned: (): void => { | ||
| returned = true; | ||
| if (settled) { | ||
| return; | ||
| } | ||
| evictStalePendingCallbackCalls(startedAtMs); | ||
| pendingId = nextPendingCallbackId++; | ||
| pendingCallbackCalls.set(pendingId, { | ||
| startedAtMs, | ||
| // Closed out before the callback fired: keep the call in the aggregate, | ||
| // but with no duration we can stand behind. | ||
| expire: (): void => { | ||
| pendingId = undefined; | ||
| settle(false, 0); | ||
| }, | ||
| }); | ||
| }, | ||
| abandon: (): boolean => { | ||
| const alreadyRecorded = settled; | ||
| settled = true; | ||
| if (pendingId !== undefined) { | ||
| pendingCallbackCalls.delete(pendingId); | ||
| pendingId = undefined; | ||
| } | ||
| return alreadyRecorded; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a stand-in for `callback` that closes the pending record before | ||
| * handing control to the original. The bookkeeping runs first so the callback's | ||
| * own body isn't counted as native time, and is isolated so a tracker failure | ||
| * can never break the user's callback. | ||
| */ | ||
| function instrumentCallback( | ||
| callback: (...a: unknown[]) => unknown, | ||
| errored: boolean, | ||
| emit: (errored: boolean) => void, | ||
| ): (...a: unknown[]) => unknown { | ||
| return function sentryTurboModuleCallback(this: unknown, ...callbackArgs: unknown[]): unknown { | ||
| try { | ||
| emit(errored); | ||
| } catch (e) { | ||
| debug.warn(`[TurboModuleTracker] callback record failed: ${String(e)}`); | ||
| } | ||
| return callback.apply(this, callbackArgs); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Closes out pending callback calls that aged out, plus the oldest entry when | ||
| * the cap is reached. Bounded per invocation so the sweep stays amortised O(1) | ||
| * on the wrap hot path. | ||
| * | ||
| * `nowMs` is the current call's start timestamp โ taken microseconds ago, so it | ||
| * saves a `Date.now()` on the hot path at the cost of an imperceptibly | ||
| * conservative cutoff. | ||
| */ | ||
| function evictStalePendingCallbackCalls(nowMs: number): void { | ||
| let budget = CALLBACK_SWEEP_BUDGET; | ||
| for (const [id, pending] of pendingCallbackCalls) { | ||
| if (budget-- <= 0 || nowMs - pending.startedAtMs <= CALLBACK_MAX_AGE_MS) { | ||
| break; | ||
| } | ||
| pendingCallbackCalls.delete(id); | ||
| pending.expire(); | ||
| } | ||
|
|
||
| while (pendingCallbackCalls.size >= MAX_PENDING_CALLBACK_CALLS) { | ||
| const oldest = pendingCallbackCalls.entries().next(); | ||
| if (oldest.done) { | ||
| break; | ||
| } | ||
| const [id, pending] = oldest.value; | ||
| pendingCallbackCalls.delete(id); | ||
| pending.expire(); | ||
| debug.log( | ||
| `[TurboModuleTracker] More than ${MAX_PENDING_CALLBACK_CALLS} callback-style calls awaiting completion โ ` + | ||
| `closing the oldest one without a duration.`, | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instrumentation can break user calls
Medium Severity
The new callback instrumentation is not isolated like the surrounding tracker calls.
instrumentTrailingCallbacksandmarkReturnedsit outsidetry/catch, so a throw can block the native call, surface after a successful return, or leave a pushed crash-attribution frame unpopped. This violates the PR review rule that Sentry instrumentation must never crash the host app.Additional Locations (1)
packages/core/src/js/turbomodule/wrapTurboModule.ts#L152-L160Triggered by project rule: PR Review Guidelines for Cursor Bot
Reviewed by Cursor Bugbot for commit 8ef7342. Configure here.