Recover Live Activity after APNs 410 token expiry#657
Open
bjorkert wants to merge 1 commit into
Open
Conversation
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.
bjorkert
added a commit
that referenced
this pull request
May 12, 2026
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.
Summary
A 6.1.0 user reported the Live Activity vanishing overnight and
refusing to come back without a manual Restart. Log trace:
APNs token expired (410) — restarting Live Activity,handleExpiredTokenends the activity.activities=1, current=nil, renewBy=0.0; everyforeground entry logs
no action needed (not in renewal window).No new LA is created.
.dismissed: endingForRestart=false, renewalFailed=false, pastDeadline=false, renewBy=0.0→ classified as user swipe,dismissedByUser=truelatches. From here every refresh logsLA update skipped — dismissedByUser=trueuntil the user opensthe app and taps Restart.
Root cause
Two cooperating bugs around an app-initiated
end():end()nullscurrentand clearslaRenewBy.handleExpiredToken'scomment said the LA would restart on the next BG refresh via
refreshFromCurrentState(), butrenewIfNeededshort-circuits whencurrentis nil, andperformRefresh'sbind-existingpath thenrebinds to the just-ended activity (still listed in
Activity<>.activitiesuntil iOS purges it).bind()clearsendingForRestart, so the late.dismissedreads asrenewBy=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
handleExpiredTokendrives the restart synchronously on iOS 17.2+(
attemptPushToStartCreate(reason: "expired-token")), so the orphanedpost-410 state is short-lived and adoption of the fresh activity
cancels the old observer via
attachStateObserver.performRefresh/updatebind-existing only to.activeactivities. Binding to an
.ended/.dismissedcorpse would clearendingForRestartand re-attach an observer that only ever delivers.dismissed..dismissedclassifier gains branch (d): stale observer. If thedismissed 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. ThewasCurrentActivitysignal is capturedbefore
currentis cleared so legitimate user swipes still classifycorrectly.