ref(android): Confine replay lifecycle to main thread - #5965
Conversation
Serialize replay lifecycle mutations on Android's main thread and keep replay cache cleanup ordered on the replay executor. Remove locks that could block lifecycle callbacks while preserving shutdown ordering. Refs JAVA-665 Co-Authored-By: OpenAI Codex <noreply@openai.com>
📲 Install BuildsAndroid
|
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| fc5ccaf | 270.49 ms | 363.90 ms | 93.41 ms |
| 9d2f4e3 | 311.75 ms | 357.80 ms | 46.05 ms |
| 694d587 | 312.37 ms | 402.77 ms | 90.41 ms |
| bb0ff41 | 317.76 ms | 384.66 ms | 66.90 ms |
| 5b66efd | 308.67 ms | 363.85 ms | 55.18 ms |
| c3ee041 | 310.64 ms | 361.90 ms | 51.26 ms |
| ad8da22 | 314.52 ms | 352.47 ms | 37.95 ms |
| 44d18f5 | 334.50 ms | 393.83 ms | 59.33 ms |
| d15471f | 303.49 ms | 439.08 ms | 135.59 ms |
| d15471f | 342.08 ms | 415.44 ms | 73.35 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| fc5ccaf | 1.58 MiB | 2.13 MiB | 557.54 KiB |
| 9d2f4e3 | 0 B | 0 B | 0 B |
| 694d587 | 1.58 MiB | 2.19 MiB | 620.06 KiB |
| bb0ff41 | 0 B | 0 B | 0 B |
| 5b66efd | 1.58 MiB | 2.13 MiB | 559.07 KiB |
| c3ee041 | 0 B | 0 B | 0 B |
| ad8da22 | 1.58 MiB | 2.29 MiB | 719.83 KiB |
| 44d18f5 | 0 B | 0 B | 0 B |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
Previous results on branch: romtsn/feat/java-665-replay-start-stop
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 5cdb0b8 | 311.75 ms | 369.08 ms | 57.33 ms |
| b208705 | 315.54 ms | 354.54 ms | 39.00 ms |
| f158a9a | 352.96 ms | 413.17 ms | 60.21 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 5cdb0b8 | 0 B | 0 B | 0 B |
| b208705 | 0 B | 0 B | 0 B |
| f158a9a | 0 B | 0 B | 0 B |
| } catch (t: Throwable) { | ||
| release() | ||
| throw t | ||
| } |
There was a problem hiding this comment.
do you know why we catch and rethrow the throwable here instead of a finally ?
| } catch (t: Throwable) { | |
| release() | |
| throw t | |
| } | |
| } finally { | |
| release() | |
| } |
There was a problem hiding this comment.
that's intentional - we don't want to release() on the happy path because we're gonna use the encoder right after and release after encoding a segment
Keep replay lifecycle state in one atomic value and serialize lifecycle transitions through the main looper. Return the replay ID synchronously so triggering events remain correlated while capture is deferred. Refs JAVA-665 Refs JAVA-656 Co-Authored-By: Codex <noreply@openai.com>
Ensure terminating capture reaches the active replay strategy before a main-thread crash blocks the looper while flushing. Co-Authored-By: Codex <noreply@openai.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e170e95. Configure here.
Use the configured thread checker for replay shutdown decisions so tests can control main-thread behavior through the existing abstraction. Co-Authored-By: Codex <noreply@openai.com>
Keep executor cleanup queued behind main-thread replay teardown when a background close reaches its timeout. This prevents delayed teardown from submitting work to an executor that has already been shut down. Co-Authored-By: Codex <noreply@openai.com>
Drain the main looper before asserting replay startup state now that lifecycle commands are always queued. Co-Authored-By: Codex <noreply@openai.com>

📜 Description
Serialize Session Replay lifecycle changes through the Android main looper and keep the active lifecycle, capture strategy, replay ID, and generation in one atomic state value. Every lifecycle command is queued, including calls already made from main, which preserves ordering across callers and defers replay startup beyond the SDK initialization critical path.
This removes the replay lifecycle and encoder locks that could block Android lifecycle callbacks and cause deadlocks or ANRs. Encoder work and cache cleanup remain ordered on the replay executor. Close performs main-thread teardown before executor shutdown; if a background close times out, the already-queued teardown still shuts the executors down after cleanup has been scheduled.
Event correlation remains synchronous where required. The internal
ReplayController.captureReplay()contract now returns the active replay ID and sets it on the scope before deferred conversion. Terminating capture reaches the active strategy immediately so a main-thread crash cannot block it behind the looper. Deferred capture callbacks validate the replay generation, ID, and strategy before changing state, preventing an old replay from mutating a restarted one.This is a breaking internal API change for hybrid SDKs that implement or bridge
ReplayController. Returning the ID binds the triggering event or feedback to the exact sampled replay snapshot; falling back to the scope preserves an existing association when no new capture is accepted.💡 Motivation and Context
Prepare Session Replay for public start and stop APIs without allowing calls from arbitrary threads to race, deadlock, or block Android lifecycle callbacks.
Always queuing startup also addresses the Session Replay initialization delay tracked in JAVA-656.
Refs JAVA-665
Refs JAVA-656
💚 How did you test it?
./gradlew spotlessApply apiDump.SentryAndroidTestsuite.📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
ReplayController.captureReplay()return type.