Fix menubar refresh recovery deadlock

This commit is contained in:
iamtoruk 2026-05-10 03:30:56 -07:00
parent 66316aba38
commit d79deefaae
3 changed files with 97 additions and 23 deletions

View file

@ -25,10 +25,14 @@ final class AppStore {
}
var showingAccentPicker: Bool = false
var currency: String = "USD"
var isLoading: Bool { loadingCount > 0 }
private var loadingCount: Int = 0
private var loadingStartedAt: Date?
var lastError: String?
var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } }
var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 }
var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) }
var lastError: String? { lastErrorByKey[currentKey] }
private var loadingCountsByKey: [PayloadCacheKey: Int] = [:]
private var loadingStartedAtByKey: [PayloadCacheKey: Date] = [:]
private var attemptedKeys: Set<PayloadCacheKey> = []
private var lastErrorByKey: [PayloadCacheKey: String] = [:]
var subscription: SubscriptionUsage?
var subscriptionError: String?
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped
@ -131,8 +135,8 @@ final class AppStore {
private var inFlightKeys: Set<PayloadCacheKey> = []
func resetLoadingState() {
loadingCount = 0
loadingStartedAt = nil
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
}
@ -140,13 +144,42 @@ final class AppStore {
@discardableResult
func clearStaleLoadingIfNeeded() -> Bool {
guard isLoading, let started = loadingStartedAt,
Date().timeIntervalSince(started) > loadingWatchdogSeconds else { return false }
NSLog("CodeBurn: loading stuck for %ds — auto-clearing", Int(Date().timeIntervalSince(started)))
resetLoadingState()
let now = Date()
let staleEntries = loadingStartedAtByKey.filter {
now.timeIntervalSince($0.value) > loadingWatchdogSeconds
}
guard !staleEntries.isEmpty else { return false }
for (key, started) in staleEntries {
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue)
loadingCountsByKey[key] = nil
loadingStartedAtByKey[key] = nil
inFlightKeys.remove(key)
if cache[key] == nil {
lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background."
}
}
return true
}
private func beginLoading(for key: PayloadCacheKey) {
if loadingCountsByKey[key, default: 0] == 0 {
loadingStartedAtByKey[key] = Date()
}
loadingCountsByKey[key, default: 0] += 1
}
private func finishLoading(for key: PayloadCacheKey) {
guard let count = loadingCountsByKey[key], count > 0 else { return }
if count == 1 {
loadingCountsByKey[key] = nil
loadingStartedAtByKey[key] = nil
} else {
loadingCountsByKey[key] = count - 1
}
}
private func invalidateStaleDayCache() {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
@ -168,10 +201,11 @@ final class AppStore {
if !force, cache[key]?.isFresh == true { return }
if !force, inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
attemptedKeys.insert(key)
lastErrorByKey[key] = nil
let didShowLoading = showLoading || cache[key] == nil
if didShowLoading {
if loadingCount == 0 { loadingStartedAt = Date() }
loadingCount += 1
beginLoading(for: key)
}
// Diagnostic anchor: if this key has been empty for a long time (the
// popover would currently be showing "Loading..."), log how stale the
@ -187,8 +221,7 @@ final class AppStore {
defer {
inFlightKeys.remove(key)
if didShowLoading {
loadingCount = max(loadingCount - 1, 0)
if loadingCount == 0 { loadingStartedAt = nil }
finishLoading(for: key)
}
}
do {
@ -211,7 +244,7 @@ final class AppStore {
}
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
lastSuccessByKey[key] = Date()
lastError = nil
lastErrorByKey[key] = nil
} catch {
if Task.isCancelled { return }
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
@ -222,14 +255,14 @@ final class AppStore {
if cacheDate != cacheDateAtStart { return }
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
lastSuccessByKey[key] = Date()
lastError = nil
lastErrorByKey[key] = nil
return
} catch {
if Task.isCancelled { return }
NSLog("CodeBurn: fallback fetch also failed: \(error)")
}
}
lastError = String(describing: error)
lastErrorByKey[key] = String(describing: error)
}
let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
@ -249,7 +282,10 @@ final class AppStore {
// Same day-rollover guard as refresh(): drop yesterday's payload if
// the calendar rolled over during the fetch.
if cacheDate != cacheDateAtStart { return }
cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date())
let key = PayloadCacheKey(period: period, provider: .all)
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil
} catch {
NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)")
}

View file

@ -5,6 +5,7 @@ import Observation
private let refreshIntervalSeconds: UInt64 = 30
private let nanosPerSecond: UInt64 = 1_000_000_000
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
private let forceRefreshWatchdogSeconds: TimeInterval = 90
private let statusItemWidth: CGFloat = NSStatusItem.variableLength
private let popoverWidth: CGFloat = 360
private let popoverHeight: CGFloat = 660
@ -36,6 +37,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var pendingRefreshWork: DispatchWorkItem?
private var refreshLoopTask: Task<Void, Never>?
private var forceRefreshTask: Task<Void, Never>?
private var forceRefreshStartedAt: Date?
private var forceRefreshGeneration: UInt64 = 0
func applicationWillFinishLaunching(_ notification: Notification) {
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
@ -90,6 +93,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
Task { @MainActor in
self?.forceRefreshTask?.cancel()
self?.forceRefreshTask = nil
self?.forceRefreshStartedAt = nil
self?.forceRefreshGeneration &+= 1
self?.refreshLoopTask?.cancel()
self?.refreshLoopTask = nil
}
@ -208,17 +213,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var lastRefreshTime: Date = .distantPast
@discardableResult
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
if let started = forceRefreshStartedAt, forceRefreshTask != nil {
let elapsed = now.timeIntervalSince(started)
guard elapsed > forceRefreshWatchdogSeconds else { return false }
NSLog("CodeBurn: force refresh stuck for %ds — cancelling and restarting", Int(elapsed))
forceRefreshTask?.cancel()
forceRefreshTask = nil
forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1
store.resetLoadingState()
return true
}
return false
}
private func forceRefresh() {
let now = Date()
_ = clearStaleForceRefreshIfNeeded(now: now)
guard now.timeIntervalSince(lastRefreshTime) > 5 else { return }
lastRefreshTime = now
forceRefreshStartedAt = now
forceRefreshGeneration &+= 1
let generation = forceRefreshGeneration
forceRefreshTask?.cancel()
forceRefreshTask = Task {
async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true)
async let today: Void = store.refreshQuietly(period: .today)
_ = await (main, today)
refreshStatusButton()
await MainActor.run { [weak self] in
guard let self, self.forceRefreshGeneration == generation else { return }
self.forceRefreshTask = nil
self.forceRefreshStartedAt = nil
self.lastRefreshTime = Date()
}
}
}
@ -259,13 +289,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
while !Task.isCancelled {
guard let self else { return }
self.store.clearStaleLoadingIfNeeded()
let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded()
let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded()
// Skip the loop's tick if a wake / manual / distributed-
// notification refresh just ran. Without this gate, every
// wake produced two refreshes (forceRefresh from the wake
// observer plus the loop's natural tick).
let sinceLast = Date().timeIntervalSince(self.lastRefreshTime)
if sinceLast >= 5 {
if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) {
if self.store.selectedPeriod != .today || self.store.selectedProvider != .all {
async let quiet: Void = self.store.refreshQuietly(period: .today)
async let main: Void = self.store.refresh(includeOptimize: false, force: true)

View file

@ -47,7 +47,10 @@ struct MenuBarContent: View {
// error, etc.), surface a retry card instead of leaving the
// user stuck on a perpetual "Loading..." spinner.
if !store.hasCachedData {
if let err = store.lastError, !store.isLoading {
if store.isCurrentKeyLoading || !store.hasAttemptedCurrentKeyLoad {
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
.transition(.opacity)
} else if let err = store.lastError {
FetchErrorOverlay(
error: err,
periodLabel: store.selectedPeriod.rawValue,
@ -55,7 +58,11 @@ struct MenuBarContent: View {
)
.transition(.opacity)
} else {
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
FetchErrorOverlay(
error: "The last refresh stopped before returning data. CodeBurn will keep retrying, or you can retry now.",
periodLabel: store.selectedPeriod.rawValue,
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
)
.transition(.opacity)
}
}