Skip to content

fix(android): Break the app start extension lock-ordering deadlock - #6007

Open
runningcode wants to merge 3 commits into
mainfrom
no/app-start-extension-no-alien-calls
Open

fix(android): Break the app start extension lock-ordering deadlock#6007
runningcode wants to merge 3 commits into
mainfrom
no/app-start-extension-no-alien-calls

Conversation

@runningcode

@runningcode runningcode commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

📜 Description

AppStartExtension held its own lock while calling finish() on the extended span (finishExtendedAppStart) and on the standalone app.start transaction (finishTransaction). This reads the fields under that lock and performs the finish outside it, and serializes the two finish paths with a separate finishLock that the re-entrant capture path never acquires.

💡 Motivation and Context

Finishing captures the transaction synchronouslySentryTracer.finish() calls scopes.captureTransaction(...) on the calling thread, which runs PerformanceAndroidEventProcessor.process(). That processor takes its own lock and then calls back into AppStartExtension. So the two locks are acquired in opposite orders:

Side Where, at cc59f486da
extension lock → processor lock AppStartExtension.finishExtendedAppStart():111 and finishTransaction():148 hold the lock across span.finish() / transaction.finish(), which capture re-entrantly
processor lock → extension lock PerformanceAndroidEventProcessor.process() acquires its lock at :82, then calls extension.isExtended() at :112 and getExtendedEndTime() at :113 inside it

Both halves were introduced together in #5604, first released in 8.48.0.

The collision window is the one the feature is built around: the app calls Sentry.finishExtendedAppStart() just as the app start deadline fires on the Sentry timer thread. If the app finishes the extended app start on the main thread, this is an ANR.

Why a second lock

Moving the finish out of lock on its own would let finishTransaction and finishExtendedAppStart interleave, which the bugbot reviews correctly caught. finishTransaction reads the extended span's finish date to clamp the transaction end, so a span finishing between that read and transaction.finish() captures the transaction with an end earlier than its own child — and both paths could enter SentryTracer.finish concurrently, which is not guarded (hasAllChildrenFinished() treats a span with a finish date but isFinished() == false as finished, so two callers can pass the same guard).

finishLock restores that mutual exclusion. It is never acquired by the re-entrant capture path, so it cannot participate in the cycle. Order when both are held: finishLock, then lock.

This restores parity with the original code rather than perfection — a caller that finishes the span directly via getExtendedAppStartSpan() bypasses both locks, exactly as it did before.

📊 Evidence in production

sdk-crashes-java, last 90 days — ANR events with AppStartExtension on the stack:

Release ANR events Customer projects
8.48.0 13 4
8.49.0 751 4
8.50.0 / 8.50.1 4 3
8.51.0 16 6

The series starts at 8.48.0 — the release that shipped #5604.

Blocked waiting for the lockSDK-CRASHES-JAVA-5MBP, main thread, foreground app start, SDK 8.49.0:

ActivityLifecycleIntegration.onFirstFrameDrawn(:812)
  ActivityLifecycleIntegration.finishAppStartSpan(:992)
    AppStartExtension.finishTransaction(AppStartExtension.java:148)
      AutoClosableReentrantLock.acquire(:37)
        ReentrantLock.lock(:323)   <- parked

In the 8.49.0 tag, AppStartExtension.java:148 is the lock.acquire() this PR moves work out of.

Holding the lockSDK-CRASHES-JAVA-5MEQ, 87 occurrences:

AppStartMetrics.lambda$scheduleHeadlessAppStartCheckOnMain$0
  AppStartExtension.finishTransaction
    SentryTracer.finish -> SentryTracer.finish

These events contain only the main thread, so the full cycle is not directly visible. What is confirmed is the mechanism this PR removes: the extension lock held across transaction.finish(), with real users' main threads blocking on it until the OS files an ANR.

💚 How did you test it?

Three regression tests in AppStartExtensionTest:

  • two that stub finish() to re-enter the extension from another thread and require that call to complete while the finish is in flight — both fail on main, where the lock is held across the finish
  • one that requires finishTransaction and finishExtendedAppStart not to interleave — fails against the first commit of this PR, which is the regression the bugbot reviews found

Full :sentry-android-core:testReleaseUnitTest passes.

📝 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

extendAppStart() still calls the listener under the lock. That path does not capture, so it is not part of this cycle.

runningcode and others added 2 commits August 26, 2026 15:38
AppStartExtension held its own lock while calling finish() on the
extended span and on the standalone app.start transaction. Finishing
captures the transaction synchronously, which runs
PerformanceAndroidEventProcessor, which acquires the processor lock and
then calls back into isExtended() and getExtendedEndTime() -- taking the
extension lock in the opposite order.

Two threads is all it takes: the app calls Sentry.finishExtendedAppStart()
(extension lock held, waiting for the processor lock) just as the app
start deadline fires on the Sentry timer thread (processor lock held,
waiting for the extension lock). Both threads hang; if the app finishes
the extended app start on the main thread, that is an ANR.

Read the fields under the lock and finish outside it, so the extension
never holds its lock across a call that reenters the SDK. Span and
transaction finishing are already idempotent, so racing callers are safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sentry

sentry Bot commented Aug 26, 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

@linear-code

linear-code Bot commented Aug 26, 2026

Copy link
Copy Markdown

JAVA-708

@runningcode
runningcode marked this pull request as ready for review August 26, 2026 14:44
@runningcode
runningcode requested a review from buenaflor August 26, 2026 14:44

@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 e328f08. Configure here.

Comment thread sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java Outdated
Comment thread sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java Outdated

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

lgtm 👍 (there are some open bugbot reviews still)

do you think it's worth adding some small concurrency section in AGENTS.md or whatever skill we have in the repo

Moving finish() out of the extension lock let finishTransaction and
finishExtendedAppStart interleave. finishTransaction reads the extended
span's finish date to clamp the transaction end, so a span finishing in
the gap between that read and transaction.finish() captures the
transaction with an end earlier than its own child, and both paths could
enter SentryTracer.finish concurrently.

Serialize the two with a dedicated finishLock that the re-entrant
capture path never acquires, so the clamp and the finish are atomic
again without putting the processor lock back into a cycle with the
extension lock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants