Skip to content

fix(widget): never crash when finishing the widget broadcast - #360

Merged
ErikBjare merged 4 commits into
masterfrom
fix/widget-onupdate-crash
Oct 11, 2026
Merged

ErikBjare merged 4 commits into
masterfrom
fix/widget-onupdate-crash

Conversation

@ErikBjare

@ErikBjare ErikBjare commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

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):

  • 248 reports, about +30 per day, type CRASH
  • net.activitywatch.android.widget.CategoryTimeWidgetProvider$onUpdate$1.invokeSuspend → java.lang.IllegalStateException
  • Sample versionCode 46 (v0.14.2). That is about 93% of v0.14.2's crash reports, and it pushed the user-perceived crash rate back above the 1.09% bad-behavior threshold (7-day 1.31%).
  • Play issue: apps/net.activitywatch.android/b2672fd447e8036e240d9aefe4f42609
  • Related ANRs: findNode / AccessibilityNodeOps.getChild / nativePollOnce while the ACTION_PERIODIC_UPDATE and APPWIDGET_UPDATE broadcasts to CategoryTimeWidgetProvider were pending, a few reports each. One of them is blamed on CategoryTimeWidgetProvider.onUpdate itself.
  • v0.14.3 (versionCode 48) changed no widget files, so the crash is still present on master.

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:

java.lang.IllegalStateException
  at android.content.BroadcastReceiver$PendingResult.sendFinished
  at android.content.BroadcastReceiver$PendingResult.finish
  at net.activitywatch.android.widget.CategoryTimeWidgetProvider$onUpdate$1.invokeSuspend
  at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith
  at kotlinx.coroutines.DispatchedTask.run
  at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run   (Dispatchers.IO)
  ...

Root cause

Confirmed (from the frames and the code):

  • The exception comes from pendingResult.finish() in the finally of onUpdate's CoroutineScope(Dispatchers.IO).launch. It does not come from the widget's data code.
  • PendingResult.sendFinished() throws IllegalStateException only when the result is already finished ("Broadcast already finished").
  • onUpdate calls finish() exactly once per goAsync(), and nothing else in the app touches that PendingResult. So something outside the app finished it first.
  • The finally was outside the try/catch, so the exception escaped a coroutine with no exception handler. On Android that kills the process.
  • ACTION_REFRESH and ACTION_PERIODIC_UPDATE used the same pattern and had the same latent crash.

Hypothesis (consistent with the data, but not reproduced on a device):

Fix

  • New widget/AsyncBroadcast.kt, used by all three async widget broadcasts (onUpdate, ACTION_REFRESH, ACTION_PERIODIC_UPDATE):
    • It finishes the PendingResult exactly once, through an AtomicBoolean guard in a finally. An IllegalStateException from finish(), meaning the result was already finished by the system, is logged instead of rethrown. This directly fixes the crash.
    • It catches everything the work throws, including Errors such as UnsatisfiedLinkError from loadLibrary, and then renders a "No data yet" fallback (zero time, no category rows, "No data yet" in the timestamp slot).
    • Time budget: the broadcast waits at most 8 s for the work, which is under the 10 s foreground broadcast limit. The work runs in a detached coroutine, because it blocks in calls that ignore cancellation (JNI, DatastoreStartup.awaitReady()). A withTimeout around 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, through partiallyUpdateAppWidget, which can't clobber whatever the work renders.
    • One pass at a time, nothing dropped (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 on Dispatchers.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.
    • The fallback also runs detached, with its own 1 s budget, so a slow updateAppWidget can'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.
  • Less redundant work per broadcast: CategoryTimeWidgetUpdater.updateWidgets() loads the data once per pass (one RustInterface, 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_UPDATE still reschedules its alarm before finishing, even when the update fails.

Not changed: watcher/AlarmReceiver uses the same goAsync + 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

  • New AsyncBroadcastTest (JVM unit tests, plain JUnit like the rest of the suite):
    • finish() throwing IllegalStateException("Broadcast already finished") does not escape the coroutine. This is the regression test for the Play crash, and it checks with a CoroutineExceptionHandler that records uncaught exceptions.
    • Successful work finishes exactly once, with no fallback.
    • Failing work (an Error) renders the fallback and still finishes.
    • A fallback that throws is logged, and the broadcast still finishes.
    • Non-cancellable blocking work past the budget: the broadcast is finished on time, and onFailure gets a TimeoutException.
    • A fallback that blocks does not delay finish(), and no further fallbacks start while it is stuck.
    • Requests queued behind a stuck pass still finish their broadcasts on time.
    • New 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.
    • FinishOnce ignores a second call.
  • ./gradlew :mobile:testStandardDebugUnitTest passes locally: 235 tests, 0 failures. For this local run only, ndkVersion was pointed at the installed NDK 27.1, because 28.2 isn't installed on this machine. That change is not committed.
  • Not tested on a device. The OEM double-finish can't be reproduced on an emulator, so the fix is verified by the unit tests, and the Play cluster for the next release will show whether it worked.

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

Copy link
Copy Markdown
Member Author

@greptileai review

@TimeToBuildBob review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T15:09:52.357351Z f8dd89f New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt Outdated
@greptile-apps

greptile-apps Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] The PR appears safe to merge with no outstanding actionable findings.

Summary

The PR guards widget broadcast completion, bounds how long broadcasts wait, and shares widget updates through one worker.

  • Widget broadcasts finish safely even when the system finishes first.
  • Widget updates run one at a time and keep queued requests.
  • Widget copies share one data load and show a fallback when it fails.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Widget broadcast] --> B[Submit or merge update request]
    B --> C[One worker updates all widgets]
    B --> D[Wait up to 8 seconds]
    D --> E{Work failed or timed out?}
    E -->|No| H[Finish broadcast once]
    E -->|Yes| F{Fallback already running?}
    F -->|Yes| H
    F -->|No| G[Start detached fallback and wait up to 1 second]
    G --> H
    G --> I[Clear flag when fallback ends]
Loading

Reviews (4) · Last reviewed commit: "fix(widget): don't stack fallbacks while..." · Reviewed by Greptile

Comment thread mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt Outdated
Comment thread mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt Outdated
… 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.
@ErikBjare

Copy link
Copy Markdown
Member Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

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:

  • P2 (IO thread exhaustion from stuck JNI work): shared across all three broadcasts via — new broadcasts join the stuck work rather than pile more jobs onto . Covered by .
  • P1 (fallback delays broadcast completion): fallback runs detached in its own 1s budget (8 s + 1 s < 10 s limit). Covered by .
  • P2 (fallback overwrites successful updates): timeout path uses (spinner-only), which can't overwrite data from the still-running work. Only the non-timeout path (work already ended) renders the full fallback, so there's no race. Test for this edge case would be nice but isn't blocking.

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt Outdated
Comment thread mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt Outdated
Comment thread mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt Outdated
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.
@ErikBjare

Copy link
Copy Markdown
Member Author

@greptileai review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt Outdated
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.
@TimeToBuildBob

TimeToBuildBob commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed the updated head a7cc39a (my earlier LGTM was for the previous single-flight implementation).

CoalescingWorker fixes both intermediate-revision bugs: running is published under the same lock as submission, and later requests share a queued follow-up rather than losing their refresh flag. The new concurrency/coalescing tests exercise those cases. The guarded finish() still directly addresses the Play crash. Greptile's profiling requirement has also been explicitly withdrawn.

Update after reading f8dd89f: Codex's fallback accumulation report is addressed. The process-wide atomic guard remains set until the fallback actually returns, rather than until its await times out. Later failures therefore cannot start additional blocked fallback IO calls. The extended latch test checks that a later failing broadcast skips its fallback while the first remains blocked and that the guard clears after release.

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 f8dd89f (15:35 UTC): all eight checks passed, including unit tests, E2E (API 29), APK/AAB builds, and the release-variant R8 smoke test; the two release jobs were skipped. This is not device reproduction of the OEM failure, and green CI does not cover the stale full-fallback ordering case above.

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.

@ErikBjare

Copy link
Copy Markdown
Member Author

@greptileai review

@TimeToBuildBob

TimeToBuildBob commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

🤖 AI code review

Adds 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 only

Confidence 4/5

1 finding · ⚠️ 1 P2

⚠️ P2 medium — mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt:130

When a broadcast's work fails and another fallback is still running, AsyncBroadcast.launch skips onFailure entirely, logging "Previous widget fallback still running; skipping this one". The fallbackRunning flag is only cleared in the fallback's finally block, which runs when that fallback's onFailure returns. If a fallback is slow or blocks (e.g., AppWidgetManager.updateAppWidget hangs on a busy system), the flag stays true for an unbounded time. Meanwhile, ACTION_REFRESH sets the loading indicator VISIBLE before launching. A subsequent refresh that fails (datastore error, Error) therefore never calls CategoryTimeWidgetUpdater.onUpdateFailed, so the spinner is neither hidden nor replaced with the "no data yet" state. The widget remains on the spinner or stale view until a later broadcast either succeeds or runs after the flag clears, which can be several minutes or indefinitely if the stuck fallback never returns. This is a user-visible stuck state, not just a skipped optimization.

How this was verified: Checked AsyncBroadcast.launch's fallback path: withTimeoutOrNull on fallback.await() does not cancel the detached fallback, and the reset lives inside the detached coroutine's finally, so a permanently stuck onFailure leaves the flag set. AsyncBroadcastTest.blockedFallback_doesNotDelayFinish itself demonstrates the flag staying set while a fallback is stuck.

Consensus: 3/3 passes agreed
Distinct keys: 1 (general)

Files changed (7) — the diff as I read it
  • mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt — New object that wraps broadcast work in a coroutine with budget and fallback, and a FinishOnce guard for PendingResult.finish().
  • mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetProvider.kt — Replaces direct CoroutineScope launches with AsyncBroadcast.launch and introduces a CoalescingWorker for updates.
  • mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt — Refactors to load WidgetData once and render to all widgets, adds onUpdateFailed and renderFallback.
  • mobile/src/main/java/net/activitywatch/android/widget/CoalescingWorker.kt — New worker that runs one pass at a time and merges requests arriving during a pass into a follow-up pass.
  • mobile/src/main/res/values/strings.xml — Adds the string resource widget_no_data_yet.
  • mobile/src/test/java/net/activitywatch/android/widget/AsyncBroadcastTest.kt — Tests for finish-once, timeout, fallback, and budget behaviors.
  • mobile/src/test/java/net/activitywatch/android/widget/CoalescingWorkerTest.kt — Tests for merging, failure propagation, and sequential execution.

Reviewed f8dd89f4f433 · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 741s · about this reviewer

Maintainer commands

@TimeToBuildBob review (own line) — fresh review · @TimeToBuildBob fix — a worker acts on the findings. Once per comment; 👀 = received.

@ErikBjare
ErikBjare merged commit 4709a86 into master Oct 11, 2026
10 checks passed
@ErikBjare
ErikBjare deleted the fix/widget-onupdate-crash branch October 11, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants