From 3ad898bbfb1db36569b8670cbb65a90cc14357bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 12 May 2026 16:31:37 +0200 Subject: [PATCH] Prevent stale foreground-restart latch from tearing down freshly renewed LA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pendingForegroundRestart could outlive the condition that triggered it: a brief foreground entry while the renewal overlay was up latched the intent, the user backgrounded before didBecomeActive ran, the background renewal then replaced the LA, and the next foreground entry minutes later fired the deferred restart against an already-fresh LA. - adoptPushToStartActivity clears pendingForegroundRestart on every adoption — a freshly-adopted LA resolves the renewal-window condition that latched the intent. - performForegroundRestart re-checks renewalFailed / overlayShowing / pushToStartLooksStuck before tearing down; bails if none still hold. - Deferred-foreground-restart push-to-start is tagged reason="deferred-foreground-restart" via a single-shot nextStartReasonOverride, so the stale-latch event is no longer indistinguishable from a real user start in logs. --- .../LiveActivity/LiveActivityManager.swift | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 3a58d12e1..be2b6f659 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -174,6 +174,17 @@ final class LiveActivityManager { Storage.shared.laRenewalFailed.value = false cancelRenewalFailedNotification() dismissedByUser = false + // A fresh LA invalidates any latched foreground-restart intent — the + // condition that prompted the latch (overlay showing / renewal failed) + // is resolved by adoption itself, so a deferred restart on the next + // didBecomeActive would needlessly tear down the just-adopted LA. + if pendingForegroundRestart { + LogManager.shared.log( + category: .general, + message: "[LA] adoption clears stale pendingForegroundRestart (LA already replaced via push-to-start)" + ) + pendingForegroundRestart = false + } bind(to: activity, logReason: "push-to-start-adopt") } @@ -291,10 +302,30 @@ final class LiveActivityManager { } private func performForegroundRestart() { + // Re-check the conditions that latched the intent. The latch can outlive its + // trigger — e.g. if the user briefly foregrounds the app while the renewal + // overlay is up, then backgrounds before didBecomeActive runs, the background + // renewal can replace the LA before the next foreground entry. By the time + // didBecomeActive eventually fires, the freshly-renewed LA is healthy and a + // restart would be gratuitous. + let renewalFailed = Storage.shared.laRenewalFailed.value + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let pushToStartLooksStuck = pushToStartSendsWithoutAdoption >= LiveActivityManager.pushToStartForceRestartThreshold + guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else { + LogManager.shared.log( + category: .general, + message: "[LA] deferred foreground restart skipped — conditions no longer hold (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), pushToStartLooksStuck=\(pushToStartLooksStuck))" + ) + return + } + // Mark restart intent BEFORE clearing storage flags, so any late .dismissed // from the old activity is never misclassified as a user swipe. endingForRestart = true dismissedByUser = false + nextStartReasonOverride = "deferred-foreground-restart" // Stop any observers/tasks tied to the previous activity instance. In the // current=nil branch below, the old observer can otherwise deliver a late @@ -458,6 +489,12 @@ final class LiveActivityManager { /// new `pushToStartToken` when the current one has gone silent /// (Apple FB21158660). private var pushToStartSendsWithoutAdoption: Int = 0 + /// Single-shot override for the next push-to-start reason tag. Consumed by + /// `startIfNeeded`. Lets the deferred-foreground-restart path tag its + /// push-to-start with a distinct label instead of "user-start", which made + /// the 8:25 stale-latch event indistinguishable from a real user start in + /// the log. + private var nextStartReasonOverride: String? // MARK: - Public API @@ -475,6 +512,9 @@ final class LiveActivityManager { return } + let startReason = nextStartReasonOverride ?? "user-start" + nextStartReasonOverride = nil + if #available(iOS 17.2, *) { // iOS 17.2+ uses push-to-start for every creation path. If an // activity is already running and not stale we adopt/reuse it @@ -495,10 +535,10 @@ final class LiveActivityManager { category: .general, message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" ) - attemptPushToStartCreate(reason: "user-start", oldActivity: existing) + attemptPushToStartCreate(reason: startReason, oldActivity: existing) return } - attemptPushToStartCreate(reason: "user-start", oldActivity: nil) + attemptPushToStartCreate(reason: startReason, oldActivity: nil) } else { startIfNeededLegacy() }