From 372681cae2e3366574aaeb48f1f6a25458ec73e4 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Thu, 28 May 2026 14:06:13 -0700 Subject: [PATCH] fix(menubar): recover from stuck loading when in-flight entry is orphaned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quiet refresh torn down across sleep/wake (or a generation reset) can leave an orphaned inFlightKeys entry for the current key. The stuck-loading recovery guard bailed whenever any in-flight entry existed, so the popover's retry loop no-oped forever and the spinner ("Loading Today...") never cleared — observed on a long-lived instance that crossed the midnight day rollover. Clear stale loading/in-flight state via the existing watchdog before the in-flight guard, so an orphaned entry can no longer trap recovery. A healthy in-flight fetch (younger than the watchdog) is still respected. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 27 ++++++++++++++++-- .../AppStoreRefreshRecoveryTests.swift | 28 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 65f30f1..531cd9f 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -225,6 +225,14 @@ final class AppStore { func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, day: String? = nil, fetchedAt: Date) { cache[PayloadCacheKey(period: period, provider: provider, day: day)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) } + + func seedInFlightForTesting(period: Period, provider: ProviderFilter, day: String? = nil, insertedAt: Date) { + inFlightKeys[PayloadCacheKey(period: period, provider: provider, day: day)] = insertedAt + } + + func isInFlightForTesting(period: Period, provider: ProviderFilter, day: String? = nil) -> Bool { + inFlightKeys[PayloadCacheKey(period: period, provider: provider, day: day)] != nil + } #endif var findingsCount: Int { @@ -404,11 +412,26 @@ final class AppStore { } func recoverFromStuckLoading() async { + guard prepareStuckLoadingRecovery() else { return } + await refresh(key: currentKey, includeOptimize: false, force: true, showLoading: true) + } + + /// Decides whether stuck-loading recovery should kick off a fresh fetch for + /// the current key, preparing the loading bookkeeping when it can. + /// + /// A quiet refresh torn down across sleep/wake (or a generation reset) can + /// leave an orphaned `inFlightKeys` entry behind. Without clearing stale + /// state first the in-flight guard would bail on every retry, trapping the + /// popover on the spinner forever. A healthy in-flight fetch (younger than + /// the watchdog) is still respected so recovery never kills it. + @discardableResult + func prepareStuckLoadingRecovery() -> Bool { + _ = clearStaleLoadingIfNeeded() let key = currentKey - guard inFlightKeys[key] == nil else { return } + guard inFlightKeys[key] == nil else { return false } loadingCountsByKey[key] = nil loadingStartedAtByKey[key] = nil - await refresh(key: key, includeOptimize: false, force: true, showLoading: true) + return true } func setRecoveryExhausted(for label: String) { diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index 778d860..24bad44 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -91,4 +91,32 @@ struct AppStoreRefreshRecoveryTests { #expect(store.shouldResetInteractiveRefreshPipeline) } + @Test("orphaned stale in-flight entry does not block stuck-loading recovery") + func staleInFlightDoesNotBlockRecovery() { + let store = AppStore() + // A quiet refresh torn down across sleep/wake can leave an in-flight + // entry behind for the current key with no cache and no active loading + // counter, far older than the watchdog window. Recovery must clear it + // and proceed instead of bailing on the in-flight guard forever. + store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date().addingTimeInterval(-3600)) + + #expect(store.isInFlightForTesting(period: .today, provider: .all)) + + let canRecover = store.prepareStuckLoadingRecovery() + + #expect(canRecover) + #expect(!store.isInFlightForTesting(period: .today, provider: .all)) + } + + @Test("healthy in-flight fetch is not killed by recovery") + func healthyInFlightFetchSurvivesRecovery() { + let store = AppStore() + store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date()) + + let canRecover = store.prepareStuckLoadingRecovery() + + #expect(!canRecover) + #expect(store.isInFlightForTesting(period: .today, provider: .all)) + } + }