Repository navigation
fix(widget): never crash when finishing the widget broadcast #360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6e043ff
fix(widget): never crash when finishing the widget broadcast
ErikBjare 5b4376b
fix(widget): single-flight widget work, detach fallback, keep data on…
ErikBjare a7cc39a
fix(widget): coalesce widget requests instead of dropping them
ErikBjare f8dd89f
fix(widget): don't stack fallbacks while one is stuck
ErikBjare File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
148 changes: 148 additions & 0 deletions
148
mobile/src/main/java/net/activitywatch/android/widget/AsyncBroadcast.kt
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| package net.activitywatch.android.widget | ||
|
|
||
| import android.util.Log | ||
| import java.util.concurrent.TimeoutException | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
| import kotlinx.coroutines.CoroutineDispatcher | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Deferred | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.Job | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.async | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.coroutines.withTimeoutOrNull | ||
|
|
||
| private const val TAG = "CategoryTimeWidget" | ||
|
|
||
| /** | ||
| * Runs the work of a widget broadcast that called `goAsync()`, so that nothing can throw out | ||
| * of the coroutine and the `PendingResult` is finished exactly once, within the broadcast's | ||
| * time budget. | ||
| * | ||
| * The widget's top Play crash (`CategoryTimeWidgetProvider$onUpdate$1.invokeSuspend`, | ||
| * `IllegalStateException`) was thrown by `PendingResult.finish()` → `sendFinished()`, which | ||
| * only throws when the result is already finished ("Broadcast already finished"). The app | ||
| * finished it once, so something else had finished it first: some OEM builds do this | ||
| * themselves, notably when the work runs past the broadcast deadline (AndroidX Glance | ||
| * catches the same exception for the same reason, b/257513022). The `finish()` call sat in | ||
| * a `finally` outside the `try`/`catch`, so its exception escaped an IO coroutine with no | ||
| * handler and killed the process. | ||
| */ | ||
| internal object AsyncBroadcast { | ||
| /** | ||
| * Foreground broadcasts get 10 s and background broadcasts 60 s before an ANR. Stay | ||
| * under the smaller one: the system and OEM watchdogs measure from dispatch, not from | ||
| * when this coroutine starts. | ||
| */ | ||
| const val DEFAULT_BUDGET_MS = 8_000L | ||
|
|
||
| /** | ||
| * Calls [finish] at most once. An [IllegalStateException] from it means the system or | ||
| * OEM framework already finished the broadcast, so it is logged, not rethrown. | ||
| */ | ||
| internal class FinishOnce( | ||
| private val finish: () -> Unit, | ||
| private val log: (String, Throwable?) -> Unit = ::logError, | ||
| ) { | ||
| private val finished = AtomicBoolean(false) | ||
|
|
||
| fun finish() { | ||
| if (!finished.compareAndSet(false, true)) return | ||
| try { | ||
| finish.invoke() | ||
| } catch (e: IllegalStateException) { | ||
| log("Broadcast was already finished by the system", e) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Budget for [launch]'s `onFailure`, on top of the work budget (8 s + 1 s < 10 s). */ | ||
| const val FALLBACK_BUDGET_MS = 1_000L | ||
|
|
||
| /** | ||
| * True while a fallback is running. A fallback stuck in `updateAppWidget` outlives its | ||
| * await, so later failures skip theirs instead of piling more onto `Dispatchers.IO`; | ||
| * the fallback is best effort and the next pass renders anyway. | ||
| */ | ||
| private val fallbackRunning = AtomicBoolean(false) | ||
|
|
||
| internal val isFallbackRunning: Boolean get() = fallbackRunning.get() | ||
|
|
||
| /** Starts [work] in a coroutine that is not a child of the caller, for [launch]. */ | ||
| fun detached( | ||
| dispatcher: CoroutineDispatcher = Dispatchers.IO, | ||
| work: suspend () -> Unit, | ||
| ): Deferred<Unit> = CoroutineScope(SupervisorJob() + dispatcher).async { work() } | ||
|
|
||
| /** | ||
| * Launches [work] for a broadcast and finishes it through [finish] exactly once. | ||
| * | ||
| * [work] starts (or queues) the actual work in a coroutine that is not a child of this one, | ||
| * e.g. with [detached] or a [CoalescingWorker], and the broadcast waits for the returned | ||
| * [Deferred] for at most [budgetMs]. Detached, because the widget's work blocks in calls | ||
| * that ignore coroutine cancellation (JNI into the Rust datastore, | ||
| * `DatastoreStartup.awaitReady()`), so `withTimeout` around a child would still wait for | ||
| * the child to return. Work that overruns keeps going and still renders its own result. | ||
| * | ||
| * If [work] throws or overruns, [onFailure] is called with the exception, or with a | ||
| * [TimeoutException] when it overran (the work is then still running, so [onFailure] must | ||
| * not overwrite what it renders). [onFailure] also runs detached, for at most | ||
| * [fallbackBudgetMs], so a slow fallback can't hold the broadcast either. Anything it | ||
| * throws is logged. | ||
| */ | ||
| fun launch( | ||
| finish: () -> Unit, | ||
| budgetMs: Long = DEFAULT_BUDGET_MS, | ||
| fallbackBudgetMs: Long = FALLBACK_BUDGET_MS, | ||
| scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), | ||
| workDispatcher: CoroutineDispatcher = Dispatchers.IO, | ||
| log: (String, Throwable?) -> Unit = ::logError, | ||
| onFailure: (Throwable) -> Unit = {}, | ||
| work: () -> Deferred<Unit>, | ||
| ): Job { | ||
| val once = FinishOnce(finish, log) | ||
| return scope.launch { | ||
| try { | ||
| val failure: Throwable? = try { | ||
| val pending = work() | ||
| val done = withTimeoutOrNull(budgetMs) { pending.await() } | ||
| if (done == null) TimeoutException("Widget update exceeded ${budgetMs} ms") else null | ||
| } catch (e: Throwable) { | ||
| e | ||
| } | ||
| if (failure != null) { | ||
| log("Widget broadcast work failed", failure) | ||
| if (fallbackRunning.compareAndSet(false, true)) { | ||
| val fallback = detached(workDispatcher) { | ||
| try { | ||
| onFailure(failure) | ||
| } catch (e: Throwable) { | ||
| log("Rendering the widget fallback failed", e) | ||
| } finally { | ||
| fallbackRunning.set(false) | ||
| } | ||
| } | ||
| if (withTimeoutOrNull(fallbackBudgetMs) { fallback.await() } == null) { | ||
| log("Widget fallback exceeded ${fallbackBudgetMs} ms", null) | ||
| } | ||
| } else { | ||
| log("Previous widget fallback still running; skipping this one", null) | ||
| } | ||
| } | ||
| } catch (e: Throwable) { | ||
| log("Widget broadcast wrapper failed", e) | ||
| } finally { | ||
| once.finish() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun logError(msg: String, e: Throwable?) { | ||
| try { | ||
| Log.e(TAG, msg, e) | ||
| } catch (_: Throwable) { | ||
| // android.util.Log is not available in JVM unit tests. | ||
| } | ||
| } | ||
| } | ||
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.