From 3751b3381ccc3a5ad0579fda9effdccb5482edc9 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 27 May 2026 04:38:42 -0700 Subject: [PATCH] fix(menubar): add watchdog backoff, remove dead RefreshBackoff code PR #393 accidentally deleted the RefreshBackoff integration from PR #388, leaving RefreshBackoff.swift as dead code and tests calling nonexistent methods. Delete the dead code and fix the broken test target. Add exponential backoff to the loading watchdog (8s, 16s, 32s... up to 60s, max 6 attempts) so it stops hammering the CLI when it's unavailable. After exhausting retries, show an error overlay with a Retry button. Fix recoverFromStuckLoading to skip recovery when a fetch is already in-flight (avoids killing healthy fetches via generation bump). Fix selectedDay to return nil for multi-day selections, and pass days through startInteractiveSelectionRefresh so the cache key matches currentKey. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 16 ++++-- .../CodeBurnMenubar/RefreshBackoff.swift | 47 --------------- .../Views/MenuBarContent.swift | 15 ++--- .../AppStoreRefreshRecoveryTests.swift | 11 ---- .../RefreshBackoffTests.swift | 57 ------------------- 5 files changed, 20 insertions(+), 126 deletions(-) delete mode 100644 mac/Sources/CodeBurnMenubar/RefreshBackoff.swift delete mode 100644 mac/Tests/CodeBurnMenubarTests/RefreshBackoffTests.swift 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) - } -}