Skip to content

Back off idle timers so the menu-bar app stops waking the main thread when idle#665

Merged
FuJacob merged 2 commits into
mainfrom
energy-idle-timers
Jun 11, 2026
Merged

Back off idle timers so the menu-bar app stops waking the main thread when idle#665
FuJacob merged 2 commits into
mainfrom
energy-idle-timers

Conversation

@FuJacob

@FuJacob FuJacob commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

Two always-on timers woke the main thread continuously regardless of whether the user was typing. On Apple Silicon, idle package power is dominated by wake frequency rather than per-wake work, so these are a real idle-energy floor (Tier C of the #661 energy audit).

  • PermissionManager polled AXIsProcessTrusted + two CGPreflight* calls every 2s for the entire session, even after every permission was already granted (the steady state for any established user). It now refreshes on NSApplication.didBecomeActive and keeps the 2s catch-up poll alive only while a required permission is still missing (onboarding). Once the required set is granted, the timer is torn down: zero idle wakeups thereafter.
  • FocusTracker ran a fixed 50ms (20Hz) timer and used FocusPollBackoff to skip the expensive AX walk when idle, but the timer still fired 20x/s and hopped to the main actor only to no-op. The backoff now drives the timer interval itself (base * stride), so an idle machine wakes ~2x/s instead of 20x/s.

Validation

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' \
  -derivedDataPath build/DerivedData -onlyUsePackageVersionsFromResolvedFile \
  -only-testing:CotabbyTests/FocusPollBackoffTests CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO test
# ** TEST SUCCEEDED **  Executed 9 tests, with 0 failures

swiftlint lint --quiet <changed files>
# exit 0

Built the full app + test target. Not yet exercised at runtime; behavior to confirm on next launch: with permissions already granted, no permission poll timer arms (and revoking a permission in System Settings is still reflected after returning to the app); focus tracking still follows the caret while typing and snaps back to fast cadence after an idle pause.

Linked issues

Refs #661 (Tier C: idle timers).

Risk / rollout notes

  • Permission revocation latency. After the required set is granted there is no standing poll, so a revocation made in System Settings is reflected when the user next returns to the app (didBecomeActive) or opens any Cotabby surface (the menu, Settings, the Permissions pane, and onboarding all already call refresh() on appear). The app cannot function without those permissions anyway, and AX calls begin failing immediately on revocation, so the cache cannot drive harmful behavior in the gap. During onboarding (required not yet granted) the 2s poll still runs for snappy feedback.
  • Focus capture cadence is unchanged. The timer now fires every base * stride and captures on every fire, which is the exact same spacing between AX walks as the previous "fire every base, skip to every stride" design. Only the no-op in-between wakes are removed. The timer is re-armed only when the stride actually changes, so active typing (stride 1) adds zero timer churn, and refreshNow() (called per keystroke) still resets to the base interval instantly.
  • API change. FocusPollBackoff.shouldCaptureOnTick() is replaced by a captureStride multiplier. The only caller was FocusTracker; the unit tests are updated to assert the stride schedule and state machine directly (9 tests, all passing).
  • No schema, settings, or pbxproj migration. No new files, so no xcodegen generate needed.

Greptile Summary

This PR reduces idle main-thread wakeups in two subsystems: PermissionManager now refreshes on NSApplication.didBecomeActive and only polls while a required permission is still missing (zero standing wakeups for established users), and FocusTracker drives its timer interval directly from the idle-backoff stride so the main thread wakes every base × stride instead of every base tick.

  • FocusPollBackoff drops ticksSinceCapture / shouldCaptureOnTick() in favour of a captureStride property; FocusTracker multiplies the base interval by this stride and re-arms the timer only when the stride actually changes, keeping active typing at full cadence with zero timer churn.
  • PermissionManager wires up a didBecomeActive observer, tears down the 2-second poll once requiredPermissionsGranted is true, and addresses the prior review's @MainActor isolation concern with MainActor.assumeIsolated in the activation callback.

Confidence Score: 5/5

Safe to merge — the idle-backoff and permission-polling changes are well-scoped, @MainActor isolation is consistent throughout both modified actors, and the state machines are directly exercised by 9 passing unit tests.

Both behavioural changes preserve the original capture cadence (active users see no difference) and handle edge cases explicitly: permission revocation is reflected on the next activation or UI surface appear; timer re-arming is guarded by an interval-equality check so active typing causes zero timer churn. The one remaining inconsistency is a style/concurrency-annotation gap with no runtime consequence under current Swift settings.

All files look solid; PermissionManager.swift has the minor DispatchQueue.main.async inconsistency noted in the inline comment.

Important Files Changed

Filename Overview
Cotabby/Services/Focus/FocusTracker.swift Replaces tick-skipping backoff with timer-interval backoff: adds scheduledInterval tracking, scheduleTimer(), and rescheduleTimerIfIntervalChanged(). Logic is sound — @MainActor isolation prevents races and stop() keeps timer/scheduledInterval in sync.
Cotabby/Services/Permission/PermissionManager.swift Adds activation-based refresh and conditional polling — correct design. One minor inconsistency: the activation observer uses MainActor.assumeIsolated but the preserved timer callback in startPollingIfNeeded still uses DispatchQueue.main.async.
Cotabby/Support/FocusPollBackoff.swift Simplifies the struct by removing ticksSinceCapture and replacing shouldCaptureOnTick() with a captureStride computed property. Logic and state machine are unchanged.
CotabbyTests/FocusPollBackoffTests.swift Tests updated to match the new stride-based API; coverage is thorough — stride schedule, monotonicity, snap-back after idle, and reset all tested directly.

Comments Outside Diff (1)

  1. Cotabby/Services/Focus/FocusTracker.swift, line 91-95 (link)

    P2 Undocumented behavior change in start(): calling it while the timer is already running previously was a no-op; it now calls refreshNow(), which resets idle backoff (snap back to stride 1) and performs an immediate AX tree walk. If any callsite invokes start() while polling is already active — e.g. as a "ensure tracking is on" guard — this introduces an unintended backoff reset and an extra synchronous AX read that can briefly spike CPU. Consider documenting the new "restart capture and reset backoff" semantic in the method's doc comment.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (2): Last reviewed commit: "Make the activation observer's main-acto..." | Re-trigger Greptile

… when idle

Two always-on timers woke the main thread continuously regardless of typing.
On Apple Silicon, wake frequency (not per-wake work) dominates idle package
power, so this is a real idle-energy floor (refs #661).

PermissionManager polled AXIsProcessTrusted + two CGPreflight calls every 2s
for the entire session, even after every permission was granted (the steady
state for any established user). It now refreshes on NSApplication.didBecomeActive
and keeps the 2s catch-up poll only while a required permission is still missing;
once the required set is granted the timer is torn down. The menu, settings, and
onboarding surfaces already call refresh() on appear, so a later revocation is
still caught promptly.

FocusTracker ran a fixed 50ms (20Hz) timer and relied on FocusPollBackoff to
*skip* the expensive AX walk when idle, but the timer still fired 20x/s and
hopped to the main actor only to no-op. The backoff now drives the timer
*interval* itself (base * stride), so an idle machine wakes ~2x/s instead of
20x/s. Capture cadence is unchanged (still base * stride between walks); only the
wasted in-between wakes are removed, and the timer is re-armed only when the
stride actually changes, so active typing (stride 1) adds zero timer churn.

FocusPollBackoff drops tick-counting (shouldCaptureOnTick) for a captureStride
multiplier; its tests now assert the stride schedule and state machine directly.
Comment thread Cotabby/Services/Permission/PermissionManager.swift
…ated

Greptile review: the addObserver(forName:queue:.main) closure is not
formally actor-isolated even though delivery is guaranteed on the main
thread, so calling the @mainactor refresh() directly would trip Swift 6
strict concurrency checking. assumeIsolated makes the hop explicit and
keeps the refresh synchronous with activation; matches the existing
SystemMetricsStore/InputMonitor pattern.
@FuJacob FuJacob merged commit 9584a35 into main Jun 11, 2026
4 checks passed
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.

1 participant