Skip to content

fix(macos): drive the device-I/O gate from powerd instead of workspace notifications - #1323

Merged
AprilNEA merged 2 commits into
masterfrom
fix/macos-power-state-gate
Oct 1, 2026
Merged

AprilNEA merged 2 commits into
masterfrom
fix/macos-power-state-gate

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

The macOS device-I/O gate 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, 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 API pmset is 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" from CGDisplayIsAsleep (#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 the extern block is hand-written and inventoried in .claude/rules/objc-ffi.md; the reasoning is recorded in docs/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

  • openlogi-agent
    • New activity_macos: PowerState classification of IOPMCapabilityBits, the ActivityGate transition authority (power × console × launch hold → DeviceIoSignal), the powerd subscription with acknowledged events and a Drop that drains the delivery queue, and the console level read.
    • tray.rs: the ActivityTarget bitmask is gone; a SessionTarget forwards only the fast-user-switch notifications. run_app_loop reads both levels after finishLaunching and releases the hold.
    • Cargo.toml: objc2-core-foundation (CFBase, CFDictionary, CFNumber, CFString) and objc2-io-kit (for IOReturn); objc2-core-graphics now needs only CGSession. Both crates were already in the workspace table; Cargo.lock gains the two edges and the gpui pins are unchanged.
  • openlogi-agent-core: one comment in watchers/gesture.rs that described the old screen-sleep source.
  • docs / rules: docs/DECISIONS.md entry for the API choice and the fail-open policy; .claude/rules/objc-ffi.md inventory rows for the new file and the SPI.

Log lines to look for: device I/O paused power=DarkWake on_console=true starting=false on a maintenance wake, device I/O resumed power=FullWake on_console=true on the wake that follows it, and could not subscribe to power management if the SPI is unavailable.

Testing

macOS 26.4 (Darwin 25.4.0), aarch64, cargo xtask ci:

  • PASS rustfmt, typos, publish closure, shell, clippy, MSRV (cargo check, macos), rustdoc (non-GUI crates), tests (macos, aarch64), cargo-deny, clippy (windows) proxy, wasm (portable crates)
  • SKIP tests (linux) — not reproducible on this host. The diff is macOS-gated (mod activity_macos and tray are cfg(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) and cargo 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 > Sleep with a Bluetooth mouse, the #656 setup), wait for a maintenance wake, open the lid, and check ~/.local/state/openlogi/agent.<date>.log for the device I/O paused / device I/O resumed pair with the power= field naming the transition. openlogi list should report the devices immediately after the wake, with no agent restart.

Fixes #1281
Refs #952

Copilot AI lite review requested due to automatic review settings September 9, 2026 14:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_macos to 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.

Comment thread crates/openlogi-agent/src/activity_macos.rs Outdated
@greptile-apps

greptile-apps Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Replaces the device-I/O gate with a new power-management subscription.

The PR appears safe to merge based on this follow-up review.

Summary

The PR replaces the macOS device-I/O gate’s workspace sleep/wake latch with powerd capability levels and a console-ownership check.

  • Startup holds device I/O until both levels have been read.
  • Power transitions refresh the console level and publish one gate decision; session notifications continue to handle fast-user switching.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  P["powerd capabilities"] --> G["ActivityGate"]
  C["Console ownership"] --> G
  N["AppKit session notifications"] --> G
  H["Startup hold"] --> G
  G --> S["DeviceIoSignal"]
  S --> I["Guarded device I/O"]
Loading

Reviews (3) · Last reviewed commit: "fix(macos): decide a power transition an..."

Comment thread crates/openlogi-agent/src/activity_macos.rs Outdated
AprilNEA and others added 2 commits October 1, 2026 16:30
…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.
@AprilNEA
AprilNEA force-pushed the fix/macos-power-state-gate branch from 5c8af8f to 62e4e75 Compare October 1, 2026 09:13
@AprilNEA
AprilNEA merged commit b605778 into master Oct 1, 2026
26 checks passed
@AprilNEA
AprilNEA deleted the fix/macos-power-state-gate branch October 1, 2026 10:15
@aprilnea aprilnea Bot mentioned this pull request Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

platform: macos macOS-specific issue type: bug Something is broken or behaves incorrectly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: macOS agent pauses device I/O until restart when a screen/session wake notification never arrives

3 participants