From 7069ddc4e4e38fceaa2d6a86a02e8a4a5b0fc8b0 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 15 Sep 2026 14:17:37 +0200 Subject: [PATCH 01/12] feat(replay): add avoidForegroundResumeHang to mitigate iOS foreground App Hang 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. A manual pause()/resume() doesn't help - cocoa's automatic resume shares the same state and unconditionally re-arms capture regardless of it. When enabled, an AppState listener stops replay just before the app backgrounds (which makes the automatic resume a safe no-op) and restarts it in buffer mode a short delay after returning to the foreground, off the watchdog window. Fixes #6701 --- CHANGELOG.md | 1 + .../src/js/replay/foregroundReplayGuard.ts | 102 ++++++++ packages/core/src/js/replay/mobilereplay.ts | 29 +++ .../test/replay/foregroundReplayGuard.test.ts | 229 ++++++++++++++++++ .../core/test/replay/mobilereplay.test.ts | 19 ++ 5 files changed, 380 insertions(+) create mode 100644 packages/core/src/js/replay/foregroundReplayGuard.ts create mode 100644 packages/core/test/replay/foregroundReplayGuard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a24c9dd435..4170aeb3f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `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 diff --git a/packages/core/src/js/replay/foregroundReplayGuard.ts b/packages/core/src/js/replay/foregroundReplayGuard.ts new file mode 100644 index 0000000000..1921808d23 --- /dev/null +++ b/packages/core/src/js/replay/foregroundReplayGuard.ts @@ -0,0 +1,102 @@ +import type { AppStateStatus } from 'react-native'; + +import { debug } from '@sentry/core'; +import { AppState, Platform } from 'react-native'; + +import { NATIVE } from '../wrapper'; + +const DEFAULT_DELAY_MS = 1000; + +/** + * 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 + * just 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): ForegroundReplayGuardState { + let pendingResume = false; + let resumeTimeout: ReturnType | null = null; + + function clearPendingResume(): void { + if (resumeTimeout !== null) { + clearTimeout(resumeTimeout); + resumeTimeout = null; + } + } + + function handleAppStateChange(state: AppStateStatus): void { + clearPendingResume(); + + if (state === 'background') { + // 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 (!NATIVE.getCurrentReplayId()) { + pendingResume = false; + return; + } + + pendingResume = true; + NATIVE.stopReplay().then(undefined, (error: unknown) => { + debug.error('[Sentry] Failed to stop replay before backgrounding', error); + }); + return; + } + + if (state === 'active' && pendingResume) { + pendingResume = false; + resumeTimeout = setTimeout(() => { + resumeTimeout = null; + NATIVE.startReplayBuffering().then(undefined, (error: unknown) => { + debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); + }); + }, delayMs); + } + } + + function detach(): void { + clearPendingResume(); + pendingResume = false; + } + + return { handleAppStateChange, detach }; +} + +/** + * Wires {@link createForegroundReplayGuardState} to React Native's `AppState`. + * iOS-only; a no-op everywhere else. + */ +export function attachForegroundReplayGuard(delayMs: number = DEFAULT_DELAY_MS): () => void { + if (Platform.OS !== 'ios' || !AppState?.isAvailable) { + return () => {}; + } + + const { handleAppStateChange, detach } = createForegroundReplayGuardState(delayMs); + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + detach(); + subscription?.remove?.(); + }; +} diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 5cf864caf5..6f37f70532 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -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 { attachForegroundReplayGuard } from './foregroundReplayGuard'; import { buildResolvedNetworkBreadcrumb, makeEnrichXhrBreadcrumbsForMobileReplay, @@ -268,6 +269,30 @@ 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 + */ + avoidForegroundResumeHang?: boolean; + + /** + * Delay, in milliseconds, before replay recording restarts after the app + * returns to the foreground, when `avoidForegroundResumeHang` is enabled. + * + * @default 1000 + * @platform ios + */ + avoidForegroundResumeHangDelayMs?: number; } const defaultOptions: MobileReplayOptions = { @@ -502,6 +527,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Initialize the cached replay ID on setup cachedReplayId = NATIVE.getCurrentReplayId(); + if (options.avoidForegroundResumeHang) { + attachForegroundReplayGuard(options.avoidForegroundResumeHangDelayMs); + } + client.on('createDsc', (dsc: DynamicSamplingContext) => { if (dsc.replay_id) { return; diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts new file mode 100644 index 0000000000..f8aa7ecce0 --- /dev/null +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -0,0 +1,229 @@ +import { debug } from '@sentry/core'; + +import { createForegroundReplayGuardState } from '../../src/js/replay/foregroundReplayGuard'; +import { NATIVE } from '../../src/js/wrapper'; + +jest.mock('../../src/js/wrapper'); + +describe('createForegroundReplayGuardState', () => { + let mockGetCurrentReplayId: jest.MockedFunction; + let mockStopReplay: jest.MockedFunction; + let mockStartReplayBuffering: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockGetCurrentReplayId = NATIVE.getCurrentReplayId as jest.MockedFunction; + mockStopReplay = NATIVE.stopReplay as jest.MockedFunction; + mockStartReplayBuffering = NATIVE.startReplayBuffering as jest.MockedFunction; + mockStopReplay.mockResolvedValue(undefined); + mockStartReplayBuffering.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('when the app backgrounds', () => { + it('stops replay when a replay is currently active', () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000); + + // Act + handleAppStateChange('background'); + + // Assert + expect(mockStopReplay).toHaveBeenCalledTimes(1); + }); + + it('does not stop replay when nothing is active', () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue(null); + const { handleAppStateChange } = createForegroundReplayGuardState(1000); + + // Act + handleAppStateChange('background'); + + // Assert + expect(mockStopReplay).not.toHaveBeenCalled(); + }); + }); + + describe('when the app returns to the foreground', () => { + it('restarts replay in buffer mode after the configured delay when it stopped an active replay', () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000); + handleAppStateChange('background'); + + // Act + handleAppStateChange('active'); + + // Assert + expect(mockStartReplayBuffering).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + expect(mockStartReplayBuffering).toHaveBeenCalledTimes(1); + }); + + it('does not restart replay when nothing was stopped', () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue(null); + const { handleAppStateChange } = createForegroundReplayGuardState(1000); + handleAppStateChange('background'); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert + expect(mockStartReplayBuffering).not.toHaveBeenCalled(); + }); + + it('does not restart replay again for a subsequent active event with nothing pending', () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000); + handleAppStateChange('background'); + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert + expect(mockStartReplayBuffering).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the app backgrounds again before the delayed restart fires', () => { + it('cancels the pending restart', () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000); + handleAppStateChange('background'); + handleAppStateChange('active'); + + // Act: replay was already stopped, so this background transition finds nothing active. + mockGetCurrentReplayId.mockReturnValue(null); + handleAppStateChange('background'); + jest.advanceTimersByTime(1000); + + // Assert + expect(mockStartReplayBuffering).not.toHaveBeenCalled(); + }); + }); + + describe('detach', () => { + it('cancels a pending restart', () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue('active-replay-id'); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000); + handleAppStateChange('background'); + handleAppStateChange('active'); + + // Act + detach(); + jest.advanceTimersByTime(1000); + + // Assert + expect(mockStartReplayBuffering).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('logs and does not throw when stopReplay rejects', async () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue('active-replay-id'); + mockStopReplay.mockRejectedValue(new Error('native error')); + const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); + const { handleAppStateChange } = createForegroundReplayGuardState(1000); + + // Act + handleAppStateChange('background'); + await Promise.resolve(); + await Promise.resolve(); + + // Assert + expect(debugErrorSpy).toHaveBeenCalled(); + }); + + it('logs and does not throw when startReplayBuffering rejects', async () => { + // Arrange + mockGetCurrentReplayId.mockReturnValue('active-replay-id'); + mockStartReplayBuffering.mockRejectedValue(new Error('native error')); + const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); + const { handleAppStateChange } = createForegroundReplayGuardState(1000); + handleAppStateChange('background'); + handleAppStateChange('active'); + + // Act + jest.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + + // Assert + expect(debugErrorSpy).toHaveBeenCalled(); + }); + }); +}); + +describe('attachForegroundReplayGuard', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('subscribes to AppState changes on iOS', () => { + // Arrange + jest.resetModules(); + jest.doMock('react-native', () => ({ + AppState: { isAvailable: true, addEventListener: jest.fn(() => ({ remove: jest.fn() })) }, + Platform: { OS: 'ios' }, + })); + const { attachForegroundReplayGuard: attach } = require('../../src/js/replay/foregroundReplayGuard'); + const { AppState } = require('react-native'); + + // Act + attach(1000); + + // Assert + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('does not subscribe on Android', () => { + // Arrange + jest.resetModules(); + jest.doMock('react-native', () => ({ + AppState: { isAvailable: true, addEventListener: jest.fn(() => ({ remove: jest.fn() })) }, + Platform: { OS: 'android' }, + })); + const { attachForegroundReplayGuard: attach } = require('../../src/js/replay/foregroundReplayGuard'); + const { AppState } = require('react-native'); + + // Act + attach(1000); + + // Assert + expect(AppState.addEventListener).not.toHaveBeenCalled(); + }); + + it('detach removes the AppState subscription', () => { + // Arrange + const removeMock = jest.fn(); + jest.resetModules(); + jest.doMock('react-native', () => ({ + AppState: { isAvailable: true, addEventListener: jest.fn(() => ({ remove: removeMock })) }, + Platform: { OS: 'ios' }, + })); + const { attachForegroundReplayGuard: attach } = require('../../src/js/replay/foregroundReplayGuard'); + + // Act + const detach = attach(1000); + detach(); + + // Assert + expect(removeMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 745e75dfaf..e4485150be 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -11,6 +11,7 @@ import type { import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { debug } from '@sentry/core'; +import { attachForegroundReplayGuard } from '../../src/js/replay/foregroundReplayGuard'; import { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay'; import { REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY } from '../../src/js/replay/xhrUtils'; import * as scopeSync from '../../src/js/scopeSync'; @@ -18,6 +19,7 @@ import * as environment from '../../src/js/utils/environment'; import { NATIVE } from '../../src/js/wrapper'; jest.mock('../../src/js/wrapper'); +jest.mock('../../src/js/replay/foregroundReplayGuard'); describe('Mobile Replay Integration', () => { let mockCaptureReplay: jest.MockedFunction; @@ -813,6 +815,23 @@ describe('Mobile Replay Integration', () => { }); }); + describe('avoidForegroundResumeHang', () => { + it('attaches the foreground replay guard with the configured delay when enabled', () => { + const integration = mobileReplayIntegration({ + avoidForegroundResumeHang: true, + avoidForegroundResumeHangDelayMs: 500, + }); + integration.setup?.(mockClient); + expect(attachForegroundReplayGuard).toHaveBeenCalledWith(500); + }); + + it('does not attach the foreground replay guard by default', () => { + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + expect(attachForegroundReplayGuard).not.toHaveBeenCalled(); + }); + }); + describe('network detail feature markers', () => { let mockAddIntegration: jest.Mock; let mockGetIntegrationByName: jest.Mock; From 6787e4c49b442e0f6d04eaf9c349cd5de4c80bbf Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 15 Sep 2026 15:04:03 +0200 Subject: [PATCH 02/12] fix(replay): address review findings in foreground replay guard - Stale cached replay id: the guard now calls into mobilereplay.ts's own cache-invalidation path instead of raw NATIVE.stopReplay/ startReplayBuffering, so getReplayId()/DSC/metric linking don't keep pointing at the pre-background session. - Dropped restart on interrupting state: the state machine no longer clears the pending restart on every AppState event - only on an actual background/active transition, with a stoppedByGuard flag instead of an eagerly-reset boolean. - Never detached: setupForegroundReplayGuard now registers detach on the client's 'close' hook. - Missing inactive handling: iOS can suspend JS between 'inactive' and 'background', so 'background' may never arrive. Mirrors onSpanEndUtils' cancelInBackground pattern - a delayed, cancelable stop on 'inactive' as a fallback. --- .../src/js/replay/foregroundReplayGuard.ts | 148 +++++++-- packages/core/src/js/replay/mobilereplay.ts | 9 +- .../test/replay/foregroundReplayGuard.test.ts | 308 +++++++++++++++--- .../core/test/replay/mobilereplay.test.ts | 16 +- 4 files changed, 393 insertions(+), 88 deletions(-) diff --git a/packages/core/src/js/replay/foregroundReplayGuard.ts b/packages/core/src/js/replay/foregroundReplayGuard.ts index 1921808d23..6a7af070f8 100644 --- a/packages/core/src/js/replay/foregroundReplayGuard.ts +++ b/packages/core/src/js/replay/foregroundReplayGuard.ts @@ -1,11 +1,26 @@ +import type { Client } from '@sentry/core'; import type { AppStateStatus } from 'react-native'; import { debug } from '@sentry/core'; import { AppState, Platform } from 'react-native'; -import { NATIVE } from '../wrapper'; +// 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; -const DEFAULT_DELAY_MS = 1000; +/** + * 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; + startReplayBuffering: () => Promise; +} /** * On iOS, sentry-cocoa resumes Session Replay capture synchronously on @@ -20,9 +35,9 @@ const DEFAULT_DELAY_MS = 1000; * * `stopReplay()` is different: it tears down the native replay session * entirely, so the automatic resume becomes a no-op. This guard stops replay - * just 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. + * 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; @@ -34,50 +49,83 @@ export interface ForegroundReplayGuardState { * drive `handleAppStateChange` directly instead of going through React * Native's `AppState` emitter. */ -export function createForegroundReplayGuardState(delayMs: number): ForegroundReplayGuardState { - let pendingResume = false; +export function createForegroundReplayGuardState( + delayMs: number, + deps: ForegroundReplayGuardDependencies, +): ForegroundReplayGuardState { + // True from the moment we ask native to stop until a restart completes. + // Guards against stopping twice, and lets a background event skip the + // (redundant) `getCurrentReplayId` check while already stopped. + let stoppedByGuard = false; + let inactiveStopTimeout: ReturnType | null = null; let resumeTimeout: ReturnType | null = null; - function clearPendingResume(): void { + function clearInactiveStopTimeout(): void { + if (inactiveStopTimeout !== null) { + clearTimeout(inactiveStopTimeout); + inactiveStopTimeout = null; + } + } + + function clearResumeTimeout(): void { if (resumeTimeout !== null) { clearTimeout(resumeTimeout); resumeTimeout = null; } } - function handleAppStateChange(state: AppStateStatus): void { - clearPendingResume(); + function stopIfNeeded(): void { + 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; + deps.stopReplay().then(undefined, (error: unknown) => { + debug.error('[Sentry] Failed to stop replay before backgrounding', error); + }); + } + + function handleAppStateChange(state: AppStateStatus): void { if (state === 'background') { - // 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 (!NATIVE.getCurrentReplayId()) { - pendingResume = false; - return; - } + clearInactiveStopTimeout(); + clearResumeTimeout(); + stopIfNeeded(); + return; + } - pendingResume = true; - NATIVE.stopReplay().then(undefined, (error: unknown) => { - debug.error('[Sentry] Failed to stop replay before backgrounding', error); - }); + if (state === 'inactive') { + if (Platform.OS === 'ios' && inactiveStopTimeout === null) { + inactiveStopTimeout = setTimeout(() => { + inactiveStopTimeout = null; + stopIfNeeded(); + }, IOS_INACTIVE_STOP_DELAY_MS); + } return; } - if (state === 'active' && pendingResume) { - pendingResume = false; - resumeTimeout = setTimeout(() => { - resumeTimeout = null; - NATIVE.startReplayBuffering().then(undefined, (error: unknown) => { - debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); - }); - }, delayMs); + if (state === 'active') { + clearInactiveStopTimeout(); + if (stoppedByGuard && resumeTimeout === null) { + resumeTimeout = setTimeout(() => { + resumeTimeout = null; + stoppedByGuard = false; + deps.startReplayBuffering().then(undefined, (error: unknown) => { + debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); + }); + }, delayMs); + } } } function detach(): void { - clearPendingResume(); - pendingResume = false; + clearInactiveStopTimeout(); + clearResumeTimeout(); } return { handleAppStateChange, detach }; @@ -87,12 +135,12 @@ export function createForegroundReplayGuardState(delayMs: number): ForegroundRep * Wires {@link createForegroundReplayGuardState} to React Native's `AppState`. * iOS-only; a no-op everywhere else. */ -export function attachForegroundReplayGuard(delayMs: number = DEFAULT_DELAY_MS): () => void { +export function attachForegroundReplayGuard(delayMs: number, deps: ForegroundReplayGuardDependencies): () => void { if (Platform.OS !== 'ios' || !AppState?.isAvailable) { return () => {}; } - const { handleAppStateChange, detach } = createForegroundReplayGuardState(delayMs); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(delayMs, deps); const subscription = AppState.addEventListener('change', handleAppStateChange); return () => { @@ -100,3 +148,37 @@ export function attachForegroundReplayGuard(delayMs: number = DEFAULT_DELAY_MS): 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; + startReplayBuffering: () => Promise; +} + +/** + * 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, + 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); +} diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 6f37f70532..ae9c058bdc 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -20,7 +20,7 @@ import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; import { registerFeatureMarker } from '../utils/featureMarkers'; import { NATIVE } from '../wrapper'; -import { attachForegroundReplayGuard } from './foregroundReplayGuard'; +import { setupForegroundReplayGuard } from './foregroundReplayGuard'; import { buildResolvedNetworkBreadcrumb, makeEnrichXhrBreadcrumbsForMobileReplay, @@ -528,7 +528,12 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau cachedReplayId = NATIVE.getCurrentReplayId(); if (options.avoidForegroundResumeHang) { - attachForegroundReplayGuard(options.avoidForegroundResumeHangDelayMs); + setupForegroundReplayGuard( + client, + options.avoidForegroundResumeHangDelayMs ?? 1000, + NATIVE, + invalidateCachedReplayId, + ); } client.on('createDsc', (dsc: DynamicSamplingContext) => { diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts index f8aa7ecce0..9af305e90f 100644 --- a/packages/core/test/replay/foregroundReplayGuard.test.ts +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -1,24 +1,30 @@ +import type { Client } from '@sentry/core'; + import { debug } from '@sentry/core'; +import type { + ForegroundReplayGuardDependencies, + ForegroundReplayGuardNativeControls, + setupForegroundReplayGuard, +} from '../../src/js/replay/foregroundReplayGuard'; + import { createForegroundReplayGuardState } from '../../src/js/replay/foregroundReplayGuard'; -import { NATIVE } from '../../src/js/wrapper'; -jest.mock('../../src/js/wrapper'); +function createDeps(replayId: string | null = 'active-replay-id'): ForegroundReplayGuardDependencies & { + getCurrentReplayId: jest.Mock; + stopReplay: jest.Mock; + startReplayBuffering: jest.Mock; +} { + return { + getCurrentReplayId: jest.fn(() => replayId), + stopReplay: jest.fn(() => Promise.resolve()), + startReplayBuffering: jest.fn(() => Promise.resolve()), + }; +} describe('createForegroundReplayGuardState', () => { - let mockGetCurrentReplayId: jest.MockedFunction; - let mockStopReplay: jest.MockedFunction; - let mockStartReplayBuffering: jest.MockedFunction; - beforeEach(() => { - jest.clearAllMocks(); jest.useFakeTimers(); - - mockGetCurrentReplayId = NATIVE.getCurrentReplayId as jest.MockedFunction; - mockStopReplay = NATIVE.stopReplay as jest.MockedFunction; - mockStartReplayBuffering = NATIVE.startReplayBuffering as jest.MockedFunction; - mockStopReplay.mockResolvedValue(undefined); - mockStartReplayBuffering.mockResolvedValue(undefined); }); afterEach(() => { @@ -28,49 +34,62 @@ describe('createForegroundReplayGuardState', () => { describe('when the app backgrounds', () => { it('stops replay when a replay is currently active', () => { // Arrange - mockGetCurrentReplayId.mockReturnValue('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000); + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); // Act handleAppStateChange('background'); // Assert - expect(mockStopReplay).toHaveBeenCalledTimes(1); + expect(deps.stopReplay).toHaveBeenCalledTimes(1); }); it('does not stop replay when nothing is active', () => { // Arrange - mockGetCurrentReplayId.mockReturnValue(null); - const { handleAppStateChange } = createForegroundReplayGuardState(1000); + const deps = createDeps(null); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + + // Act + handleAppStateChange('background'); + + // Assert + expect(deps.stopReplay).not.toHaveBeenCalled(); + }); + + it('does not stop replay twice for repeated background events', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); // Act handleAppStateChange('background'); // Assert - expect(mockStopReplay).not.toHaveBeenCalled(); + expect(deps.stopReplay).toHaveBeenCalledTimes(1); }); }); describe('when the app returns to the foreground', () => { it('restarts replay in buffer mode after the configured delay when it stopped an active replay', () => { // Arrange - mockGetCurrentReplayId.mockReturnValue('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000); + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); // Act handleAppStateChange('active'); // Assert - expect(mockStartReplayBuffering).not.toHaveBeenCalled(); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); - expect(mockStartReplayBuffering).toHaveBeenCalledTimes(1); + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); }); it('does not restart replay when nothing was stopped', () => { // Arrange - mockGetCurrentReplayId.mockReturnValue(null); - const { handleAppStateChange } = createForegroundReplayGuardState(1000); + const deps = createDeps(null); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); // Act @@ -78,13 +97,28 @@ describe('createForegroundReplayGuardState', () => { jest.advanceTimersByTime(1000); // Assert - expect(mockStartReplayBuffering).not.toHaveBeenCalled(); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); }); - it('does not restart replay again for a subsequent active event with nothing pending', () => { + it('does not schedule a second restart for a repeated active event', () => { // Arrange - mockGetCurrentReplayId.mockReturnValue('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000); + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + }); + + it('does not restart again for a subsequent active event once already restarted', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); handleAppStateChange('active'); jest.advanceTimersByTime(1000); @@ -94,33 +128,97 @@ describe('createForegroundReplayGuardState', () => { jest.advanceTimersByTime(1000); // Assert - expect(mockStartReplayBuffering).toHaveBeenCalledTimes(1); + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + }); + }); + + describe('when an inactive transition is not followed by background or active', () => { + it('stops replay after the iOS inactive fallback delay', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + + // Act + handleAppStateChange('inactive'); + + // Assert: not stopped immediately - only after the fallback delay. + expect(deps.stopReplay).not.toHaveBeenCalled(); + jest.advanceTimersByTime(5000); + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + }); + }); + + describe('when active follows inactive before the fallback delay elapses', () => { + it('cancels the fallback stop', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('inactive'); + + // Act: a brief interruption, not a real backgrounding. + handleAppStateChange('active'); + jest.advanceTimersByTime(5000); + + // Assert + expect(deps.stopReplay).not.toHaveBeenCalled(); + }); + }); + + describe('when background follows inactive before the fallback delay elapses', () => { + it('stops immediately and does not double-stop when the fallback timer would have fired', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('inactive'); + + // Act + handleAppStateChange('background'); + jest.advanceTimersByTime(5000); + + // Assert + expect(deps.stopReplay).toHaveBeenCalledTimes(1); }); }); describe('when the app backgrounds again before the delayed restart fires', () => { - it('cancels the pending restart', () => { + it('cancels the pending restart and does not restart when eventually foregrounded past the original delay', () => { // Arrange - mockGetCurrentReplayId.mockReturnValue('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000); + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); handleAppStateChange('active'); - // Act: replay was already stopped, so this background transition finds nothing active. - mockGetCurrentReplayId.mockReturnValue(null); + // Act: background again before the 1000ms restart delay elapses. handleAppStateChange('background'); jest.advanceTimersByTime(1000); + // Assert: the original restart never fires while backgrounded. + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + }); + + it('still restarts once foregrounded again', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + handleAppStateChange('background'); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + // Assert - expect(mockStartReplayBuffering).not.toHaveBeenCalled(); + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); }); }); describe('detach', () => { it('cancels a pending restart', () => { // Arrange - mockGetCurrentReplayId.mockReturnValue('active-replay-id'); - const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000); + const deps = createDeps('active-replay-id'); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); handleAppStateChange('active'); @@ -129,17 +227,31 @@ describe('createForegroundReplayGuardState', () => { jest.advanceTimersByTime(1000); // Assert - expect(mockStartReplayBuffering).not.toHaveBeenCalled(); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + }); + + it('cancels a pending inactive fallback stop', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('inactive'); + + // Act + detach(); + jest.advanceTimersByTime(5000); + + // Assert + expect(deps.stopReplay).not.toHaveBeenCalled(); }); }); describe('error handling', () => { it('logs and does not throw when stopReplay rejects', async () => { // Arrange - mockGetCurrentReplayId.mockReturnValue('active-replay-id'); - mockStopReplay.mockRejectedValue(new Error('native error')); + const deps = createDeps('active-replay-id'); + deps.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); - const { handleAppStateChange } = createForegroundReplayGuardState(1000); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); // Act handleAppStateChange('background'); @@ -152,10 +264,10 @@ describe('createForegroundReplayGuardState', () => { it('logs and does not throw when startReplayBuffering rejects', async () => { // Arrange - mockGetCurrentReplayId.mockReturnValue('active-replay-id'); - mockStartReplayBuffering.mockRejectedValue(new Error('native error')); + const deps = createDeps('active-replay-id'); + deps.startReplayBuffering.mockReturnValue(Promise.reject(new Error('native error'))); const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); - const { handleAppStateChange } = createForegroundReplayGuardState(1000); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); handleAppStateChange('active'); @@ -186,7 +298,7 @@ describe('attachForegroundReplayGuard', () => { const { AppState } = require('react-native'); // Act - attach(1000); + attach(1000, createDeps()); // Assert expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); @@ -203,7 +315,7 @@ describe('attachForegroundReplayGuard', () => { const { AppState } = require('react-native'); // Act - attach(1000); + attach(1000, createDeps()); // Assert expect(AppState.addEventListener).not.toHaveBeenCalled(); @@ -220,10 +332,110 @@ describe('attachForegroundReplayGuard', () => { const { attachForegroundReplayGuard: attach } = require('../../src/js/replay/foregroundReplayGuard'); // Act - const detach = attach(1000); + const detach = attach(1000, createDeps()); detach(); // Assert expect(removeMock).toHaveBeenCalledTimes(1); }); }); + +describe('setupForegroundReplayGuard', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + function setUp(): { + setup: typeof setupForegroundReplayGuard; + client: { on: jest.Mock }; + native: ForegroundReplayGuardNativeControls & { [K in keyof ForegroundReplayGuardNativeControls]: jest.Mock }; + invalidateCachedReplayId: jest.Mock; + simulateAppStateChange: (state: string) => void; + } { + jest.resetModules(); + let listener: ((state: string) => void) | undefined; + jest.doMock('react-native', () => ({ + AppState: { + isAvailable: true, + addEventListener: jest.fn((_event: string, cb: (state: string) => void) => { + listener = cb; + return { remove: jest.fn() }; + }), + }, + Platform: { OS: 'ios' }, + })); + const { setupForegroundReplayGuard: setup } = require('../../src/js/replay/foregroundReplayGuard'); + + return { + setup, + client: { on: jest.fn() }, + native: { + getCurrentReplayId: jest.fn(() => 'active-replay-id'), + stopReplay: jest.fn(() => Promise.resolve()), + startReplayBuffering: jest.fn(() => Promise.resolve()), + }, + invalidateCachedReplayId: jest.fn(), + simulateAppStateChange: (state: string) => listener?.(state), + }; + } + + it('registers a close handler on the client', () => { + // Arrange + const { setup, client, native, invalidateCachedReplayId } = setUp(); + + // Act + setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); + + // Assert + expect(client.on).toHaveBeenCalledWith('close', expect.any(Function)); + }); + + it('invalidates the cached replay id after stopping on background', async () => { + // Arrange + const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); + setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); + + // Act + simulateAppStateChange('background'); + await Promise.resolve(); + + // Assert + expect(native.stopReplay).toHaveBeenCalledTimes(1); + expect(invalidateCachedReplayId).toHaveBeenCalledTimes(1); + }); + + it('invalidates the cached replay id again after restarting on foreground', async () => { + // Arrange + jest.useFakeTimers(); + const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); + setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); + simulateAppStateChange('background'); + await Promise.resolve(); + + // Act + simulateAppStateChange('active'); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + + // Assert + expect(native.startReplayBuffering).toHaveBeenCalledTimes(1); + expect(invalidateCachedReplayId).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('invalidates the cached replay id even when stopReplay rejects', async () => { + // Arrange + const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); + native.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); + jest.spyOn(debug, 'error').mockImplementation(() => {}); + setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); + + // Act + simulateAppStateChange('background'); + await Promise.resolve(); + await Promise.resolve(); + + // Assert + expect(invalidateCachedReplayId).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index e4485150be..7a517a5576 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -11,7 +11,7 @@ import type { import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { debug } from '@sentry/core'; -import { attachForegroundReplayGuard } from '../../src/js/replay/foregroundReplayGuard'; +import { setupForegroundReplayGuard } from '../../src/js/replay/foregroundReplayGuard'; import { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay'; import { REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY } from '../../src/js/replay/xhrUtils'; import * as scopeSync from '../../src/js/scopeSync'; @@ -816,19 +816,25 @@ describe('Mobile Replay Integration', () => { }); describe('avoidForegroundResumeHang', () => { - it('attaches the foreground replay guard with the configured delay when enabled', () => { + it('sets up the foreground replay guard with the configured delay when enabled', () => { const integration = mobileReplayIntegration({ avoidForegroundResumeHang: true, avoidForegroundResumeHangDelayMs: 500, }); integration.setup?.(mockClient); - expect(attachForegroundReplayGuard).toHaveBeenCalledWith(500); + expect(setupForegroundReplayGuard).toHaveBeenCalledWith(mockClient, 500, NATIVE, expect.any(Function)); }); - it('does not attach the foreground replay guard by default', () => { + it('defaults the delay to 1000ms when not configured', () => { + const integration = mobileReplayIntegration({ avoidForegroundResumeHang: true }); + integration.setup?.(mockClient); + expect(setupForegroundReplayGuard).toHaveBeenCalledWith(mockClient, 1000, NATIVE, expect.any(Function)); + }); + + it('does not set up the foreground replay guard by default', () => { const integration = mobileReplayIntegration(); integration.setup?.(mockClient); - expect(attachForegroundReplayGuard).not.toHaveBeenCalled(); + expect(setupForegroundReplayGuard).not.toHaveBeenCalled(); }); }); From 80ec35a437471bdedf9d1195b803ec9fff6cff77 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 15 Sep 2026 15:52:04 +0200 Subject: [PATCH 03/12] fix(replay): close two race conditions in the foreground replay guard - stoppedByGuard was cleared before startReplayBuffering() settled, so a background event landing while the restart was still in flight could miss stopping the newly-started (unprotected) session. Track the in-flight restart separately and stop the new session once it resolves if a background event arrived in the meantime. - A rejected stopReplay() left stoppedByGuard set to true, so a later foreground event would schedule a restart against a session that may never have actually stopped. Reset the flag on stop failure instead. Also moves the restart delay's default (1000ms) into setupForegroundReplayGuard itself, keeping the mobilereplay.ts call site short enough to avoid the formatter wrapping it across extra lines. --- .../src/js/replay/foregroundReplayGuard.ts | 47 +++++++-- packages/core/src/js/replay/mobilereplay.ts | 7 +- .../test/replay/foregroundReplayGuard.test.ts | 99 +++++++++++++++++++ .../core/test/replay/mobilereplay.test.ts | 4 +- 4 files changed, 140 insertions(+), 17 deletions(-) diff --git a/packages/core/src/js/replay/foregroundReplayGuard.ts b/packages/core/src/js/replay/foregroundReplayGuard.ts index 6a7af070f8..f6539996bb 100644 --- a/packages/core/src/js/replay/foregroundReplayGuard.ts +++ b/packages/core/src/js/replay/foregroundReplayGuard.ts @@ -53,10 +53,15 @@ export function createForegroundReplayGuardState( delayMs: number, deps: ForegroundReplayGuardDependencies, ): ForegroundReplayGuardState { - // True from the moment we ask native to stop until a restart completes. - // Guards against stopping twice, and lets a background event skip the - // (redundant) `getCurrentReplayId` check while already stopped. + // 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; + let backgroundedDuringRestart = false; let inactiveStopTimeout: ReturnType | null = null; let resumeTimeout: ReturnType | null = null; @@ -75,6 +80,12 @@ export function createForegroundReplayGuardState( } function stopIfNeeded(): void { + if (restartInFlight) { + // A restart is already underway; stop the just-started session once it + // settles instead of leaving it unprotected. + backgroundedDuringRestart = true; + return; + } if (stoppedByGuard) { return; } @@ -87,10 +98,31 @@ export function createForegroundReplayGuardState( stoppedByGuard = true; 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); }); } + function restart(): void { + restartInFlight = true; + deps.startReplayBuffering().then( + () => { + restartInFlight = false; + stoppedByGuard = false; + if (backgroundedDuringRestart) { + backgroundedDuringRestart = false; + stopIfNeeded(); + } + }, + (error: unknown) => { + restartInFlight = false; + backgroundedDuringRestart = false; + debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); + }, + ); + } + function handleAppStateChange(state: AppStateStatus): void { if (state === 'background') { clearInactiveStopTimeout(); @@ -111,13 +143,10 @@ export function createForegroundReplayGuardState( if (state === 'active') { clearInactiveStopTimeout(); - if (stoppedByGuard && resumeTimeout === null) { + if (stoppedByGuard && resumeTimeout === null && !restartInFlight) { resumeTimeout = setTimeout(() => { resumeTimeout = null; - stoppedByGuard = false; - deps.startReplayBuffering().then(undefined, (error: unknown) => { - debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); - }); + restart(); }, delayMs); } } @@ -167,7 +196,7 @@ export interface ForegroundReplayGuardNativeControls { */ export function setupForegroundReplayGuard( client: Client, - delayMs: number, + delayMs: number = 1000, native: ForegroundReplayGuardNativeControls, invalidateCachedReplayId: () => void, ): void { diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index ae9c058bdc..282885a9b3 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -528,12 +528,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau cachedReplayId = NATIVE.getCurrentReplayId(); if (options.avoidForegroundResumeHang) { - setupForegroundReplayGuard( - client, - options.avoidForegroundResumeHangDelayMs ?? 1000, - NATIVE, - invalidateCachedReplayId, - ); + setupForegroundReplayGuard(client, options.avoidForegroundResumeHangDelayMs, NATIVE, invalidateCachedReplayId); } client.on('createDsc', (dsc: DynamicSamplingContext) => { diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts index 9af305e90f..b7ff93c159 100644 --- a/packages/core/test/replay/foregroundReplayGuard.test.ts +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -22,6 +22,21 @@ function createDeps(replayId: string | null = 'active-replay-id'): ForegroundRep }; } +/** A promise whose resolution is controlled from outside, to pin down in-flight timing precisely. */ +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('createForegroundReplayGuardState', () => { beforeEach(() => { jest.useFakeTimers(); @@ -214,6 +229,72 @@ describe('createForegroundReplayGuardState', () => { }); }); + describe('when the app backgrounds while a scheduled restart is already in flight', () => { + it('stops the newly-started replay once the in-flight restart resolves', async () => { + // Arrange + const deps = createDeps('active-replay-id'); + const startDeferred = createDeferred(); + deps.startReplayBuffering.mockReturnValue(startDeferred.promise); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + + // Act: background again while the restart is still in flight - must not + // be missed just because `startReplayBuffering()` hasn't resolved yet. + deps.getCurrentReplayId.mockReturnValue('new-replay-id'); + handleAppStateChange('background'); + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + + startDeferred.resolve(); + await startDeferred.promise; + await Promise.resolve(); + + // Assert: the just-restarted session gets stopped instead of left running unprotected. + expect(deps.stopReplay).toHaveBeenCalledTimes(2); + }); + + it('does not double-schedule a restart for an active event received while restarting', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const startDeferred = createDeferred(); + deps.startReplayBuffering.mockReturnValue(startDeferred.promise); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + + // Act: a spurious/duplicate active event while the restart is in flight. + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + }); + }); + + describe('when stopReplay rejects', () => { + it('does not schedule a restart on a later active event', async () => { + // Arrange + const deps = createDeps('active-replay-id'); + deps.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); + jest.spyOn(debug, 'error').mockImplementation(() => {}); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + await Promise.resolve(); + await Promise.resolve(); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert: the guard isn't confident replay actually stopped, so it doesn't restart it. + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + }); + }); + describe('detach', () => { it('cancels a pending restart', () => { // Arrange @@ -390,6 +471,24 @@ describe('setupForegroundReplayGuard', () => { expect(client.on).toHaveBeenCalledWith('close', expect.any(Function)); }); + it('defaults the restart delay to 1000ms when not given', () => { + // Arrange + jest.useFakeTimers(); + const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); + + // Act + setup(client as unknown as Client, undefined, native, invalidateCachedReplayId); + simulateAppStateChange('background'); + simulateAppStateChange('active'); + jest.advanceTimersByTime(999); + expect(native.startReplayBuffering).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + + // Assert + expect(native.startReplayBuffering).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + it('invalidates the cached replay id after stopping on background', async () => { // Arrange const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 7a517a5576..aed7a3e094 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -825,10 +825,10 @@ describe('Mobile Replay Integration', () => { expect(setupForegroundReplayGuard).toHaveBeenCalledWith(mockClient, 500, NATIVE, expect.any(Function)); }); - it('defaults the delay to 1000ms when not configured', () => { + it('passes the configured delay through as-is, letting setupForegroundReplayGuard default it when undefined', () => { const integration = mobileReplayIntegration({ avoidForegroundResumeHang: true }); integration.setup?.(mockClient); - expect(setupForegroundReplayGuard).toHaveBeenCalledWith(mockClient, 1000, NATIVE, expect.any(Function)); + expect(setupForegroundReplayGuard).toHaveBeenCalledWith(mockClient, undefined, NATIVE, expect.any(Function)); }); it('does not set up the foreground replay guard by default', () => { From 95c4652a5a62f47f51dbc8d672ec2e988c969d70 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 15 Sep 2026 16:23:53 +0200 Subject: [PATCH 04/12] test(replay): consolidate overlapping foreground guard test cases Merges near-duplicate cases (repeated background/active events tested across separate its; the two cache-invalidation tests) and removes a test that duplicated the "stopReplay rejects" scenario with only a different assertion. No coverage lost - one regression case remains per behavior/bug found, just fewer redundant setups. --- .../test/replay/foregroundReplayGuard.test.ts | 149 +++++------------- 1 file changed, 42 insertions(+), 107 deletions(-) diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts index b7ff93c159..57a66487d5 100644 --- a/packages/core/test/replay/foregroundReplayGuard.test.ts +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -47,13 +47,14 @@ describe('createForegroundReplayGuardState', () => { }); describe('when the app backgrounds', () => { - it('stops replay when a replay is currently active', () => { + it('stops replay when active, and only once for repeated background events', () => { // Arrange const deps = createDeps('active-replay-id'); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); // Act handleAppStateChange('background'); + handleAppStateChange('background'); // Assert expect(deps.stopReplay).toHaveBeenCalledTimes(1); @@ -70,19 +71,6 @@ describe('createForegroundReplayGuardState', () => { // Assert expect(deps.stopReplay).not.toHaveBeenCalled(); }); - - it('does not stop replay twice for repeated background events', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - - // Act - handleAppStateChange('background'); - - // Assert - expect(deps.stopReplay).toHaveBeenCalledTimes(1); - }); }); describe('when the app returns to the foreground', () => { @@ -115,30 +103,16 @@ describe('createForegroundReplayGuardState', () => { expect(deps.startReplayBuffering).not.toHaveBeenCalled(); }); - it('does not schedule a second restart for a repeated active event', () => { + it('does not schedule extra restarts for repeated active events, before or after the delay fires', () => { // Arrange const deps = createDeps('active-replay-id'); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); handleAppStateChange('active'); - // Act - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - - // Assert - expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); - }); - - it('does not restart again for a subsequent active event once already restarted', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); + // Act: a repeat before the timer fires, then another after it already has. handleAppStateChange('active'); jest.advanceTimersByTime(1000); - - // Act handleAppStateChange('active'); jest.advanceTimersByTime(1000); @@ -147,8 +121,8 @@ describe('createForegroundReplayGuardState', () => { }); }); - describe('when an inactive transition is not followed by background or active', () => { - it('stops replay after the iOS inactive fallback delay', () => { + describe('the iOS inactive fallback (background may never arrive if JS suspends)', () => { + it('stops replay after the fallback delay when inactive is not followed by background or active', () => { // Arrange const deps = createDeps('active-replay-id'); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); @@ -161,26 +135,22 @@ describe('createForegroundReplayGuardState', () => { jest.advanceTimersByTime(5000); expect(deps.stopReplay).toHaveBeenCalledTimes(1); }); - }); - describe('when active follows inactive before the fallback delay elapses', () => { - it('cancels the fallback stop', () => { - // Arrange + it('cancels the fallback stop when active follows before the delay elapses', () => { + // Arrange: a brief interruption, not a real backgrounding. const deps = createDeps('active-replay-id'); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('inactive'); - // Act: a brief interruption, not a real backgrounding. + // Act handleAppStateChange('active'); jest.advanceTimersByTime(5000); // Assert expect(deps.stopReplay).not.toHaveBeenCalled(); }); - }); - describe('when background follows inactive before the fallback delay elapses', () => { - it('stops immediately and does not double-stop when the fallback timer would have fired', () => { + it('stops once (not twice) when background follows before the fallback delay elapses', () => { // Arrange const deps = createDeps('active-replay-id'); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); @@ -196,7 +166,7 @@ describe('createForegroundReplayGuardState', () => { }); describe('when the app backgrounds again before the delayed restart fires', () => { - it('cancels the pending restart and does not restart when eventually foregrounded past the original delay', () => { + it('cancels the pending restart while still backgrounded, then still restarts once foregrounded again', () => { // Arrange const deps = createDeps('active-replay-id'); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); @@ -210,21 +180,12 @@ describe('createForegroundReplayGuardState', () => { // Assert: the original restart never fires while backgrounded. expect(deps.startReplayBuffering).not.toHaveBeenCalled(); expect(deps.stopReplay).toHaveBeenCalledTimes(1); - }); - - it('still restarts once foregrounded again', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - handleAppStateChange('active'); - handleAppStateChange('background'); - // Act + // Act: foreground again. handleAppStateChange('active'); jest.advanceTimersByTime(1000); - // Assert + // Assert: this time the restart goes through. expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); }); }); @@ -275,12 +236,13 @@ describe('createForegroundReplayGuardState', () => { }); }); - describe('when stopReplay rejects', () => { - it('does not schedule a restart on a later active event', async () => { - // Arrange + describe('error handling', () => { + it('logs and does not schedule a restart when stopReplay rejects', async () => { + // Arrange: the guard isn't confident replay actually stopped, so it + // doesn't restart it on a later active event. const deps = createDeps('active-replay-id'); deps.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); - jest.spyOn(debug, 'error').mockImplementation(() => {}); + const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); await Promise.resolve(); @@ -290,9 +252,28 @@ describe('createForegroundReplayGuardState', () => { handleAppStateChange('active'); jest.advanceTimersByTime(1000); - // Assert: the guard isn't confident replay actually stopped, so it doesn't restart it. + // Assert + expect(debugErrorSpy).toHaveBeenCalled(); expect(deps.startReplayBuffering).not.toHaveBeenCalled(); }); + + it('logs and does not throw when startReplayBuffering rejects', async () => { + // Arrange + const deps = createDeps('active-replay-id'); + deps.startReplayBuffering.mockReturnValue(Promise.reject(new Error('native error'))); + const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + + // Act + jest.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + + // Assert + expect(debugErrorSpy).toHaveBeenCalled(); + }); }); describe('detach', () => { @@ -325,42 +306,6 @@ describe('createForegroundReplayGuardState', () => { expect(deps.stopReplay).not.toHaveBeenCalled(); }); }); - - describe('error handling', () => { - it('logs and does not throw when stopReplay rejects', async () => { - // Arrange - const deps = createDeps('active-replay-id'); - deps.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); - const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - - // Act - handleAppStateChange('background'); - await Promise.resolve(); - await Promise.resolve(); - - // Assert - expect(debugErrorSpy).toHaveBeenCalled(); - }); - - it('logs and does not throw when startReplayBuffering rejects', async () => { - // Arrange - const deps = createDeps('active-replay-id'); - deps.startReplayBuffering.mockReturnValue(Promise.reject(new Error('native error'))); - const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - handleAppStateChange('active'); - - // Act - jest.advanceTimersByTime(1000); - await Promise.resolve(); - await Promise.resolve(); - - // Assert - expect(debugErrorSpy).toHaveBeenCalled(); - }); - }); }); describe('attachForegroundReplayGuard', () => { @@ -489,29 +434,19 @@ describe('setupForegroundReplayGuard', () => { jest.useRealTimers(); }); - it('invalidates the cached replay id after stopping on background', async () => { + it('invalidates the cached replay id on both the stop and the restart', async () => { // Arrange + jest.useFakeTimers(); const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); - // Act + // Act: background stops replay. simulateAppStateChange('background'); await Promise.resolve(); - - // Assert expect(native.stopReplay).toHaveBeenCalledTimes(1); expect(invalidateCachedReplayId).toHaveBeenCalledTimes(1); - }); - - it('invalidates the cached replay id again after restarting on foreground', async () => { - // Arrange - jest.useFakeTimers(); - const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); - setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); - simulateAppStateChange('background'); - await Promise.resolve(); - // Act + // Act: foreground restarts it. simulateAppStateChange('active'); jest.advanceTimersByTime(1000); await Promise.resolve(); From 1a819f1b3dc8b35fa883eb3b80d7636fc6992712 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 15 Sep 2026 16:26:32 +0200 Subject: [PATCH 05/12] test(replay): cut foreground guard tests down to one case per behavior 26 tests / 540 lines was excessive for this module. Down to 11 tests / 287 lines: one test per real behavior or regression (each bug found in review still has a dedicated case), dropped separate tests for symmetric/lower-risk paths (e.g. startReplayBuffering rejection logging, detach's inactive-fallback branch) and combined idempotency checks into the tests they naturally belong to instead of standalone cases. --- .../test/replay/foregroundReplayGuard.test.ts | 446 +++++------------- 1 file changed, 129 insertions(+), 317 deletions(-) diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts index 57a66487d5..65c4f4cdf7 100644 --- a/packages/core/test/replay/foregroundReplayGuard.test.ts +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -26,15 +26,12 @@ function createDeps(replayId: string | null = 'active-replay-id'): ForegroundRep function createDeferred(): { promise: Promise; resolve: (value: T) => void; - reject: (error: unknown) => void; } { let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { + const promise = new Promise(res => { resolve = res; - reject = rej; }); - return { promise, resolve, reject }; + return { promise, resolve }; } describe('createForegroundReplayGuardState', () => { @@ -46,265 +43,139 @@ describe('createForegroundReplayGuardState', () => { jest.useRealTimers(); }); - describe('when the app backgrounds', () => { - it('stops replay when active, and only once for repeated background events', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + it('stops replay on background and restarts it in buffer mode after the delay on foreground', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - // Act - handleAppStateChange('background'); - handleAppStateChange('background'); + // Act + handleAppStateChange('background'); + handleAppStateChange('background'); // repeated - must not stop twice + handleAppStateChange('active'); + handleAppStateChange('active'); // repeated - must not schedule twice - // Assert - expect(deps.stopReplay).toHaveBeenCalledTimes(1); - }); + // Assert + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + }); - it('does not stop replay when nothing is active', () => { - // Arrange - const deps = createDeps(null); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + it('does nothing when there is no active replay to protect', () => { + // Arrange + const deps = createDeps(null); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - // Act - handleAppStateChange('background'); + // Act + handleAppStateChange('background'); + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); - // Assert - expect(deps.stopReplay).not.toHaveBeenCalled(); - }); + // Assert + expect(deps.stopReplay).not.toHaveBeenCalled(); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); }); - describe('when the app returns to the foreground', () => { - it('restarts replay in buffer mode after the configured delay when it stopped an active replay', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - - // Act - handleAppStateChange('active'); - - // Assert - expect(deps.startReplayBuffering).not.toHaveBeenCalled(); - jest.advanceTimersByTime(1000); - expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); - }); - - it('does not restart replay when nothing was stopped', () => { - // Arrange - const deps = createDeps(null); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - - // Act - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - - // Assert - expect(deps.startReplayBuffering).not.toHaveBeenCalled(); - }); - - it('does not schedule extra restarts for repeated active events, before or after the delay fires', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - handleAppStateChange('active'); - - // Act: a repeat before the timer fires, then another after it already has. - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - - // Assert - expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); - }); + it('falls back to stopping on iOS inactive when background never follows (JS may suspend), unless active cancels it', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + + // Act: inactive alone, past the fallback delay, stops replay. + handleAppStateChange('inactive'); + expect(deps.stopReplay).not.toHaveBeenCalled(); + jest.advanceTimersByTime(5000); + expect(deps.stopReplay).toHaveBeenCalledTimes(1); }); - describe('the iOS inactive fallback (background may never arrive if JS suspends)', () => { - it('stops replay after the fallback delay when inactive is not followed by background or active', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - - // Act - handleAppStateChange('inactive'); - - // Assert: not stopped immediately - only after the fallback delay. - expect(deps.stopReplay).not.toHaveBeenCalled(); - jest.advanceTimersByTime(5000); - expect(deps.stopReplay).toHaveBeenCalledTimes(1); - }); - - it('cancels the fallback stop when active follows before the delay elapses', () => { - // Arrange: a brief interruption, not a real backgrounding. - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('inactive'); - - // Act - handleAppStateChange('active'); - jest.advanceTimersByTime(5000); - - // Assert - expect(deps.stopReplay).not.toHaveBeenCalled(); - }); - - it('stops once (not twice) when background follows before the fallback delay elapses', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('inactive'); - - // Act - handleAppStateChange('background'); - jest.advanceTimersByTime(5000); - - // Assert - expect(deps.stopReplay).toHaveBeenCalledTimes(1); - }); + it('cancels the inactive fallback stop when active follows quickly (a brief interruption, not backgrounding)', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('inactive'); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(5000); + + // Assert + expect(deps.stopReplay).not.toHaveBeenCalled(); }); - describe('when the app backgrounds again before the delayed restart fires', () => { - it('cancels the pending restart while still backgrounded, then still restarts once foregrounded again', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - handleAppStateChange('active'); - - // Act: background again before the 1000ms restart delay elapses. - handleAppStateChange('background'); - jest.advanceTimersByTime(1000); - - // Assert: the original restart never fires while backgrounded. - expect(deps.startReplayBuffering).not.toHaveBeenCalled(); - expect(deps.stopReplay).toHaveBeenCalledTimes(1); - - // Act: foreground again. - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - - // Assert: this time the restart goes through. - expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); - }); + it('cancels a pending restart if backgrounded again first, then still restarts once truly foregrounded', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + handleAppStateChange('background'); + jest.advanceTimersByTime(1000); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); }); - describe('when the app backgrounds while a scheduled restart is already in flight', () => { - it('stops the newly-started replay once the in-flight restart resolves', async () => { - // Arrange - const deps = createDeps('active-replay-id'); - const startDeferred = createDeferred(); - deps.startReplayBuffering.mockReturnValue(startDeferred.promise); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); - - // Act: background again while the restart is still in flight - must not - // be missed just because `startReplayBuffering()` hasn't resolved yet. - deps.getCurrentReplayId.mockReturnValue('new-replay-id'); - handleAppStateChange('background'); - expect(deps.stopReplay).toHaveBeenCalledTimes(1); - - startDeferred.resolve(); - await startDeferred.promise; - await Promise.resolve(); - - // Assert: the just-restarted session gets stopped instead of left running unprotected. - expect(deps.stopReplay).toHaveBeenCalledTimes(2); - }); - - it('does not double-schedule a restart for an active event received while restarting', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const startDeferred = createDeferred(); - deps.startReplayBuffering.mockReturnValue(startDeferred.promise); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); - - // Act: a spurious/duplicate active event while the restart is in flight. - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - - // Assert - expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); - }); + it('stops a session restarted while backgrounding was still in flight, instead of leaving it unprotected', async () => { + // Arrange - regression for a race where the restart's in-flight state was + // dropped too early, so a background event landing mid-restart was missed. + const deps = createDeps('active-replay-id'); + const startDeferred = createDeferred(); + deps.startReplayBuffering.mockReturnValue(startDeferred.promise); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); // startReplayBuffering() now in flight + + // Act: background again before the in-flight restart resolves. + deps.getCurrentReplayId.mockReturnValue('new-replay-id'); + handleAppStateChange('background'); + startDeferred.resolve(); + await startDeferred.promise; + await Promise.resolve(); + + // Assert: the just-restarted session gets stopped, not left running. + expect(deps.stopReplay).toHaveBeenCalledTimes(2); }); - describe('error handling', () => { - it('logs and does not schedule a restart when stopReplay rejects', async () => { - // Arrange: the guard isn't confident replay actually stopped, so it - // doesn't restart it on a later active event. - const deps = createDeps('active-replay-id'); - deps.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); - const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - await Promise.resolve(); - await Promise.resolve(); - - // Act - handleAppStateChange('active'); - jest.advanceTimersByTime(1000); - - // Assert - expect(debugErrorSpy).toHaveBeenCalled(); - expect(deps.startReplayBuffering).not.toHaveBeenCalled(); - }); - - it('logs and does not throw when startReplayBuffering rejects', async () => { - // Arrange - const deps = createDeps('active-replay-id'); - deps.startReplayBuffering.mockReturnValue(Promise.reject(new Error('native error'))); - const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); - const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - handleAppStateChange('active'); - - // Act - jest.advanceTimersByTime(1000); - await Promise.resolve(); - await Promise.resolve(); - - // Assert - expect(debugErrorSpy).toHaveBeenCalled(); - }); + it('does not restart after a failed stop, and logs the failure', async () => { + // Arrange - regression: a rejected stopReplay() must not leave the guard + // thinking it stopped, or it schedules a restart against a session that + // may never have actually stopped. + const deps = createDeps('active-replay-id'); + deps.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); + const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + await Promise.resolve(); + await Promise.resolve(); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert + expect(debugErrorSpy).toHaveBeenCalled(); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); }); - describe('detach', () => { - it('cancels a pending restart', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('background'); - handleAppStateChange('active'); - - // Act - detach(); - jest.advanceTimersByTime(1000); - - // Assert - expect(deps.startReplayBuffering).not.toHaveBeenCalled(); - }); - - it('cancels a pending inactive fallback stop', () => { - // Arrange - const deps = createDeps('active-replay-id'); - const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); - handleAppStateChange('inactive'); - - // Act - detach(); - jest.advanceTimersByTime(5000); - - // Assert - expect(deps.stopReplay).not.toHaveBeenCalled(); - }); + it('detach cancels a pending restart', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + + // Act + detach(); + jest.advanceTimersByTime(5000); + + // Assert + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); }); }); @@ -313,21 +184,24 @@ describe('attachForegroundReplayGuard', () => { jest.clearAllMocks(); }); - it('subscribes to AppState changes on iOS', () => { + it('subscribes to AppState on iOS, and detach unsubscribes', () => { // Arrange + const removeMock = jest.fn(); jest.resetModules(); jest.doMock('react-native', () => ({ - AppState: { isAvailable: true, addEventListener: jest.fn(() => ({ remove: jest.fn() })) }, + AppState: { isAvailable: true, addEventListener: jest.fn(() => ({ remove: removeMock })) }, Platform: { OS: 'ios' }, })); const { attachForegroundReplayGuard: attach } = require('../../src/js/replay/foregroundReplayGuard'); const { AppState } = require('react-native'); // Act - attach(1000, createDeps()); + const detach = attach(1000, createDeps()); // Assert expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + detach(); + expect(removeMock).toHaveBeenCalledTimes(1); }); it('does not subscribe on Android', () => { @@ -346,24 +220,6 @@ describe('attachForegroundReplayGuard', () => { // Assert expect(AppState.addEventListener).not.toHaveBeenCalled(); }); - - it('detach removes the AppState subscription', () => { - // Arrange - const removeMock = jest.fn(); - jest.resetModules(); - jest.doMock('react-native', () => ({ - AppState: { isAvailable: true, addEventListener: jest.fn(() => ({ remove: removeMock })) }, - Platform: { OS: 'ios' }, - })); - const { attachForegroundReplayGuard: attach } = require('../../src/js/replay/foregroundReplayGuard'); - - // Act - const detach = attach(1000, createDeps()); - detach(); - - // Assert - expect(removeMock).toHaveBeenCalledTimes(1); - }); }); describe('setupForegroundReplayGuard', () => { @@ -405,45 +261,18 @@ describe('setupForegroundReplayGuard', () => { }; } - it('registers a close handler on the client', () => { - // Arrange - const { setup, client, native, invalidateCachedReplayId } = setUp(); - - // Act - setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); - - // Assert - expect(client.on).toHaveBeenCalledWith('close', expect.any(Function)); - }); - - it('defaults the restart delay to 1000ms when not given', () => { - // Arrange - jest.useFakeTimers(); - const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); - - // Act - setup(client as unknown as Client, undefined, native, invalidateCachedReplayId); - simulateAppStateChange('background'); - simulateAppStateChange('active'); - jest.advanceTimersByTime(999); - expect(native.startReplayBuffering).not.toHaveBeenCalled(); - jest.advanceTimersByTime(1); - - // Assert - expect(native.startReplayBuffering).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); - - it('invalidates the cached replay id on both the stop and the restart', async () => { - // Arrange + it('registers detach on client close, and invalidates the cached replay id on both stop and restart', async () => { + // Arrange - regression: the guard used to call NATIVE directly, bypassing + // the integration's own cache invalidation, so getReplayId() kept + // pointing at the pre-background session. jest.useFakeTimers(); const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); + expect(client.on).toHaveBeenCalledWith('close', expect.any(Function)); // Act: background stops replay. simulateAppStateChange('background'); await Promise.resolve(); - expect(native.stopReplay).toHaveBeenCalledTimes(1); expect(invalidateCachedReplayId).toHaveBeenCalledTimes(1); // Act: foreground restarts it. @@ -452,24 +281,7 @@ describe('setupForegroundReplayGuard', () => { await Promise.resolve(); // Assert - expect(native.startReplayBuffering).toHaveBeenCalledTimes(1); expect(invalidateCachedReplayId).toHaveBeenCalledTimes(2); jest.useRealTimers(); }); - - it('invalidates the cached replay id even when stopReplay rejects', async () => { - // Arrange - const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); - native.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); - jest.spyOn(debug, 'error').mockImplementation(() => {}); - setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); - - // Act - simulateAppStateChange('background'); - await Promise.resolve(); - await Promise.resolve(); - - // Assert - expect(invalidateCachedReplayId).toHaveBeenCalledTimes(1); - }); }); From 4f9409e6e3d9c569412387c2db6428ccbad57da7 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 15 Sep 2026 16:39:41 +0200 Subject: [PATCH 06/12] fix(replay): guard the foreground replay guard against two more races - detach() only cleared timers, not an in-flight restart's own promise callback. If the client closed while a restart was underway and a background event had landed, the callback could still fire stopReplay() after close. Added a detached flag checked before any such post-detach side effect. - restart() fired startReplayBuffering() purely on a fixed delay, assuming that was enough time for stopReplay() to finish. If stop is slow (or the configured delay is short), the calls could overlap. restart() now waits for the in-flight stop to actually settle first. --- .../src/js/replay/foregroundReplayGuard.ts | 44 ++++++++++++------- .../test/replay/foregroundReplayGuard.test.ts | 37 +++++++++++++--- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/packages/core/src/js/replay/foregroundReplayGuard.ts b/packages/core/src/js/replay/foregroundReplayGuard.ts index f6539996bb..4819932be6 100644 --- a/packages/core/src/js/replay/foregroundReplayGuard.ts +++ b/packages/core/src/js/replay/foregroundReplayGuard.ts @@ -62,6 +62,11 @@ export function createForegroundReplayGuardState( // instead of missing the new (unprotected) session entirely. let restartInFlight = false; let backgroundedDuringRestart = 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 | null = null; let inactiveStopTimeout: ReturnType | null = null; let resumeTimeout: ReturnType | null = null; @@ -80,6 +85,9 @@ export function createForegroundReplayGuardState( } function stopIfNeeded(): void { + if (detached) { + return; + } if (restartInFlight) { // A restart is already underway; stop the just-started session once it // settles instead of leaving it unprotected. @@ -97,7 +105,7 @@ export function createForegroundReplayGuardState( } stoppedByGuard = true; - deps.stopReplay().then(undefined, (error: unknown) => { + 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); @@ -106,21 +114,26 @@ export function createForegroundReplayGuardState( function restart(): void { restartInFlight = true; - deps.startReplayBuffering().then( - () => { - restartInFlight = false; - stoppedByGuard = false; - if (backgroundedDuringRestart) { + // Wait for any in-flight stop to actually settle first - startReplayBuffering() + // must never overlap with a still-running stopReplay() call. `pendingStop` + // always resolves (its own rejection handler never rethrows). + Promise.resolve(pendingStop) + .then(() => deps.startReplayBuffering()) + .then( + () => { + restartInFlight = false; + stoppedByGuard = false; + if (backgroundedDuringRestart && !detached) { + backgroundedDuringRestart = false; + stopIfNeeded(); + } + }, + (error: unknown) => { + restartInFlight = false; backgroundedDuringRestart = false; - stopIfNeeded(); - } - }, - (error: unknown) => { - restartInFlight = false; - backgroundedDuringRestart = false; - debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); - }, - ); + debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); + }, + ); } function handleAppStateChange(state: AppStateStatus): void { @@ -153,6 +166,7 @@ export function createForegroundReplayGuardState( } function detach(): void { + detached = true; clearInactiveStopTimeout(); clearResumeTimeout(); } diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts index 65c4f4cdf7..e264123e44 100644 --- a/packages/core/test/replay/foregroundReplayGuard.test.ts +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -43,7 +43,7 @@ describe('createForegroundReplayGuardState', () => { jest.useRealTimers(); }); - it('stops replay on background and restarts it in buffer mode after the delay on foreground', () => { + it('stops replay on background and restarts it in buffer mode after the delay on foreground', async () => { // Arrange const deps = createDeps('active-replay-id'); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); @@ -57,7 +57,7 @@ describe('createForegroundReplayGuardState', () => { // Assert expect(deps.stopReplay).toHaveBeenCalledTimes(1); expect(deps.startReplayBuffering).not.toHaveBeenCalled(); - jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(1000); expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); }); @@ -102,7 +102,7 @@ describe('createForegroundReplayGuardState', () => { expect(deps.stopReplay).not.toHaveBeenCalled(); }); - it('cancels a pending restart if backgrounded again first, then still restarts once truly foregrounded', () => { + it('cancels a pending restart if backgrounded again first, then still restarts once truly foregrounded', async () => { // Arrange const deps = createDeps('active-replay-id'); const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); @@ -114,7 +114,7 @@ describe('createForegroundReplayGuardState', () => { // Act handleAppStateChange('active'); - jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(1000); // Assert expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); @@ -129,7 +129,8 @@ describe('createForegroundReplayGuardState', () => { const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); handleAppStateChange('background'); handleAppStateChange('active'); - jest.advanceTimersByTime(1000); // startReplayBuffering() now in flight + await jest.advanceTimersByTimeAsync(1000); // resume timer fires; startReplayBuffering() now in flight + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); // Act: background again before the in-flight restart resolves. deps.getCurrentReplayId.mockReturnValue('new-replay-id'); @@ -177,6 +178,29 @@ describe('createForegroundReplayGuardState', () => { // Assert expect(deps.startReplayBuffering).not.toHaveBeenCalled(); }); + + it('does not stop replay again after detach, even if a restart was still in flight', async () => { + // Arrange - regression: detach() only cleared timers, so an in-flight + // restart's own resolution could still fire a stopReplay() call post-close. + const deps = createDeps('active-replay-id'); + const startDeferred = createDeferred(); + deps.startReplayBuffering.mockReturnValue(startDeferred.promise); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); // restart in flight + deps.getCurrentReplayId.mockReturnValue('new-replay-id'); + handleAppStateChange('background'); // marks the in-flight restart for a follow-up stop + + // Act + detach(); + startDeferred.resolve(); + await startDeferred.promise; + await Promise.resolve(); + + // Assert: no follow-up stop after detach. + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + }); }); describe('attachForegroundReplayGuard', () => { @@ -277,8 +301,7 @@ describe('setupForegroundReplayGuard', () => { // Act: foreground restarts it. simulateAppStateChange('active'); - jest.advanceTimersByTime(1000); - await Promise.resolve(); + await jest.advanceTimersByTimeAsync(1000); // Assert expect(invalidateCachedReplayId).toHaveBeenCalledTimes(2); From 49b65f6c96165da9cb0e2ff9b8143546bd0b2180 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 15 Sep 2026 16:56:48 +0200 Subject: [PATCH 07/12] fix(replay): check current app state, not a latched flag, after a restart backgroundedDuringRestart was set once (on a background event landing mid-restart) and never revisited. If the app went active again before the restart settled, its resolution still stopped the just-started session based on that stale flag - and nothing rescheduled a restart, since we were already active. Replaced it with isBackgrounded, updated on every transition and checked (not latched) when the restart resolves, so the decision reflects where we ended up, not where we were partway through. --- .../src/js/replay/foregroundReplayGuard.ts | 17 +++++++------ .../test/replay/foregroundReplayGuard.test.ts | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/replay/foregroundReplayGuard.ts b/packages/core/src/js/replay/foregroundReplayGuard.ts index 4819932be6..e00b549372 100644 --- a/packages/core/src/js/replay/foregroundReplayGuard.ts +++ b/packages/core/src/js/replay/foregroundReplayGuard.ts @@ -61,7 +61,11 @@ export function createForegroundReplayGuardState( // flight, so a background event that lands mid-restart can mark itself // instead of missing the new (unprotected) session entirely. let restartInFlight = false; - let backgroundedDuringRestart = 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 @@ -89,9 +93,8 @@ export function createForegroundReplayGuardState( return; } if (restartInFlight) { - // A restart is already underway; stop the just-started session once it - // settles instead of leaving it unprotected. - backgroundedDuringRestart = true; + // A restart is already underway; its own resolution checks + // `isBackgrounded` and stops the just-started session if still needed. return; } if (stoppedByGuard) { @@ -123,14 +126,12 @@ export function createForegroundReplayGuardState( () => { restartInFlight = false; stoppedByGuard = false; - if (backgroundedDuringRestart && !detached) { - backgroundedDuringRestart = false; + if (isBackgrounded && !detached) { stopIfNeeded(); } }, (error: unknown) => { restartInFlight = false; - backgroundedDuringRestart = false; debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); }, ); @@ -138,6 +139,7 @@ export function createForegroundReplayGuardState( function handleAppStateChange(state: AppStateStatus): void { if (state === 'background') { + isBackgrounded = true; clearInactiveStopTimeout(); clearResumeTimeout(); stopIfNeeded(); @@ -155,6 +157,7 @@ export function createForegroundReplayGuardState( } if (state === 'active') { + isBackgrounded = false; clearInactiveStopTimeout(); if (stoppedByGuard && resumeTimeout === null && !restartInFlight) { resumeTimeout = setTimeout(() => { diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts index e264123e44..082f17792d 100644 --- a/packages/core/test/replay/foregroundReplayGuard.test.ts +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -143,6 +143,31 @@ describe('createForegroundReplayGuardState', () => { expect(deps.stopReplay).toHaveBeenCalledTimes(2); }); + it('does not stop the newly-started replay if foregrounded again before the in-flight restart resolves', async () => { + // Arrange - regression: a background event landing while the restart was + // still waiting on the prior stop (before startReplayBuffering() was even + // called) used to latch a stale "stop it again" flag, even if the app was + // back in the foreground by the time the restart actually finished. + const deps = createDeps('active-replay-id'); + const stopDeferred = createDeferred(); + deps.stopReplay.mockReturnValue(stopDeferred.promise); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); // stopReplay() in flight + handleAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); // resume timer fires; restart() now waiting on the stop + + // Act: background then active again before the still-pending stop settles. + handleAppStateChange('background'); + handleAppStateChange('active'); + stopDeferred.resolve(); + await stopDeferred.promise; + await Promise.resolve(); + await Promise.resolve(); + + // Assert: we ended up foregrounded, so the just-started replay stays running. + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + }); + it('does not restart after a failed stop, and logs the failure', async () => { // Arrange - regression: a rejected stopReplay() must not leave the guard // thinking it stopped, or it schedules a restart against a session that From 6cdaed40c457a40367e3d5a563da51c39c3b9663 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 16 Sep 2026 14:35:05 +0200 Subject: [PATCH 08/12] docs(replay): clarify why a failed restart doesn't reset stoppedByGuard Leaving it true is intentional: the next 'active' event retries the restart. Resetting it would suppress that implicit retry. --- packages/core/src/js/replay/foregroundReplayGuard.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/js/replay/foregroundReplayGuard.ts b/packages/core/src/js/replay/foregroundReplayGuard.ts index e00b549372..1b9d80a8c5 100644 --- a/packages/core/src/js/replay/foregroundReplayGuard.ts +++ b/packages/core/src/js/replay/foregroundReplayGuard.ts @@ -132,6 +132,8 @@ export function createForegroundReplayGuardState( }, (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); }, ); From fd68917dc627ff121da7415ff259ac6aa8d94360 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 17 Sep 2026 10:16:57 +0200 Subject: [PATCH 09/12] style(replay): drop braces on a single-statement guard to fit max-lines Merging with the per-class masking PR (#6725) pushed mobilereplay.ts back over the 300-line lint budget. --- packages/core/src/js/replay/mobilereplay.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 282885a9b3..8eee5df0dc 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -527,9 +527,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Initialize the cached replay ID on setup cachedReplayId = NATIVE.getCurrentReplayId(); - if (options.avoidForegroundResumeHang) { + if (options.avoidForegroundResumeHang) setupForegroundReplayGuard(client, options.avoidForegroundResumeHangDelayMs, NATIVE, invalidateCachedReplayId); - } client.on('createDsc', (dsc: DynamicSamplingContext) => { if (dsc.replay_id) { From 79681d2ad090572fe777c595a42f1d5e43aaea0f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 17 Sep 2026 10:28:26 +0200 Subject: [PATCH 10/12] style(replay): restore braces on the guard, compact mergeOptions instead yarn fix doesn't enforce max-lines (only lint:oxlint's --deny-warnings does), so the previous brace removal was masking the real budget issue rather than fixing it. Compacting the pre-existing merged-object literal in mergeOptions frees enough room to keep braces on the guard. --- packages/core/src/js/replay/mobilereplay.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 8eee5df0dc..cde06fafbf 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -311,10 +311,7 @@ const defaultOptions: MobileReplayOptions = { }; function mergeOptions(initOptions: Partial): MobileReplayOptions { - const merged = { - ...defaultOptions, - ...initOptions, - }; + const merged = { ...defaultOptions, ...initOptions }; if (initOptions.enableViewRendererV2 === undefined && initOptions.enableExperimentalViewRenderer !== undefined) { merged.enableViewRendererV2 = initOptions.enableExperimentalViewRenderer; @@ -527,8 +524,9 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Initialize the cached replay ID on setup cachedReplayId = NATIVE.getCurrentReplayId(); - if (options.avoidForegroundResumeHang) + if (options.avoidForegroundResumeHang) { setupForegroundReplayGuard(client, options.avoidForegroundResumeHangDelayMs, NATIVE, invalidateCachedReplayId); + } client.on('createDsc', (dsc: DynamicSamplingContext) => { if (dsc.replay_id) { From 5a6973813a9444d93443632f3cb76f55c8d74fd6 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 17 Sep 2026 11:16:06 +0200 Subject: [PATCH 11/12] docs(replay): mark avoidForegroundResumeHang as experimental A stopgap for a sentry-cocoa issue, not a permanent API commitment. Per antonis's review - avoids locking in new public surface while the proper fix is pursued upstream. --- CHANGELOG.md | 2 +- packages/core/src/js/replay/mobilereplay.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a78fc0cb1..ee9f326410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ ``` - Add per-class Session Replay masking via `maskedViewClasses` / `unmaskedViewClasses` on `mobileReplayIntegration` ([#6725](https://github.com/getsentry/sentry-react-native/pull/6725)) -- Add `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)) +- 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 diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index cde06fafbf..47348754dd 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -282,6 +282,8 @@ export interface MobileReplayOptions { * * @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; @@ -291,6 +293,8 @@ export interface MobileReplayOptions { * * @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; } From 18012de51f5e7c566da310393144b63d1825ccb8 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 17 Sep 2026 11:23:50 +0200 Subject: [PATCH 12/12] fix(replay): don't start a new replay session after detach restart() guarded the post-success stopIfNeeded() call against a detach that happened mid-restart, but not the startReplayBuffering() call itself: closing the client while still waiting on pendingStop left the scheduled restart free to start a new native session anyway. --- .../src/js/replay/foregroundReplayGuard.ts | 6 ++++- .../test/replay/foregroundReplayGuard.test.ts | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/replay/foregroundReplayGuard.ts b/packages/core/src/js/replay/foregroundReplayGuard.ts index 1b9d80a8c5..9d0f8569ec 100644 --- a/packages/core/src/js/replay/foregroundReplayGuard.ts +++ b/packages/core/src/js/replay/foregroundReplayGuard.ts @@ -121,7 +121,11 @@ export function createForegroundReplayGuardState( // must never overlap with a still-running stopReplay() call. `pendingStop` // always resolves (its own rejection handler never rethrows). Promise.resolve(pendingStop) - .then(() => deps.startReplayBuffering()) + .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; diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts index 082f17792d..2cee83594a 100644 --- a/packages/core/test/replay/foregroundReplayGuard.test.ts +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -226,6 +226,29 @@ describe('createForegroundReplayGuardState', () => { // Assert: no follow-up stop after detach. expect(deps.stopReplay).toHaveBeenCalledTimes(1); }); + + it('does not start a new replay session if detached while still waiting for the prior stop to settle', async () => { + // Arrange - regression: detach() during the pendingStop wait (before + // startReplayBuffering() was even called) didn't stop the restart from + // starting a new native session once the stop eventually resolved. + const deps = createDeps('active-replay-id'); + const stopDeferred = createDeferred(); + deps.stopReplay.mockReturnValue(stopDeferred.promise); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); // stopReplay() in flight + handleAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); // restart begins waiting on the still-pending stop + + // Act: close before the stop settles. + detach(); + stopDeferred.resolve(); + await stopDeferred.promise; + await Promise.resolve(); + await Promise.resolve(); + + // Assert: no session gets started after detach. + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + }); }); describe('attachForegroundReplayGuard', () => {