mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-31 01:56:02 +00:00
fix(menubar): add watchdog backoff, remove dead RefreshBackoff code
PR #393 accidentally deleted the RefreshBackoff integration from PR #388, leaving RefreshBackoff.swift as dead code and tests calling nonexistent methods. Delete the dead code and fix the broken test target. Add exponential backoff to the loading watchdog (8s, 16s, 32s... up to 60s, max 6 attempts) so it stops hammering the CLI when it's unavailable. After exhausting retries, show an error overlay with a Retry button. Fix recoverFromStuckLoading to skip recovery when a fetch is already in-flight (avoids killing healthy fetches via generation bump). Fix selectedDay to return nil for multi-day selections, and pass days through startInteractiveSelectionRefresh so the cache key matches currentKey.
This commit is contained in:
parent
40dcb410a5
commit
3751b3381c
5 changed files with 20 additions and 126 deletions
|
|
@ -40,7 +40,7 @@ final class AppStore {
|
|||
var selectedDays: Set<String> = []
|
||||
|
||||
var selectedDay: String? {
|
||||
guard selectedDays.count == 1 else { return selectedDays.min() }
|
||||
guard selectedDays.count == 1 else { return nil }
|
||||
return selectedDays.first
|
||||
}
|
||||
private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() {
|
||||
|
|
@ -294,7 +294,8 @@ final class AppStore {
|
|||
let period = selectedPeriod
|
||||
let provider = selectedProvider
|
||||
let day = selectedDay
|
||||
let key = PayloadCacheKey(period: period, provider: provider, day: day)
|
||||
let days = selectedDays
|
||||
let key = PayloadCacheKey(period: period, provider: provider, day: day, days: days)
|
||||
lastErrorByKey[key] = nil
|
||||
switchTask = Task {
|
||||
if provider == .all {
|
||||
|
|
@ -403,8 +404,15 @@ final class AppStore {
|
|||
}
|
||||
|
||||
func recoverFromStuckLoading() async {
|
||||
resetLoadingState()
|
||||
await refresh(key: currentKey, includeOptimize: false, force: true, showLoading: true)
|
||||
let key = currentKey
|
||||
guard inFlightKeys[key] == nil else { return }
|
||||
loadingCountsByKey[key] = nil
|
||||
loadingStartedAtByKey[key] = nil
|
||||
await refresh(key: key, includeOptimize: false, force: true, showLoading: true)
|
||||
}
|
||||
|
||||
func setRecoveryExhausted(for label: String) {
|
||||
lastErrorByKey[currentKey] = "Could not load \(label). Check that the codeburn CLI is installed and working."
|
||||
}
|
||||
|
||||
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
struct RefreshBackoff {
|
||||
let stallThreshold: Int
|
||||
let initialDelay: TimeInterval
|
||||
let maximumDelay: TimeInterval
|
||||
|
||||
private(set) var consecutiveStalls = 0
|
||||
private(set) var pausedUntil: Date?
|
||||
|
||||
init(stallThreshold: Int = 3, initialDelay: TimeInterval = 30, maximumDelay: TimeInterval = 300) {
|
||||
self.stallThreshold = stallThreshold
|
||||
self.initialDelay = initialDelay
|
||||
self.maximumDelay = maximumDelay
|
||||
}
|
||||
|
||||
mutating func recordStall(now: Date = Date()) -> Date? {
|
||||
consecutiveStalls += 1
|
||||
guard consecutiveStalls >= stallThreshold else { return nil }
|
||||
|
||||
let exponent = max(0, consecutiveStalls - stallThreshold)
|
||||
let multiplier = pow(2.0, Double(exponent))
|
||||
let delay = min(initialDelay * multiplier, maximumDelay)
|
||||
let until = now.addingTimeInterval(delay)
|
||||
pausedUntil = until
|
||||
return until
|
||||
}
|
||||
|
||||
mutating func recordSuccess() {
|
||||
consecutiveStalls = 0
|
||||
pausedUntil = nil
|
||||
}
|
||||
|
||||
mutating func retryNow(resetStallCount: Bool = false) {
|
||||
pausedUntil = nil
|
||||
if resetStallCount {
|
||||
consecutiveStalls = 0
|
||||
}
|
||||
}
|
||||
|
||||
mutating func isPaused(now: Date = Date()) -> Bool {
|
||||
guard let pausedUntil else { return false }
|
||||
if pausedUntil > now { return true }
|
||||
self.pausedUntil = nil
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -60,16 +60,17 @@ struct MenuBarContent: View {
|
|||
BurnLoadingOverlay(periodLabel: store.selectionLabel)
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
// Keep retrying until data loads or the view is removed.
|
||||
// The original one-shot recovery silently gave up if the
|
||||
// first attempt also stalled (generation mismatch, CLI
|
||||
// timeout, day rollover race). Looping guarantees the
|
||||
// user never sees a permanent spinner.
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
var delay: Duration = .seconds(8)
|
||||
let maxDelay: Duration = .seconds(60)
|
||||
let maxAttempts = 6
|
||||
for attempt in 1...maxAttempts {
|
||||
try? await Task.sleep(for: delay)
|
||||
guard !Task.isCancelled, !store.hasCachedData else { return }
|
||||
await store.recoverFromStuckLoading()
|
||||
if attempt < maxAttempts { delay = min(delay * 2, maxDelay) }
|
||||
}
|
||||
guard !Task.isCancelled, !store.hasCachedData else { return }
|
||||
store.setRecoveryExhausted(for: store.selectionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,15 +91,4 @@ struct AppStoreRefreshRecoveryTests {
|
|||
#expect(store.shouldResetInteractiveRefreshPipeline)
|
||||
}
|
||||
|
||||
@Test("refresh pause message is visible and clearable")
|
||||
func refreshPauseMessageIsVisibleAndClearable() {
|
||||
let store = AppStore()
|
||||
|
||||
store.pauseAutomaticRefresh(until: Date(timeIntervalSince1970: 4_000), consecutiveStalls: 3)
|
||||
#expect(store.refreshPauseMessage?.contains("Refresh paused") == true)
|
||||
#expect(store.refreshPauseMessage?.contains("3 stalled attempts") == true)
|
||||
|
||||
store.clearRefreshPause()
|
||||
#expect(store.refreshPauseMessage == nil)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Refresh backoff")
|
||||
struct RefreshBackoffTests {
|
||||
@Test("pauses after threshold and escalates exponentially")
|
||||
func pausesAfterThresholdAndEscalatesExponentially() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
var backoff = RefreshBackoff(stallThreshold: 3, initialDelay: 30, maximumDelay: 300)
|
||||
|
||||
#expect(backoff.recordStall(now: now) == nil)
|
||||
#expect(backoff.recordStall(now: now) == nil)
|
||||
|
||||
let firstPause = backoff.recordStall(now: now)
|
||||
#expect(firstPause == now.addingTimeInterval(30))
|
||||
let pausedBeforeExpiry = backoff.isPaused(now: now.addingTimeInterval(29))
|
||||
#expect(pausedBeforeExpiry)
|
||||
let pausedAfterExpiry = backoff.isPaused(now: now.addingTimeInterval(31))
|
||||
#expect(!pausedAfterExpiry)
|
||||
|
||||
let secondPause = backoff.recordStall(now: now)
|
||||
#expect(secondPause == now.addingTimeInterval(60))
|
||||
|
||||
_ = backoff.recordStall(now: now)
|
||||
_ = backoff.recordStall(now: now)
|
||||
_ = backoff.recordStall(now: now)
|
||||
_ = backoff.recordStall(now: now)
|
||||
let cappedPause = backoff.recordStall(now: now)
|
||||
#expect(cappedPause == now.addingTimeInterval(300))
|
||||
}
|
||||
|
||||
@Test("success clears stall count and pause")
|
||||
func successClearsStallCountAndPause() {
|
||||
let now = Date(timeIntervalSince1970: 2_000)
|
||||
var backoff = RefreshBackoff(stallThreshold: 1, initialDelay: 30, maximumDelay: 300)
|
||||
|
||||
#expect(backoff.recordStall(now: now) == now.addingTimeInterval(30))
|
||||
backoff.recordSuccess()
|
||||
|
||||
#expect(backoff.consecutiveStalls == 0)
|
||||
#expect(backoff.pausedUntil == nil)
|
||||
#expect(backoff.recordStall(now: now) == now.addingTimeInterval(30))
|
||||
}
|
||||
|
||||
@Test("manual retry clears pause without erasing stall history")
|
||||
func manualRetryClearsPauseWithoutErasingStallHistory() {
|
||||
let now = Date(timeIntervalSince1970: 3_000)
|
||||
var backoff = RefreshBackoff(stallThreshold: 1, initialDelay: 30, maximumDelay: 300)
|
||||
|
||||
_ = backoff.recordStall(now: now)
|
||||
backoff.retryNow(resetStallCount: false)
|
||||
|
||||
#expect(backoff.pausedUntil == nil)
|
||||
#expect(backoff.consecutiveStalls == 1)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue