fix(macos): drive the device-I/O gate from powerd instead of workspace notifications - #1323
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The power-transition callback can transiently publish an incorrect device-I/O allow-state because it updates power and console levels in separate gate updates.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR hardens the macOS device-I/O gate by replacing edge-latched NSWorkspace sleep/wake notifications with level-based power state reporting from powerd (IOPMConnection), combined with a console-ownership level from CGSessionCopyCurrentDictionary, to prevent indefinite “paused until restart” outages when wake edges are dropped.
Changes:
- Add
activity_macosto drive the device-I/O gate from powerd capability levels (Sleep/DarkWake/FullWake) plus console ownership, with a launch hold and fail-open behavior when the SPI subscription cannot be established. - Simplify the AppKit tray loop to forward only fast-user-switch session edges to the gate and sequence the launch hold after
finishLaunching. - Document the SPI decision and inventory the new unsafe/FFI surface; update deps/lockfile accordingly.
File summaries
| File | Description |
|---|---|
| docs/DECISIONS.md | Records the rationale for using IOPMConnection SPI and the fail-open policy. |
| crates/openlogi-agent/src/tray.rs | Removes sleep/wake edge latch; forwards only session active/inactive edges and sequences the launch hold with activity_macos. |
| crates/openlogi-agent/src/main.rs | Wires in the new activity_macos module and updates the launch-hold comment. |
| crates/openlogi-agent/src/activity_macos.rs | New level-driven gate authority, powerd subscription, and console-level read. |
| crates/openlogi-agent/Cargo.toml | Adds CoreFoundation/IOKit objc2 deps needed for the new macOS integration. |
| crates/openlogi-agent-core/src/watchers/gesture.rs | Updates commentary to match the new “paused device I/O” framing. |
| Cargo.lock | Updates the lockfile for the new objc2 dependency edges. |
| .claude/rules/objc-ffi.md | Inventories the new SPI/unsafe surface and updates the macOS FFI table. |
Review details
- Files reviewed: 7/8 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
…e notifications The gate that keeps HID access off while the Mac is dark (#656) latched on NSWorkspace edges: sleep and screen-sleep notifications closed it and only ScreensDidWake / SessionDidBecomeActive reopened it. macOS does not guarantee the wake edge on a lid-close/open cycle (Apple DTS, forums thread 796109), and one dropped edge paused device I/O until the agent was restarted. Subscribe to powerd through IOPMConnection instead. Every Sleep / DarkWake / FullWake transition arrives as the complete capability set of the new state on a dispatch queue this process owns, and the current set can be read on demand, so every input to the gate is a level: nothing pairs, a missed event is corrected by the next, and there is no timer. The gate opens only in a full wake while this login session owns the console; the console level is read from CGSessionCopyCurrentDictionary, with the AppKit session edges forwarded as hints. A launch reads both levels before releasing its hold, so a relaunch during a sleep transition no longer guesses "awake" from CGDisplayIsAsleep. Screen sleep is no longer a suspend source: it only ever stood in for "not a DarkWake", which powerd now names directly. The SPI is exported by IOKit since 10.6 and is what pmset is built on, but it is declared only in Apple's open-source IOKitUser, so the extern block is hand-written and inventoried. If the subscription cannot be made the gate follows the console alone and logs an error rather than leaving an agent that can never touch hardware. Fixes #1281 Refs #952 Co-authored-by: Harry Xie <harryhsieh963@yahoo.com>
…te update The powerd callback wrote the new power state and the freshly read console level as two separate gate updates, each of which can publish. A full wake into a console another user now owns — the session notification having been dropped while the machine was dark — opened the gate for the instant between the two writes, long enough for a HID open to start. Read the console first and apply both levels in one update; a watch-channel version test pins that no Allowed is ever published on that wake.
5c8af8f to
62e4e75
Compare
Summary
The macOS device-I/O gate latched on
NSWorkspaceedges: sleep and screen-sleep notifications closed it, and onlyScreensDidWake/SessionDidBecomeActivereopened it. macOS does not guarantee the wake edge on a lid-close/open cycle (Apple DTS, developer forums thread 796109, also says the system may not run the app's run loop to deliver it), so one dropped edge paused device I/O until the agent was restarted — 66 and 47 minutes in the two reports on #1281.This replaces the edge latch with levels read from power management itself.
IOPMConnection(the powerd client APIpmsetis built on) reports every Sleep / DarkWake / FullWake transition as the complete capability set of the new state, delivered to a dispatch queue this process owns, and reads the current set on demand. Nothing has to pair, a missed event is corrected by the next, and there is no reconciler timer. The gate opens only in a full wake while this login session owns the console (CGSessionCopyCurrentDictionary, with the AppKit session edges forwarded as hints and the level re-read on every power transition). A launch reads both levels before releasing its hold, so a watchdog relaunch during a sleep transition no longer guesses "awake" fromCGDisplayIsAsleep(#952). Screen sleep is no longer a suspend source: in #1142 it only stood in for "not a DarkWake", which powerd now names directly, and pre-0.8.2 never gated on it.The SPI is exported by IOKit since 10.6 but declared only in Apple's open-source
IOKitUser, so theexternblock is hand-written and inventoried in.claude/rules/objc-ffi.md; the reasoning is recorded indocs/DECISIONS.md. If the subscription cannot be made, the gate follows the console alone and logs an error rather than leaving an agent that can never touch hardware.This re-homes the analysis behind #1283 (the dropped-edge diagnosis, the unsound startup snapshot, the DarkWake-vs-FullWake distinction) onto a level-based source instead of a 2 s reconciler with idle-time heuristics.
Changes
activity_macos:PowerStateclassification ofIOPMCapabilityBits, theActivityGatetransition authority (power × console × launch hold →DeviceIoSignal), the powerd subscription with acknowledged events and aDropthat drains the delivery queue, and the console level read.tray.rs: theActivityTargetbitmask is gone; aSessionTargetforwards only the fast-user-switch notifications.run_app_loopreads both levels afterfinishLaunchingand releases the hold.Cargo.toml:objc2-core-foundation(CFBase,CFDictionary,CFNumber,CFString) andobjc2-io-kit(forIOReturn);objc2-core-graphicsnow needs onlyCGSession. Both crates were already in the workspace table;Cargo.lockgains the two edges and the gpui pins are unchanged.watchers/gesture.rsthat described the old screen-sleep source.docs/DECISIONS.mdentry for the API choice and the fail-open policy;.claude/rules/objc-ffi.mdinventory rows for the new file and the SPI.Log lines to look for:
device I/O paused power=DarkWake on_console=true starting=falseon a maintenance wake,device I/O resumed power=FullWake on_console=trueon the wake that follows it, andcould not subscribe to power managementif the SPI is unavailable.Testing
macOS 26.4 (Darwin 25.4.0), aarch64,
cargo xtask ci:mod activity_macosandtrayarecfg(target_os = "macos"), the manifest change is in the macOS dependency table), so the Linux and Windows lanes see no source change.Focused:
cargo test -p openlogi-agent(34 passed; seven new tests cover the classification, the launch hold, a launch into a DarkWake, the DarkWake blip from #1281, sleep → DarkWake → FullWake, a fast-user-switched console, and an unprivileged powerd subscription against the host's IOKit) andcargo clippy -p openlogi-agent --all-targets -- -D warnings.Not runtime-tested on hardware: no sleep cycle was run on the development machine. To verify: install, close the lid (or
Apple menu > Sleepwith a Bluetooth mouse, the #656 setup), wait for a maintenance wake, open the lid, and check~/.local/state/openlogi/agent.<date>.logfor thedevice I/O paused/device I/O resumedpair with thepower=field naming the transition.openlogi listshould report the devices immediately after the wake, with no agent restart.Fixes #1281
Refs #952