Skip to content

ref(android): Confine replay lifecycle to main thread - #5965

Open
romtsn wants to merge 2 commits into
mainfrom
romtsn/feat/java-665-replay-start-stop
Open

ref(android): Confine replay lifecycle to main thread#5965
romtsn wants to merge 2 commits into
mainfrom
romtsn/feat/java-665-replay-start-stop

Conversation

@romtsn

@romtsn romtsn commented Aug 13, 2026

Copy link
Copy Markdown
Member

📜 Description

Confine Session Replay lifecycle mutations to Android's main thread and keep replay cache cleanup ordered on the replay executor. Background shutdown waits only for main-thread teardown to queue cleanup before stopping the executors.

This also removes the lifecycle and encoder locks that are no longer needed, together with the unused tryAcquire() helper.

💡 Motivation and Context

Prepare Session Replay for public start and stop APIs without allowing lifecycle calls from arbitrary threads to race or block Android lifecycle callbacks.

JAVA-656 remains related follow-up work because automatic replay startup is still synchronous when SDK initialization already runs on the main thread.

Refs JAVA-665
Refs JAVA-656

💚 How did you test it?

  • Ran ./gradlew spotlessApply apiDump.
  • Ran all Session Replay unit tests: 241 passed and 1 skipped.
  • Ran the focused replay lifecycle, shutdown, executor-ordering, and lock tests.

📝 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.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

🔮 Next steps

  • Add the public Session Replay start and stop APIs in JAVA-325.
  • Document manual replay lifecycle control in JAVA-691.

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>
@linear-code

linear-code Bot commented Aug 13, 2026

Copy link
Copy Markdown

JAVA-665

JAVA-656

@sentry

sentry Bot commented Aug 13, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

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

⚙️ sentry-android Build Distribution Settings

@github-actions

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 315.54 ms 354.54 ms 39.00 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
9e60aca 316.18 ms 345.04 ms 28.86 ms
6b019b7 403.90 ms 546.09 ms 142.19 ms
d15471f 310.66 ms 368.19 ms 57.53 ms
d217708 375.27 ms 415.68 ms 40.41 ms
22f4345 314.79 ms 375.02 ms 60.23 ms
fcec2f2 328.91 ms 387.75 ms 58.84 ms
d501a7e 307.33 ms 341.94 ms 34.61 ms
7414e9b 315.69 ms 367.66 ms 51.97 ms
fcec2f2 314.96 ms 373.66 ms 58.70 ms
e2dce0b 308.96 ms 360.10 ms 51.14 ms

App size

Revision Plain With Sentry Diff
9e60aca 0 B 0 B 0 B
6b019b7 0 B 0 B 0 B
d15471f 1.58 MiB 2.13 MiB 559.54 KiB
d217708 1.58 MiB 2.10 MiB 532.97 KiB
22f4345 1.58 MiB 2.29 MiB 719.83 KiB
fcec2f2 1.58 MiB 2.12 MiB 551.50 KiB
d501a7e 0 B 0 B 0 B
7414e9b 0 B 0 B 0 B
fcec2f2 1.58 MiB 2.12 MiB 551.50 KiB
e2dce0b 0 B 0 B 0 B

@romtsn
romtsn marked this pull request as ready for review August 13, 2026 13:12
Comment on lines +247 to 252
lifecycle.currentState = RESUMED
captureStrategy?.resume()
recorder?.resume()
}

override fun captureReplay(isTerminating: Boolean?) {

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: A race condition exists where captureReplay() can be called from a background thread while stopInternal() nullifies captureStrategy on the main thread, leading to inconsistent state.
Severity: HIGH

Suggested Fix

Re-introduce a lock or use another thread-safe mechanism to guard the read, modification, and write operations on the captureStrategy object across captureReplay() and stopInternal(). Simply using @Volatile is not sufficient for these compound operations.

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:
sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt#L247-L252

Potential issue: The `captureReplay()` method can be invoked from any thread, but it is
not thread-safe. A race condition occurs if one thread calls `captureReplay()` while
another thread (e.g., the main thread during shutdown) calls `stopInternal()`, which
sets the `captureStrategy` field to `null`. The lambda passed to
`captureStrategy?.captureReplay()` can execute after `captureStrategy` has been
nullified, causing operations within the lambda to access a stale or null reference.
This leads to inconsistent replay state and potential data corruption. The `@Volatile`
annotation on `captureStrategy` is insufficient to protect this sequence of non-atomic
operations.

Did we get this right? 👍 / 👎 to inform future reviews.

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

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 43758cc. Configure here.

// the main looper handler. Prevents deadlocks when lifecycle-lock-acquiring code (e.g.
// checkCanRecord -> pauseInternal) is called from the replay executor thread.
// Runs [block] on the main thread. If already there, executes inline; otherwise posts via the
// main looper handler so lifecycle mutations are serialized without locking.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lifecycle calls race across threads

Medium Severity

postOnMainThread runs inline when already on the main thread, so those calls jump ahead of lifecycle work previously posted from a background thread. A main-thread stop or resume can therefore run while state is still INITIAL/PAUSED and become a no-op, after which the queued start or stop still executes. LifecycleWatcher already mixes main-thread start/resume with timer-thread stop, so a session can end up STOPPED in the foreground or keep recording after stop.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 43758cc. Configure here.

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

left two thoughts!

Comment on lines +168 to 171
} catch (t: Throwable) {
release()
throw t
}

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.

do you know why we catch and rethrow the throwable here instead of a finally ?

Suggested change
} catch (t: Throwable) {
release()
throw t
}
} finally {
release()
}

recorder = null
rootViewsSpy.close()
lifecycle.currentState = CLOSED
val isMainThread = Looper.myLooper() == Looper.getMainLooper()

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.

how come we don't use options.threadChecker.isMainThread here ?

)
return
}
postOnMainThread { startInternal() }

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.

postOnMainThread is a bit of a misnomer since it doesn't post if we're already on the main thread.
Two issues:

  1. when called during startup, it doesn't post it for later meaning it doesn't improve the startup time.
  2. calls can get out of order. not sure if this was an explicit goal, if we always post to the main thread, then the events are queue in order, if we sometimes post and sometimes execute inline then we dno't have any ordering guarantee if these are called from different threads.

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