From 478131d5b7138cecc12dff9e180d0136baa39b55 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Thu, 14 May 2026 17:57:36 -0700 Subject: [PATCH] Harden menubar status refresh timer --- mac/Sources/CodeBurnMenubar/AppStore.swift | 36 ++++- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 147 ++++++++++++++---- .../AppStoreRefreshRecoveryTests.swift | 10 ++ 3 files changed, 157 insertions(+), 36 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 79ef9ca..20854ae 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -65,6 +65,10 @@ final class AppStore { return Date().timeIntervalSince(last) } + private var todayAllKey: PayloadCacheKey { + PayloadCacheKey(period: .today, provider: .all) + } + private var currentKey: PayloadCacheKey { PayloadCacheKey(period: selectedPeriod, provider: selectedProvider) } @@ -76,7 +80,16 @@ final class AppStore { /// Today (across all providers) is pinned for the always-visible menubar icon, independent of /// the popover's selected period or provider. var todayPayload: MenubarPayload? { - cache[PayloadCacheKey(period: .today, provider: .all)]?.payload + cache[todayAllKey]?.payload + } + + var todayPayloadAgeSeconds: Int? { + guard let cached = cache[todayAllKey] else { return nil } + return Int(Date().timeIntervalSince(cached.fetchedAt)) + } + + var needsStatusPayloadRefresh: Bool { + cache[todayAllKey]?.isFresh != true } /// All-provider payload for the selected period. Used by the tab strip to show @@ -111,7 +124,7 @@ final class AppStore { var staleInteractivePayloadAgeSeconds: Int? { let keys = Set([ currentKey, - PayloadCacheKey(period: .today, provider: .all), + todayAllKey, PayloadCacheKey(period: selectedPeriod, provider: .all), ]) let staleAges = keys.compactMap { key -> TimeInterval? in @@ -123,10 +136,9 @@ final class AppStore { } var needsInteractivePayloadRefresh: Bool { - let todayKey = PayloadCacheKey(period: .today, provider: .all) let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all) return cache[currentKey]?.isFresh != true || - cache[todayKey]?.isFresh != true || + cache[todayAllKey]?.isFresh != true || cache[periodAllKey]?.isFresh != true || hasStaleLoading } @@ -269,7 +281,7 @@ final class AppStore { let cacheDateAtStart = cacheDate let generationAtStart = payloadRefreshGeneration if !force, cache[key]?.isFresh == true { return } - if !force, inFlightKeys.contains(key) { return } + if inFlightKeys.contains(key) { return } inFlightKeys.insert(key) attemptedKeys.insert(key) lastErrorByKey[key] = nil @@ -349,10 +361,21 @@ final class AppStore { /// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge). /// Does not toggle isLoading, so the popover's loading overlay is unaffected. /// Always uses the .all provider since the menubar badge shows total spend. - func refreshQuietly(period: Period) async { + func refreshQuietly(period: Period, force: Bool = false) async { invalidateStaleDayCache() + let key = PayloadCacheKey(period: period, provider: .all) + if !force, cache[key]?.isFresh == true { return } + if inFlightKeys.contains(key) { return } + inFlightKeys.insert(key) + attemptedKeys.insert(key) let cacheDateAtStart = cacheDate let generationAtStart = payloadRefreshGeneration + if period == .today, let age = todayPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: refreshing stale today status payload after %ds", age) + } + defer { + inFlightKeys.remove(key) + } do { let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false) if generationAtStart != payloadRefreshGeneration { @@ -362,7 +385,6 @@ 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 } - let key = PayloadCacheKey(period: period, provider: .all) cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) lastSuccessByKey[key] = Date() lastErrorByKey[key] = nil diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 36ff798..80bc471 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -3,10 +3,9 @@ import AppKit 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 refreshLoopWatchdogSeconds: TimeInterval = 90 +private let statusPayloadRefreshWatchdogSeconds: TimeInterval = 60 private let refreshRateLimitSeconds: TimeInterval = 5 private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30 private let statusItemWidth: CGFloat = NSStatusItem.variableLength @@ -38,10 +37,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { /// Held for the lifetime of the app to opt out of App Nap and Automatic Termination. private var backgroundActivity: NSObjectProtocol? private var pendingRefreshWork: DispatchWorkItem? - private var refreshLoopTask: Task? + private var refreshTimer: DispatchSourceTimer? private var forceRefreshTask: Task? private var forceRefreshStartedAt: Date? private var forceRefreshGeneration: UInt64 = 0 + private var statusPayloadRefreshTask: Task? + private var statusPayloadRefreshStartedAt: Date? + private var statusPayloadRefreshGeneration: UInt64 = 0 private var manualRefreshTask: Task? private var manualRefreshGeneration: UInt64 = 0 private var refreshLoopHeartbeatAt: Date = .distantPast @@ -146,9 +148,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { manualRefreshTask?.cancel() manualRefreshTask = nil manualRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 store.resetLoadingState() - refreshLoopTask?.cancel() - refreshLoopTask = nil + stopRefreshTimer() refreshLoopHeartbeatAt = .distantPast lastRefreshTime = .distantPast } @@ -162,21 +167,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { manualRefreshTask?.cancel() manualRefreshTask = nil manualRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 store.resetRefreshState(clearCache: clearCache) } else { _ = store.clearStaleLoadingIfNeeded() } let now = Date() let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt) - if refreshLoopTask == nil || loopAge > refreshLoopWatchdogSeconds { - if refreshLoopTask != nil { - NSLog("CodeBurn: refresh loop stale for %ds after %@ — restarting", Int(loopAge), reason) + if refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds { + if refreshTimer != nil { + NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason) } - refreshLoopTask?.cancel() - refreshLoopTask = nil startRefreshLoop() + } else { + runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: true) } - forceRefresh(bypassRateLimit: true, forceQuota: true) } private func installLaunchAgentIfNeeded() { @@ -281,9 +289,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return false } + @discardableResult + private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool { + if statusPayloadRefreshTask != nil { + guard let started = statusPayloadRefreshStartedAt else { + NSLog("CodeBurn: today status refresh task had no start timestamp - clearing") + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshGeneration &+= 1 + return true + } + let elapsed = now.timeIntervalSince(started) + guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false } + NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed)) + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + return true + } + return false + } + + private func refreshTodayStatusPayloadIfNeeded(reason: String, force: Bool = false) { + let now = Date() + _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) + guard statusPayloadRefreshTask == nil else { return } + guard force || store.needsStatusPayloadRefresh else { return } + + if let age = store.todayPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: today status payload stale for %ds on %@ refresh", age, reason) + } + + statusPayloadRefreshStartedAt = now + statusPayloadRefreshGeneration &+= 1 + let generation = statusPayloadRefreshGeneration + statusPayloadRefreshTask = Task { [weak self] in + guard let self else { return } + await self.store.refreshQuietly(period: .today, force: true) + self.refreshStatusButton() + guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return } + self.statusPayloadRefreshTask = nil + self.statusPayloadRefreshStartedAt = nil + } + } + private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) { let now = Date() _ = clearStaleForceRefreshIfNeeded(now: now) + if forceRefreshTask != nil { + refreshTodayStatusPayloadIfNeeded(reason: "blocked force refresh") + } guard forceRefreshTask == nil else { return } if !bypassRateLimit { guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return } @@ -392,23 +448,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { ) } - private func startRefreshLoop() { - refreshLoopTask?.cancel() + private func stopRefreshTimer() { + refreshTimer?.setEventHandler {} + refreshTimer?.cancel() + refreshTimer = nil + } + + private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) { refreshLoopHeartbeatAt = Date() - forceRefresh(bypassRateLimit: true, forceQuota: true) - refreshLoopTask = Task { [weak self] in - while !Task.isCancelled { - guard let self else { return } - self.refreshLoopHeartbeatAt = Date() - let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded() - let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded() - let sinceLast = Date().timeIntervalSince(self.lastRefreshTime) - if clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= TimeInterval(refreshIntervalSeconds) { - self.forceRefresh(bypassRateLimit: true) - } - try? await Task.sleep(nanoseconds: refreshIntervalNanos) + let hadForceRefreshInFlight = forceRefreshTask != nil + let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded() + let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded() + let clearedStaleLoading = store.clearStaleLoadingIfNeeded() + let statusPayloadStale = store.needsStatusPayloadRefresh + let sinceLast = Date().timeIntervalSince(lastRefreshTime) + let shouldForceRefresh = forcePayload || + clearedStaleForceRefresh || + clearedStaleLoading || + sinceLast >= TimeInterval(refreshIntervalSeconds) + + if shouldForceRefresh { + forceRefresh(bypassRateLimit: true, forceQuota: forceQuota) + } + + let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil + if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) { + refreshTodayStatusPayloadIfNeeded(reason: reason, force: forcePayload) + } + } + + private func startRefreshLoop() { + stopRefreshTimer() + runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: true) + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule( + deadline: .now() + .seconds(Int(refreshIntervalSeconds)), + repeating: .seconds(Int(refreshIntervalSeconds)), + leeway: .seconds(2) + ) + timer.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + self?.runRefreshLoopTick(reason: "timer") } } + refreshTimer = timer + refreshLoopHeartbeatAt = Date() + timer.resume() } @MainActor @@ -420,10 +506,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { forceRefreshTask = nil forceRefreshStartedAt = nil forceRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 pendingRefreshWork?.cancel() pendingRefreshWork = nil - refreshLoopTask?.cancel() - refreshLoopTask = nil + stopRefreshTimer() store.resetRefreshState(clearCache: true) lastRefreshTime = .distantPast refreshStatusButton() @@ -437,7 +526,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true) async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true) if needsTodayTotal { - await self.store.refreshQuietly(period: .today) + await self.store.refreshQuietly(period: .today, force: true) } _ = await payload guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } @@ -446,7 +535,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { _ = await quotas guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } self.manualRefreshTask = nil - if self.refreshLoopTask == nil { + if self.refreshTimer == nil { self.startRefreshLoop() } } diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index ca8219e..fd75fec 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -38,6 +38,7 @@ struct AppStoreRefreshRecoveryTests { #expect(store.todayPayload?.current.cost == 92.33) #expect(store.needsInteractivePayloadRefresh) + #expect(store.needsStatusPayloadRefresh) #expect(store.hasStaleInteractivePayload) #expect(store.shouldResetInteractiveRefreshPipeline) @@ -57,10 +58,19 @@ struct AppStoreRefreshRecoveryTests { ) #expect(!store.needsInteractivePayloadRefresh) + #expect(!store.needsStatusPayloadRefresh) #expect(!store.hasStaleInteractivePayload) #expect(!store.shouldResetInteractiveRefreshPipeline) } + @Test("missing today status payload needs status refresh") + func missingTodayStatusPayloadNeedsStatusRefresh() { + let store = AppStore() + + #expect(store.todayPayload == nil) + #expect(store.needsStatusPayloadRefresh) + } + @Test("missing unattempted payload triggers hard recovery") func missingUnattemptedPayloadTriggersHardRecovery() { let store = AppStore()