Conversation
…ain thread on Android handleOnResume and handleOnPause ran registerDefaultNetworkCallback, unregisterNetworkCallback and getNetworkStatus (getActiveNetwork and getNetworkCapabilities) on the main thread. These are synchronous binder calls into system_server and can block long enough to trigger an ANR. Run that work on the bridge plugin thread via Bridge.execute, the same serial thread plugin methods run on, so pause/resume stay ordered with each other and with getStatus. Also reuse the active network in getNetworkStatus instead of querying it twice. Closes ionic-team#2563
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
NetworkPlugin.handleOnResume()andhandleOnPause()run on the main thread (called fromBridgeActivity.onResume/onPauseviaBridge.onResume/onPause) and make synchronous binder calls intosystem_server:handleOnResume:registerDefaultNetworkCallback(viaNetwork.startMonitoring), thengetNetworkStatus(), which callsgetActiveNetwork()twice andgetNetworkCapabilities().handleOnPause:getNetworkStatus()(same three calls), thenunregisterNetworkCallback(viaNetwork.stopMonitoring).That is 4 blocking IPCs on the main thread per resume and 4 per pause.
When
ConnectivityServiceis slow to answer (busysystem_server, network transitions, low-end devices), the main thread waits and the app ANRs, which is exactly the stack trace in #2563 (IConnectivityManager$Stub$Proxy.getActiveNetwork<-Network.getNetworkStatus<-NetworkPlugin.handleOnResume<-Bridge.onResume).This PR:
handleOnResumeandhandleOnPausethroughgetBridge().execute(...), i.e. on the bridge'sCapacitorPluginsHandlerThread, the same serial thread that plugin methods such asgetStatusalready run on.Because that thread is a single looper, pause and resume work stays strictly ordered (and ordered with
getStatuscalls),prePauseNetworkStatusis only touched from one thread, and the callback is never registered twice or unregistered before it was registered.Pending work posted from
onPausestill runs on destroy, becauseBridge.onDestroyuseshandlerThread.quitSafely().activeNetworkinNetwork.getNetworkStatus()instead of callinggetActiveNetwork()a second time.This removes one binder call per status query and makes the network/capabilities pair consistent (previously the two calls could return different networks).
No public API change.
networkStatusChangeevents,getStatus()results and the pause/resume behavior (callback unregistered while in the background, "pre-pause vs after-pause mismatch" notification on resume) are unchanged.Change Type
Rationale / Problems Fixed
Closes #2563
ConnectivityManager.getActiveNetwork(),getNetworkCapabilities(),registerDefaultNetworkCallback()andunregisterNetworkCallback()are all synchronousIConnectivityManagerbinder transactions, so their latency is bounded only bysystem_server.Android's ANR guidance calls out binder calls on the main thread as a common ANR cause and recommends moving them to a worker thread.
The bridge plugin thread is the natural place for this in Capacitor (plugin calls and Cordova calls already run there through
Bridge.execute), so no new thread or executor is introduced.I considered registering the callback once in
load()and never unregistering it on pause, but that changes behavior (events would be delivered while the app is in the background) and still leaves thegetNetworkStatus()calls inhandleOnResume, so I kept the existing lifecycle and only moved the work off the main thread.Tests or Reproductions
The plugin's Android project has no meaningful test setup (only the template
ExampleUnitTest/ExampleInstrumentedTest), and StrictMode does not flag binder calls (detectAll()covers disk, network sockets, custom slow calls etc., not IPC), so I verified with a system trace on an emulator instead.Setup: a minimal Capacitor 8.5.2 app (
BridgeActivity+ this plugin as a local Gradle module), Android 16 / API 36 emulator (userdebug).The app registers a test-only subclass of
NetworkPluginthat wrapssuper.handleOnResume()/super.handleOnPause()inTrace.beginSection(...), and a page that subscribes tonetworkStatusChangeand callsgetStatus()on load and on everyvisibilitychange.I captured
atrace -a <pkg> binder_driverwhile sending the app to the background and back three times, then countedbinder_transactionevents issued by the app's main thread inside those sections.All counted transactions target the same
system_serverbinder node (ConnectivityService).Before (main): every lifecycle callback makes 4 binder calls on the main thread (consistent over 3 runs).
After (this PR): zero binder calls on the main thread; the same calls now come from the
CapacitorPluginsthread (consistent over 3 runs).Main-thread time inside the lifecycle callbacks went from roughly 0.4 to 2.6 ms on an idle emulator (unbounded on a busy device) to roughly 10 to 400 us (just posting a Runnable).
Behavior is unchanged (identical JS event sequence before and after, same harness):
adb shell cmd connectivity airplane-mode enable:networkStatusChange {"connected":false,"connectionType":"none"}.networkStatusChangeevents for cellular then wifi, ending in{"connected":true,"connectionType":"wifi"}.Detected pre-pause and after-pause network status mismatch...is logged,networkStatusChange {"connected":false,"connectionType":"none"}fires, andgetStatus()after resume returns{"connected":false,"connectionType":"none"}.networkStatusChange {"connected":true,"connectionType":"wifi"}fires andgetStatus()returns the same.NetworkCallback was already registerederrors, anddumpsys connectivityshows everyREGISTERfrom the app matched by aRELEASE.Repo checks:
npm run lint(prettier-plugin-java formatting check passes; the 2 SwiftLint warnings are pre-existing inReachability.swift),npm run verify:android(./gradlew clean build test, afternpm run set-settings-gradle-for-monorepoas in CI) andnpm run verify:weball pass.Not tested: a real ANR on a physical device (it depends on
system_serverlatency and is not reproducible on demand; the trace above shows the blocking calls are gone from the main thread instead), Android versions other than API 36, and iOS/Web (untouched).Screenshots / Media
N/A
Platforms Affected
Notes / Comments
If the bridge plugin thread is busy with a long-running plugin call from another plugin, the network resume/pause work now waits behind it instead of blocking the UI.
That only delays re-registration of the callback slightly, and keeping it on the serial plugin thread is what guarantees pause/resume ordering, so I preferred it over a separate executor.
🤖 Generated with Claude Code