Skip to content

fix: resume suspended polling when screensDidWake is missed - #401

Merged
chattymin merged 1 commit into
chattymin:mainfrom
Borisserz:fix/self-heal-suspended-polling
Oct 5, 2026
Merged

chattymin merged 1 commit into
chattymin:mainfrom
Borisserz:fix/self-heal-suspended-polling

Conversation

@Borisserz

Copy link
Copy Markdown
Contributor

Summary

Fixes the "providers list goes empty after prolonged uptime" report. The fix makes polling recover by itself instead of needing a restart.

Root cause. UsageStore stops the poll timer on NSWorkspace.screensDidSleepNotification (to save battery) and restarts it only on screensDidWake. Nothing else could clear pollingSuspended: didWake, network reconnects, and the midnight NSCalendarDayChanged handler each ran one refresh(), but none of them rescheduled the timer. So if a screensDidWake was ever missed, polling stayed off for the rest of the process lifetime.

This also explains the exact symptom. The overnight NSCalendarDayChanged refresh runs at 00:00 with displays asleep. No tokens exist yet for the new day, so it correctly writes providers: [] / menuTitle: "0" with an empty lastError. With the timer gone, nothing replaces that snapshot. It stays frozen while Claude Code keeps producing usage, and a restart fixes it because init reschedules the timer. The popover doesn't refresh on open, so it shows the same frozen state.

Fix.

  • While suspended, a low-frequency probe (every 5 minutes, 20% tolerance) checks the real display state with CGDisplayIsAsleep(CGMainDisplayID()). If the screens are awake, it resumes polling and refreshes once.
  • didWake and every refresh() entry run the same check. Any trigger (system wake, network reconnect, manual refresh) heals a stale suspension right away instead of waiting for the probe.
  • When displays really are asleep, nothing changes: polling stays suspended, and only the probe timer runs, which costs one cheap CG call every 5 minutes.
  • Each recovery writes polling resumed without screensDidWake (<reason>) to ~/Library/Logs/PokeTokenBar.log, so a missed wake shows up in field logs.

About usage-cache.json. The reporter saw it as an unreadable binary blob. That's expected: the cache is zlib-compressed JSON (LocalUsageCache.saveIfNeeded), so it isn't related to this bug.

Fixes #350

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • Other:

Checklist

  • swift build and swift test pass locally (scripts/test-gate.sh: 1389 tests, 0 failures, logic-core coverage 93.35%)
  • PR title and description are written in English
  • UI changes are described above (before/after — images optional) — no UI changes
  • No copyrighted assets, secrets, or private tooling references are committed (see CONTRIBUTING)
  • Tests were added or updated for this change

Test plan

New UsageStoreTests cases. Display state is injected through the new displaysAsleep init parameter, which defaults to CGDisplayIsAsleep.

  • testSuspendedPollingProbeResumesWhenDisplaysAreAwake: the trigger branch (suspended, screens awake, no screensDidWake). The probe resumes the timer, removes itself, and the refresh fills the snapshot.
  • testSuspendedPollingProbeStaysSuspendedWhileDisplaysSleep: displays asleep, so polling stays suspended.
  • testSystemWakeResumesSuspendedPollingWhenDisplaysAreAwake: the didWake path alone.
  • testRefreshWhileDisplaysAwakeClearsAStaleSuspension: the refresh() path alone, without the probe or wake.
  • testRefreshWhileDisplaysAsleepKeepsPollingSuspended: the midnight refresh with screens off doesn't resume polling.
  • Fault injection: making the stale-suspension check always return false makes the 3 recovery tests fail (9 assertions). The 2 negative tests still pass, as expected.
  • Recorded the defect class in docs/reference/defect-log.md (energy section).

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 (chattymin#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.
@chattymin
chattymin merged commit 906bbb9 into chattymin:main Oct 5, 2026
2 checks passed
donky-ey pushed a commit to donky-ey/PokeDexBar that referenced this pull request Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Providers list goes empty after prolonged uptime (v2.5.1)

2 participants