Skip to content

fix(replay): Dispatch session-deadline stop off the replay worker thread#5826

Open
runningcode wants to merge 4 commits into
mainfrom
no/fix-replay-deadline-stop-anr
Open

fix(replay): Dispatch session-deadline stop off the replay worker thread#5826
runningcode wants to merge 4 commits into
mainfrom
no/fix-replay-deadline-stop-anr

Conversation

@runningcode

Copy link
Copy Markdown
Contributor

📜 Description

When a session replay reaches its duration deadline (default 1h), the resulting stop() was invoked inline from the replay worker thread, inside the add_frame task in SessionCaptureStrategy.onScreenshotRecorded. Because ReplayExecutorService.submit() runs tasks synchronously when the caller is already on a SentryReplayIntegration-* thread, stop() encoded the final video segment and recursively deleted the replay cache while holding the replay lifecycle lock (AutoClosableReentrantLock).

A foreground start() (LifecycleWatcher.onForegroundstartSession) runs on the main thread and must acquire that same lock, so it parked long enough to trigger a background ANR.

This change dispatches the deadline stop() through options.executorService (a non-worker thread). stop() then submits the segment encoding asynchronously onto the single-threaded replay executor (preserving ordering), and the lifecycle lock is held only for the fast state transition — matching the existing timer-executor endSession stop path.

💡 Motivation and Context

Fixes the background ANR reported in getsentry/sentry-dart#3556 (Linear: DART-323), whose stack shows the main thread parked on the replay lifecycle lock in ReplayIntegration.start() on foreground. The deadline stop re-entering the lifecycle lock from the worker thread is the only path where the segment encode runs synchronously under the lock; every other start/stop/pause/resume caller already submits the encode asynchronously.

💚 How did you test it?

Added a regression test in SessionCaptureStrategyTest using a deferred executor that asserts the deadline stop() is dispatched off the calling thread (not run inline) and only runs once the executor drains. Updated the existing deadline test's fixture to use an immediate executor so it still exercises the dispatched stop.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the 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.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

None.

runningcode and others added 2 commits July 23, 2026 14:16
…ead (DART-323)

When a session replay reaches its duration deadline, the stop was invoked
inline from the replay worker thread inside the frame-processing task. Because
ReplayExecutorService.submit() runs tasks synchronously when already on that
thread, stop() encoded the final segment and deleted the replay cache while
holding the replay lifecycle lock. A foreground start() on the main thread then
parked on that lock long enough to trigger a background ANR.

Dispatch the deadline stop through options.executorService so the segment
encoding is enqueued asynchronously and the lifecycle lock is only held for the
fast state transition, matching the existing timer-executor stop path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sentry

sentry Bot commented Jul 23, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.50.0 (1) release

⚙️ sentry-android Build Distribution Settings

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 304.19 ms 373.02 ms 68.83 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
22f4345 307.87 ms 354.51 ms 46.64 ms
8c7718c 307.42 ms 374.84 ms 67.42 ms
bb0ff41 321.00 ms 378.28 ms 57.28 ms
c8125f3 397.65 ms 485.14 ms 87.49 ms
eb95ded 317.51 ms 369.08 ms 51.57 ms
f634d01 375.06 ms 420.04 ms 44.98 ms
9fbb112 359.71 ms 421.85 ms 62.14 ms
d501a7e 307.33 ms 341.94 ms 34.61 ms
bb0ff41 317.76 ms 384.66 ms 66.90 ms
9054d65 330.94 ms 403.24 ms 72.30 ms

App size

Revision Plain With Sentry Diff
22f4345 1.58 MiB 2.29 MiB 719.83 KiB
8c7718c 0 B 0 B 0 B
bb0ff41 0 B 0 B 0 B
c8125f3 1.58 MiB 2.10 MiB 532.32 KiB
eb95ded 0 B 0 B 0 B
f634d01 1.58 MiB 2.10 MiB 533.40 KiB
9fbb112 1.58 MiB 2.11 MiB 539.18 KiB
d501a7e 0 B 0 B 0 B
bb0ff41 0 B 0 B 0 B
9054d65 1.58 MiB 2.29 MiB 723.38 KiB

Previous results on branch: no/fix-replay-deadline-stop-anr

Startup times

Revision Plain With Sentry Diff
9542c9e 338.21 ms 382.47 ms 44.25 ms

App size

Revision Plain With Sentry Diff
9542c9e 0 B 0 B 0 B

@romtsn
romtsn marked this pull request as ready for review July 24, 2026 14:49

@romtsn romtsn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

looks good!

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

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 608c130. Configure here.

// start() on the main thread (ANR)
options.executorService.submitSafely(options, "$TAG.stop") {
options.replayController.stop()
}

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 deadline stop races new session

Low Severity

The deadline stop() is queued on options.executorService with no replay-id or generation check, and unlike the LifecycleWatcher end-session path it cannot be cancelled. If the current session is stopped and a new one starts before that task runs, the deferred stop() tears down the new session.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 608c130. Configure here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants