mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Harden menubar status refresh timer
This commit is contained in:
parent
00c55873a4
commit
478131d5b7
3 changed files with 157 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue