Skip to content

Recover Live Activity after APNs 410 token expiry#657

Open
bjorkert wants to merge 1 commit into
devfrom
fix-la-410-recovery
Open

Recover Live Activity after APNs 410 token expiry#657
bjorkert wants to merge 1 commit into
devfrom
fix-la-410-recovery

Conversation

@bjorkert
Copy link
Copy Markdown
Member

Summary

A 6.1.0 user reported the Live Activity vanishing overnight and
refusing to come back without a manual Restart. Log trace:

  • 04:12 — scheduled renewal succeeds, new LA adopted.
  • 04:42 — APNs token expired (410) — restarting Live Activity,
    handleExpiredToken ends the activity.
  • 04:42 – 08:42 — activities=1, current=nil, renewBy=0.0; every
    foreground entry logs no action needed (not in renewal window).
    No new LA is created.
  • 08:42 — .dismissed: endingForRestart=false, renewalFailed=false, pastDeadline=false, renewBy=0.0 → classified as user swipe,
    dismissedByUser=true latches. From here every refresh logs
    LA update skipped — dismissedByUser=true until the user opens
    the app and taps Restart.

Root cause

Two cooperating bugs around an app-initiated end():

  1. end() nulls current and clears laRenewBy. handleExpiredToken's
    comment said the LA would restart on the next BG refresh via
    refreshFromCurrentState(), but renewIfNeeded short-circuits when
    current is nil, and performRefresh's bind-existing path then
    rebinds to the just-ended activity (still listed in
    Activity<>.activities until iOS purges it). bind() clears
    endingForRestart, so the late .dismissed reads as renewBy=0 /
    renewalFailed=false / endingForRestart=false — branch (c) "USER"
    in the classifier.

  2. The classifier had no way to recognize a stale observer firing for an
    activity the app no longer tracks.

Fixes

  • handleExpiredToken drives the restart synchronously on iOS 17.2+
    (attemptPushToStartCreate(reason: "expired-token")), so the orphaned
    post-410 state is short-lived and adoption of the fresh activity
    cancels the old observer via attachStateObserver.
  • performRefresh / update bind-existing only to .active
    activities.
    Binding to an .ended/.dismissed corpse would clear
    endingForRestart and re-attach an observer that only ever delivers
    .dismissed.
  • .dismissed classifier gains branch (d): stale observer. If the
    dismissed activity is not the one we currently track, log and take no
    action. Only the foreground LA can be user-swiped, so a stale-observer
    delivery for an already-replaced activity must not latch
    dismissedByUser=true. The wasCurrentActivity signal is captured
    before current is cleared so legitimate user swipes still classify
    correctly.

A 6.1.0 user reported the Live Activity vanishing and refusing to come
back without a manual Restart. Trace: APNs returned 410 on the per-
activity push token at 04:42; handleExpiredToken ended the activity but
the eventual iOS .dismissed (4 h later, under the default dismissal
policy) was classified as a user swipe and locked dismissedByUser=true.

Root cause is two cooperating bugs around an app-initiated end():

- end() nulls `current` and clears laRenewBy. handleExpiredToken's
  comment said "Activity will restart on next BG refresh via
  refreshFromCurrentState()", but renewIfNeeded short-circuits when
  current is nil and performRefresh's bind-existing path rebinds to the
  just-ended activity. bind() then clears endingForRestart, so the late
  .dismissed reads as renewBy=0 / renewalFailed=false / endingForRestart=
  false — branch (c) "USER" in the classifier.

- The classifier had no way to recognize a stale observer firing for an
  activity the app no longer tracks.

Fixes:

- handleExpiredToken drives the restart synchronously on iOS 17.2+
  (attemptPushToStartCreate "expired-token"), so the orphaned post-410
  state is short-lived and adoption of the fresh activity cancels the
  old observer.

- performRefresh / update bind-existing only to activities in
  .active state. Binding to an .ended/.dismissed corpse would clear
  endingForRestart and re-attach an observer that only ever delivers
  .dismissed.

- .dismissed classifier gains branch (d): if the dismissed activity is
  not the one we currently track, log and take no action — only the
  foreground LA can be user-swiped, so a stale-observer delivery for an
  already-replaced activity must not latch dismissedByUser=true.
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