Harden menubar status refresh timer

This commit is contained in:
iamtoruk 2026-05-14 17:57:36 -07:00
parent 00c55873a4
commit 478131d5b7
3 changed files with 157 additions and 36 deletions

View file

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

View file

@ -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<Void, Never>?
private var refreshTimer: DispatchSourceTimer?
private var forceRefreshTask: Task<Void, Never>?
private var forceRefreshStartedAt: Date?
private var forceRefreshGeneration: UInt64 = 0
private var statusPayloadRefreshTask: Task<Void, Never>?
private var statusPayloadRefreshStartedAt: Date?
private var statusPayloadRefreshGeneration: UInt64 = 0
private var manualRefreshTask: Task<Void, Never>?
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()
}
}

View file

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