Fix Live Activity background renewal; add push-to-start fallback#611
Open
Fix Live Activity background renewal; add push-to-start fallback#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.
The widget extension cannot persist writes to the shared App Group UserDefaults — its sandbox silently blocks them. The liveness probe therefore never recorded any extension renders, and the stuck-detection heuristic fired false positives that pushed users into the renewal overlay and the local notification path. The 7.5h deadline renewal remains the safety net for the underlying 8h Live Activity ceiling.
Extend the Live Activity logs so a post-mortem can answer "did the restart land before the 8-hour ceiling, and if not why" from a single log file: - `renewIfNeeded`: log laAge, overdueBy, and timeToCeiling when the renewal deadline trips. Positive timeToCeiling means we still beat the 8h ceiling; negative means we missed it. - `bind`: log renewIn / ceilingIn so each LA's lifetime can be anchored without cross-referencing storage keys. - `startPushToStartTokenObservation` / `startActivityUpdatesObservation`: log start and stream-end, count deliveries, and mark the iOS<17.2 / iOS<16.2 unavailable branch explicitly. A silent stream end is a common "why push-to-start stopped working" fingerprint. - `adoptPushToStartActivity`: log staleIn, totalActivities, incoming seq, and the delay since the last successful push-to-start send. - `attemptPushToStartIfEligible`: log staleIn, existingActivities, and the current bound id at the firing site. - `handlePushToStartResult`: log APNs round-trip elapsedMs so a slow APNs call can be distinguished from a slow iOS adoption.
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. Two 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 both 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, and the new activity is bound through the same code path as a normal start.attemptLARestarthelper so the deadline-renewal path takes a consistent set of foreground / background / push-to-start / mark-failed decisions: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: attempted and removed
iOS sometimes stops invoking the widget extension well before the 8-hour deadline — the LA keeps its shared content state but the lock screen freezes because the body is never re-rendered. An earlier commit in this PR added a
LALivenessMarkerSwiftUI view inside the widget body that wrote the last-renderedseq+ timestamp to a shared App GroupUserDefaults, and ashouldRestartBecauseExtensionLooksStuck()helper in the main app that triggered a restart when the extension went quiet.It didn't work: the widget extension's sandbox silently blocks writes to shared App Group
UserDefaults. The writes return without throwing but nothing persists, solastExtensionSeenAtstayed0.0forever regardless of how many times the widget re-rendered. Field logLoopFollow 2026-04-21.logshowed this directly —seenSeq=0, lastSeenAt=0.0across hours of runtime whileexpectedSeqclimbed past 20, each check firing a push-to-start that then got rate-limited into amarkRenewalFailednotification. An earlier commit in this PR tried to fix this by usingAppGroupID.current()as the suite (the store was previously on the bare bundle id); with the correct suite the writes still don't cross the sandbox, so the fix didn't resolve it either.The final commit on this branch removes
LALivenessStore,LALivenessMarker,shouldRestartBecauseExtensionLooksStuck,restartIfExtensionLooksStuck, and the six scatteredLALivenessStore.clear()calls. The 7.5h deadline renewal (now with push-to-start fallback) remains the safety net for the 8-hour ceiling.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 path falls straight through tomarkRenewalFailedFromBackground+ local notification exactly as before.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.