Fix Live Activity background renewal; add push-to-start fallback and stuck-widget recovery#611
Open
Fix Live Activity background renewal; add push-to-start fallback and stuck-widget recovery#611
Conversation
- handleExpiredToken, endOnTerminate, forceRestart: mark endingForRestart before ending so the state observer does not misclassify the resulting .dismissed as a user swipe (which would set dismissedByUser=true and block auto-restart on the next background refresh). - Defer foreground restart from willEnterForeground to didBecomeActive so Activity.request() is not called before the scene is active (avoids the "visibility" failure). - Remove duplicate orphan LiveActivitySettingsView.swift under Settings/ (not referenced by the Xcode project).
- startIfNeeded: log entry state (authorized, activities, current, flags) and enrich Activity.request failure with NSError domain/code + scene state. - renewIfNeeded: enrich catch with NSError domain/code + authorization state. - handleForeground / handleDidBecomeActive: include applicationState and the existing activities count at entry. - observePushToken: log token fingerprint (last 8 chars) and prior value so token rotations are visible. - update: log when the direct ActivityKit update is skipped (app backgrounded) and when APNs is skipped because no push token has been received yet. - performRefresh: log the gate that blocks LA updates — especially dismissedByUser=true, which previously caused silent extended outages. - handleExpiredToken: log current id, activities count, and flags before ending so APNs 410/404 events are correlatable to the restart path. - bind: include activityState and the previous endingForRestart value so the dismissal-classification path is traceable.
When the 8-hour renewal deadline passes while the app isn't foregroundActive, Activity.request() fails with `visibility` and the LA decays until the user foregrounds the app. On iOS 17.2+, fall back to an APNs push-to-start: LoopFollow — already awake in the background via silent tune / bluetooth — sends the start payload to itself. The new activity is discovered via Activity.activityUpdates, the old one is ended, and the renewal deadline is reset. Attempts are gated by a stored backoff: base 5 min, doubled on APNs 429 up to 60 min; a 410/404 clears the stored token so the next pushToStartTokenUpdates delivery re-arms it. On iOS <17.2 no token is ever stored, the push-to-start path no-ops, and the existing markRenewalFailed + local-notification fallback runs unchanged.
iOS occasionally stops invoking the widget extension long before the 8-hour renewal deadline — the LA keeps its shared content state but the lock screen shows stale glucose because the body is never re-rendered. `LALivenessStore` (written from `LALivenessMarker` in the widget body) already tracks the last rendered seq and timestamp, and `shouldRestartBecauseExtensionLooksStuck()` already encodes the "behind on seq AND silent for 15 min" decision, but nothing called it. `performRefresh` now checks it every tick after `renewIfNeeded`. Both paths delegate to a new shared `attemptLARestart` helper so deadline renewal and stuck-extension restart take the same decisions: foreground does end + `Activity.request`, background tries push-to-start (iOS 17.2+) and falls back to `markRenewalFailedFromBackground` + local notification. Running every tick is safe because `laLastPushToStartAt` / `laPushToStartBackoff` and `isFirstFailure` naturally dedupe repeat firings. Also clear `LALivenessStore` when a fresh LA takes over — both in the foreground restart path and in `adoptPushToStartActivity` — so the previous LA's last-seen seq can't leave the new one looking stuck on its first refresh ticks.
LALivenessStore was using the bare bundle id as the UserDefaults suite, so the widget extension's liveness marker and the main app ended up on different backing stores. The app-side read of lastExtensionSeenAt was permanently 0.0, which made shouldRestartBecauseExtensionLooksStuck() short-circuit as "extension has never checked in → treat as silent". Use AppGroupID.current() so both processes share the same suite, the marker actually crosses the boundary, and stuck-detection only fires after real silence. Also drop the alert block from the push-to-start APNs payload so background Live Activity restarts happen silently, without a banner.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Why
Live Activities have an 8-hour ceiling, so LoopFollow ends the current LA and starts a fresh one before the deadline. Three things made that fragile:
Activity.request()was called anyway. It can only succeed from aforegroundActivescene, so it always failed withvisibility, the LA decayed, and the user only got their Live Activity back when they next opened the app..dismissedcallback for the old activity raced against the storage-flag clears. On the unlucky ordering the old LA's dismissal was classified as a user swipe —dismissedByUser=true— which then blocked the auto-restart entirely. Logs showed this triggering on nearly every app-terminate / restart sequence.Four user-provided log files (2026-04-19, 2026-04-19-1, 2026-04-20, 2026-04-20-1) contained direct evidence of the first two failure modes, including multiple
Live Activity failed to start: visibilityentries and.dismissed: endingForRestart=false, renewBy=0.0 → dismissed by USERlines immediately followed by[LA] ended on app terminate.Changes to the general LA lifecycle
endingForRestartflag). Set before any internalactivity.end()call —endOnTerminate,handleExpiredToken,forceRestart, and the foreground renewal path. The.dismissedstate observer now checks this flag first, so a system-initiated end is never misclassified as a user swipe regardless of which MainActor hop runs first.willEnterForegroundNotificationfires before the scene reachesforegroundActive, so restarting from there still throwsvisibility.handleForegroundnow just setspendingForegroundRestart = trueand the actual end+restart runs fromhandleDidBecomeActive, whereActivity.request()is guaranteed to succeed.startIfNeedednow detects an existing-but-stale LA on launch (laRenewalFailed, in the renewal window, or paststaleDate) and ends+restarts it, covering the path wherehandleForegroundnever fires because the app was killed while the overlay was showing.handleBackgroundAudioFailedforces the renewal overlay immediately so the user sees "Tap to update" instead of silently losing updates.handleForeground,handleDidBecomeActive,startIfNeeded,handleExpiredToken, and the.dismissedbranch so classification decisions are visible in the log stream.Push-to-start fallback (iOS 17.2+)
The real fix for background renewal is iOS 17.2's push-to-start. LoopFollow is already awake in the background (silent tune / bluetooth session), so when the renewal deadline passes while the app is backgrounded, it can send an APNs
event: "start"payload to itself and iOS will spawn a fresh Live Activity without needing aforegroundActivescene.Mechanics
LiveActivityManageropens two long-lived streams at init (both behind#availableguards, so the targets stay intact on older iOS):Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates(iOS 17.2+) — the device-level push-to-start token is persisted toStorage.shared.laPushToStartTokeneach time iOS issues one. The token survives app relaunches.Activity<GlucoseLiveActivityAttributes>.activityUpdates(iOS 16.2+) — any new activity not already bound is adopted: the old LA is ended immediately,laRenewByis reset to a fresh 7.5h deadline,laRenewalFailedis cleared,LALivenessStoreis cleared, and the new activity is bound through the same code path as a normal start.attemptLARestarthelper so deadline renewal and stuck-widget restart (below) take the same path:Activity.request.attemptPushToStartIfEligiblebuilds a fresh snapshot, bumpsseq, and POSTs to/3/device/{token}withapns-push-type: liveactivityandevent: "start"viaAPNSClient.sendLiveActivityStart. If the request can't be dispatched (no token, rate-limited), the existingmarkRenewalFailedFromBackgroundpath runs and schedules the local notification.The start payload omits an
alertblock, so the restart is silent — iOS spawns the fresh Live Activity without surfacing a banner to the user.Rate limiting
Once-per-failed-renewal with exponential backoff stored in
laPushToStartBackoff/laLastPushToStartAt:activityUpdatesadoption don't re-fire.markRenewalFailedfor this cycle.pushToStartTokenUpdatesdelivery overwrites it; backoff reset.Stuck-widget recovery
The widget body contains a small
LALivenessMarkerSwiftUI view whose.task(id:)fires on every re-render and writes the renderedseq+ timestamp into an app-groupUserDefaultsviaLALivenessStore. The main app already had ashouldRestartBecauseExtensionLooksStuck()helper (behind on seq AND no render for 15+ minutes) but nothing called it.LALivenessStorewas pointing at the bare bundle id (com.<team>.LoopFollow) rather than the App Group suite (group.<base>), so the widget's writes and the app's reads were on different backing stores —lastExtensionSeenAtstayed0.0forever and theextensionHasNeverCheckedInbranch short-circuited as "silent" on every fresh LA. Field logLoopFollow 2026-04-21.logshows this exactly:seenSeq=0, lastSeenAt=0.0across hours of runtime whileexpectedSeqclimbs past 20, each check firing a push-to-start that then gets rate-limited into amarkRenewalFailednotification. The fix is one line: useAppGroupID.current()so both processes share the suite.performRefreshnow callsrestartIfExtensionLooksStuck(snapshot:)every tick, right afterrenewIfNeeded. When it fires, the sameattemptLARestarthelper runs — so a frozen widget triggers a push-to-start on iOS 17.2+ or an overlay + local notification on iOS 16.x, exactly like a deadline-based renewal. Running on every refresh is safe because the push-to-start backoff andlaRenewalFailed's first-failure gate naturally dedupe repeat firings.LALivenessStoreis cleared whenever a fresh LA takes over (start-new, renew, force-restart, end, terminate, and now the foregroundattemptLARestartpath plusadoptPushToStartActivity), so the previous LA's last-seen seq cannot leave the new one looking stuck on its first refresh ticks.iOS 16 vs iOS 17 behavior after this PR
The minimum deployment target stays at iOS 16. The push-to-start code is all gated by
#available(iOS 17.2, *)(for the token observer) or by the presence of a stored token (which can only be populated on iOS 17.2+).pushToStartTokenUpdatesis never observed,laPushToStartTokenstays empty,attemptPushToStartIfEligibleno-ops and returnsfalse, and the background renewal / stuck-widget paths fall straight through tomarkRenewalFailedFromBackground+ local notification exactly as before. No behavioral change except for the stuck-widget detection, which will now mark renewal failed and notify instead of silently showing a frozen lock screen.activityUpdatesis observed and the adoption path works, so out-of-band starts would be picked up if they ever occurred. In practice on 16.2–17.1 nothing else starts LAs for us, so it's a no-op but costs nothing.