Skip to content

Fix Live Activity background renewal; add push-to-start fallback and stuck-widget recovery#611

Open
bjorkert wants to merge 5 commits intodevfrom
fix/live-activity-background-renewal
Open

Fix Live Activity background renewal; add push-to-start fallback and stuck-widget recovery#611
bjorkert wants to merge 5 commits intodevfrom
fix/live-activity-background-renewal

Conversation

@bjorkert
Copy link
Copy Markdown
Contributor

@bjorkert bjorkert commented Apr 21, 2026

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:

  1. When the renewal deadline passed while the app was in the background, Activity.request() was called anyway. It can only succeed from a foregroundActive scene, so it always failed with visibility, the LA decayed, and the user only got their Live Activity back when they next opened the app.
  2. When the renewal path did run, the .dismissed callback 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.
  3. iOS sometimes stops invoking the widget extension well before the 8-hour deadline. The LA's shared content state is still correct, but the lock screen freezes because the body is never re-rendered — and nothing noticed.

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: visibility entries and .dismissed: endingForRestart=false, renewBy=0.0 → dismissed by USER lines immediately followed by [LA] ended on app terminate.

Changes to the general LA lifecycle

  • Restart classification (endingForRestart flag). Set before any internal activity.end() call — endOnTerminate, handleExpiredToken, forceRestart, and the foreground renewal path. The .dismissed state 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.
  • Foreground-race fix. willEnterForegroundNotification fires before the scene reaches foregroundActive, so restarting from there still throws visibility. handleForeground now just sets pendingForegroundRestart = true and the actual end+restart runs from handleDidBecomeActive, where Activity.request() is guaranteed to succeed.
  • Cold-start staleness check. startIfNeeded now detects an existing-but-stale LA on launch (laRenewalFailed, in the renewal window, or past staleDate) and ends+restarts it, covering the path where handleForeground never fires because the app was killed while the overlay was showing.
  • Background-audio fallback. When the background audio session permanently fails, handleBackgroundAudioFailed forces the renewal overlay immediately so the user sees "Tap to update" instead of silently losing updates.
  • Diagnostics. Added structured logs around handleForeground, handleDidBecomeActive, startIfNeeded, handleExpiredToken, and the .dismissed branch 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 a foregroundActive scene.

Mechanics

  • LiveActivityManager opens two long-lived streams at init (both behind #available guards, so the targets stay intact on older iOS):
    • Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates (iOS 17.2+) — the device-level push-to-start token is persisted to Storage.shared.laPushToStartToken each 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, laRenewBy is reset to a fresh 7.5h deadline, laRenewalFailed is cleared, LALivenessStore is cleared, and the new activity is bound through the same code path as a normal start.
  • Restart decisions run through a single attemptLARestart helper so deadline renewal and stuck-widget restart (below) take the same path:
    • Foreground: end old + Activity.request.
    • Background: attemptPushToStartIfEligible builds a fresh snapshot, bumps seq, and POSTs to /3/device/{token} with apns-push-type: liveactivity and event: "start" via APNSClient.sendLiveActivityStart. If the request can't be dispatched (no token, rate-limited), the existing markRenewalFailedFromBackground path runs and schedules the local notification.

The start payload omits an alert block, 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:

  • Success: backoff set to base (300s) so refresh ticks between the send and activityUpdates adoption don't re-fire.
  • 429 (rate limited): backoff doubled, capped at 60 min, then falls back to markRenewalFailed for this cycle.
  • 404/410 (token invalid): stored token cleared so the next pushToStartTokenUpdates delivery overwrites it; backoff reset.
  • Other failure: backoff bumped to at least base to avoid hammering APNs.

Stuck-widget recovery

The widget body contains a small LALivenessMarker SwiftUI view whose .task(id:) fires on every re-render and writes the rendered seq + timestamp into an app-group UserDefaults via LALivenessStore. The main app already had a shouldRestartBecauseExtensionLooksStuck() helper (behind on seq AND no render for 15+ minutes) but nothing called it.

LALivenessStore was 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 — lastExtensionSeenAt stayed 0.0 forever and the extensionHasNeverCheckedIn branch short-circuited as "silent" on every fresh LA. Field log LoopFollow 2026-04-21.log shows this exactly: seenSeq=0, lastSeenAt=0.0 across hours of runtime while expectedSeq climbs past 20, each check firing a push-to-start that then gets rate-limited into a markRenewalFailed notification. The fix is one line: use AppGroupID.current() so both processes share the suite.

performRefresh now calls restartIfExtensionLooksStuck(snapshot:) every tick, right after renewIfNeeded. When it fires, the same attemptLARestart helper 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 and laRenewalFailed's first-failure gate naturally dedupe repeat firings.

LALivenessStore is cleared whenever a fresh LA takes over (start-new, renew, force-restart, end, terminate, and now the foreground attemptLARestart path plus adoptPushToStartActivity), 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+).

  • iOS 16.xpushToStartTokenUpdates is never observed, laPushToStartToken stays empty, attemptPushToStartIfEligible no-ops and returns false, and the background renewal / stuck-widget paths fall straight through to markRenewalFailedFromBackground + 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.
  • iOS 16.2+activityUpdates is 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.
  • iOS 17.2+ — the full push-to-start fallback is active. Both a passed renewal deadline and a stuck widget extension will restart the LA in place without requiring the user to open the app.

- 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.
@bjorkert bjorkert changed the title Fix Live Activity background renewal; add push-to-start fallback (iOS 17.2+) Fix Live Activity background renewal; add push-to-start fallback and stuck-widget recovery Apr 21, 2026
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.
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.

1 participant