Skip to content

Fix Live Activity background renewal; add push-to-start fallback#611

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

Fix Live Activity background renewal; add push-to-start fallback#611
bjorkert wants to merge 7 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. Two 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.

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: 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, and the new activity is bound through the same code path as a normal start.
  • Restart decisions run through a single attemptLARestart helper so the deadline-renewal path takes a consistent set of foreground / background / push-to-start / mark-failed decisions:
    • 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: 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 LALivenessMarker SwiftUI view inside the widget body that wrote the last-rendered seq + timestamp to a shared App Group UserDefaults, and a shouldRestartBecauseExtensionLooksStuck() 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, so lastExtensionSeenAt stayed 0.0 forever regardless of how many times the widget re-rendered. Field log LoopFollow 2026-04-21.log showed this directly — seenSeq=0, lastSeenAt=0.0 across hours of runtime while expectedSeq climbed past 20, each check firing a push-to-start that then got rate-limited into a markRenewalFailed notification. An earlier commit in this PR tried to fix this by using AppGroupID.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 scattered LALivenessStore.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+).

  • iOS 16.xpushToStartTokenUpdates is never observed, laPushToStartToken stays empty, attemptPushToStartIfEligible no-ops and returns false, and the background renewal path falls straight through to markRenewalFailedFromBackground + local notification exactly as before.
  • 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. A passed renewal deadline 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.
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.
@bjorkert bjorkert changed the title Fix Live Activity background renewal; add push-to-start fallback and stuck-widget recovery Fix Live Activity background renewal; add push-to-start fallback Apr 22, 2026
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.
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