Repository navigation
fix(widget): never crash when finishing the widget broadcast - #360
Conversation
The top Play crash (CategoryTimeWidgetProvider$onUpdate$1.invokeSuspend, IllegalStateException) is thrown by PendingResult.finish() -> sendFinished(), which only throws when the broadcast was already finished. The app finished it once, so the system or OEM framework had finished it first (AndroidX Glance catches the same exception, b/257513022). finish() sat in a finally outside the try/catch, so the exception escaped the IO coroutine and killed the process. - Add AsyncBroadcast: finishes the PendingResult exactly once, tolerates an already-finished result, catches everything the work throws (including Errors such as UnsatisfiedLinkError), and renders a "No data yet" fallback on failure. - Bound the work to 8 s, under the 10 s foreground broadcast limit. The work runs detached because it blocks in JNI and DatastoreStartup.awaitReady(), which ignore cancellation. - Load the widget data once per update pass instead of opening RustInterface and querying the datastore once per widget instance. - Route onUpdate, ACTION_REFRESH and ACTION_PERIODIC_UPDATE through it.
|
@greptileai review @TimeToBuildBob review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6e043ff607
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
… timeout - SingleFlight: a broadcast that arrives while earlier (possibly stuck) work is in flight waits for it instead of starting another blocked job on Dispatchers.IO. - The fallback runs detached with its own 1 s budget, so a slow updateAppWidget can't hold the broadcast open. - On a timeout the work is still running and renders data or its own fallback, so only the loading spinner is hidden, through partiallyUpdateAppWidget, which can't overwrite that result.
|
@greptileai review |
|
Reviewed. Root cause and fix: confirmed correct. The from when the OEM pre-finishes the broadcast is the exact crash pattern in the Play frames. (AtomicBoolean + catch IllegalStateException) directly prevents it. The AndroidX Glance precedent (b/257513022) is the right anchor. The three Greptile findings are already resolved in the current code:
Tests: comprehensive — the test with a real latch correctly reproduces the non-cancellable-JNI scenario; is the direct regression test for the crash. Minor nit: — returns non-null ; the null-safe call is unnecessary and slightly misleads about whether null is possible. Not a bug. This is a solid fix. LGTM. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5b4376b9e0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Joining in-flight work dropped what a later broadcast asked for: a refresh tap during onUpdate skipped re-parsing usage, and a newly added widget could miss the pass. SingleFlight's lazy start could also let two passes start. Replace it with CoalescingWorker: one pass at a time, guarded by a lock, and at most one queued follow-up that merges every request made meanwhile (refreshUsage is OR'd). Each pass renders all current widget ids, so new widgets are always covered.
|
@greptileai review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a7cc39a9e4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A fallback blocked in updateAppWidget outlives its 1 s await. Skip new fallbacks until it ends, so repeated failures can't pile them onto Dispatchers.IO.
|
Reviewed the updated head
Update after reading Remaining P2: write ordering. A full failure fallback can outlive its 1 s wait and run after the coalescing worker has rendered a later successful pass. The new fallback guard bounds fallback concurrency but does not serialize fallback writes with successful update passes. An older failed pass can therefore still leave “No data yet” over newer data. Serialize full fallback rendering with update passes, or otherwise ensure an older failure cannot overwrite newer data; a regression test should release an old blocked fallback after a later successful render. Timeout's spinner-only partial update already avoids this particular data overwrite. CI verified at This is a source/test review, not a local Android build or device verification. I have not pushed changes onto your actively moving branch. The original double-finish crash fix is sound; the remaining item is a stale-render risk, not a request for another profiling/review-score round. |
|
@greptileai review |
🤖 AI code reviewAdds AsyncBroadcast and CoalescingWorker to run widget broadcasts with a time budget, finish the PendingResult exactly once, and serialize/merge update passes. Reworks CategoryTimeWidgetProvider to route onUpdate, refresh, and periodic updates through these helpers, and refactors CategoryTimeWidgetUpdater to load data once and render to all widgets. Adds unit tests for the new helpers and a 'No data yet' string. Needs a look — P2 onlyConfidence 4/5 1 finding ·
|
Fixes the top crash in the Play console for v0.14.2.
Play cluster
From Play Developer Reporting, as collected in ActivityWatch/stats under
data/android-errors/current.md(collected 2026-10-10, 7-day window):net.activitywatch.android.widget.CategoryTimeWidgetProvider$onUpdate$1.invokeSuspend→java.lang.IllegalStateExceptionapps/net.activitywatch.android/b2672fd447e8036e240d9aefe4f42609findNode/AccessibilityNodeOps.getChild/nativePollOncewhile theACTION_PERIODIC_UPDATEandAPPWIDGET_UPDATEbroadcasts toCategoryTimeWidgetProviderwere pending, a few reports each. One of them is blamed onCategoryTimeWidgetProvider.onUpdateitself.Play gives no exception message, but the history snapshots in ActivityWatch/stats (
data/android-errors/history/*.json, every day from 2026-09-26 on) include the frames:Root cause
Confirmed (from the frames and the code):
pendingResult.finish()in thefinallyofonUpdate'sCoroutineScope(Dispatchers.IO).launch. It does not come from the widget's data code.PendingResult.sendFinished()throwsIllegalStateExceptiononly when the result is already finished ("Broadcast already finished").onUpdatecallsfinish()exactly once pergoAsync(), and nothing else in the app touches thatPendingResult. So something outside the app finished it first.finallywas outside thetry/catch, so the exception escaped a coroutine with no exception handler. On Android that kills the process.ACTION_REFRESHandACTION_PERIODIC_UPDATEused the same pattern and had the same latent crash.Hypothesis (consistent with the data, but not reproduced on a device):
goAsynchelper catches exactly this exception fromfinish(), with the comment "On some OEM devices, this may throw an error about 'Broadcast already finished'. See b/257513022." (glance/glance-appwidget/.../CoroutineBroadcastReceiver.kt).RustInterface(loadLibrary,initialize,setDataDir) and made three blocking JNI datastore calls (getSetting("startOfDay"),androidQuery,getSetting("classes")). On a cold process start fromAPPWIDGET_UPDATE(boot, app update, the 30 minupdatePeriodMillis), these compete with the watchers and startup migrations for the datastore. The widget-broadcast ANRs show these broadcasts do overrun.classes, fix(widget): read category colors from aw-webui settings #289) and reworked the startup paths around the datastore (fix(android): harden startup paths that turned a slow datastore into a hang #262, fix(android): migrate unsanitized hostname after sanitizer change #273). This is plausible but not measured. On master,RustInterface's constructor also waits onDatastoreStartup.awaitReady()with no timeout (fix(startup): rewrite bucket hostnames before anything opens the datastore #354).Fix
widget/AsyncBroadcast.kt, used by all three async widget broadcasts (onUpdate,ACTION_REFRESH,ACTION_PERIODIC_UPDATE):PendingResultexactly once, through anAtomicBooleanguard in afinally. AnIllegalStateExceptionfromfinish(), meaning the result was already finished by the system, is logged instead of rethrown. This directly fixes the crash.Errors such asUnsatisfiedLinkErrorfromloadLibrary, and then renders a "No data yet" fallback (zero time, no category rows, "No data yet" in the timestamp slot).DatastoreStartup.awaitReady()). AwithTimeoutaround a child coroutine would still wait for that child to return. Work that overruns keeps running and still renders its own result (data, or "No data yet" if the load fails). So on a timeout the helper does not overwrite that result. It only hides the loading spinner, throughpartiallyUpdateAppWidget, which can't clobber whatever the work renders.widget/CoalescingWorker.kt): all widget broadcasts submit a request to one shared worker. While a pass is running, possibly stuck in JNI, new requests don't start more blocked jobs onDispatchers.IO. They are merged into a single queued follow-up pass (a refresh's "re-parse usage first" flag is OR'd), and each broadcast waits for the pass that covers it, within its budget. Every pass renders all current widget ids, so a widget added in the meantime is covered.updateAppWidgetcan't hold the broadcast open. While a fallback is still stuck, later failures skip theirs, so fallbacks can't pile up either. Errors from the fallback are logged.CategoryTimeWidgetUpdater.updateWidgets()loads the data once per pass (oneRustInterface, one query) and renders it to every widget id. Before, it did this once per widget instance. Rendering is split from loading, and a widget that can't be updated is logged and skipped.ACTION_PERIODIC_UPDATEstill reschedules its alarm before finishing, even when the update fails.Not changed:
watcher/AlarmReceiveruses the samegoAsync+finally { finish() }pattern, but it has no crash cluster, and putting a time budget on heartbeat parsing changes behavior. It is a candidate for the same helper in a follow-up.Testing
AsyncBroadcastTest(JVM unit tests, plain JUnit like the rest of the suite):finish()throwingIllegalStateException("Broadcast already finished")does not escape the coroutine. This is the regression test for the Play crash, and it checks with aCoroutineExceptionHandlerthat records uncaught exceptions.Error) renders the fallback and still finishes.onFailuregets aTimeoutException.finish(), and no further fallbacks start while it is stuck.CoalescingWorkerTest: requests made during a stuck pass are merged into exactly one follow-up and not dropped; a failed pass reports to its callers and the worker keeps going; concurrent submits from 8 threads never run passes in parallel.FinishOnceignores a second call../gradlew :mobile:testStandardDebugUnitTestpasses locally: 235 tests, 0 failures. For this local run only,ndkVersionwas pointed at the installed NDK 27.1, because 28.2 isn't installed on this machine. That change is not committed.