fix(menubar): recover from stuck loading state

Commit 5800179 (multi-day calendar) accidentally removed stuck-loading
recovery. Re-add an 8-second watchdog loop in MenuBarContent that calls
recoverFromStuckLoading(). Change inFlightKeys from Set to timestamped
Dict so the watchdog can detect orphaned quiet-refresh entries. Stop
nuking payloadRefreshGeneration in the watchdog (it was killing healthy
in-flight fetches). Bump Package.swift platform to macOS 15 to match
NSAttributedString(attachment:) usage.
This commit is contained in:
iamtoruk 2026-05-27 04:06:48 -07:00
parent ce043631ed
commit ac35746209
3 changed files with 38 additions and 13 deletions

View file

@ -4,7 +4,7 @@ import PackageDescription
let package = Package(
name: "CodeBurnMenubar",
platforms: [
.macOS(.v14)
.macOS(.v15)
],
products: [
.executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"])

View file

@ -307,7 +307,7 @@ final class AppStore {
}
}
private var inFlightKeys: Set<PayloadCacheKey> = []
private var inFlightKeys: [PayloadCacheKey: Date] = [:]
func resetLoadingState() {
payloadRefreshGeneration &+= 1
@ -333,22 +333,30 @@ final class AppStore {
@discardableResult
func clearStaleLoadingIfNeeded() -> Bool {
let now = Date()
let staleEntries = loadingStartedAtByKey.filter {
let staleLoading = loadingStartedAtByKey.filter {
now.timeIntervalSince($0.value) > loadingWatchdogSeconds
}
guard !staleEntries.isEmpty else { return false }
let staleInFlight = inFlightKeys.filter { (key, insertedAt) in
now.timeIntervalSince(insertedAt) > loadingWatchdogSeconds &&
loadingStartedAtByKey[key] == nil
}
guard !staleLoading.isEmpty || !staleInFlight.isEmpty else { return false }
payloadRefreshGeneration &+= 1
for (key, started) in staleEntries {
for (key, started) in staleLoading {
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
Int(now.timeIntervalSince(started)), key.label, key.provider.rawValue)
loadingCountsByKey[key] = nil
loadingStartedAtByKey[key] = nil
inFlightKeys.remove(key)
inFlightKeys[key] = nil
if cache[key] == nil {
lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background."
}
}
for (key, insertedAt) in staleInFlight {
NSLog("CodeBurn: orphaned in-flight key stuck for %ds on %@/%@ — clearing",
Int(now.timeIntervalSince(insertedAt)), key.label, key.provider.rawValue)
inFlightKeys[key] = nil
}
return true
}
@ -394,6 +402,11 @@ final class AppStore {
cache.removeAll()
}
func recoverFromStuckLoading() async {
resetLoadingState()
await refresh(key: currentKey, includeOptimize: false, force: true, showLoading: true)
}
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading)
}
@ -404,8 +417,8 @@ final class AppStore {
let generationAtStart = payloadRefreshGeneration
if Task.isCancelled { return }
if !force, cache[key]?.isFresh == true { return }
if inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
if inFlightKeys[key] != nil { return }
inFlightKeys[key] = Date()
attemptedKeys.insert(key)
lastErrorByKey[key] = nil
let didShowLoading = showLoading || cache[key] == nil
@ -425,7 +438,7 @@ final class AppStore {
}
defer {
let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration
inFlightKeys.remove(key)
inFlightKeys[key] = nil
if didShowLoading {
finishLoading(for: key)
}
@ -496,8 +509,8 @@ final class AppStore {
invalidateStaleDayCache()
let key = PayloadCacheKey(period: period, provider: .all, day: day)
if !force, cache[key]?.isFresh == true { return }
if inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
if inFlightKeys[key] != nil { return }
inFlightKeys[key] = Date()
attemptedKeys.insert(key)
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
@ -505,7 +518,7 @@ final class AppStore {
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
}
defer {
inFlightKeys.remove(key)
inFlightKeys[key] = nil
}
do {
let fresh = try await DataClient.fetch(period: period, day: day, provider: .all, includeOptimize: false)

View file

@ -59,6 +59,18 @@ struct MenuBarContent: View {
} else {
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))
guard !Task.isCancelled, !store.hasCachedData else { return }
await store.recoverFromStuckLoading()
}
}
}
}
}