Skip to content

fix(android): Don't block callers on network status registration - #6537

Merged
0xadam-brown merged 10 commits into
mainfrom
fix/dont-block-main-on-connectivity-registration
Nov 10, 2026
Merged

0xadam-brown merged 10 commits into
mainfrom
fix/dont-block-main-on-connectivity-registration

Conversation

@0xadam-brown

@0xadam-brown 0xadam-brown commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

This PR's diff looks more involved than it actually is. Most of the changes are new docs and tests. Prod changes are limited to introducing a new ConnectionSnapshot that combines a number of previously distinct fields + minor revisions in bookkeeping needed to update that snapshot vs the previous fields.

📜 Description

AndroidConnectionStatusProvider no longer makes callers wait while it registers its network and telephony callbacks with system services. That's good because one of those callers is Sentry.init.

The problem

Before this change, AndroidConnectionStatusProvider used one lock to guard three code paths: callback registration, connection cache refreshes, and adding or removing observers. The SDK executor held that lock across ConnectivityManager.registerDefaultNetworkCallback and, on API 31+, TelephonyManager.registerTelephonyCallback. Both are calls into system services that can occasionally block for seconds.

The main thread waited on the same lock in two places:

  1. Session Replay adding its observer during Sentry.init.
  2. Every captured event reading the connection type or status.

If the system service registrations took a while, the main thread would stall. Scenario (1) in particular could lead to ANRs during app startup. Oof.

A road not taken

Any fix has to isolate the slow part (registration) so that the main thread can return quickly when reading cached network info or adding observers.

The narrowest approach would be to give registration its own lock and keep a second lock for the cache and observers. But a refresh would then either hold the cache lock while querying Android, making readers wait on that query, or need a version counter that every write must remember to update. Even worse, two locks would need to document order they're taken in to avoid deadlock, which would be easy to get wrong.

That's^^ the approach my clanker initially implemented (see commit 1). But there's a better way...

A better approach (and the one this PR implements)

The approach I ultimately landed on was to avoid having the main thread hold a lock entirely, and instead rely on atomically publishing cached network info (see commit 2). I did so by introducing a new ConnectionSnapshot class, which is immutable and can be safely shared across threads. I stored observers in copy-on-write lists rather than having them be explicitly locked, and I confined registration to the the SDK executor (it's still defensively guarded by its own lock).

Callers that need to access cached network info or register observers can now return quickly. Either of approach would have allowed as much, of course, but the benefit of introducing the snapshot it that it also:

  • lets us consolidate a lot of related state that the provider previously had to handle separately
  • avoids the pain of managing (yet another) lock
  • lays the groundwork for future PRs that confine refreshes to the SDK executor and let snapshots tell a lost network from capabilities that are simply unknown.

Bonus points

It took me a while to figure out exactly what policies AndroidConnectionStatusProvider implemented, and so I've updated the docs to make that clearer. I've also renamed a few things that threw me for a loop (eg, isConnected -> onAvailableForwarded, as it's actually a flag to make sure the provider only forwards onAvailable calls to clients at most once, and shouldn't be used to determine whether the network is currently connected).

Reviewing by commit

In case it's helpful, I got my clanker to break up this work into coherent commits. Personally, I'd limit myself to reviewing the final diff, but up to you.

Commit Change
1. fix: Don't block callers on connection status registration The core fix and its concurrency tests. The cache still uses a lock plus a version counter here.
2. ref: Hold the connection cache in an immutable snapshot Replaces that lock and counter. It also fixes two races: onAvailable wrote the network without the lock, and the status lookup read the capabilities twice.
3. fix: Don't register connection callbacks again after close Fixes a leak introduced by commit 1's asynchronous registration.
4. ref: Rename isConnected to onAvailableForwarded Rename only.
5. `fix: Build the connection cache debug log only when debug logging is on
6. docs: Document the threading and exception policies Javadoc and field order. No behavior change.
7. test: Improve the concurrency tests

Risks and costs

Performance and memory: Negligible.

  • One small snapshot object per network event or cache refresh.
  • Copy-on-write lists, which only change at init and close; notifying doesn't allocate.
  • One extra executor task per app foreground or background switch.

Crash risk: No new risks, and one existing risk removed.

The new copy-on-write lists eliminate a latent ConcurrentModificationException, which could happen under the previous approach when an observer removed itself on the notifying thread.

Leaks: No new risks.

The closed flag covers the one potential leak asynchronous registration would otherwise add.

Two leaks predate this PR and are unchanged:

  • If the executor drops the close task, the system callbacks stay registered.
  • An observer added after close() is never cleared.

Behavior changes: None visible to users under normal operation.

  • In a rare race after the app returns to the foreground, a network breadcrumb could be duplicated or missed. No crash or data loss.
  • Closing the SDK while registration is still pending now skips the registration entirely. Nothing is left registered either way.

💡 Motivation and Context

App-start ANRs reported in #6509, a regression from #6146 (8.60.0 through 8.63.0).

💚 How did you test it?

  • Concurrency tests
  • Unit tests
  • Had my clanker manually check via the sample app that the approach here fixes things.

📝 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

  • Hybrid SDKs: once released, ask them to upgrade, and add a known-issue note to 8.60.0 through 8.63.0.

Possible follow-ups for AndroidConnectionStatusProvider, in separate PRs:
(Existing issues I noticed while implementing the current PR . Out of scope for now.)

  • Stale-cache reads still query the ConnectivityManager on the caller's thread.
  • A snapshot without capabilities falls back to that query on every read.
  • We don't extend the cache lifetime for network capability changes that don't affect the status.
  • Concurrent stale reads can query for network info more than once.
  • Registration state could also become an explicit state model.
  • Fix the potential (and preexisting) leaks mentioned under "Risks and Costs".

AndroidConnectionStatusProvider held its lock on the SDK executor while
registering network and telephony callbacks, which are slow system service
calls. The main thread waited on the same lock when Session Replay added an
observer during Sentry.init, and when events were captured with a stale
connection cache, causing app-start ANRs.

Registration now uses its own executor-only lock. The cache lock is held
only for field writes, and cache refreshes query the active network outside
it, discarding their result if a network callback wrote the cache meanwhile.
Observers and child network callbacks are copy-on-write lists, notified
without holding a lock. addConnectionStatusObserver schedules registration
instead of running it on the caller, and always returns true.

Lifecycle callbacks now check registration state on the executor, so
backgrounding during the initial registration unregisters the callbacks, and
foregrounding before a queued unregistration runs registers them again.

Fixes #6509
The cache was four fields (capabilities, network, freshness deadline and a
version counter) guarded by a lock, written in four places that each had to
remember to bump the version. onAvailable wrote the network without the lock,
and the status lookup read the capabilities twice, so a concurrent unregister
could make it report DISCONNECTED instead of querying.

Replace them with an immutable ConnectionSnapshot in an AtomicReference.
Writes swap in a new snapshot with compare-and-set, a refresh stores its
result only if the snapshot is unchanged, and readers derive status and type
from one snapshot. The capabilities that decide a significant change are now
per instance instead of a static array rewritten by every constructor.

Refs #6509
Registration now runs on the SDK executor, so a registration queued after close() ran after its cleanup and registered the network and cellular callbacks again, with nothing left to unregister them. close() now sets a flag first that queued registration and foreground work check.

Refs #6509
The flag doesn't track connectivity. It only stops child network
callbacks from getting onAvailable again when the network callback is
registered again on a return to the foreground. Name it for that and
document it.

Refs #6509
…gging is on

Without network capabilities, building the "Cache updated" message
queried the ConnectivityManager up to twice more on the caller's
thread, even with debug logging off.

Refs #6509
…nd exception policies

Describe how clients use the provider, which calls may block, and the
rules that keep the main thread from waiting: never lock the
connection cache, never hold a lock while notifying, and only take the
registration lock on the SDK executor. Also document the fields and
group them, and make the snapshot's fields private. No behavior change.

Refs #6509
Make tests that passed on the pre-fix code fail on it: let close() run
before the follow-up call, and keep removeConnectionStatusObserver from
waiting in its own setup. Check what a reader racing a network callback
gets back, and fail tests whose background notification threads throw
or hang. Add tests that getConnectionType, adding or removing an
observer during a notification, and reading a fresh cache during another
caller's refresh don't wait. Drop a test that pinned the cached
capabilities instance, remove a duplicate static-state reset, and
shorten names.

Refs #6509
@linear-code

linear-code Bot commented Nov 10, 2026

Copy link
Copy Markdown

JAVA-828

@sentry

sentry Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

📲 Install Builds

Android

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

⚙️ sentry-android Build Distribution Settings

@0xadam-brown
0xadam-brown marked this pull request as ready for review November 10, 2026 10:51
@0xadam-brown
0xadam-brown requested review from a team and adinauer as code owners November 10, 2026 10:51
@0xadam-brown
0xadam-brown requested review from markushi and romtsn and removed request for a team November 10, 2026 10:51

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

@0xadam-brown 0xadam-brown added the sanity-check PR needs a lightweight review for obvious issues label Nov 10, 2026
…the network changes during it

onAvailable replaces the connection snapshot with one that only has
the new network, keeping the old capabilities and expired deadline. A
refresh racing it saw the snapshot change, returned that stale snapshot
and discarded its own query, so a read during a network handover could
report the previous network's type or status.

A refresh now gives way only to a fresh snapshot. If the newer snapshot
is stale, the refresh stores its result on it, keeping its network.

Refs #6509
@0xadam-brown 0xadam-brown changed the title fix(android): Don't block callers on connection status registration fix(android): Don't block callers on network status registration Nov 10, 2026

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

Thanks for coming up with a fix and iterating on it to come up with the best solution!

Print the network, a summary of the capabilities the provider reads
(transports, internet, validated) and whether the snapshot is still
fresh. The full NetworkCapabilities isn't printed, because on API 29+
it can include the Wi-Fi SSID.

Refs #6509
@0xadam-brown
0xadam-brown merged commit 349566b into main Nov 10, 2026
46 checks passed
@0xadam-brown
0xadam-brown deleted the fix/dont-block-main-on-connectivity-registration branch November 10, 2026 14:20
runningcode added a commit that referenced this pull request Nov 10, 2026
Error Prone's NotJavadoc flags Javadoc on methods of local classes, which
#6537 added in AndroidConnectionStatusProvider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
runningcode added a commit that referenced this pull request Nov 11, 2026
* ref(android): Fix Error Prone warnings in sentry-android-core

Error Prone never ran on sentry-android-core. Enabling it reports 59 warnings outside Javadoc,
which fail the build under -Werror.

- Suppress FutureReturnValueIgnored, JavaUtilDate, and ReferenceEquality where identity is
  intended, as the sentry module does.
- Keep the AnrProfilingIntegration updater a field, since removeCallbacks needs the instance that
  was posted, and suppress UnnecessaryLambda there.
- Read the ANR thread dump and the app start profiling config as UTF-8, Android's default charset.
- Remove unused fields and parameters, add missing @OverRide, make long-to-double conversions
  explicit, and narrow members that are effectively private or protected in a final class.
- Explain the two catch blocks that ignore their exception.

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

* ref(android): Fix the Error Prone warnings only main has

- ANRWatchDog.monotonicTicker is never read (#6041), since the ticker lambda captures the
  constructor parameter. Remove the field and keep the parameter.
- ActivityFramesTracker.lock is protected in a final class; make it package-private.
- LifecycleWatcher reads the session start Date to seed the session window; suppress JavaUtilDate
  there, as the other Date uses are.

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

* ref(android): Keep the default charset reads as they were

Android's default charset is always UTF-8, so DefaultCharset is
disabled for the Android modules in #6493 instead of being fixed here.

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

* ref(android): Drop the UnnecessaryLambda suppressions

#6493 disables UnnecessaryLambda for the Android modules, so the
suppressions on the ANRWatchDog and AnrProfilingIntegration fields
are redundant.

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

* ref(android): Drop the updater field comment

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

* docs(android): Make the local-class method comment a plain comment

Error Prone's NotJavadoc flags Javadoc on methods of local classes, which
#6537 added in AndroidConnectionStatusProvider.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
runningcode added a commit that referenced this pull request Nov 11, 2026
…vider

NullAway treats AtomicReference.get() as nullable, so the snapshot
reads added in #6537 fail once NullAway runs on sentry-android-core.
The snapshot is never set to null, so read it through one accessor
that suppresses the finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
runningcode added a commit that referenced this pull request Nov 11, 2026
…6493)

* build(android): Run Error Prone and NullAway on the Android modules

net.ltgt.errorprone dropped Android support in 3.0.0, which we have used since 6.7. Since then
it only enables Error Prone and adds it to the processor path for Gradle's JVM source sets. On
sentry-android-core and sentry-uitest-android the errorprone dependencies and the NullAway ERROR
setting had no effect, and the Java compile tasks ran plain javac.

Use the AGP variant API to add the errorprone configuration to each variant's annotation
processor configuration and enable Error Prone on its JavaCompile task. Both APIs exist in AGP
9.0.0, the oldest version in the AGP matrix.

Pass -XDandroidCompatible=true so Error Prone treats these modules as Android code: it skips
checks that don't apply there, such as DefaultCharset, and runs its Android checks.

sentry-uitest-android has no hand-written Java, so exclude the view binding classes AGP
generates, which trip LabelledBreakTarget.

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

* fix(android): Resolve NullAway findings in AndroidConnectionStatusProvider

NullAway treats AtomicReference.get() as nullable, so the snapshot
reads added in #6537 fail once NullAway runs on sentry-android-core.
The snapshot is never set to null, so read it through one accessor
that suppresses the finding.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sanity-check PR needs a lightweight review for obvious issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ANR: SentryAndroid.init blocks main thread on AndroidConnectionStatusProvider lock held across registerTelephonyCallback binder call (8.60.0+)

2 participants