fix: [SDK-4995] harden background notification ingress against ANRs - #2712
fix: [SDK-4995] harden background notification ingress against ANRs#2712fadi-george wants to merge 4 commits into
Conversation
📊 Diff Coverage ReportDiff Coverage Report (Changed Lines Only)Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff). Changed Files Coverage
Overall (aggregate gate)537/619 touched executable lines covered (86.8% — requires ≥ 80%) Per-file detail (informational; gate is aggregate above):
|
There was a problem hiding this comment.
Multi-model adversarial review (interrogate)
Verdict: request changes. The durable-ingress direction matches the ANR/lost-work intent, but several paths can still strand or drop work, and SyncJobService stop ownership looks broken in production.
Intent
Harden cold-start notification receivers and background sync against ANRs and lost work via gated dispatcher init, a durable FCM/dismiss journal + WorkManager drain, exact-once broadcast completion, and race-safe
SyncJobServiceownership — without changing customer-facing notification APIs.
Reviewers
- A: claude-fable-5-thinking-xhigh — 14 findings (2 critical)
- B: gpt-5.6-sol-xhigh — 8 findings (5 critical)
- C: cursor-grok-4.5-high-fast — 11 findings (3 critical)
- D: claude-opus-5-thinking-xhigh — 22 findings (6 critical)
Act On
ExistingWorkPolicy.KEEP+ one-shotstore.list()strands mid-drain inserts (A/B/C/D) — drain can finish while newer journal rows have no successor work.SyncJobService.onStopJobusesJobParametersidentity (===) (B/D) — Binder-delivered params are distinct instances; stop likely never cancels/reschedules. Tests reuse one mock and miss this.- No poison-record / attempt isolation in drain (A/D) — one bad row or
IngressKind.valueOffailure head-of-line blocks the queue with unboundedResult.retry(). - FCM/dismiss handoffs have no
timeoutMs(A/B/C/D) — ANR-sensitive receivers rely only oninvokeOnCompletion; Boot/Upgrade uniquely get the 8s deadline. - Broadcast completes before drain enqueue is durable (B/C) —
scheduleDrainBestEffortfire-and-forgets on IO; process death afterfinish()can leave journal rows unscheduled until a later startup/ingress.
Consider
- Delete-after-process without durable downstream ack → possible double-display on crash (A/C).
- Journal
putfailure is swallowed → silent drop with no inline fallback (A/D). - Bootstrap thread has no
finallyaroundinitialize(); fallback throw can wedgerunning=true(A/B/D). goAsync()makessetAbort()/setSuccessfulResultCode()dead; abort criterion also changed to “persisted” (A/C/D).- Missing drain/KEEP/poison/JobParameters-distinct tests for the headline guarantees (A/C/D).
Noted
Extra WM hop latency; SerialIO/Ingress now share a 200-cap queue; restore not actually journaled; BundleCodec lossiness; companion restored static; production @ForTest seams.
Dismissed
- “Rewrite dispatchers simpler” as a ship-blocker — complexity is high, but the gated warm path is the stated ANR fix; treat as follow-up hardening, not a revert.
- HMS/ADM not on the journal path — out of this PR’s stated FCM/dismiss scope.
- Dismiss
hashCodeid collisions — low practical risk given idempotent dismiss processing.
Agreement map
Strongest consensus: KEEP/drain backlog race, missing FCM/dismiss deadlines, and weak verification of drain semantics. Independent high-severity hits from B+D on JobParameters ===. Divergence mostly on how hard to push structural simplification vs. surgical durability fixes.
Inline comments cover the Act On items.
Sent by Cursor Automation: Untitled
Ensure queued notification work cannot be stranded or blocked by poison records, and make job and dispatcher ownership resilient across Android callback and bootstrap races. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Hey @fadi-george , went through this PR. The direction is right. goAsync() doesn’t extend the ~10s budget, and prewarm() doesn’t help if IO is already stuck on init HTTP. Persist cheaply, finish the broadcast, drain off the IO pool. That’s the right shape. What’s hard to review is that this is three changes:
One thing I’d still want in (1): timeout enqueueDrain()?.result?.get(). The 8s deadline finishes this PendingResult, but Ingress is a single thread, so a hung WorkManager wait still blocks later pushes. Can we put (1) behind a flag with the existing Turbine flags, APP_STARTUP, default off, persist the last value so the next process can read it without waiting on init. Don’t use an IMMEDIATE remote flag on cold FCM. That fetch is part of the work we’re trying to get off this path. I wouldn’t flag (2). Flagging (1) also means we keep both receiver paths until we delete it. You could also create a new class where the conditional happens around what path to use. Impact I’m thinking about: fewer receiver ANRs, but slower/later display from the SQLite + WM hop. Drain is a default OneTimeWorkRequest with no expedited, so on Tecno/Infinix/Xiaomi this can delay or drop, not just add latency. Crash after display and before journal delete can duplicate. HMS/ADM unchanged. No public API. I’d rather land (1) behind the flag, split out (2)/(3), add the WM timeout, and decide on expedited before we roll it out. |


Description
One Line Summary
Harden cold-start notification receivers and background sync jobs against ANRs, stranded work, and queue-blocking failures.
Details
Motivation
Chronic background ANRs continued around
FCMBroadcastReceiverdespite best-effort dispatcher prewarming. Cold dispatcher initialization, unbounded receiver work, incompletePendingResult.finish()paths, andSyncJobServicelifecycle races could still block Android component deadlines or strand notification work.Scope
APPEND_OR_REPLACEso records arriving during an active drain always receive successor work.SyncJobServicecallbacks by stable job ID and make completion and cancellation ownership race-safe.OneSignalDispatchers.launchOnIngressas the shared ingress dispatch entry point. There are no customer-facing API changes.Testing
Unit testing
Added coverage for:
SyncJobServicecallbacks using distinctJobParametersinstances with matching or different job IDs.Validated with:
./gradlew spotlessCheck detekt --console=plain./coverage/checkCoverage.shChanged-line coverage is 86.9% against an 80% requirement.
Manual testing
Installed the local GMS demo on
emulator-5556, queued offline background work, and forced JobScheduler job2071862118. The sync ran onOneSignal-IO-1, completed throughjobFinished, and produced no ANR or crash.Affected code checklist
Checklist
Overview
Testing
Final pass