Skip to content

Commit d79deef

Browse files
committed
Fix menubar refresh recovery deadlock
1 parent 66316ab commit d79deef

3 files changed

Lines changed: 97 additions & 23 deletions

File tree

mac/Sources/CodeBurnMenubar/AppStore.swift

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@ final class AppStore {
2525
}
2626
var showingAccentPicker: Bool = false
2727
var currency: String = "USD"
28-
var isLoading: Bool { loadingCount > 0 }
29-
private var loadingCount: Int = 0
30-
private var loadingStartedAt: Date?
31-
var lastError: String?
28+
var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } }
29+
var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 }
30+
var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) }
31+
var lastError: String? { lastErrorByKey[currentKey] }
32+
private var loadingCountsByKey: [PayloadCacheKey: Int] = [:]
33+
private var loadingStartedAtByKey: [PayloadCacheKey: Date] = [:]
34+
private var attemptedKeys: Set<PayloadCacheKey> = []
35+
private var lastErrorByKey: [PayloadCacheKey: String] = [:]
3236
var subscription: SubscriptionUsage?
3337
var subscriptionError: String?
3438
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped
@@ -131,22 +135,51 @@ final class AppStore {
131135
private var inFlightKeys: Set<PayloadCacheKey> = []
132136

133137
func resetLoadingState() {
134-
loadingCount = 0
135-
loadingStartedAt = nil
138+
loadingCountsByKey.removeAll()
139+
loadingStartedAtByKey.removeAll()
136140
inFlightKeys.removeAll()
137141
}
138142

139143
private let loadingWatchdogSeconds: TimeInterval = 60
140144

141145
@discardableResult
142146
func clearStaleLoadingIfNeeded() -> Bool {
143-
guard isLoading, let started = loadingStartedAt,
144-
Date().timeIntervalSince(started) > loadingWatchdogSeconds else { return false }
145-
NSLog("CodeBurn: loading stuck for %ds — auto-clearing", Int(Date().timeIntervalSince(started)))
146-
resetLoadingState()
147+
let now = Date()
148+
let staleEntries = loadingStartedAtByKey.filter {
149+
now.timeIntervalSince($0.value) > loadingWatchdogSeconds
150+
}
151+
guard !staleEntries.isEmpty else { return false }
152+
153+
for (key, started) in staleEntries {
154+
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
155+
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue)
156+
loadingCountsByKey[key] = nil
157+
loadingStartedAtByKey[key] = nil
158+
inFlightKeys.remove(key)
159+
if cache[key] == nil {
160+
lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background."
161+
}
162+
}
147163
return true
148164
}
149165

166+
private func beginLoading(for key: PayloadCacheKey) {
167+
if loadingCountsByKey[key, default: 0] == 0 {
168+
loadingStartedAtByKey[key] = Date()
169+
}
170+
loadingCountsByKey[key, default: 0] += 1
171+
}
172+
173+
private func finishLoading(for key: PayloadCacheKey) {
174+
guard let count = loadingCountsByKey[key], count > 0 else { return }
175+
if count == 1 {
176+
loadingCountsByKey[key] = nil
177+
loadingStartedAtByKey[key] = nil
178+
} else {
179+
loadingCountsByKey[key] = count - 1
180+
}
181+
}
182+
150183
private func invalidateStaleDayCache() {
151184
let formatter = DateFormatter()
152185
formatter.dateFormat = "yyyy-MM-dd"
@@ -168,10 +201,11 @@ final class AppStore {
168201
if !force, cache[key]?.isFresh == true { return }
169202
if !force, inFlightKeys.contains(key) { return }
170203
inFlightKeys.insert(key)
204+
attemptedKeys.insert(key)
205+
lastErrorByKey[key] = nil
171206
let didShowLoading = showLoading || cache[key] == nil
172207
if didShowLoading {
173-
if loadingCount == 0 { loadingStartedAt = Date() }
174-
loadingCount += 1
208+
beginLoading(for: key)
175209
}
176210
// Diagnostic anchor: if this key has been empty for a long time (the
177211
// popover would currently be showing "Loading..."), log how stale the
@@ -187,8 +221,7 @@ final class AppStore {
187221
defer {
188222
inFlightKeys.remove(key)
189223
if didShowLoading {
190-
loadingCount = max(loadingCount - 1, 0)
191-
if loadingCount == 0 { loadingStartedAt = nil }
224+
finishLoading(for: key)
192225
}
193226
}
194227
do {
@@ -211,7 +244,7 @@ final class AppStore {
211244
}
212245
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
213246
lastSuccessByKey[key] = Date()
214-
lastError = nil
247+
lastErrorByKey[key] = nil
215248
} catch {
216249
if Task.isCancelled { return }
217250
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
@@ -222,14 +255,14 @@ final class AppStore {
222255
if cacheDate != cacheDateAtStart { return }
223256
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
224257
lastSuccessByKey[key] = Date()
225-
lastError = nil
258+
lastErrorByKey[key] = nil
226259
return
227260
} catch {
228261
if Task.isCancelled { return }
229262
NSLog("CodeBurn: fallback fetch also failed: \(error)")
230263
}
231264
}
232-
lastError = String(describing: error)
265+
lastErrorByKey[key] = String(describing: error)
233266
}
234267

235268
let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
@@ -249,7 +282,10 @@ final class AppStore {
249282
// Same day-rollover guard as refresh(): drop yesterday's payload if
250283
// the calendar rolled over during the fetch.
251284
if cacheDate != cacheDateAtStart { return }
252-
cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date())
285+
let key = PayloadCacheKey(period: period, provider: .all)
286+
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
287+
lastSuccessByKey[key] = Date()
288+
lastErrorByKey[key] = nil
253289
} catch {
254290
NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)")
255291
}

mac/Sources/CodeBurnMenubar/CodeBurnApp.swift

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Observation
55
private let refreshIntervalSeconds: UInt64 = 30
66
private let nanosPerSecond: UInt64 = 1_000_000_000
77
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
8+
private let forceRefreshWatchdogSeconds: TimeInterval = 90
89
private let statusItemWidth: CGFloat = NSStatusItem.variableLength
910
private let popoverWidth: CGFloat = 360
1011
private let popoverHeight: CGFloat = 660
@@ -36,6 +37,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
3637
private var pendingRefreshWork: DispatchWorkItem?
3738
private var refreshLoopTask: Task<Void, Never>?
3839
private var forceRefreshTask: Task<Void, Never>?
40+
private var forceRefreshStartedAt: Date?
41+
private var forceRefreshGeneration: UInt64 = 0
3942

4043
func applicationWillFinishLaunching(_ notification: Notification) {
4144
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
@@ -90,6 +93,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
9093
Task { @MainActor in
9194
self?.forceRefreshTask?.cancel()
9295
self?.forceRefreshTask = nil
96+
self?.forceRefreshStartedAt = nil
97+
self?.forceRefreshGeneration &+= 1
9398
self?.refreshLoopTask?.cancel()
9499
self?.refreshLoopTask = nil
95100
}
@@ -208,17 +213,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
208213

209214
private var lastRefreshTime: Date = .distantPast
210215

216+
@discardableResult
217+
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
218+
if let started = forceRefreshStartedAt, forceRefreshTask != nil {
219+
let elapsed = now.timeIntervalSince(started)
220+
guard elapsed > forceRefreshWatchdogSeconds else { return false }
221+
NSLog("CodeBurn: force refresh stuck for %ds — cancelling and restarting", Int(elapsed))
222+
forceRefreshTask?.cancel()
223+
forceRefreshTask = nil
224+
forceRefreshStartedAt = nil
225+
forceRefreshGeneration &+= 1
226+
store.resetLoadingState()
227+
return true
228+
}
229+
return false
230+
}
231+
211232
private func forceRefresh() {
212233
let now = Date()
234+
_ = clearStaleForceRefreshIfNeeded(now: now)
213235
guard now.timeIntervalSince(lastRefreshTime) > 5 else { return }
214236
lastRefreshTime = now
237+
forceRefreshStartedAt = now
238+
forceRefreshGeneration &+= 1
239+
let generation = forceRefreshGeneration
215240

216-
forceRefreshTask?.cancel()
217241
forceRefreshTask = Task {
218242
async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true)
219243
async let today: Void = store.refreshQuietly(period: .today)
220244
_ = await (main, today)
221245
refreshStatusButton()
246+
await MainActor.run { [weak self] in
247+
guard let self, self.forceRefreshGeneration == generation else { return }
248+
self.forceRefreshTask = nil
249+
self.forceRefreshStartedAt = nil
250+
self.lastRefreshTime = Date()
251+
}
222252
}
223253
}
224254

@@ -259,13 +289,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
259289
}
260290
while !Task.isCancelled {
261291
guard let self else { return }
262-
self.store.clearStaleLoadingIfNeeded()
292+
let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded()
293+
let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded()
263294
// Skip the loop's tick if a wake / manual / distributed-
264295
// notification refresh just ran. Without this gate, every
265296
// wake produced two refreshes (forceRefresh from the wake
266297
// observer plus the loop's natural tick).
267298
let sinceLast = Date().timeIntervalSince(self.lastRefreshTime)
268-
if sinceLast >= 5 {
299+
if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) {
269300
if self.store.selectedPeriod != .today || self.store.selectedProvider != .all {
270301
async let quiet: Void = self.store.refreshQuietly(period: .today)
271302
async let main: Void = self.store.refresh(includeOptimize: false, force: true)

mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,22 @@ struct MenuBarContent: View {
4747
// error, etc.), surface a retry card instead of leaving the
4848
// user stuck on a perpetual "Loading..." spinner.
4949
if !store.hasCachedData {
50-
if let err = store.lastError, !store.isLoading {
50+
if store.isCurrentKeyLoading || !store.hasAttemptedCurrentKeyLoad {
51+
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
52+
.transition(.opacity)
53+
} else if let err = store.lastError {
5154
FetchErrorOverlay(
5255
error: err,
5356
periodLabel: store.selectedPeriod.rawValue,
5457
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
5558
)
5659
.transition(.opacity)
5760
} else {
58-
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
61+
FetchErrorOverlay(
62+
error: "The last refresh stopped before returning data. CodeBurn will keep retrying, or you can retry now.",
63+
periodLabel: store.selectedPeriod.rawValue,
64+
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
65+
)
5966
.transition(.opacity)
6067
}
6168
}

0 commit comments

Comments
 (0)