-
-
Notifications
You must be signed in to change notification settings - Fork 367
feat(replay): add avoidForegroundResumeHang to mitigate iOS foreground App Hang #6727
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
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
7069ddc
feat(replay): add avoidForegroundResumeHang to mitigate iOS foregroun…
alwx 6787e4c
fix(replay): address review findings in foreground replay guard
alwx 80ec35a
fix(replay): close two race conditions in the foreground replay guard
alwx 95c4652
test(replay): consolidate overlapping foreground guard test cases
alwx 1a819f1
test(replay): cut foreground guard tests down to one case per behavior
alwx 4f9409e
fix(replay): guard the foreground replay guard against two more races
alwx 49b65f6
fix(replay): check current app state, not a latched flag, after a res…
alwx 6cdaed4
docs(replay): clarify why a failed restart doesn't reset stoppedByGuard
alwx fd68917
style(replay): drop braces on a single-statement guard to fit max-lines
alwx 79681d2
style(replay): restore braces on the guard, compact mergeOptions instead
alwx e7e9f8f
Merge branch 'main' into alwx/fix/6701
alwx 5a69738
docs(replay): mark avoidForegroundResumeHang as experimental
alwx 18012de
fix(replay): don't start a new replay session after detach
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
Some comments aren't visible on the classic Files Changed page.
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
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,236 @@ | ||
| import type { Client } from '@sentry/core'; | ||
| import type { AppStateStatus } from 'react-native'; | ||
|
|
||
| import { debug } from '@sentry/core'; | ||
| import { AppState, Platform } from 'react-native'; | ||
|
|
||
| // iOS may suspend the JS runtime between 'inactive' and 'background', so | ||
| // 'background' can arrive late or not at all while the app is still | ||
| // responsive. Mirrors the same fallback pattern (and delay) as | ||
| // `cancelInBackground` in `../tracing/onSpanEndUtils.ts`: on 'inactive', | ||
| // schedule the action after a delay, cancelable by a subsequent 'active'. | ||
| const IOS_INACTIVE_STOP_DELAY_MS = 5_000; | ||
|
|
||
| /** | ||
| * The native calls this guard needs. Callers must invalidate their own cached | ||
| * replay id inside `stopReplay`/`startReplayBuffering` - this module has no | ||
| * knowledge of that cache. | ||
| */ | ||
| export interface ForegroundReplayGuardDependencies { | ||
| getCurrentReplayId: () => string | null; | ||
| stopReplay: () => Promise<void>; | ||
| startReplayBuffering: () => Promise<void>; | ||
| } | ||
|
|
||
| /** | ||
| * On iOS, sentry-cocoa resumes Session Replay capture synchronously on | ||
| * `UIApplicationDidBecomeActiveNotification`. On a heavy view hierarchy this | ||
| * can block the main thread past iOS's foreground-transition watchdog and get | ||
| * the app killed (`Fatal App Hang Fully Blocked`). | ||
| * | ||
| * There is no way to prevent that automatic resume from JS: it calls the same | ||
| * shared `pause`/`resume` state that our own `pauseReplay`/`resumeReplay` | ||
| * bridge calls do, so a manual `pause()` before backgrounding does not stick | ||
| * - the native resume unconditionally re-arms capture regardless of it. | ||
| * | ||
| * `stopReplay()` is different: it tears down the native replay session | ||
| * entirely, so the automatic resume becomes a no-op. This guard stops replay | ||
| * before the app backgrounds, and restarts it (in buffer mode) a short delay | ||
| * after the app returns to the foreground - safely outside the watchdog | ||
| * window. See getsentry/sentry-react-native#6701. | ||
| */ | ||
| export interface ForegroundReplayGuardState { | ||
| handleAppStateChange: (state: AppStateStatus) => void; | ||
| detach: () => void; | ||
| } | ||
|
|
||
| /** | ||
| * Builds the guard's state machine without touching `AppState`, so tests can | ||
| * drive `handleAppStateChange` directly instead of going through React | ||
| * Native's `AppState` emitter. | ||
| */ | ||
| export function createForegroundReplayGuardState( | ||
| delayMs: number, | ||
| deps: ForegroundReplayGuardDependencies, | ||
| ): ForegroundReplayGuardState { | ||
| // True whenever we don't currently trust a guarded replay is running: from | ||
| // a successful stop until a restart successfully completes, or after a | ||
| // stop/restart attempt failed and native state became unknown. | ||
| let stoppedByGuard = false; | ||
| // True while a scheduled restart's `startReplayBuffering()` call is in | ||
| // flight, so a background event that lands mid-restart can mark itself | ||
| // instead of missing the new (unprotected) session entirely. | ||
| let restartInFlight = false; | ||
| // The app's current state, updated on every transition - checked (not | ||
| // latched) once a restart resolves, so a background/active toggle that | ||
| // happens mid-restart is judged by where we ended up, not where we were | ||
| // when the restart started. | ||
| let isBackgrounded = false; | ||
| let detached = false; | ||
| // The in-flight `stopReplay()` call, if any. `restart()` waits for it so a | ||
| // slow stop can never overlap with `startReplayBuffering()` - the fixed | ||
| // delay alone isn't a guarantee. | ||
| let pendingStop: Promise<unknown> | null = null; | ||
| let inactiveStopTimeout: ReturnType<typeof setTimeout> | null = null; | ||
| let resumeTimeout: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| function clearInactiveStopTimeout(): void { | ||
| if (inactiveStopTimeout !== null) { | ||
| clearTimeout(inactiveStopTimeout); | ||
| inactiveStopTimeout = null; | ||
| } | ||
| } | ||
|
|
||
| function clearResumeTimeout(): void { | ||
| if (resumeTimeout !== null) { | ||
| clearTimeout(resumeTimeout); | ||
| resumeTimeout = null; | ||
| } | ||
| } | ||
|
|
||
| function stopIfNeeded(): void { | ||
| if (detached) { | ||
| return; | ||
| } | ||
| if (restartInFlight) { | ||
| // A restart is already underway; its own resolution checks | ||
| // `isBackgrounded` and stops the just-started session if still needed. | ||
| return; | ||
| } | ||
| if (stoppedByGuard) { | ||
| return; | ||
| } | ||
| // Only stop (and later restart) a replay we actually found running. If | ||
| // the user already stopped it themselves, or it was never sampled in, | ||
| // there is nothing to protect and nothing to restart. | ||
| if (!deps.getCurrentReplayId()) { | ||
| return; | ||
| } | ||
|
|
||
| stoppedByGuard = true; | ||
| pendingStop = deps.stopReplay().then(undefined, (error: unknown) => { | ||
| // Native state is unknown after a failed stop - don't act as if it's guarded. | ||
| stoppedByGuard = false; | ||
| debug.error('[Sentry] Failed to stop replay before backgrounding', error); | ||
| }); | ||
|
alwx marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function restart(): void { | ||
| restartInFlight = true; | ||
| // Wait for any in-flight stop to actually settle first - startReplayBuffering() | ||
| // must never overlap with a still-running stopReplay() call. `pendingStop` | ||
|
sentry[bot] marked this conversation as resolved.
|
||
| // always resolves (its own rejection handler never rethrows). | ||
| Promise.resolve(pendingStop) | ||
| .then(() => { | ||
| // The client may have closed while we were waiting for the prior | ||
| // stop to settle - don't start a new native session after that. | ||
| return detached ? undefined : deps.startReplayBuffering(); | ||
| }) | ||
| .then( | ||
| () => { | ||
| restartInFlight = false; | ||
| stoppedByGuard = false; | ||
| if (isBackgrounded && !detached) { | ||
| stopIfNeeded(); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
sentry[bot] marked this conversation as resolved.
|
||
| }, | ||
| (error: unknown) => { | ||
| restartInFlight = false; | ||
| // stoppedByGuard stays true on purpose: the next 'active' event | ||
| // retries the restart. Resetting it here would suppress that retry. | ||
| debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); | ||
| }, | ||
|
sentry[bot] marked this conversation as resolved.
|
||
| ); | ||
| } | ||
|
|
||
| function handleAppStateChange(state: AppStateStatus): void { | ||
| if (state === 'background') { | ||
| isBackgrounded = true; | ||
| clearInactiveStopTimeout(); | ||
| clearResumeTimeout(); | ||
| stopIfNeeded(); | ||
| return; | ||
| } | ||
|
|
||
| if (state === 'inactive') { | ||
| if (Platform.OS === 'ios' && inactiveStopTimeout === null) { | ||
| inactiveStopTimeout = setTimeout(() => { | ||
| inactiveStopTimeout = null; | ||
| stopIfNeeded(); | ||
| }, IOS_INACTIVE_STOP_DELAY_MS); | ||
| } | ||
|
sentry[bot] marked this conversation as resolved.
|
||
| return; | ||
| } | ||
|
|
||
| if (state === 'active') { | ||
| isBackgrounded = false; | ||
| clearInactiveStopTimeout(); | ||
| if (stoppedByGuard && resumeTimeout === null && !restartInFlight) { | ||
| resumeTimeout = setTimeout(() => { | ||
| resumeTimeout = null; | ||
| restart(); | ||
| }, delayMs); | ||
| } | ||
|
alwx marked this conversation as resolved.
sentry[bot] marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
alwx marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function detach(): void { | ||
| detached = true; | ||
| clearInactiveStopTimeout(); | ||
| clearResumeTimeout(); | ||
| } | ||
|
|
||
| return { handleAppStateChange, detach }; | ||
| } | ||
|
|
||
| /** | ||
| * Wires {@link createForegroundReplayGuardState} to React Native's `AppState`. | ||
| * iOS-only; a no-op everywhere else. | ||
| */ | ||
| export function attachForegroundReplayGuard(delayMs: number, deps: ForegroundReplayGuardDependencies): () => void { | ||
| if (Platform.OS !== 'ios' || !AppState?.isAvailable) { | ||
| return () => {}; | ||
| } | ||
|
|
||
| const { handleAppStateChange, detach } = createForegroundReplayGuardState(delayMs, deps); | ||
| const subscription = AppState.addEventListener('change', handleAppStateChange); | ||
|
|
||
| return () => { | ||
| detach(); | ||
| subscription?.remove?.(); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * The native replay bridge calls the guard needs. Passed by reference (e.g. | ||
| * the `NATIVE` singleton) - never spread/destructured, since its methods rely | ||
| * on their receiver (`this.enableNative`, etc.) to read live state. | ||
| */ | ||
| export interface ForegroundReplayGuardNativeControls { | ||
| getCurrentReplayId: () => string | null; | ||
| stopReplay: () => Promise<void>; | ||
| startReplayBuffering: () => Promise<void>; | ||
| } | ||
|
|
||
| /** | ||
| * Attaches the guard to `client`, composing `native`'s calls with | ||
| * `invalidateCachedReplayId` (the cache invalidation only the integration | ||
| * knows how to do), and detaches it when the client closes. | ||
| */ | ||
| export function setupForegroundReplayGuard( | ||
| client: Client, | ||
| delayMs: number = 1000, | ||
| native: ForegroundReplayGuardNativeControls, | ||
| invalidateCachedReplayId: () => void, | ||
| ): void { | ||
| const detach = attachForegroundReplayGuard(delayMs, { | ||
| getCurrentReplayId: () => native.getCurrentReplayId(), | ||
| stopReplay: () => | ||
| native.stopReplay().then(invalidateCachedReplayId, (error: unknown) => { | ||
| invalidateCachedReplayId(); | ||
| throw error; | ||
| }), | ||
| startReplayBuffering: () => native.startReplayBuffering().then(invalidateCachedReplayId), | ||
| }); | ||
| client.on('close', detach); | ||
| } | ||
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 |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ import { hasHooks } from '../utils/clientutils'; | |
| import { isExpoGo, notMobileOs } from '../utils/environment'; | ||
| import { registerFeatureMarker } from '../utils/featureMarkers'; | ||
| import { NATIVE } from '../wrapper'; | ||
| import { setupForegroundReplayGuard } from './foregroundReplayGuard'; | ||
| import { | ||
| buildResolvedNetworkBreadcrumb, | ||
| makeEnrichXhrBreadcrumbsForMobileReplay, | ||
|
|
@@ -268,6 +269,34 @@ export interface MobileReplayOptions { | |
| * @default [] | ||
| */ | ||
| networkResponseHeaders?: string[]; | ||
|
|
||
| /** | ||
| * Mitigates a fatal iOS App Hang (watchdog kill) that can occur when Session | ||
| * Replay resumes capture on returning to the foreground with a heavy view | ||
| * hierarchy on screen. When enabled, recording is stopped just before the app | ||
| * backgrounds and restarted in buffer mode shortly after it foregrounds. | ||
| * | ||
| * @note A full-session recording is downgraded to buffer mode after every | ||
| * background/foreground cycle while this is enabled. See | ||
| * https://github.com/getsentry/sentry-react-native/issues/6701. | ||
| * | ||
| * @default false | ||
| * @platform ios | ||
| * @experimental This is a stopgap mitigation and may change or be removed | ||
| * once the underlying issue is addressed upstream in sentry-cocoa. | ||
| */ | ||
| avoidForegroundResumeHang?: boolean; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: Since this is an API change though not marked as such let's also loop in other mobile folks. Also, is there a way to fix that on the Cocoa side? Should we mark the additions as experimental to have the flexibility of removing them? |
||
|
|
||
| /** | ||
| * Delay, in milliseconds, before replay recording restarts after the app | ||
| * returns to the foreground, when `avoidForegroundResumeHang` is enabled. | ||
| * | ||
| * @default 1000 | ||
| * @platform ios | ||
| * @experimental This is a stopgap mitigation and may change or be removed | ||
| * once the underlying issue is addressed upstream in sentry-cocoa. | ||
| */ | ||
| avoidForegroundResumeHangDelayMs?: number; | ||
| } | ||
|
|
||
| const defaultOptions: MobileReplayOptions = { | ||
|
|
@@ -286,10 +315,7 @@ const defaultOptions: MobileReplayOptions = { | |
| }; | ||
|
|
||
| function mergeOptions(initOptions: Partial<MobileReplayOptions>): MobileReplayOptions { | ||
| const merged = { | ||
| ...defaultOptions, | ||
| ...initOptions, | ||
| }; | ||
| const merged = { ...defaultOptions, ...initOptions }; | ||
|
|
||
| if (initOptions.enableViewRendererV2 === undefined && initOptions.enableExperimentalViewRenderer !== undefined) { | ||
| merged.enableViewRendererV2 = initOptions.enableExperimentalViewRenderer; | ||
|
|
@@ -502,6 +528,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau | |
| // Initialize the cached replay ID on setup | ||
| cachedReplayId = NATIVE.getCurrentReplayId(); | ||
|
|
||
| if (options.avoidForegroundResumeHang) { | ||
| setupForegroundReplayGuard(client, options.avoidForegroundResumeHangDelayMs, NATIVE, invalidateCachedReplayId); | ||
| } | ||
|
|
||
| client.on('createDsc', (dsc: DynamicSamplingContext) => { | ||
| if (dsc.replay_id) { | ||
| return; | ||
|
|
||
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.
Guard skips later session stop
Medium Severity
stopIfNeededreturns early whilestoppedByGuardis true, so a replay started after the guard's own stop is left running. AgetReplay().start()(or similar) during the foreground delay, followed by another background, skipsstopReplay(). Cocoa then resumes that live session on the next foreground, which is the hang this option is meant to prevent.Additional Locations (1)
packages/core/src/js/replay/foregroundReplayGuard.ts#L160-L169Reviewed by Cursor Bugbot for commit e7e9f8f. Configure here.
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.
Real but narrow scope: this needs the app to call replay.start()/startBuffering() manually while avoidForegroundResumeHang is also managing it automatically. Same category as the existing manual pause()-interaction caveat - documented limitation, not fixing: mixing manual replay controls with this automatic guard isn't supported.