Files
steipete--codexbar/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift
T
2026-07-13 12:22:33 +08:00

126 lines
5.8 KiB
Swift

import Foundation
/// Wiring around `AdaptiveRefreshPolicy` for `UsageStore.startTimer()`: gathering live signals,
/// logging the resulting decision, and applying the DEBUG-only sleep-duration override used by
/// tests. Split out of UsageStore.swift to keep that file's class body under the lint line limit.
extension UsageStore {
func effectiveTimerSleepDuration(_ computed: Duration) -> Duration {
#if DEBUG
self.refreshTimerSleepOverrideForTesting ?? computed
#else
computed
#endif
}
/// Pure wiring helper: builds the `AdaptiveRefreshPolicy.Input` from explicit values and
/// returns the resulting decision. `startTimer()` supplies live `ProcessInfo` state and
/// `lastMenuOpenAt` at call time; this stays a plain, testable function of its arguments.
nonisolated static func adaptiveRefreshDecision(
now: Date,
lastMenuOpenAt: Date?,
lowPowerModeEnabled: Bool,
thermalState: ProcessInfo.ThermalState,
policy: AdaptiveRefreshPolicy = AdaptiveRefreshPolicy()) -> AdaptiveRefreshPolicy.Decision
{
policy.nextDelay(for: AdaptiveRefreshPolicy.Input(
now: now,
lastMenuOpenAt: lastMenuOpenAt,
lowPowerModeEnabled: lowPowerModeEnabled,
thermalState: thermalState))
}
nonisolated static func shouldAdvanceAdaptiveTimer(scheduledAt: Date?, candidate: Date) -> Bool {
guard let scheduledAt else { return true }
return candidate < scheduledAt
}
/// Advances a fixed timer from the last scheduled tick instead of the refresh completion time.
/// Missed ticks are skipped so a refresh that runs longer than its interval does not create
/// overlapping catch-up refreshes.
nonisolated static func nextFixedTimerScheduledAt(
previousScheduledAt: ContinuousClock.Instant,
completedAt: ContinuousClock.Instant,
interval: Duration) -> ContinuousClock.Instant
{
precondition(interval > .zero)
var scheduledAt = previousScheduledAt + interval
while scheduledAt <= completedAt {
scheduledAt += interval
}
return scheduledAt
}
nonisolated static func runFixedRefreshTimer(
interval: Duration,
sleepOverride: Duration? = nil,
now: @escaping @Sendable () async -> ContinuousClock.Instant = { ContinuousClock.now },
sleep: @escaping @Sendable (Duration) async throws -> Void = { duration in
try await Task.sleep(for: duration)
},
refresh: @escaping @Sendable () async -> Void) async
{
precondition(interval > .zero)
var scheduledAt = await now() + interval
while !Task.isCancelled {
let current = await now()
let computedSleep = current >= scheduledAt ? .zero : scheduledAt - current
do {
try await sleep(sleepOverride ?? computedSleep)
} catch {
return
}
guard !Task.isCancelled else { return }
await refresh()
scheduledAt = await self.nextFixedTimerScheduledAt(
previousScheduledAt: scheduledAt,
completedAt: now(),
interval: interval)
}
}
func logAdaptiveRefreshDecision(_ decision: AdaptiveRefreshPolicy.Decision) {
// Reason and delay only; never provider/account/email/path/credential/response data.
// No "adaptive refresh: " prefix — the adaptiveRefresh log category already identifies the source.
self.adaptiveRefreshLogger.debug(
"reason=\(decision.reason.rawValue) delay=\(decision.delay.components.seconds)s")
}
/// Computes this tick's adaptive sleep duration (and logs the decision) while briefly holding a
/// strong reference to `store`; returns nil once the store has deallocated, ending the loop.
/// Kept as a separate call so the strong reference doesn't extend into the caller's `Task.sleep`.
static func nextAdaptiveTimerSleepDuration(for store: UsageStore?) async -> Duration? {
guard let store else { return nil }
let now = Date()
let decision = Self.adaptiveRefreshDecision(
now: now,
lastMenuOpenAt: store.lastMenuOpenAt,
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled,
thermalState: ProcessInfo.processInfo.thermalState)
store.adaptiveRefreshScheduledAt = now.addingTimeInterval(TimeInterval(decision.delay.components.seconds))
store.logAdaptiveRefreshDecision(decision)
return store.effectiveTimerSleepDuration(decision.delay)
}
/// The refresh interval scheduling *heuristics* (reset-boundary refresh, OpenAI web staleness,
/// persistent-CLI-session idle windows) should use as "how often does a normal refresh happen".
/// This is deliberately distinct from `RefreshFrequency.seconds`, which is nil for both `.manual`
/// (no timer at all — heuristics correctly get nil here too) and `.adaptive` (no *fixed*
/// interval, but ticks are still happening on a real, computable cadence). For `.adaptive`, this
/// resolves to what `AdaptiveRefreshPolicy` would decide right now from live signals, so those
/// heuristics stay active and roughly proportionate instead of silently behaving like manual.
func normalRefreshIntervalForHeuristics() -> TimeInterval? {
switch self.settings.refreshFrequency {
case .manual:
nil
case .adaptive:
TimeInterval(Self.adaptiveRefreshDecision(
now: Date(),
lastMenuOpenAt: self.lastMenuOpenAt,
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled,
thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds)
default:
self.settings.refreshFrequency.seconds
}
}
}