From 3b9163c5c9a405e57cf3dff1ec34017b25875ac7 Mon Sep 17 00:00:00 2001 From: Federico Benedetti Date: Mon, 8 Jun 2026 22:26:30 +0200 Subject: [PATCH 1/3] docs: add quiet-fans-on-sleep design spec (issue #2) --- .../2026-06-08-quiet-fans-on-sleep-design.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md diff --git a/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md b/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md new file mode 100644 index 0000000..fd5fc3a --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md @@ -0,0 +1,163 @@ +# Quiet Fans on Sleep — Design + +**Date:** 2026-06-08 +**Status:** Approved (design) +**Issue:** #2 — "There's no need for the fan to spin when it's hibernating" + +## Goal + +When the Mac goes to **system sleep**, fans should stop whining. Today they keep +spinning at whatever speed Mystral last forced, because the SMC firmware retains +the forced fan mode + commanded RPM while the app's poll loop is suspended. + +Fix: on sleep, **hand fan control back to macOS auto mode** so the firmware idles +the fans the way it normally would during sleep. On wake, Mystral re-applies its +curve (already handled). + +## Root cause (verified by reading the code) + +- `FanController` (app, `@MainActor`, 2 s `Timer`) forces fan mode + (`setForcedMode(forced: true)`) and writes curve-driven speeds each tick. +- The app cannot write the SMC itself — only the **root helper daemon** + (`SMCHelperMode`, a KeepAlive LaunchDaemon) can. The app sends commands to the + helper via JSON files in `/tmp/mystral-cmds`, which the helper drains on its own + 2 s `DispatchSource` timer. +- On system sleep both timers are suspended. The SMC keeps honoring the last + `setForcedMode(true)` + last `setFanSpeed`, so the fan holds that RPM → whine. +- Wake is already handled: `FanController.handleWake` (`didWakeNotification`) + resets `forcedModeSet = false` and grants a 15 s helper grace, so the next tick + re-asserts forced mode + curve. +- There is **no `willSleep` handling anywhere** today. + +## Why the helper (not the app) must do this — chosen approach + +Restoring auto mode means an SMC write, and only the helper can write the SMC. +Two ways to trigger it: + +- **App-driven (rejected):** app observes `NSWorkspace.willSleepNotification` → + writes a `setForcedMode(false)` command file → helper drains it on its 2 s + timer. Racy: the system can sleep before the helper polls, and the stale command + then applies on *wake*, fighting `handleWake`. The file queue is too slow/unordered + for sleep transitions. +- **Helper-driven via IOKit (chosen):** the helper registers with + `IORegisterForSystemPower` and, on `kIOMessageSystemWillSleep`, restores auto + mode **in-process** (no file round-trip) before acking the power change. The SMC + owner does the write directly, synchronously, inside the pre-sleep window. This + is the same operation the helper's existing `SIGTERM` handler already performs. + +Behavior is **always-on** — no setting, no UI. Quieting fans during sleep is +unambiguously correct. + +## Architecture + +All changes are confined to **`Mystral/Services/SMCHelperMode.swift`** (plus a +test-only mock extension). No app-side, `SMCProxyService`, or UI changes. + +### 1. Shared "restore auto" helper + +Factor the auto-restore logic (currently inline in the `SIGTERM` handler) into one +testable function and use it from both the `SIGTERM` path and the new sleep path: + +```swift +static func restoreAutoMode(smc: SMCServiceProtocol) { + let count = (try? smc.getAllFans().count) ?? 2 + try? smc.setForcedMode(fanCount: count, forced: false) +} +``` + +Takes the protocol type so the existing `MockSMCService` can drive it in tests. +Concrete `SMCService` (used by the helper) conforms to `SMCServiceProtocol`. + +### 2. IOKit system-power registration + +`import IOKit.pwr_mgt` (and `IOKit.IOMessage` for the message constants). + +In `run()`, after the SMC is opened and the dispatch `queue` is created, register +for power notifications and bind delivery to the **same `queue`** the helper's +timer uses, so the callback is serialized with `processCommands`/SMC reads (no +concurrent SMC access): + +```swift +var notifyPort: IONotificationPortRef? +var notifier: io_object_t = 0 +let rootPort = IORegisterForSystemPower(nil, ¬ifyPort, mystralPowerCallback, ¬ifier) +if rootPort != 0, let notifyPort { + rootPowerPort = rootPort // static, read by the C callback + powerSMC = smc // static, read by the C callback + IONotificationPortSetDispatchQueue(notifyPort, queue) + // notifyPort / notifier retained for process lifetime (run() never returns) +} else { + logger.error("SMCHelper — IORegisterForSystemPower failed; sleep auto-restore disabled") +} +``` + +`IORegisterForSystemPower`'s callback is a C function pointer and cannot capture +context, so the bits it needs are held in file-scope static storage on +`SMCHelperMode`, set once in `run()`. They are `fileprivate` (not `private`) so the +same-file C callback can read them: + +```swift +fileprivate static var rootPowerPort: io_connect_t = 0 +fileprivate static var powerSMC: SMCServiceProtocol? +``` + +### 3. The callback (C-compatible, file-private free function) + +```swift +private func mystralPowerCallback(_ refcon: UnsafeMutableRawPointer?, + _ service: io_service_t, + _ messageType: UInt32, + _ messageArgument: UnsafeMutableRawPointer?) { + switch messageType { + case UInt32(kIOMessageCanSystemSleep): + // We never veto idle sleep — must ack promptly or sleep is delayed 30 s. + IOAllowPowerChange(SMCHelperMode.rootPowerPort, Int(bitPattern: messageArgument)) + case UInt32(kIOMessageSystemWillSleep): + if let smc = SMCHelperMode.powerSMC { SMCHelperMode.restoreAutoMode(smc: smc) } + IOAllowPowerChange(SMCHelperMode.rootPowerPort, Int(bitPattern: messageArgument)) + default: + break // kIOMessageSystemHasPoweredOn: app's handleWake re-applies the curve + } +} +``` + +`rootPowerPort` and `powerSMC` are `fileprivate static` on `SMCHelperMode` so the +same-file free function can read them; `restoreAutoMode` is `static` (default +internal) and likewise reachable. + +### Wake path — unchanged + +On `kIOMessageSystemHasPoweredOn` the helper does nothing. The app's existing +`handleWake` resets `forcedModeSet`, grants the 15 s grace, and the next 2 s tick +re-asserts forced mode + writes curve speeds. A brief (≤2 s) auto-mode window right +after wake is harmless. If the app isn't running, nothing was forced anyway, so +there's nothing to re-apply. + +## Testing + +- **Unit (no hardware):** extend `MockSMCService` to record forced-mode calls + (`lastForcedModeForced`, `lastForcedModeFanCount`), then assert + `SMCHelperMode.restoreAutoMode(smc:)` calls `setForcedMode(fanCount: 2, forced: false)` + for the 2-fan mock. This covers the actual sleep response logic. +- **Manual:** run the app with a forced curve, sleep the Mac, confirm the fan + spins down (or audibly quiets); wake and confirm the curve re-engages within one + tick. IOKit registration / callback delivery verified by Console logs. + +## Out of scope (YAGNI) + +- Any user setting / toggle for the behavior. +- Forcing a fixed minimum RPM during sleep (rejected in favor of firmware auto). +- Explicit IOKit teardown (`IODeregisterForSystemPower`/`IOServiceClose`) — the + helper holds the registration for its whole life; process exit reclaims it. +- Display-only sleep — only full system sleep (`kIOMessageSystemWillSleep`) is + handled; the curve should keep running while the display alone sleeps. + +## Risks + +- **`IORegisterForSystemPower` registration fails** → logged, and the helper + behaves exactly as today (no regression). Graceful degradation. +- **Must always `IOAllowPowerChange`** for both `kIOMessageCanSystemSleep` and + `kIOMessageSystemWillSleep`, or sleep is delayed ~30 s. Both paths ack + unconditionally. +- **Concurrency:** the callback shares the helper's serial `queue` with the timer, + so SMC access stays serialized. From df23e94a8bda8eee4380dcafaac7e2e497ade268 Mon Sep 17 00:00:00 2001 From: Federico Benedetti Date: Mon, 8 Jun 2026 23:12:58 +0200 Subject: [PATCH 2/3] fix(helper): hand fans to macOS auto on system sleep When the Mac sleeps, the SMC keeps honoring Mystral's last forced fan mode + RPM (the app's poll loop is suspended), so the fan keeps spinning and whines. The root helper now registers for IOKit system-power notifications and, on kIOMessageSystemWillSleep, releases forced mode so the firmware idles the fans; it always acks via IOAllowPowerChange to avoid the ~30s sleep stall. Wake re-applies the curve via the app's existing handleWake. Routing is factored into a unit-tested handlePowerMessage(); the C callback is a thin shell. SIGTERM reuses the shared restoreAutoMode(). Closes #2 --- Mystral/Services/SMCHelperMode.swift | 75 ++++++++++++++++++- MystralTests/SMCServiceTests.swift | 39 +++++++++- .../2026-06-08-quiet-fans-on-sleep-design.md | 13 +++- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/Mystral/Services/SMCHelperMode.swift b/Mystral/Services/SMCHelperMode.swift index db2a5be..ba70a06 100644 --- a/Mystral/Services/SMCHelperMode.swift +++ b/Mystral/Services/SMCHelperMode.swift @@ -1,4 +1,6 @@ import Foundation +import IOKit +import IOKit.pwr_mgt import os private let logger = Logger(subsystem: "com.fexxdev.Mystral", category: "SMCHelper") @@ -8,6 +10,11 @@ enum SMCHelperMode { static let cmdDir = "/tmp/mystral-cmds" static let pidPath = "/tmp/mystral-helper.pid" + /// State the C system-power callback needs (it can't capture context). Set once in + /// run(); read only from the callback, which runs on the helper's serial queue. + nonisolated(unsafe) fileprivate static var rootPowerPort: io_connect_t = 0 + nonisolated(unsafe) fileprivate static var powerSMC: SMCServiceProtocol? + static var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" } @@ -74,8 +81,7 @@ enum SMCHelperMode { signal(SIGTERM, SIG_IGN) sigSource.setEventHandler { logger.info("SMCHelper — SIGTERM received, restoring auto mode and cleaning up") - let count = (try? smc.getAllFans().count) ?? 2 - try? smc.setForcedMode(fanCount: count, forced: false) + restoreAutoMode(smc: smc) cleanup() exit(0) } @@ -113,11 +119,57 @@ enum SMCHelperMode { } timer.resume() + registerForSleepNotifications(smc: smc, queue: queue) + withExtendedLifetime(activityToken) { dispatchMain() } } + /// Registers the helper for system-power notifications so it restores auto fan mode + /// just before the Mac sleeps (issue #2). Delivery is bound to the helper's serial + /// `queue`, so the callback never races the polling timer's SMC access. + private static func registerForSleepNotifications(smc: SMCServiceProtocol, queue: DispatchQueue) { + powerSMC = smc + var notifyPort: IONotificationPortRef? + var notifier: io_object_t = 0 + let port = IORegisterForSystemPower(nil, ¬ifyPort, mystralSleepWakeCallback, ¬ifier) + guard port != 0, let notifyPort else { + logger.error("SMCHelper — IORegisterForSystemPower failed; sleep auto-restore disabled") + return + } + rootPowerPort = port + IONotificationPortSetDispatchQueue(notifyPort, queue) + logger.info("SMCHelper — registered for system sleep/wake power notifications") + } + + /// Hand fan control back to macOS auto mode (releases the forced setpoint the SMC + /// otherwise holds). Used both on SIGTERM and when the system is about to sleep so + /// the firmware idles the fans instead of whining at the last forced RPM (issue #2). + static func restoreAutoMode(smc: SMCServiceProtocol) { + let count = (try? smc.getAllFans().count) ?? 2 + try? smc.setForcedMode(fanCount: count, forced: false) + } + + /// Routes a system-power message to its fan action: on "will sleep" it hands fans + /// back to macOS auto so the firmware idles them (issue #2); other messages leave + /// fans alone (wake re-apply is the app's `handleWake`). Returns whether the message + /// is a sleep query/notification the caller must acknowledge with `IOAllowPowerChange` + /// — failing to ack stalls sleep ~30s. IOKit-free so it's unit-testable. + @discardableResult + static func handlePowerMessage(_ messageType: UInt32, smc: SMCServiceProtocol?) -> Bool { + switch messageType { + case kMystralMsgSystemWillSleep: + logger.info("SMCHelper — system will sleep, restoring auto fan mode") + if let smc { restoreAutoMode(smc: smc) } + return true + case kMystralMsgCanSystemSleep: + return true // never veto idle sleep; ack only + default: + return false // e.g. kIOMessageSystemHasPoweredOn — app's handleWake re-applies + } + } + private static func dumpDiagnostics(smc: SMCService) { let diagPath = "/tmp/mystral-diagnostics.log" var log = "=== Mystral SMC Diagnostics ===\n" @@ -224,3 +276,22 @@ enum SMCHelperMode { try? FileManager.default.removeItem(atPath: cmdDir) } } + +// IOKit's kIOMessage* power constants are nested function-like C macros +// (`iokit_common_msg(x)` = `(UInt32)(sys_iokit | x)`, with `sys_iokit = 0x38 << 26`) +// that Swift's importer can't translate, so reconstruct the two we handle. +// See . +private let kMystralMsgCanSystemSleep: UInt32 = (0x38 << 26) | 0x270 // 0xE0000270 +private let kMystralMsgSystemWillSleep: UInt32 = (0x38 << 26) | 0x280 // 0xE0000280 + +/// C-compatible (`@convention(c)`) system-power callback — captures nothing, so it reads +/// the SMC handle and root port from `SMCHelperMode`'s static storage and delegates the +/// (unit-tested) routing to `handlePowerMessage`. Its only job here is the IOKit ack. +private func mystralSleepWakeCallback(_ refcon: UnsafeMutableRawPointer?, + _ service: io_service_t, + _ messageType: UInt32, + _ messageArgument: UnsafeMutableRawPointer?) { + if SMCHelperMode.handlePowerMessage(messageType, smc: SMCHelperMode.powerSMC) { + IOAllowPowerChange(SMCHelperMode.rootPowerPort, Int(bitPattern: messageArgument)) + } +} diff --git a/MystralTests/SMCServiceTests.swift b/MystralTests/SMCServiceTests.swift index 1a484ae..67792bc 100644 --- a/MystralTests/SMCServiceTests.swift +++ b/MystralTests/SMCServiceTests.swift @@ -13,6 +13,8 @@ final class MockSMCService: SMCServiceProtocol, @unchecked Sendable { var lastSetFanIndex: Int? var lastSetPercentage: Double? var lastSetMode: FanMode? + var lastForcedModeFanCount: Int? + var lastForcedModeForced: Bool? func getAllSensors() throws -> [Sensor] { sensors } func readTemperature(key: String) throws -> Double { sensors.first { $0.id == key }?.temperature ?? 0 } @@ -20,7 +22,7 @@ final class MockSMCService: SMCServiceProtocol, @unchecked Sendable { func readFanSpeed(index: Int) throws -> Int { fans[index].currentRPM } func setFanSpeed(index: Int, percentage: Double) throws { lastSetFanIndex = index; lastSetPercentage = percentage } func setFanMode(index: Int, mode: FanMode) throws { lastSetMode = mode } - func setForcedMode(fanCount: Int, forced: Bool) throws {} + func setForcedMode(fanCount: Int, forced: Bool) throws { lastForcedModeFanCount = fanCount; lastForcedModeForced = forced } } final class SMCServiceTests: XCTestCase { @@ -42,4 +44,39 @@ final class SMCServiceTests: XCTestCase { XCTAssertEqual(svc.lastSetFanIndex, 0) XCTAssertEqual(svc.lastSetPercentage, 75.0) } + + // Issue #2: on system sleep the helper must hand fan control back to macOS + // auto mode so the firmware idles the fans (no whining during sleep). + func testRestoreAutoModeReleasesForcedControl() { + let svc = MockSMCService() + SMCHelperMode.restoreAutoMode(smc: svc) + XCTAssertEqual(svc.lastForcedModeForced, false) + XCTAssertEqual(svc.lastForcedModeFanCount, 2) // mock reports 2 fans + } + + // Issue #2: the exact routing the IOKit sleep/wake callback runs, driven with the + // real IOKit ABI message numbers from . handlePowerMessage + // returns whether the message must be acked via IOAllowPowerChange. + + func testSystemWillSleepHandsFansBackToAuto() { + let svc = MockSMCService() + let needsAck = SMCHelperMode.handlePowerMessage(0xE000_0280, smc: svc) // kIOMessageSystemWillSleep + XCTAssertEqual(svc.lastForcedModeForced, false) + XCTAssertEqual(svc.lastForcedModeFanCount, 2) + XCTAssertTrue(needsAck) // must ack or the system stalls ~30s before sleeping + } + + func testCanSystemSleepIsAckedWithoutTouchingFans() { + let svc = MockSMCService() + let needsAck = SMCHelperMode.handlePowerMessage(0xE000_0270, smc: svc) // kIOMessageCanSystemSleep + XCTAssertNil(svc.lastForcedModeForced) // fans left untouched + XCTAssertTrue(needsAck) + } + + func testPoweredOnLeavesFansToTheAppAndNeedsNoAck() { + let svc = MockSMCService() + let needsAck = SMCHelperMode.handlePowerMessage(0xE000_0300, smc: svc) // kIOMessageSystemHasPoweredOn + XCTAssertNil(svc.lastForcedModeForced) // app's handleWake re-applies the curve + XCTAssertFalse(needsAck) + } } diff --git a/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md b/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md index fd5fc3a..ca97983 100644 --- a/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md +++ b/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md @@ -70,7 +70,9 @@ Concrete `SMCService` (used by the helper) conforms to `SMCServiceProtocol`. ### 2. IOKit system-power registration -`import IOKit.pwr_mgt` (and `IOKit.IOMessage` for the message constants). +`import IOKit.pwr_mgt` (for `IORegisterForSystemPower`/`IOAllowPowerChange`/ +`IONotificationPortSetDispatchQueue`) and `import IOKit` (for the `kIOMessage*` +constants from `IOMessage.h`). In `run()`, after the SMC is opened and the dispatch `queue` is created, register for power notifications and bind delivery to the **same `queue`** the helper's @@ -94,11 +96,14 @@ if rootPort != 0, let notifyPort { `IORegisterForSystemPower`'s callback is a C function pointer and cannot capture context, so the bits it needs are held in file-scope static storage on `SMCHelperMode`, set once in `run()`. They are `fileprivate` (not `private`) so the -same-file C callback can read them: +same-file C callback can read them, and `nonisolated(unsafe)` because the project +builds in **Swift 6 language mode** (mutable statics are otherwise rejected as +nonisolated global shared mutable state). Safe here: written once at startup, read +only from the callback, which runs on the helper's serial `queue`: ```swift -fileprivate static var rootPowerPort: io_connect_t = 0 -fileprivate static var powerSMC: SMCServiceProtocol? +nonisolated(unsafe) fileprivate static var rootPowerPort: io_connect_t = 0 +nonisolated(unsafe) fileprivate static var powerSMC: SMCServiceProtocol? ``` ### 3. The callback (C-compatible, file-private free function) From 93879f633fdcf538373b8ed875928a2150bcfadd Mon Sep 17 00:00:00 2001 From: Federico Benedetti Date: Tue, 9 Jun 2026 09:50:46 +0200 Subject: [PATCH 3/3] chore: bump version to 1.1.1 (build 18) --- Mystral/Info.plist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mystral/Info.plist b/Mystral/Info.plist index d82932a..1eb059e 100644 --- a/Mystral/Info.plist +++ b/Mystral/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_TYPE) CFBundleShortVersionString - 1.1.0 + 1.1.1 CFBundleVersion - 17 + 18 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement