Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
```

- Add per-class Session Replay masking via `maskedViewClasses` / `unmaskedViewClasses` on `mobileReplayIntegration` ([#6725](https://github.com/getsentry/sentry-react-native/pull/6725))
- Add experimental `avoidForegroundResumeHang` (iOS) to `mobileReplayIntegration` to work around a fatal App Hang that can occur when Session Replay resumes capture on returning to the foreground with a heavy view hierarchy on screen ([#6727](https://github.com/getsentry/sentry-react-native/pull/6727))

### Fixes

Expand Down
236 changes: 236 additions & 0 deletions packages/core/src/js/replay/foregroundReplayGuard.ts
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;
}

Copy link
Copy Markdown

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

stopIfNeeded returns early while stoppedByGuard is true, so a replay started after the guard's own stop is left running. A getReplay().start() (or similar) during the foreground delay, followed by another background, skips stopReplay(). Cocoa then resumes that live session on the next foreground, which is the hang this option is meant to prevent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e7e9f8f. Configure here.

Copy link
Copy Markdown
Contributor Author

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.

// 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);
});
Comment thread
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`
Comment thread
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();
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
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);
},
Comment thread
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);
}
Comment thread
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);
}
Comment thread
alwx marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
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);
}
38 changes: 34 additions & 4 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

@antonis antonis Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 = {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading