diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 6678143e..65f30f10 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -40,7 +40,7 @@ final class AppStore { var selectedDays: Set = [] var selectedDay: String? { - guard selectedDays.count == 1 else { return selectedDays.min() } + guard selectedDays.count == 1 else { return nil } return selectedDays.first } private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() { @@ -294,7 +294,8 @@ final class AppStore { let period = selectedPeriod let provider = selectedProvider let day = selectedDay - let key = PayloadCacheKey(period: period, provider: provider, day: day) + let days = selectedDays + let key = PayloadCacheKey(period: period, provider: provider, day: day, days: days) lastErrorByKey[key] = nil switchTask = Task { if provider == .all { @@ -403,8 +404,15 @@ final class AppStore { } func recoverFromStuckLoading() async { - resetLoadingState() - await refresh(key: currentKey, includeOptimize: false, force: true, showLoading: true) + let key = currentKey + guard inFlightKeys[key] == nil else { return } + loadingCountsByKey[key] = nil + loadingStartedAtByKey[key] = nil + await refresh(key: key, includeOptimize: false, force: true, showLoading: true) + } + + func setRecoveryExhausted(for label: String) { + lastErrorByKey[currentKey] = "Could not load \(label). Check that the codeburn CLI is installed and working." } func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { diff --git a/mac/Sources/CodeBurnMenubar/RefreshBackoff.swift b/mac/Sources/CodeBurnMenubar/RefreshBackoff.swift deleted file mode 100644 index 717f6780..00000000 --- a/mac/Sources/CodeBurnMenubar/RefreshBackoff.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -struct RefreshBackoff { - let stallThreshold: Int - let initialDelay: TimeInterval - let maximumDelay: TimeInterval - - private(set) var consecutiveStalls = 0 - private(set) var pausedUntil: Date? - - init(stallThreshold: Int = 3, initialDelay: TimeInterval = 30, maximumDelay: TimeInterval = 300) { - self.stallThreshold = stallThreshold - self.initialDelay = initialDelay - self.maximumDelay = maximumDelay - } - - mutating func recordStall(now: Date = Date()) -> Date? { - consecutiveStalls += 1 - guard consecutiveStalls >= stallThreshold else { return nil } - - let exponent = max(0, consecutiveStalls - stallThreshold) - let multiplier = pow(2.0, Double(exponent)) - let delay = min(initialDelay * multiplier, maximumDelay) - let until = now.addingTimeInterval(delay) - pausedUntil = until - return until - } - - mutating func recordSuccess() { - consecutiveStalls = 0 - pausedUntil = nil - } - - mutating func retryNow(resetStallCount: Bool = false) { - pausedUntil = nil - if resetStallCount { - consecutiveStalls = 0 - } - } - - mutating func isPaused(now: Date = Date()) -> Bool { - guard let pausedUntil else { return false } - if pausedUntil > now { return true } - self.pausedUntil = nil - return false - } -} diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 8efdfb8e..12dec279 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -60,16 +60,17 @@ struct MenuBarContent: View { 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)) + var delay: Duration = .seconds(8) + let maxDelay: Duration = .seconds(60) + let maxAttempts = 6 + for attempt in 1...maxAttempts { + try? await Task.sleep(for: delay) guard !Task.isCancelled, !store.hasCachedData else { return } await store.recoverFromStuckLoading() + if attempt < maxAttempts { delay = min(delay * 2, maxDelay) } } + guard !Task.isCancelled, !store.hasCachedData else { return } + store.setRecoveryExhausted(for: store.selectionLabel) } } } diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index ee14c50c..778d8602 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -91,15 +91,4 @@ struct AppStoreRefreshRecoveryTests { #expect(store.shouldResetInteractiveRefreshPipeline) } - @Test("refresh pause message is visible and clearable") - func refreshPauseMessageIsVisibleAndClearable() { - let store = AppStore() - - store.pauseAutomaticRefresh(until: Date(timeIntervalSince1970: 4_000), consecutiveStalls: 3) - #expect(store.refreshPauseMessage?.contains("Refresh paused") == true) - #expect(store.refreshPauseMessage?.contains("3 stalled attempts") == true) - - store.clearRefreshPause() - #expect(store.refreshPauseMessage == nil) - } } diff --git a/mac/Tests/CodeBurnMenubarTests/RefreshBackoffTests.swift b/mac/Tests/CodeBurnMenubarTests/RefreshBackoffTests.swift deleted file mode 100644 index dcb0eb6f..00000000 --- a/mac/Tests/CodeBurnMenubarTests/RefreshBackoffTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import Testing -@testable import CodeBurnMenubar - -@Suite("Refresh backoff") -struct RefreshBackoffTests { - @Test("pauses after threshold and escalates exponentially") - func pausesAfterThresholdAndEscalatesExponentially() { - let now = Date(timeIntervalSince1970: 1_000) - var backoff = RefreshBackoff(stallThreshold: 3, initialDelay: 30, maximumDelay: 300) - - #expect(backoff.recordStall(now: now) == nil) - #expect(backoff.recordStall(now: now) == nil) - - let firstPause = backoff.recordStall(now: now) - #expect(firstPause == now.addingTimeInterval(30)) - let pausedBeforeExpiry = backoff.isPaused(now: now.addingTimeInterval(29)) - #expect(pausedBeforeExpiry) - let pausedAfterExpiry = backoff.isPaused(now: now.addingTimeInterval(31)) - #expect(!pausedAfterExpiry) - - let secondPause = backoff.recordStall(now: now) - #expect(secondPause == now.addingTimeInterval(60)) - - _ = backoff.recordStall(now: now) - _ = backoff.recordStall(now: now) - _ = backoff.recordStall(now: now) - _ = backoff.recordStall(now: now) - let cappedPause = backoff.recordStall(now: now) - #expect(cappedPause == now.addingTimeInterval(300)) - } - - @Test("success clears stall count and pause") - func successClearsStallCountAndPause() { - let now = Date(timeIntervalSince1970: 2_000) - var backoff = RefreshBackoff(stallThreshold: 1, initialDelay: 30, maximumDelay: 300) - - #expect(backoff.recordStall(now: now) == now.addingTimeInterval(30)) - backoff.recordSuccess() - - #expect(backoff.consecutiveStalls == 0) - #expect(backoff.pausedUntil == nil) - #expect(backoff.recordStall(now: now) == now.addingTimeInterval(30)) - } - - @Test("manual retry clears pause without erasing stall history") - func manualRetryClearsPauseWithoutErasingStallHistory() { - let now = Date(timeIntervalSince1970: 3_000) - var backoff = RefreshBackoff(stallThreshold: 1, initialDelay: 30, maximumDelay: 300) - - _ = backoff.recordStall(now: now) - backoff.retryNow(resetStallCount: false) - - #expect(backoff.pausedUntil == nil) - #expect(backoff.consecutiveStalls == 1) - } -}