From 560b53d235cb5511ac2a060b91737023911931f6 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 3 Oct 2026 21:11:21 +0300 Subject: [PATCH] fix: resume suspended polling when screensDidWake is missed Display sleep stopped the poll timer and only screensDidWake brought it back. If that notification never arrived, polling stayed off until a restart, and the empty snapshot written by the midnight day-change refresh (no tokens yet at 00:00) froze the menu bar at 0 with an empty providers list and no error (#350). While suspended, a 5-minute probe checks the real display state and resumes polling when the screens are awake. didWake and every refresh run the same check, so any trigger heals a stale suspension. --- Sources/PokeTokenBar/Core/UsageStore.swift | 52 ++++++++++- Tests/PokeTokenBarTests/UsageStoreTests.swift | 86 +++++++++++++++++++ docs/reference/defect-log.md | 8 ++ 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 2b8421548..b7ac37a3c 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -241,6 +241,11 @@ final class UsageStore { private var timer: Timer? private var networkMonitor: NetworkReachabilityMonitor? private var pollingSuspended = false // 디스플레이 꺼짐 동안 폴링 정지 (배터리) + /// 정지 중에만 도는 저빈도 점검. screensDidWake 를 놓치면 폴링이 영영 안 돌아와 마지막 스냅샷 + /// (자정에 기록된 빈 스냅샷 등)이 재시작 전까지 굳었다(#350). + private var suspendedProbeTimer: Timer? + static let suspendedProbeInterval: TimeInterval = 300 + private let displaysAsleep: @MainActor () -> Bool private var emptyUsageRetryTask: Task? /// 한도 알림 상태(엣지 트리거) — 창 이름 → 이미 알린 최고 tier(0=없음, 1=경고, 2=위험). /// utilization 이 경고선 아래로 내려가면 맵에서 제거해 재무장. resets_at 같은 매 fetch 변하는 @@ -748,8 +753,10 @@ final class UsageStore { SessionKeyLimitsProvider(store: .forConfigRoot($0)) }, autoRefresh: Bool = true, - defaults: UserDefaults = .standard) { + defaults: UserDefaults = .standard, + displaysAsleep: @escaping @MainActor () -> Bool = { CGDisplayIsAsleep(CGMainDisplayID()) != 0 }) { self.providers = providers + self.displaysAsleep = displaysAsleep self.limitsProvider = claudeLimitsProvider self.additionalClaudeLimitsProvider = additionalClaudeLimitsProvider self.discoverClaudeConfigDirs = discoverClaudeConfigDirs @@ -806,7 +813,7 @@ final class UsageStore { NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in await self?.refresh() } + Task { @MainActor in await self?.handleSystemWake() } } // 디스플레이 꺼짐 → 폴링(로그 파싱 + 한도 조회 + codex 서브프로세스) 일시정지, 켜짐 → 재개 + 즉시 갱신 (배터리) NSWorkspace.shared.notificationCenter.addObserver( @@ -849,19 +856,55 @@ final class UsageStore { timer = t } + var isPollingSuspended: Bool { pollingSuspended } + var isPollingTimerScheduled: Bool { timer != nil } + var isSuspendedProbeScheduled: Bool { suspendedProbeTimer != nil } + /// 디스플레이 꺼짐 → 폴링 타이머 정지(예약된 로그 파싱·한도 조회 중단). - private func suspendPolling() { + func suspendPolling() { pollingSuspended = true timer?.invalidate() timer = nil + suspendedProbeTimer?.invalidate() + let probe = Timer(timeInterval: Self.suspendedProbeInterval, repeats: true) { _ in + Task { @MainActor [weak self] in await self?.runSuspendedPollingProbe() } + } + probe.tolerance = Self.suspendedProbeInterval * 0.2 + RunLoop.main.add(probe, forMode: .common) + suspendedProbeTimer = probe } /// 디스플레이 켜짐 → 폴링 재개 + 즉시 1회 갱신(켜졌을 때 메뉴 숫자 최신화). private func resumePolling() { guard pollingSuspended else { return } + clearPollingSuspension() + Task { await refresh() } + } + + private func clearPollingSuspension() { pollingSuspended = false + suspendedProbeTimer?.invalidate() + suspendedProbeTimer = nil reschedule() - Task { await refresh() } + } + + /// screensDidWake 없이 화면이 이미 켜져 있으면 정지를 푼다. 풀었으면 true. + @discardableResult + private func clearStaleSuspension(reason: String) -> Bool { + guard pollingSuspended, !displaysAsleep() else { return false } + AppLog.write("polling resumed without screensDidWake (\(reason))") + clearPollingSuspension() + return true + } + + func runSuspendedPollingProbe() async { + guard clearStaleSuspension(reason: "probe") else { return } + await refresh() + } + + func handleSystemWake() async { + clearStaleSuspension(reason: "didWake") + await refresh() } // MARK: 갱신 @@ -872,6 +915,7 @@ final class UsageStore { // Claude 한도가 다음 수동 액션까지 빈 채로 남던 회귀 방지. if isRefreshing { refreshPending = true; return } isRefreshing = true + clearStaleSuspension(reason: "refresh") // App Nap 방지 — 백그라운드 스로틀로 로그 파싱·codex 조회가 타임아웃되는 것을 막는다 (시스템 슬립은 허용) let activity = ProcessInfo.processInfo.beginActivity( options: .userInitiatedAllowingIdleSystemSleep, reason: "PokeTokenBar usage refresh") diff --git a/Tests/PokeTokenBarTests/UsageStoreTests.swift b/Tests/PokeTokenBarTests/UsageStoreTests.swift index 154f09329..ddf55873e 100644 --- a/Tests/PokeTokenBarTests/UsageStoreTests.swift +++ b/Tests/PokeTokenBarTests/UsageStoreTests.swift @@ -127,6 +127,12 @@ private func sessionOrg(_ id: String, hasUsage: Bool) -> SessionKeyOrganization limits: claudeLimits(fiveHourUtil: hasUsage ? 7 : 0)) } +/// 디스플레이 슬립 상태 스텁 — 테스트가 "화면은 켜졌는데 screensDidWake 는 안 온" 상황을 만든다. +private final class DisplayState: @unchecked Sendable { + nonisolated(unsafe) var asleep: Bool + init(asleep: Bool) { self.asleep = asleep } +} + private final class FakeStatusProvider: ProviderStatusProviding, @unchecked Sendable { nonisolated(unsafe) var result: [String: ProviderStatus] init(_ result: [String: ProviderStatus] = [:]) { self.result = result } @@ -233,6 +239,86 @@ final class UsageStoreTests: XCTestCase { XCTAssertGreaterThanOrEqual(calls, 2, "겹친 refresh 는 완료 후 1회 재실행돼야 한다(드롭 금지)") } + // MARK: 디스플레이 슬립 폴링 정지 자가 복구 (#350) + + private func makeSleepStore(displays: DisplayState, + provider: FakeUsageProvider? = nil) -> UsageStore { + let p = provider ?? FakeUsageProvider(id: "claude_code", displayName: "Claude Code", + daily: todayDaily(1_000)) + return UsageStore(providers: [p], + claudeLimitsProvider: FakeClaudeLimits(status: nil), + codexLimitsProvider: FakeCodexLimits(status: nil), + antigravityLimitsProvider: FakeAntigravityLimits(status: nil), + statusProvider: FakeStatusProvider([:]), + autoRefresh: false, defaults: testDefaults, + displaysAsleep: { displays.asleep }) + } + + /// [회귀 #350] screensDidSleep 뒤 screensDidWake 를 놓치면 폴링 타이머가 영영 안 돌아와, 자정에 + /// 기록된 빈 스냅샷(menuTitle "0", providers [])이 재시작 전까지 굳었다. 정지 중에는 저빈도 + /// 점검이 돌아 화면이 실제로 켜져 있으면 폴링을 되살려야 한다. + func testSuspendedPollingProbeResumesWhenDisplaysAreAwake() async { + let displays = DisplayState(asleep: true) + let store = makeSleepStore(displays: displays) + store.suspendPolling() + XCTAssertTrue(store.isPollingSuspended) + XCTAssertFalse(store.isPollingTimerScheduled, "정지 중엔 폴링 타이머가 없어야 한다(배터리)") + XCTAssertTrue(store.isSuspendedProbeScheduled, "정지 중엔 복구 점검 타이머가 있어야 한다") + + displays.asleep = false // 화면은 켜졌지만 screensDidWake 는 오지 않았다 + await store.runSuspendedPollingProbe() + + XCTAssertFalse(store.isPollingSuspended, "화면이 켜져 있으면 점검이 폴링을 되살려야 한다") + XCTAssertTrue(store.isPollingTimerScheduled) + XCTAssertFalse(store.isSuspendedProbeScheduled, "복구 뒤 점검 타이머는 정리돼야 한다") + XCTAssertEqual(store.todayTotalTokens, 1_000, "복구 직후 1회 갱신으로 스냅샷이 채워져야 한다") + } + + func testSuspendedPollingProbeStaysSuspendedWhileDisplaysSleep() async { + let displays = DisplayState(asleep: true) + let store = makeSleepStore(displays: displays) + store.suspendPolling() + await store.runSuspendedPollingProbe() + XCTAssertTrue(store.isPollingSuspended, "화면이 꺼져 있는 동안은 계속 정지해야 한다") + XCTAssertFalse(store.isPollingTimerScheduled) + XCTAssertTrue(store.isSuspendedProbeScheduled) + } + + /// 시스템 wake(didWake)는 오고 screensDidWake 만 빠진 경우 — 점검 주기를 기다리지 않고 바로 복구. + func testSystemWakeResumesSuspendedPollingWhenDisplaysAreAwake() async { + let displays = DisplayState(asleep: true) + let store = makeSleepStore(displays: displays) + store.suspendPolling() + displays.asleep = false + await store.handleSystemWake() + XCTAssertFalse(store.isPollingSuspended) + XCTAssertTrue(store.isPollingTimerScheduled) + XCTAssertEqual(store.todayTotalTokens, 1_000) + } + + /// 어떤 경로든(네트워크 재연결·수동 새로고침·날짜 변경) refresh 가 돌 때 화면이 켜져 있으면 + /// 정지 상태를 바로잡는다 — 점검·wake 와 무관한 단독 경로. + func testRefreshWhileDisplaysAwakeClearsAStaleSuspension() async { + let displays = DisplayState(asleep: true) + let store = makeSleepStore(displays: displays) + store.suspendPolling() + displays.asleep = false + await store.refresh(scheduleEmptyRetry: false) + XCTAssertFalse(store.isPollingSuspended) + XCTAssertTrue(store.isPollingTimerScheduled) + XCTAssertFalse(store.isSuspendedProbeScheduled) + } + + /// 화면이 꺼진 채 도는 refresh(자정 NSCalendarDayChanged 등)는 정지를 풀지 않는다. + func testRefreshWhileDisplaysAsleepKeepsPollingSuspended() async { + let displays = DisplayState(asleep: true) + let store = makeSleepStore(displays: displays) + store.suspendPolling() + await store.refresh(scheduleEmptyRetry: false) + XCTAssertTrue(store.isPollingSuspended) + XCTAssertFalse(store.isPollingTimerScheduled) + } + // MARK: Keychain 프롬프트 경로 분리 (회귀) /// 회귀 가드: 자동 폴링은 프롬프트 없는 경로(allowKeychainPrompt=false)로만 한도를 조회하고, diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 2cba9ec05..301ccb5de 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -777,6 +777,14 @@ read_when: ## 에너지 (상시 표시 애니메이션) +- **절전용 정지 상태는 짝 알림 하나에만 복구를 맡기지 마라.** `screensDidSleep` 이 폴링 타이머를 끄고 + `screensDidWake` 만 되살렸다. wake 알림을 놓치면 폴링이 영영 멈추고, 자정 `NSCalendarDayChanged` 갱신이 + 남긴 빈 스냅샷(menuTitle "0", providers [])이 재시작 전까지 굳었다(#350, `lastError` 도 비어 무증상). + **왜 못 걸렀나:** 정지/재개 경로에 테스트가 하나도 없었고, 실기기에서는 알림이 대개 짝지어 와서 재현이 + 안 됐다. → 정지 중엔 5분 점검 타이머가 실제 디스플레이 상태(`CGDisplayIsAsleep`)를 보고 복구하고, + `didWake`·모든 `refresh()` 진입도 같은 검사를 한다. 회귀 가드: `UsageStoreTests` 의 + `testSuspendedPollingProbe*`·`testSystemWakeResumes*`·`testRefreshWhileDisplays*`. + - **메뉴바 상태아이템 = idle CPU 저격수 (두 규칙 필수).** 실측: 라이브 앱 idle ~14% CPU → 수정 후 ~2%. ① **`statusItem.button.image` 대입은 반드시 `setDisableActions` 트랜잭션 안에서** (`AppDelegate.setStatusImage`). 레이어 백드 `NSStatusBarButton` 은 이미지 대입마다 `NSStatusItemScene` 암묵적 전환 애니메이션