Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@ import kotlinx.coroutines.withTimeoutOrNull

private const val TAG = "WidgetRefresh"

// A widget refresh hits the network, so it outlives onUpdate. Without goAsync()
// the receiver is finished the moment onUpdate returns and the process becomes
// killable, so the refresh can be cut off mid-request and the widget just stays
// stale. goAsync() keeps the process alive until finish() is called.
// A widget refresh hits the network, so it outlives onUpdate. Once the receiver
// finishes, the process becomes killable and the refresh can be cut off
// mid-request, leaving the widget stale until the next tick.
//
// goAsync() is how a receiver asks to stay alive, but there is only one
// PendingResult per dispatch and GlanceAppWidgetReceiver.onUpdate claims it for
// its own compose before any of this runs. So in practice these refreshes are
// NOT holding the broadcast open, and have not been since this was written.
// Losing a refresh to process death is a stale widget, not lost data, so this
// is a known limitation rather than a live bug; the durable fix is to enqueue
// the refresh as expedited work instead of doing it in the receiver.
//
// The broadcast still has a hard deadline (10s in the foreground, 60s in the
// background) before the system complains, so the work is bounded well inside
Expand All @@ -31,7 +38,13 @@ fun BroadcastReceiver.refreshWidgets(
appWidgetIds: IntArray,
action: ActionCallback,
) {
val pending = goAsync()
// goAsync() hands out the receiver's PendingResult exactly once and nulls
// its own reference, so a second caller in the same dispatch gets null.
// GlanceAppWidgetReceiver.onUpdate already calls it for its own compose,
// and every caller here runs after super.onUpdate(), so null is the normal
// case rather than an edge one. Treating it as non-null threw out of the
// finally below and took the process with it.
val pending: BroadcastReceiver.PendingResult? = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
withTimeoutOrNull(REFRESH_TIMEOUT_MS) {
Expand All @@ -44,7 +57,12 @@ fun BroadcastReceiver.refreshWidgets(
} catch (t: Throwable) {
Log.w(TAG, "widget refresh failed: ${t::class.simpleName}")
} finally {
pending.finish()
// Nothing to release when Glance holds the broadcast; the refresh
// still runs, it just isn't protected from the process being
// killed. A cut-short refresh leaves the widget stale until the
// next tick, which is what happens today anyway.
runCatching { pending?.finish() }
.onFailure { Log.w(TAG, "could not finish broadcast: ${it::class.simpleName}") }
}
}
}
Loading