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.
This commit is contained in:
iamtoruk 2026-05-27 04:38:42 -07:00
parent 40dcb410a5
commit 3751b3381c
5 changed files with 20 additions and 126 deletions

View file

@ -40,7 +40,7 @@ final class AppStore {
var selectedDays: Set<String> = []
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 {

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}