Skip to content

[ANR] FCMBroadcastReceiver goAsync starved by 2-thread IO pool during init HTTP (android_params.js / sdk/features) #2722

Closed

Description

@JunWeiUp

Summary

FCMBroadcastReceiver ANRs on Android when a push arrives while OneSignal’s 2-thread IO pool is blocked on init HTTP to api.onesignal.com (android_params.js and sdk/features). goAsync() does not extend the ~10s broadcast timeout, so pendingResult.finish() never runs in time.

This is not a main-thread Application.onCreate stall. initWithContext returns in ~100ms on the main thread; the ANR is the broadcast watchdog.

Related: #2709 (open). Play Vitals data there showed 5.9.5 prewarm did not reduce FCMBroadcastReceiver ANR share. That issue attributes the problem to dispatcher construction race. Our traces show the dispatchers are already warm and both IO workers are stuck in TCP connect — pool saturation + timeout mismatch (and on many devices, IPv6 with no Happy Eyeballs).

Also related (different root cause, same symptom string): #1542, #1436, #2324, #2619.

Environment

SDK 5.9.8 and 5.9.9 (SDK-Version=onesignal/android/050908 and 050909). Also reproduced under Gradle dynamic range [5.6.1, 5.9.99].
Device Xiaomi Mi 10 (umi), Android 13 / MIUI
App Process started by FCM or already in foreground; OneSignal.initWithContext(applicationContext, appId) from Application.onCreate
Network China Mobile; device has a global IPv6 address; api.onesignal.com has AAAA (Cloudflare)

Repro

  1. Force-stop the app (or wait until the process is dead).
  2. Send an FCM / OneSignal push so Play services delivers com.google.android.c2dm.intent.RECEIVE (or open the app and send a push in the first ~60s while init HTTP is still in flight).
  3. Within ~10s: ANR Broadcast of Intent { act=com.google.android.c2dm.intent.RECEIVE cmp=.../com.onesignal.notifications.receivers.FCMBroadcastReceiver }.

Warm process after init HTTP has finished: later pushes usually do not ANR.

Timeline (5.9.8, one occurrence)

Times local GMT+8, pid 20919:

Time Event
16:19:03 OneSignalImp initialization completed on main (~96ms)
16:19:03–04 GET .../android_params.js on OneSignal-IO-2; GET .../sdk/features/android/050908 on OneSignal-IO-1
~16:19:24 FCM google.sent_time
16:19:35 System ANR (broadcast ~10s)
16:20:08 Both HTTP responses (~64s); then NotificationWorkManager enqueueing notification work

Same pattern on 5.9.9 with the app already in the foreground: init finished in 132ms; both GETs still connecting; Timeout of broadcast ... started 10001ms ago → ANR.

What the ANR dump shows

Main thread: typically idle (MessageQueue.nativePollOnce). Sometimes unrelated Compose draw. Not blocked in OneSignal.

OneSignal-IO-1 **and **OneSignal-IO-2 (both Native / poll):

at libcore.io.Linux.poll
at libcore.io.IoBridge.isConnected
at libcore.io.IoBridge.connectErrno
at java.net.PlainSocketImpl.socketConnect
at java.net.Socket.connect
at com.android.okhttp.internal.io.RealConnection.connectSocket
at com.android.okhttp.internal.huc.HttpURLConnectionImpl.getResponseCode
at com.onesignal.core.internal.http.impl.HttpClient$makeRequestIODispatcher$job$1.invokeSuspend(HttpClient.kt:206)
at com.onesignal.common.threading.ThreadUtilsKt$launchOnIO$1.invokeSuspend
at com.onesignal.common.threading.OneSignalDispatchers$launchOnIO$1.invokeSuspend(OneSignalDispatchers.kt:202)

SDK code (5.9.9) that matches this

  1. FCMBroadcastReceiver.onReceive: OneSignalDispatchers.prewarm() then goAsync() then suspendifyOnIO { initWithContext; processBundle; pendingResult.finish() }.
  2. OneSignalDispatchers: ThreadPoolExecutor(core=2, max=3, LinkedBlockingQueue(200)). JDK semantics: queue fills before max, so effective concurrency is 2.
  3. ConfigModel.httpGetTimeout default 60000ms; HttpClient applies it to connect and read timeout. One hung GET holds an IO thread for up to 60s.
  4. Cold start fires two GETs at once (android_params.js + sdk/features) → both IO threads busy.
  5. Broadcast budget is ~10s. goAsync() does not extend it (Chronic background ANRs (FCMBroadcastReceiver, NotificationDismissReceiver, BootUpReceiver, SyncJobService) unchanged after 5.9.5 prewarm (PR #2664) — version-code data #2709). 60s vs 10s.
sequenceDiagram
  participant Play as PlayServices
  participant Rx as FCMBroadcastReceiver
  participant IO as OneSignal_IO_pool_2
  participant API as api.onesignal.com

  Note over IO: GET android_params.js
  Note over IO: GET sdk/features
  IO->>API: TCP connect (can hang 60s)
  Play->>Rx: c2dm RECEIVE
  Rx->>Rx: goAsync
  Rx->>IO: suspendifyOnIO processBundle
  Note over IO: queued, both workers in connect
  Note over Rx: 10s, finish never called, ANR
Loading

IPv6 / Happy Eyeballs (why connect hangs)

Same device, same URL, same session:

Client Result
curl (Happy Eyeballs, IPv4 fallback) connect ~0.3s, HTTP 200, total <1s
curl -6 fails after ~15s (code=000)
SDK HttpURLConnection same request in-flight 10–64s; ANR dump in IoBridge.connectErrno
  • Device has a global IPv6 address.
  • api.onesignal.com has AAAA (Cloudflare). IPv6 path to that anycast is broken on this carrier.
  • Java HttpURLConnection / InetAddress has no Happy Eyeballs (RFC 8305); it serializes addresses and sits on IPv6 until connectTimeout (60s).
  • System.setProperty("java.net.preferIPv4Stack", "true") in Application.attachBaseContext did not change SDK connect behavior on this device.

This also fits #2709’s device mix (emerging-market OEMs where IPv6 is on but Cloudflare IPv6 routing is poor).

What we already ruled out (app side)

  • Main-thread OneSignal.User / feature-gate runBlocking (separate ANR class; we moved those reads off main — main is idle in these dumps).
  • Native POST_NOTIFICATIONS vs OneSignal.Notifications.requestPermission.
  • R8 / missing -keep (staging/debug, minify off; stacks show full OneSignal names).
  • UncaughtExceptionHandler (this is a watchdog timeout, not a crash).
  • Downgrade-only experiments: 5.9.8 reproduced; 5.9.9 reproduced. [Bug]: One Signal causing too many ANRS #2619 also reported the same broadcast ANR string on 5.7.7 (different inner stack).
  • App FirebaseMessagingService: Play still delivers c2dm to OneSignal’s receiver at priority 999 first.

Requested fix

Any of these would close the 10s window:

  1. Do not share the 2-thread IO pool with goAsync work. Use a dedicated thread/pool (or Dispatchers.IO unbounded) so processBundle + finish() cannot wait behind 60s connects.
  2. **Enqueue WorkManager then **finish() immediately — you already have NotificationWorkManager; today enqueue still sits behind the hung GETs.
  3. Happy Eyeballs or IPv4-first for HttpURLConnection (or switch the SDK HTTP stack to OkHttp, which does HE).
  4. Much shorter connect timeout for init GETs (seconds, not 60s), without blocking the FCM receiver on those calls.

prewarm() only helps pool construction, not workers stuck in connect().

Happy to attach a truncated dropbox excerpt (IO threads only) if useful.

Activity

  1. TairineAlvesTW commented on Sep 16, 2026

    @TairineAlvesTW

    We reproduced this on a physical device with a full ANR dump, and it matches the description here: FCMBroadcastReceiver starved because both OneSignal-IO threads are blocked in an HTTP connect.

    Setup

    • OneSignalSDK.DotNet 6.3.0 (native Android SDK 5.10.0, sdkVersion=051000 in logcat), .NET MAUI 10 app
    • Samsung Galaxy A54 (SM-A546E), Android 16
    • Wi-Fi network with broken IPv6: DNS returns AAAA records and the device tries IPv6 first, but IPv6 has 100% packet loss (ping6 to the OneSignal host times out, IPv4 works)

    What happened

    1. The user placed an order in the app and stayed on the confirmation screen (app in foreground).
    2. Our backend sent a push right away.
    3. FCMBroadcastReceiver hit the 10 s foreground broadcast timeout and Android showed the "app isn't responding" dialog.

    ANR dump (Subject: Broadcast of Intent ... FCMBroadcastReceiver, Foreground: Yes)

    • main: idle in MessageQueue.nativePollOnce
    • OneSignal-IO-1 and OneSignal-IO-2: both blocked in a socket connect from the SDK HTTP client:
    java.net.Socket.connect
    com.android.okhttp.internal.huc.HttpsURLConnectionImpl.getResponseCode
    com.onesignal.core.internal.http.impl.HttpClient$makeRequestIODispatcher$job$1.invokeSuspend
    com.onesignal.common.threading.ThreadUtilsKt$launchOnIO$1.invokeSuspend
    com.onesignal.common.threading.OneSignalDispatchers$launchOnIO$1.invokeSuspend
    kotlinx.coroutines.DispatchedTask.run
    

    With the whole 2-thread IO pool waiting on connects that go to IPv6 first, the receiver work queued behind them cannot run before the broadcast deadline.

    Production impact
    In Play Console this is our largest ANR cluster: FCMBroadcastReceiver / nativePollOnce, 70 users over 28 days on one app, still present on our current release. Networks with broken IPv6 are not rare for our users in Brazil (home routers and some ISPs), so this is not a lab-only case.

    As a mitigation on our side we stopped sending the "order placed" push, which reached users while the app was open. That only reduces exposure. The receiver itself still depends on the IO pool being free.

    Questions

    • Could the HTTP client use a shorter connect timeout, or fall back to IPv4 faster (Happy Eyeballs style), so a broken IPv6 route doesn't hold an IO thread for a long time?
    • Could the notification display path in FCMBroadcastReceiver avoid waiting on the shared IO pool, or run on a separate executor, so pending HTTP calls can't starve it?
    • Is fix: [SDK-4995] durable ingress lane for background notification receivers #2712 (durable ingress with the 8 s broadcast deadline) still planned? It looks like it would cover exactly this case.

    Happy to share the full thread dump or test a build if that helps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions