From ac35746209684d4e30c18f5344641ade29a33e37 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 27 May 2026 04:06:48 -0700 Subject: [PATCH] fix(menubar): recover from stuck loading state Commit 5800179 (multi-day calendar) accidentally removed stuck-loading recovery. Re-add an 8-second watchdog loop in MenuBarContent that calls recoverFromStuckLoading(). Change inFlightKeys from Set to timestamped Dict so the watchdog can detect orphaned quiet-refresh entries. Stop nuking payloadRefreshGeneration in the watchdog (it was killing healthy in-flight fetches). Bump Package.swift platform to macOS 15 to match NSAttributedString(attachment:) usage. --- mac/Package.swift | 2 +- mac/Sources/CodeBurnMenubar/AppStore.swift | 37 +++++++++++++------ .../Views/MenuBarContent.swift | 12 ++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/mac/Package.swift b/mac/Package.swift index 67509f2..51898da 100644 --- a/mac/Package.swift +++ b/mac/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "CodeBurnMenubar", platforms: [ - .macOS(.v14) + .macOS(.v15) ], products: [ .executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"]) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 7796a59..6678143 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -307,7 +307,7 @@ final class AppStore { } } - private var inFlightKeys: Set = [] + private var inFlightKeys: [PayloadCacheKey: Date] = [:] func resetLoadingState() { payloadRefreshGeneration &+= 1 @@ -333,22 +333,30 @@ final class AppStore { @discardableResult func clearStaleLoadingIfNeeded() -> Bool { let now = Date() - let staleEntries = loadingStartedAtByKey.filter { + let staleLoading = loadingStartedAtByKey.filter { now.timeIntervalSince($0.value) > loadingWatchdogSeconds } - guard !staleEntries.isEmpty else { return false } + let staleInFlight = inFlightKeys.filter { (key, insertedAt) in + now.timeIntervalSince(insertedAt) > loadingWatchdogSeconds && + loadingStartedAtByKey[key] == nil + } + guard !staleLoading.isEmpty || !staleInFlight.isEmpty else { return false } - payloadRefreshGeneration &+= 1 - for (key, started) in staleEntries { + for (key, started) in staleLoading { NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing", Int(now.timeIntervalSince(started)), key.label, key.provider.rawValue) loadingCountsByKey[key] = nil loadingStartedAtByKey[key] = nil - inFlightKeys.remove(key) + inFlightKeys[key] = nil if cache[key] == nil { lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background." } } + for (key, insertedAt) in staleInFlight { + NSLog("CodeBurn: orphaned in-flight key stuck for %ds on %@/%@ — clearing", + Int(now.timeIntervalSince(insertedAt)), key.label, key.provider.rawValue) + inFlightKeys[key] = nil + } return true } @@ -394,6 +402,11 @@ final class AppStore { cache.removeAll() } + func recoverFromStuckLoading() async { + resetLoadingState() + await refresh(key: currentKey, includeOptimize: false, force: true, showLoading: true) + } + func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading) } @@ -404,8 +417,8 @@ final class AppStore { let generationAtStart = payloadRefreshGeneration if Task.isCancelled { return } if !force, cache[key]?.isFresh == true { return } - if inFlightKeys.contains(key) { return } - inFlightKeys.insert(key) + if inFlightKeys[key] != nil { return } + inFlightKeys[key] = Date() attemptedKeys.insert(key) lastErrorByKey[key] = nil let didShowLoading = showLoading || cache[key] == nil @@ -425,7 +438,7 @@ final class AppStore { } defer { let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration - inFlightKeys.remove(key) + inFlightKeys[key] = nil if didShowLoading { finishLoading(for: key) } @@ -496,8 +509,8 @@ final class AppStore { invalidateStaleDayCache() let key = PayloadCacheKey(period: period, provider: .all, day: day) if !force, cache[key]?.isFresh == true { return } - if inFlightKeys.contains(key) { return } - inFlightKeys.insert(key) + if inFlightKeys[key] != nil { return } + inFlightKeys[key] = Date() attemptedKeys.insert(key) let cacheDateAtStart = cacheDate let generationAtStart = payloadRefreshGeneration @@ -505,7 +518,7 @@ final class AppStore { NSLog("CodeBurn: refreshing stale today status payload after %ds", age) } defer { - inFlightKeys.remove(key) + inFlightKeys[key] = nil } do { let fresh = try await DataClient.fetch(period: period, day: day, provider: .all, includeOptimize: false) diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 0e89bb2..8efdfb8 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -59,6 +59,18 @@ struct MenuBarContent: View { } else { BurnLoadingOverlay(periodLabel: store.selectionLabel) .transition(.opacity) + .task { + // Keep retrying until data loads or the view is removed. + // The original one-shot recovery silently gave up if the + // first attempt also stalled (generation mismatch, CLI + // timeout, day rollover race). Looping guarantees the + // user never sees a permanent spinner. + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(8)) + guard !Task.isCancelled, !store.hasCachedData else { return } + await store.recoverFromStuckLoading() + } + } } } }