Skip to content

fix(fans): use IOKit power notifications for sleep/wake fan state#3189

Closed
marxo126 wants to merge 2 commits into
exelban:masterfrom
marxo126:fix/sleep-wake-iokit
Closed

fix(fans): use IOKit power notifications for sleep/wake fan state#3189
marxo126 wants to merge 2 commits into
exelban:masterfrom
marxo126:fix/sleep-wake-iokit

Conversation

@marxo126
Copy link
Copy Markdown

@marxo126 marxo126 commented May 7, 2026

Problem

Fan sleep/wake handling currently lives in Modules/Sensors/popup.swift and uses NSWorkspace.didWakeNotification / NSWorkspace.willSleepNotification. This has three structural issues:

  1. NSWorkspace.didWakeNotification arrives after SMC is ready only intermittently — IORegisterForSystemPower (IOKit) fires earlier and more reliably.
  2. The sleep/wake observers are added on the popup view (FanView). If the user closes the menu bar popup between sleep and wake, the observers are torn down with the view and the user's manual fan mode is never restored.
  3. willSleepMode / willSleepSpeed snapshot lives on the popup view, so it is only valid while the popup is alive.

This causes the regressions reported in #3091 ("manual mode resets to auto after sleep/wake") and #2977 ("fan goes to 100% after sleep" — same root cause path: snapshot lost, restoration falls back to default).

Change

  • New Modules/Sensors/fanPower.swiftFanPowerManager.shared singleton subscribing to IORegisterForSystemPower. Holds the per-fan (mode, speed) snapshot dictionary at the app level, independent of any view lifecycle.
  • On kIOMessageSystemWillSleep: snapshot every registered fan with non-automatic mode, then set automatic before sleep. Acknowledge with IOAllowPowerChange.
  • On kIOMessageSystemHasPoweredOn: 2-second delay (let SMC settle), then restore mode + speed via SMCHelper.shared. Clear snapshot.
  • Modules/Sensors/popup.swift — removed the NSWorkspace observers, wakeListener, sleepListener, willSleepMode, willSleepSpeed, and the now-dead resetModeAfterSleep re-sync block. The reader's normal tick picks up the restored mode on next read.
  • Modules/Sensors/main.swift — registers each discovered fan with FanPowerManager.shared at module init time.

Net diff

4 files changed, 121 insertions(+), 55 deletions(-)
 Modules/Sensors/fanPower.swift  | 114 ++++++++++++++++ (new)
 Modules/Sensors/main.swift      |   3 +
 Modules/Sensors/popup.swift     | -55
 Stats.xcodeproj/project.pbxproj |   4 +

Testing

Logic-reviewed against the existing fan code paths but not yet tested on real hardware — flagging this as Draft so you can take a look at the approach before I run it through sleep/wake on Apple Silicon.

If the approach lands well I'm happy to:

  • Run a sleep/wake matrix on Apple Silicon (M-series) and report
  • Add an integration shim if you want this gated behind a settings toggle for safety during rollout

Marking Draft. Happy to iterate or split further if you'd prefer the new singleton lives elsewhere (e.g. Kit/).

marxo126 added 2 commits May 7, 2026 10:59
NSWorkspace.didWakeNotification fires too late and unreliably for SMC
restoration. Move sleep/wake handling to IORegisterForSystemPower in a
new app-level FanPowerManager singleton, decoupled from the popup view.

Fixes the case where the popup is closed at sleep time — previously the
observers were torn down with the popup and the user's manual fan mode
was lost on wake.

Closes exelban#3091
Closes exelban#2977
…onstants

Build fixes after initial commit:
- IOPowerSourceCallbackType does not exist; correct type is IOServiceInterestCallback
- kIOMessageSystemWillSleep/kIOMessageSystemHasPoweredOn are not bridged
  through IOKit.pwr_mgt; declare locally with their canonical hex values
@marxo126
Copy link
Copy Markdown
Author

Status update — runtime-verified at the init layer; sleep/wake event not exercised in CI.

What was tested on macOS 26.4.1, M4 Max

  • App launches with no crash, FanPowerManager.shared initialises, IORegisterForSystemPower returns a valid rootPort, the dispatch queue is wired
  • register(fan:) callbacks fire from Modules/Sensors/main.swift at module init for both real fans (id 0, id 1)
  • popup.swift no longer references the removed wakeListener / sleepListener / willSleepMode / willSleepSpeed / resetModeAfterSleep ivars — clean compile, zero deprecation warnings

What was NOT tested

  • The sleep → wake → restore round-trip itself. Putting the test machine to sleep mid-session was disruptive. The actual kIOMessageSystemHasPoweredOn callback path is logic-reviewed but not exercised on hardware in this PR. The 2-second post-wake delay before SMC restore is a literal copy of the pattern that fixed similar issues elsewhere; happy to add an os_log instrumented verification run if you want a recorded trace.

Maintainability notes

  • The state (per-fan (mode, speed) snapshot) lives on the singleton, not on any view. If you ever rip out the popup again or change FanView's lifecycle, sleep/wake handling stays intact.
  • FanPowerManager deinitialises via IODeregisterForSystemPower + IONotificationPortDestroy. App quit path is clean.
  • The IOKit notification fires on a background queue (DispatchQueue.global(qos: .utility)); SMC writes within the handler use SMCHelper.shared which is already thread-safe via XPC.
  • One narrow assumption: fans returned by the sensor reader's list.sensors are stable across the app lifetime. If a fan ever hot-unplugs (rare on Apple Silicon, but possible on external display dock combos) the state dictionary would have a stale entry. Not currently a concern, but a documented unknown.

Caveats

  • Tested with an unsigned dev build (Apple Developer Program account is pending Apple's approval — submitted, awaiting). When the cert lands I will rebuild signed and re-run the sleep/wake matrix; will post results here.
  • The fix doesn't depend on signing — IORegisterForSystemPower is unrestricted. Runtime behaviour in a Developer-ID-signed production build is identical.

Closes #3091. Closes #2977.

@marxo126
Copy link
Copy Markdown
Author

Closing — won't pursue upstream. Thanks for Stats, used it long-term and learned a lot from the codebase. Work continues on personal fork at marxo126/stats for own M4 Max use only.

@marxo126 marxo126 closed this May 11, 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.

1 participant