Skip to content

feat(replay): add avoidForegroundResumeHang to mitigate iOS foreground App Hang - #6727

Open
alwx wants to merge 9 commits into
mainfrom
alwx/fix/6701
Open

alwx wants to merge 9 commits into
mainfrom
alwx/fix/6701

Conversation

@alwx

@alwx alwx commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

Adds an opt-in avoidForegroundResumeHang option to mobileReplayIntegration() (iOS only). When enabled, an AppState listener stops Session Replay just before the app backgrounds and restarts it in buffer mode a short delay after returning to the foreground.

💡 Motivation and Context

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).

The already-shipped pause()/resume() runtime controls (#6703) don't help here: cocoa's automatic resume shares the same underlying state and unconditionally re-arms capture regardless of a prior manual pause(). stopReplay() is different - it tears down the native session entirely, so the automatic resume becomes a safe no-op. This option uses that to stop replay before backgrounding and restart it (in buffer mode) safely outside the watchdog window on foreground.

Investigated and confirmed there's no way to have it resume in the exact prior mode (session vs. buffer) - neither sentry-cocoa nor sentry-java currently expose that via the surfaces this SDK can reach, so it always restarts in buffer mode. Documented as a trade-off on the option.

Fixes #6701

💚 How did you test it?

  • Added unit tests for the new foregroundReplayGuard module (background/foreground transitions, pending-restart cancellation, native rejection handling) and for the mobileReplayIntegration wiring.
  • yarn build, yarn test, yarn lint, yarn circularDepCheck, yarn api-report:check all pass (no public API change - the new option isn't exported from the barrel).

📝 Checklist

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

🔮 Next steps

…d 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
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

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


  • feat(replay): add avoidForegroundResumeHang to mitigate iOS foreground App Hang by alwx in #6727
  • feat(replay): Add per-class Session Replay masking by antonis in #6725
  • fix(replay): Mask React Native images on iOS New Architecture by antonis in #6726
  • test(e2e): Verify replay runtime controls drive native recording by antonis in #6704
  • feat(replay): Add Session Replay runtime controls by antonis in #6703
  • fix(android): honor host app's pinned ndkVersion by alwx in #6724
  • chore: Deduplicate yarn.lock by antonis in #6706
  • fix(tracing): background root spans overwrite native propagation context by alwx in #6720
  • ci(e2e): Fix broken idb-companion install on iOS e2e by antonis in #6719
  • chore(deps): Bump smol-toml to ^1.7.1 by antonis in #6713
  • chore(deps): Bump js-yaml to patched versions to resolve security alerts by antonis in #6710
  • chore(deps): Bump morgan to ^1.12.0 to resolve security alert by antonis in #6712
  • chore(deps): Bump joi to ^17.13.6 by antonis in #6714
  • chore(deps): Bump sharp to ^0.35.4 to resolve security alert by antonis in #6711
  • chore(deps): bump actions/setup-java from 6.0.0 to 6.0.1 by dependabot in #6716
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.30.1 to 2.31.0 by dependabot in #6718
  • chore(deps): bump getsentry/craft from 2.30.1 to 2.31.0 by dependabot in #6717
  • chore(deps): bump the codeql-action group with 3 updates by dependabot in #6715
  • fix(core): Fix process.exit typo in debugid script by friederbluemle in #6707
  • chore(ci): Only build production builds for visionOS and tvOS by antonis in #6700
  • chore(deps): update Sentry Android Gradle Plugin to v6.22.0 by github-actions in #6699

🤖 This preview updates automatically when you update the PR.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor
Fails
🚫 Pull request is not ready for merge, please add the "ready-to-merge" label to the pull request
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against a4c199e

@alwx

alwx commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts Outdated
Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts
Comment thread packages/core/src/js/replay/mobilereplay.ts
Comment thread packages/core/test/replay/foregroundReplayGuard.test.ts Outdated
- 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.
Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts
Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts
- 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.
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.
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.
@alwx
alwx marked this pull request as ready for review September 15, 2026 14:27
Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts Outdated
Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts
- 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.
Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e8e1309. Configure here.

Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts
…tart

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.
Comment thread packages/core/src/js/replay/foregroundReplayGuard.ts
@antonis antonis added the ready-to-merge Triggers the full CI test suite label Sep 16, 2026

@antonis antonis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall LGMT after the full test suite is 🟢 (blocked on merging and resolving the changelog conflict).
Worth checking the last Sentry comment and the not raised Warden findings (if not fixed already).

Leaving it true is intentional: the next 'active' event retries the
restart. Resetting it would suppress that implicit retry.
Comment on lines +153 to +157
inactiveStopTimeout = setTimeout(() => {
inactiveStopTimeout = null;
stopIfNeeded();
}, IOS_INACTIVE_STOP_DELAY_MS);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: On iOS, after a replay is restarted, a subsequent 'inactive' state lasting 5+ seconds (e.g., a phone call) will incorrectly stop the replay because stopIfNeeded() is called unconditionally.
Severity: MEDIUM

Suggested Fix

In stopIfNeeded(), add a guard to prevent stopping the replay if the app is not actually backgrounding. This could be achieved by checking if isBackgrounded is false. Alternatively, prevent the 'inactive' timer from being scheduled if a replay has been successfully restarted and is not in a backgrounding transition.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/core/src/js/replay/foregroundReplayGuard.ts#L153-L157

Potential issue: After a replay session is successfully restarted (e.g., after the app
returns to the foreground), the logic for the `'inactive'` state unconditionally
schedules `stopIfNeeded()` to run after 5 seconds. This is intended as a fallback for
when the app is backgrounding. However, `stopIfNeeded()` does not check if the app is
actually in the process of backgrounding (e.g., by checking an `isBackgrounded` flag).
Consequently, if the app enters an 'inactive' state for 5+ seconds (like during a phone
call) while a replay is active, the timer will fire and incorrectly stop the running
replay session, leading to data loss.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not valid as suggested: checking isBackgrounded before the inactive-fallback stop would defeat the fallback's own purpose - it exists precisely for when 'background' never arrives (isBackgrounded never becomes true), e.g. JS suspended before delivery. Same 5s threshold/trade-off is already accepted for the identical scenario in onSpanEndUtils.cancelInBackground (IOS_INACTIVE_CANCEL_DELAY_MS). Leaving as-is.

@alwx
alwx requested a review from antonis September 16, 2026 12:59
* @default false
* @platform ios
*/
avoidForegroundResumeHang?: boolean;

@antonis antonis Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Q: Since this is an API change though not marked as such let's also loop in other mobile folks. Also, is there a way to fix that on the Cocoa side? Should we mark the additions as experimental to have the flexibility of removing them?

Comment thread CHANGELOG.md
replay?.flush(); // flush the buffered replay to Sentry
```

- 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Q: Since this is mitigating a feature bug I wonder if it should be moved in the fixes section 🤔

@antonis antonis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this approach LGTM as a temporary mitigation. Let's merge the latest from main and resolve the conflict to run the full ci suite.

Added a couple of questions. I think that we should raise this with the iOS folks before shipping this. If we decide to ship I would advocate on using an experimental flag and opening a Cocoa issue to track a follow up fix on the iOS side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Triggers the full CI test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session Replay resumes capture on didBecomeActive with no delay, causing fatal iOS App Hangs (watchdog kill)

2 participants