Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
interaction: .userInitiated)
} else {
await self.performStoreRefresh(
forceTokenUsage: true,
forceTokenUsage: false,
refreshOpenMenusWhenComplete: true,
interaction: .userInitiated)
}
Expand Down
15 changes: 15 additions & 0 deletions Sources/CodexBar/UsageStore+AdaptiveRefresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ extension UsageStore {
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
{
var scheduledAt = previousScheduledAt + interval
while scheduledAt <= completedAt {
scheduledAt += interval
}
return scheduledAt
}

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.
Expand Down
14 changes: 12 additions & 2 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -769,13 +769,23 @@ final class UsageStore {
guard let wait = frequency.seconds else { return }

// Background poller so the menu stays responsive; canceled when settings change or store deallocates.
// `self` is only briefly borrowed to read the (DEBUG-only) sleep override, never held across the sleep.
// Fixed cadence is anchored to the scheduled tick time, not refresh completion, so slow provider
// work doesn't permanently stretch a two-minute interval into "refresh duration + two minutes".
self.timerTask = Task.detached(priority: .utility) { [weak self] in
let interval = Duration.seconds(wait)
let clock = ContinuousClock()
var scheduledAt = clock.now + interval
while !Task.isCancelled {
let sleepDuration = await self?.effectiveTimerSleepDuration(.seconds(wait)) ?? .seconds(wait)
let now = clock.now
let computedSleep = now >= scheduledAt ? .zero : scheduledAt - now
let sleepDuration = await self?.effectiveTimerSleepDuration(computedSleep) ?? computedSleep
try? await Task.sleep(for: sleepDuration)
guard !Task.isCancelled else { return }
await self?.refresh()
scheduledAt = Self.nextFixedTimerScheduledAt(
previousScheduledAt: scheduledAt,
completedAt: clock.now,
interval: interval)
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,25 @@ struct AdaptiveRefreshTimerTests {
#expect(store.completedRefreshCountForTesting >= 2)
}

@Test
func `fixed cadence advances from scheduled tick instead of refresh completion`() {
let interval = Duration.milliseconds(100)
let start = ContinuousClock.now
let firstScheduledAt = start + interval

let nextAfterSlowRefresh = UsageStore.nextFixedTimerScheduledAt(
previousScheduledAt: firstScheduledAt,
completedAt: firstScheduledAt + .milliseconds(60),
interval: interval)
#expect(nextAfterSlowRefresh == start + .milliseconds(200))

let nextAfterMissedTicks = UsageStore.nextFixedTimerScheduledAt(
previousScheduledAt: firstScheduledAt,
completedAt: firstScheduledAt + .milliseconds(260),
interval: interval)
#expect(nextAfterMissedTicks == start + .milliseconds(400))
}

@Test
func `adaptive mode keeps recomputing and refreshing across menu-open changes`() async throws {
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-adaptive", frequency: .adaptive)
Expand Down
8 changes: 4 additions & 4 deletions Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1201,17 +1201,17 @@ extension StatusMenuPersistentRefreshTests {
}

controller.refreshNow()
let manualTask = try? #require(controller.manualRefreshTasks[.global])
await tokenRefreshStarted.wait()
await manualTask?.value

#expect(controller.store.isRefreshing)
#expect(!controller.store.isRefreshing)
#expect(controller.store.refreshingProviders.isEmpty)
#expect(monitor.isManualRefreshInFlight)
#expect(!monitor.isManualRefreshInFlight)
#expect(!monitor.isManualRefreshInFlight(for: .codex))
#expect(monitor.subtitle(for: .codex, fallback: fallback).style == .info)

releaseTokenRefresh.resume()
await controller.manualRefreshTasks[.global]?.value
#expect(!monitor.isManualRefreshInFlight)
}

@Test
Expand Down