Skip to content

fix(macos): prove the display and session instead of trusting one notification - #1283

Open
hyspacex wants to merge 5 commits into
AprilNEA:masterfrom
hyspacex:fix/macos-resume-gate
Open

hyspacex wants to merge 5 commits into
AprilNEA:masterfrom
hyspacex:fix/macos-resume-gate

Conversation

@hyspacex

@hyspacex hyspacex commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The macOS device-I/O gate trusts two things it should not. Both failed on my machine in one evening. Fixes #1281, refs #952 (the relaunch half).

The notifications are edges. WillSleep / ScreensDidSleep / SessionDidResignActive close the gate, ScreensDidWake / SessionDidBecomeActive reopen it, and the workspace center guarantees neither delivery nor pairing. When a suspend edge arrives and its partner never does, the mask keeps that bit for the life of the process: device I/O stays paused, openlogi list reports paired slots as "Unknown device", and restarting the agent is the only recovery.

The launch snapshot is unsound. CGDisplayIsAsleep(CGMainDisplayID()) seeds the gate at startup, and it cannot see the state a relaunched agent actually lands in.

Evidence

macOS 26.6.2, aarch64. MacBook in clamshell on one external display behind a Thunderbolt dock; MX Master 3 on a Unifying receiver plugged into that dock.

The blink (#1281). A one-second display blink at unlock logged display/session suspended — pausing device I/O at 23:04:39Z with nothing after it. The agent then ran 66 minutes with the gate shut while the machine was in use. pmset -g log: Entering DarkWake state due to 'Clamshell Sleep' at 16:04:39 local, DarkWake to FullWake … due to HID Activity at 16:04:40.

The relaunch loop (#952). 43 watchdog exits in one day, every one inside the same cycle:

offset event
0 s pmset: Entering Sleep state due to 'Maintenance Sleep'
+3–12 s (median 4) agent exits: HID CGEventTap lifecycle did not make progress before deadline
+0.3 s launchd relaunches it; the new agent logs display/session resumed — enabling device I/O
+7–19 s (median 12) pmset: DarkWake from Deep Idle [CDNP] : due to ATC0.PMGRCIOWakeup — the dock the receiver is on
+45 s Entering Sleep again, and round it goes

43 of 43 exits fit that shape (16 of them back to back over 18:43–18:59). So the stall that trips the watchdog is at the system sleep transition, not merely "while the display is off" — my #1282 description had that wrong. I am not claiming the relaunched agent's HID open is what wakes the dock; I have not proved that. What is certain is that the relaunches keep reopening the gate, and that the Mac did not stay asleep with the lid shut.

What CGDisplayIsAsleep actually gets wrong. A read-only probe logged the display list and the levels every 5 s. For a plain idle blank at 18:42:25 the display left the active list and reported asleep=1, and the agent handled that sleep and its wake correctly. The false negative is specifically a reconfiguration: at 18:43:40 the lid closed, the external display re-enumerated under a new CGDirectDisplayID (3 → 13), and that new id reported asleep=0 through every DarkWake until the real wake at 19:54. That is why all 43 relaunches opened the gate. Widening the read past CGMainDisplayID does not help — with the lid shut that panel is both the main and the only online display — and there is nothing to read instead: on Apple Silicon IODisplayWrangler carries no IOPowerManagement dictionary.

The fix

The gate still closes on the notifications; it now reopens on proof.

  • kCGSSessionOnConsoleKey (CGSession.h) is the level whose edges SessionDidBecomeActive / DidResignActive announce, and it is trustworthy in both directions, so it discharges the session source on its own — saying nothing about the display, exactly like that notification.
  • Any HID input wakes a sleeping display, so CGEventSourceSecondsSinceLastEventType on the HID system state is what proves one is on. Which comparison is valid depends on what the owner knows. With no display-sleep report (a relaunched agent has no history) only an idle timeout could have blanked the panel, and pmset displaysleep 1 is the shortest blank macOS allows, so input newer than 60 s rules it out. Once ScreensDidSleep has been recorded that argument is void — a hot corner blanks the display a second after the last keystroke — so there, only input newer than the suspension proves the display came back.
  • CGDisplayIsAsleep is kept as a positive-only short circuit: true is a definite "the user can see nothing"; false proves nothing. An empty or failed display list reads the same way, so a headless or screen-shared Mac still works.
  • A DarkWake proves nothing at all. Everything above is window-server state, and none of it separates a DarkWake from a full wake — which is the state a relaunch during the sleep transition lands in. The first relaunch after my lid closed had ~27 s of HID idle (I had unlocked 27 s earlier), so the 60 s floor alone would have "proved" the display awake. The gate now also reads IOPMrootDomain's System Capabilities property: a readable value without kIOPMSystemCapabilityGraphics means a DarkWake, and then nothing may be discharged — not the startup hold, not a screen sleep, not an inactive session. An additional necessary condition, not a replacement.
  • Every source, SYSTEM_SLEEP included, is reconcilable, because WillSleep is no more guaranteed a partner than any other edge: a sleep the system aborts, or one it enters and leaves without the screens or the session ever moving, gets no wake notification at all and strands the gate (see the 2026-09-11 field failure in the comments). What keeps [Bug]: External display wakes from sleep #656 out is the proof rather than the parking: a system sleep is discharged only on a positive kIOPMSystemCapabilityGraphics read, with no display reporting itself asleep, on console, and input that landed more than 30 s past the suspension — IOPMLib's acknowledgment bound for kIOMessageSystemWillSleep, the documented ceiling on how long the machine can stay fully awake after the notification the gate closed on; a margin of its own, not a multiple of the reconcile tick. A maintenance DarkWake reads graphics-down and discharges nothing, and an unreadable capability set, which degrades gracefully for every other source, is refused outright here because the positive read is the whole of this proof. Suspensions are timed on a clock that counts sleep (CLOCK_MONOTONIC, not std::time::Instant, which is CLOCK_UPTIME_RAW on Darwin), and every suspend edge restarts that clock and bumps a generation counter. The reconciler and the startup path capture the generation and the instant before they sample the levels, and a sample read under an older generation discharges nothing, so neither a real freeze, a repeated WillSleep, nor an edge landing mid-read can leave held_for describing a different sleep than the one being proved.
  • One reconciler thread parks on a condvar and wakes only while a dischargeable suspension has stood for two seconds — short because the startup hold now fails closed, and being early costs the missed-wake case nothing, since a shorter interval makes the relative proof stricter rather than racier. No timed work and no CoreGraphics call while the gate is open; during a real sleep the process is frozen, and during a DarkWake it issues only the window-server and IORegistry reads it already issues through a screen sleep, which is itself a DarkWake.
  • finish_startup fails closed: an unproven display keeps the startup hold. ScreensDidWake discharges it directly when the display really returns.
  • Suspend/resume lines name their sources (source=screens-asleep, cleared=system-sleep+screens-asleep), and the unproven-launch and reconciler lines carry the levels they decided from.

On the capability read

The bit values are public — <IOKit/pwr_mgt/IOPM.h> declares kIOPMSystemCapabilityCPU/Graphics/Audio/Network and objc2-io-kit generates them, so no discriminant is hardcoded. The key that carries the current set, "System Capabilities", is not in any SDK header; I want to be plain about that. Reading it is still an ordinary IORegistry lookup through the documented API (IOServiceMatching + IOServiceGetMatchingService + IORegistryEntryCreateCFProperty, the pair ioreg uses), not a private SPI, and needs no entitlement. Because the key is undocumented, an unreadable or missing property is a third state that proves nothing either way, so a macOS that renames it degrades to the levels above rather than wedging the gate shut forever.

It reads 15 in full wake here, and pmset's own log draws the same line (DarkWake … [CDNP] versus FullWake … [CDNVA], the V being video). The probe has since caught the DarkWake sample: during the 2026-09-11 'Notification Wake Back to Sleep' DarkWake (see the comments) it read 9 (CPU + Network, no Graphics), and 15 again ten seconds later after the HID wake. The bit does separate the two states.

Changes

All of it in crates/openlogi-agent/src/tray.rs. Suspensions are stamped with a small ContinuousInstant newtype over clock_gettime(CLOCK_MONOTONIC) (hence libc in the macOS dependency block), and suspend_from_at / discharge_at take an explicit now so the clock rules are unit-tested too; the notification registration is observe_activity(center, object, signal), which production calls with the workspace center and the tests call with a private NSNotificationCenter and a sentinel object. ActivityTarget's ivars become an Arc<ActivitySources> holding the suspend mask and the instant it was recorded (the relative proof needs both) plus a condvar — still one transition authority for the gate. ActivityLevels (console, displays-report-asleep, system graphics, idle) and the pure discharged_by / display_is_proven_awake decide from injected readings, so every rule above is unit-tested without a display; the graphics level is a three-state SystemGraphics { Up, Down, Unknown } rather than an Option<bool>. finish_startup takes a reading and returns StartupDisplay, ScreensDidWake also discharges the startup hold, and run_app_loop starts the reconciler only after finish_startup so it cannot race the launch sequence.

Cargo.toml: objc2-core-foundation with CFDictionary/CFNumber/CFString; objc2-core-graphics gains CGError, CGEventSource, CGEventTypes, CGSession; new objc2-io-kit with std/libc/pwr_mgt. All three are already in the workspace table. Cargo.lock gains two lines; the gpui pins are unchanged. .claude/rules/objc-ffi.md's inventory row and unsafe list for tray.rs cover the new CoreGraphics and IOKit reads.

Testing

macOS 26.6.2, aarch64, RUSTFLAGS="-D warnings":

  • cargo fmt --all -- --check
  • cargo clippy -p openlogi-agent --all-targets -- -D warnings
  • cargo test -p openlogi-agent: 44 passed, repeated runs green, no --test-threads=1 (the observer tests no longer share the global workspace center). New or reworked tests also cover the stranded system sleep reconciled by later input, the DarkWake and Unknown refusals for that source, the 30 s margin including the boundary, a repeated WillSleep restarting the clock, and a suspend edge landing during a level read discarding that read; the earlier set covers the missed screen wake discharged by later input; a forced display sleep not discharged by input that preceded it (the case an absolute idle floor gets wrong); the missed session activation; the SYSTEM_SLEEP non-regression for [Bug]: External display wakes from sleep #656; the unproven launch hold and that it does not mask a second suspend source; the 60 s floor including an unreadable idle timer failing closed; the positive-only CGDisplayIsAsleep short circuit; a DarkWake with 27 s of idle discharging nothing from any held source, and the same launch in full wake discharging the startup hold; an unreadable capability set neither proving nor blocking anything; fast-user-switching; the reconcilable truth table; the log-source rendering.
  • cargo clippy --workspace --all-targets --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay -- -D warnings
  • cargo test --workspace --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay: 1163 passed
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay --exclude openlogi-agent
  • FFI cross-checked against a C probe built on the affected machine: kCGAnyInputEventType is 0xffffffff (objc2 generates the CGEventType newtype but not that constant), and run back to back the Rust path read 0.800 s idle against the probe's 0.814 s. system_graphics() run here returns Up, matching ioreg -n IOPMrootDomain's "System Capabilities" = 15.

Not run:

  • The three GPUI crates, and so the full-workspace clippy / tests (macos) jobs: gpui_macos's build script fails here with cannot execute tool 'metal' due to missing Metal Toolchain. Nothing depends on openlogi-agent, and both the code and the manifest change are inside cfg(target_os = "macos") for that leaf binary.
  • clippy (linux), clippy (windows), wasm, MSRV: no cross targets locally. Hand-audited: mod tray is macOS-gated, every new item is private to tray.rs, and the manifest edit is inside [target.'cfg(target_os = "macos")'.dependencies].
  • Hardware. This branch merged with fix(hook): give the macOS tap's capability probes their own watchdog budget #1282 is now installed on the affected machine as a temporary launchd agent; I will report back after a few lid closes. What I am looking for is display state unproven at launch with graphics=Down in place of display/session resumed, and the Mac staying asleep with the lid shut.

Composes with #1164 (the input hook joins this same gate, through the same DeviceIoSignal) and does not overlap #1236: the tests register on a private NSNotificationCenter, never on the process-global NSWorkspace center.

@greptile-apps

greptile-apps Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no actionable correctness, security, or repository-rule violation remains.

Summary

  • Tracks each suspension occurrence with a sleep-counting monotonic timestamp and generation counter.
  • Reconciles missing wake and session notifications while refusing to discharge holds during DarkWake.
  • Fails closed during an unproven launch and adds source-specific diagnostics.
  • Adds the macOS CoreFoundation, CoreGraphics, IOKit, and libc bindings required by the new state probes.
  • Updates the Objective-C/FFI inventory and safety documentation.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Launch or suspend notification] --> B[Record hold, timestamp, generation]
    B --> C[Pause device I/O]
    C --> D{Direct wake notification?}
    D -->|Yes| E[Clear matching proven sources]
    D -->|No| F[Reconciler samples current levels]
    F --> G{Same suspension generation?}
    G -->|No| F
    G -->|Yes| H{Console session active?}
    H -->|No| C
    H -->|Yes| I{Graphics capability down?}
    I -->|Yes: DarkWake| C
    I -->|No or unknown| J[Evaluate display and input proof]
    J --> K{Held sources proven over?}
    K -->|No| C
    K -->|Yes| E
    E --> L{Any holds remain?}
    L -->|Yes| C
    L -->|No| M[Enable device I/O]
Loading

Reviews (5) · Last reviewed commit: "fix(macos): judge the activity levels ag..."

Comment thread crates/openlogi-agent/src/tray.rs
Comment thread crates/openlogi-agent/src/tray.rs Outdated
@AprilNEA

AprilNEA commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Thanks — the diagnosis is right, and the evidence here is what pointed at the real fix.

I've taken a different route in #1323: instead of reconciling on a timer, the gate subscribes to powerd through IOPMConnection, which reports every Sleep / DarkWake / FullWake transition as a level. No pairing, no timer, and the two open review findings go away with it. You're credited on the commit.

Since your setup reproduces both the blink and the relaunch loop, could you run #1323 through a few lid closes? Look for device I/O paused power=DarkWake followed by device I/O resumed power=FullWake in the agent log, with openlogi list correct right after the wake.

@mayaanhafeez the same build should cover your Bolt setup too. The stale-snapshot point is a separate issue; I'll open it.

@hyspacex

Copy link
Copy Markdown
Contributor Author

Field failure on this branch's build (87cb4f31 = master + #1282 + this PR), 2026-09-11 20:16 PDT. The gate closed on a WillSleep and never reopened. Fixed in 922773e, with a test-isolation commit 2a75877 alongside.

pmset -g log:

20:16:13  DarkWake   from Deep Idle : due to USB-C_plug   1 secs
20:16:14  Wake       DarkWake to FullWake : due to Notification
20:16:15  Sleep      Entering DarkWake state due to 'Notification Wake Back to Sleep'  6 secs
20:16:21  Wake       DarkWake to FullWake : due to HID Activity

Agent log (UTC):

03:16:14.506  display/session resumed — enabling device I/O cleared=startup
03:16:15.560  display/session suspended — pausing device I/O source=system-sleep

Nothing after that. The gate stayed shut until a manual kickstart at 03:20:26, four minutes in which openlogi list showed only the UVC camera (not behind the gate) while WindowServer held a UserIsActive assertion from the mouse's "USB Receiver".

Mechanism. Plugging the dock in dark-woke the machine; macOS promoted that to a full wake to show a notification and then posted WillSleep to drop back (the "Notification Wake Back to Sleep" path). HID activity promoted it to a full wake again six seconds later. The screens never slept and the session never resigned, so ScreensDidWake and SessionDidBecomeActive had nothing to announce and neither fired. SYSTEM_SLEEP was cleared only by those two, reconcilable() returned false whenever it was held, and discharged_by() never returned it, so the reconciler parked on the condvar for good. Any aborted or cancelled sleep strands it identically.

The read-only probe that reads the same IOPMrootDomain "System Capabilities" property corroborates the shape and settles the caveat in the description: caps=9 (CPU + Network, no Graphics) at 03:16:16, caps=15 at 03:16:26 after the HID wake, onConsole=1 and no display reporting itself asleep throughout. So the bit does separate a DarkWake from a full wake, and the fix is decidable from the levels.

922773e. A level read may now discharge SYSTEM_SLEEP, under a proof strictly stronger than the others: graphics == Up as a positive read (Unknown is refused here, because the state it must rule out is the DarkWake every other level gets wrong, i.e. #656 itself), no display reporting asleep, on console, and input newer than the suspension. A DarkWake reads Down and discharges nothing, so the reconciler may now tick while a system sleep is held: during a real sleep the process is frozen, and during a DarkWake it does only the window-server and IORegistry reads read_levels() already performs through every screen sleep (itself a DarkWake), and no HID. DidWake is still not registered: besides firing for maintenance DarkWakes, it is unverified whether macOS re-posts it on a DarkWake→FullWake promotion, which is exactly the shape this failed in, so a DidWake-based fix might not have caught this case at all.

Three details the relative proof needs to be worth anything:

  • A five-second margin before input counts. Between WillSleep and the freeze (1 s here, but macOS gives every observer up to 30 s to return) graphics are still up and a brushed mouse does not cancel a sleep already under way; input that close would open the gate on the way into a sleep. It is its own constant, not a multiple of RECONCILE_INTERVAL, so tuning the tick cannot shrink it.
  • Every suspend edge restarts the clock, not only one recording a source the set does not already hold. Otherwise a SYSTEM_SLEEP stranded by an aborted sleep hands its whole age to the next sleep attempt.
  • The suspension is timed on CLOCK_MONOTONIC rather than std::time::Instant. Instant is CLOCK_UPTIME_RAW on Darwin and stops at the freeze, and CGEventSourceSecondsSinceLastEventType measures idle on that same uptime clock, so after a real sleep the waking input would sit a fixed pre-freeze gap behind the suspension however long the sleep was, and could never prove the wake. Before any sleep the two clocks agree exactly, so the margin above is unaffected. This adds libc to the crate's macOS dependency block (already in the tree via openlogi-hook).

STARTUP, SCREEN_SLEEP and SESSION_INACTIVE keep their existing proofs. The reconciler's "a wake notification never arrived; reconciled" warning now covers system-sleep, so the recovery shows up in a user's log.

2a75877. Fixes a pre-existing flake in the same file: the two tray tests that drive notifications both registered on, and posted to, the process-global NSWorkspace center, so each received the other's posts. cargo test -p openlogi-agent failed 6 runs in 10 here. Each test now runs the same registration on a private NSNotificationCenter with a sentinel object; 12 consecutive runs pass without --test-threads=1.

Checks on macOS 26.6.2 / aarch64 with RUSTFLAGS="-D warnings": fmt, clippy -p openlogi-agent --all-targets, test -p openlogi-agent (43 passed, ×12), workspace clippy and tests excluding the three GPUI crates (1153 passed). The rebuilt agent is installed on the affected machine; I will report back after real sleep/wake cycles.

Comment thread crates/openlogi-agent/src/tray.rs Outdated
…ification

The agent's hardware gate is driven by five NSWorkspace notifications:
WillSleep / ScreensDidSleep / SessionDidResignActive close it, ScreensDidWake /
SessionDidBecomeActive reopen it, and `CGDisplayIsAsleep(CGMainDisplayID())`
seeds it at launch. Both halves of that are unsound, and a user's log shows each
failing on the same machine within one evening.

The notifications are edges, and the workspace center guarantees neither
delivery nor pairing. On 2026-09-05 a one-second display blink at unlock logged
"display/session suspended — pausing device I/O" with nothing after it, and the
agent then ran 66 minutes with the gate shut while the machine was in use;
`openlogi list` reported every paired receiver slot as "Unknown device" because
the inventory probe was gated off. Restarting the agent was the only recovery.
The line did not name the notification that suspended it, so the report could
not say which edge went missing.

The launch snapshot is worse. Across a 2.5 h display sleep the agent was
relaunched 27 times, and every one logged "display/session resumed — enabling
device I/O" — so CGDisplayIsAsleep answered false for the whole blank that
NSWorkspaceScreensDidSleep had reported correctly a second earlier. Reading the
full active list changes nothing: with the lid shut the external panel is both
the main and the only online display. Nor is there a registry node to read
instead — on Apple Silicon IODisplayWrangler carries no IOPowerManagement
dictionary. CGDisplayIsAsleep has false negatives here; it is trustworthy only
when it says asleep.

So the gate now closes on the notifications and reopens on proof:

- kCGSSessionOnConsoleKey is the level whose edges SessionDidBecomeActive /
  DidResignActive announce, and it is trustworthy in both directions, so it
  discharges the session source on its own.
- Any HID input wakes a sleeping display, so
  CGEventSourceSecondsSinceLastEventType on the HID system state is what proves
  one is on. Which comparison is valid depends on what the owner knows. With no
  display-sleep report — a relaunched agent — only an idle timeout could have
  blanked the panel, and one minute is the shortest blank macOS can be
  configured for, so input newer than that rules it out. Once ScreensDidSleep
  *has* been recorded that argument is void, because a hot corner blanks the
  display a second after the last keystroke; there, only input newer than the
  suspension itself proves the display came back.
- One reconciler thread parks on a condvar and wakes only while a discharge-able
  suspension has stood for two seconds. It performs no timed work and issues no
  CoreGraphics call while the gate is open or during a system sleep.
- SYSTEM_SLEEP is never discharged by a level read. The process runs during a
  maintenance DarkWake, and opening HID there is what promoted an invisible wake
  into a full display wake (AprilNEA#656), so a system sleep is still cleared only by the
  notification that pairs with it — and the reconciler is parked, not polling,
  for the whole of it.
- finish_startup fails closed: an unproven display keeps STARTUP held rather than
  resuming. ScreensDidWake discharges it directly when the display really
  returns.
- Suspend and resume log lines now name their sources, so the next report of
  either shape is diagnosable from the agent log.

Composes with AprilNEA#1164 rather than duplicating it: that PR ties the input hook to
this same gate, so both a missed wake and an unproven launch would take the
button remaps down with device I/O. The proof reopens the gate through the same
DeviceIoSignal, so its lifecycle task reinstalls the tap with no extra wiring.

Refs AprilNEA#952
…I/O gate

The window-server levels the gate proves itself back from cannot tell a
DarkWake from a full wake. Closing the lid on this machine puts the Mac
into a DarkWake seconds after the user was last at it: the session is
still on console, the HID idle timer still reads well under the
one-minute display-sleep floor, and the external panel re-enumerates
under a fresh CGDirectDisplayID that reports itself awake. Every level
says "the user is here" while nothing is on screen.

Read IOPMrootDomain's "System Capabilities" property alongside them. A
readable value without kIOPMSystemCapabilityGraphics means a DarkWake,
and then nothing may be discharged — not the startup hold, not a screen
sleep, not an inactive session. An unreadable or missing property is a
third state that proves nothing either way, so a macOS that renames the
key degrades to the old behaviour instead of wedging the gate shut.

The capability bits are public in <IOKit/pwr_mgt/IOPM.h> and generated
by objc2-io-kit; only the registry key that carries the current set is
undocumented, and reading it is an ordinary IORegistry lookup needing no
entitlement.

Also corrects the CGDisplayIsAsleep note: it is right about an ordinary
idle blank and wrong after a display reconfiguration, which is a
narrower claim than the one it carried.
…rives

The gate closed on NSWorkspaceWillSleepNotification and could only be
reopened again by ScreensDidWake or SessionDidBecomeActive. Those are not
guaranteed partners. On 2026-09-11 at 20:16 PDT this machine ran the
sequence pmset logged as

  20:16:13  DarkWake from Deep Idle : due to USB-C_plug      1 secs
  20:16:14  Wake     DarkWake to FullWake : due to Notification
  20:16:15  Sleep    Entering DarkWake state due to
                     'Notification Wake Back to Sleep'       6 secs
  20:16:21  Wake     DarkWake to FullWake : due to HID Activity

— a dock hotplug, a notification that promoted the DarkWake to a full
wake, and the sleep back out of it. The agent logged

  03:16:14.506  display/session resumed — enabling device I/O cleared=startup
  03:16:15.560  display/session suspended — pausing device I/O source=system-sleep

and then nothing. The screens had never slept and the session had never
resigned, so neither wake notification had anything to announce and
neither fired. SYSTEM_SLEEP stayed held, `reconcilable` refused every
level read while it was, and the reconciler parked on the condvar for
good: `openlogi list` reported only the UVC camera — which is not behind
the gate — while WindowServer held a UserIsActive assertion from the
mouse the agent had stopped talking to. A manual kickstart four minutes
later was the only recovery. Any aborted or cancelled sleep strands the
gate the same way.

Excluding SYSTEM_SLEEP from reconciliation was the wrong shape of
defence against AprilNEA#656. What must not happen there is opening HID during a
maintenance DarkWake, and a DarkWake is a state the levels can now name:
`system_wake_is_proven` discharges a system sleep only on a positive
kIOPMSystemCapabilityGraphics read, with no display reporting itself
asleep, on console, and input newer than the suspension. A DarkWake
reads SystemGraphics::Down and discharges nothing, so the reconciler can
tick through one without doing anything but reading levels — the same
window-server and IORegistry reads it already performs through every
screen sleep, which is also a DarkWake. A real sleep is unaffected: the
process is frozen, so nothing runs at all until the machine is back.

Unlike every other source, this one refuses SystemGraphics::Unknown.
Elsewhere the capability read only vetoes levels that are themselves the
proof, so an unreadable key degrades to them; here it *is* the proof,
and the state it rules out is exactly the one every other level gets
wrong. A macOS that renames the key loses this recovery rather than
trading it for the regression.

Three things the relative proof needs to be worth anything:

- A five-second margin. Between WillSleep and the freeze — 1 s here, but
  macOS gives every observer up to 30 s to return, so there is no bound
  worth trusting — graphics are still up and a mouse brushed there does
  not cancel a sleep already under way. Input that close to the
  suspension is not accepted, or the gate opens on the way *into* a
  sleep and puts a full HID enumeration on the wire as the machine goes
  down. It is its own constant rather than a multiple of
  RECONCILE_INTERVAL: the tick is a responsiveness knob, and shortening
  it to make a startup hold recover faster must not shrink this.

- Every suspend edge restarts the clock, not only one that records a
  source the set does not already hold. Otherwise a SYSTEM_SLEEP left
  standing by an aborted sleep hands its whole age to the next sleep
  attempt, and input a moment into that one clears an hours-old
  held_for. Restarting unconditionally only ever makes a proof stricter;
  SESSION_INACTIVE, the one source with no relative proof, does not care
  either way. `suspend_from_at` opens the same explicit-`now` seam on
  the recording side that `discharge_at` already opens on the proving
  one, so which instant an edge writes is testable.

- The suspension is timed on a clock that keeps running while the
  machine is asleep. std::time::Instant is CLOCK_UPTIME_RAW on Darwin
  and stops at the freeze, and CGEventSourceSecondsSinceLastEventType
  measures idle time against that same uptime clock, so a real sleep
  would be invisible to held_for: whatever the sleep's length, the input
  that woke the machine would sit a fixed pre-freeze gap *behind* the
  suspension and could never prove the wake. Darwin's CLOCK_MONOTONIC is
  the same monotonic clock with sleep counted in. Before any sleep the
  two agree exactly, so the pre-sleep margin above is unaffected.

STARTUP, SCREEN_SLEEP and SESSION_INACTIVE keep their existing proofs
untouched. The reconciler's existing "a wake notification never arrived;
reconciled" warning now also covers system-sleep, so this recovery is
visible in a user's log.

Also drops a stale clause from the on-console comment: the input hook
does not follow this gate on master — that is AprilNEA#1164.

Refs AprilNEA#1281, AprilNEA#656
…e center

`cargo test -p openlogi-agent` failed 6 runs in 10 on this machine, always
on `startup_stays_suspended_when_the_display_is_already_asleep`, and never
in a way either test could see.

Both tests that drive notifications registered their `ActivityTarget` on
the process-global `NSWorkspace` notification center and posted to it
filtered by the one shared workspace object. That filter is not an
isolation boundary: the two targets are equally valid observers of the
same name/object pair, so whenever the two tests overlapped, the
`WillSleep` / `ScreensDidSleep` / `SessionDidResignActive` posts of the
overlapping-sources test also suspended the startup test's gate, and its
own `ScreensDidWake` then had two sources to clear instead of one.

Nothing in these tests needs the workspace's own center — the names are
ordinary `NSNotificationName`s. So the registration moves into
`observe_activity(center, object, signal)`, with `install_activity_observer`
passing the workspace pair production actually uses, and each test running
that same function on a private `NSNotificationCenter` with an `NSObject`
sentinel of its own. No shared state left to collide over, and no
serialization needed.

Pre-existing on master; fixed here because the file is under change and the
local gate has to be green.

12 consecutive runs of `cargo test -p openlogi-agent` pass after this,
against 4 of 10 before.

Replaces the serialization added in 86e9997: with each test on its own
center there is no shared state left to take a lock over, so the
WORKSPACE_NOTIFICATIONS mutex and its two lock sites go with it.
Two findings from review of the system-sleep reconciliation, and both are
about a level reading being judged against a suspension it does not
describe.

The reconciler reads the levels outside the lock — they are window-server
round trips, and nothing else may block on them — and the discharge step
then re-locked and measured `held_for` from whatever `suspension.since`
said at that moment. Now that every suspend edge resets `since`, those two
halves can come from different suspensions: a reading taken while an old
one stood reports that the user has been present for a second, and measured
against an edge that landed during the read it becomes input newer than a
suspension it actually predates. A hot corner blanking the display while a
stranded suspension is being reconciled would therefore reopen the gate one
second after a `ScreensDidSleep` that was delivered perfectly correctly.

The same read was also timed from its end rather than its beginning. The
idle timer is sampled somewhere inside `read_levels`, so taking the instant
afterwards inflates `held_for` by however long the window server took to
answer, and compares a too-small idle against a too-large suspension age —
the lenient direction, for a call with no bound on it. That it happens to
be harmless today rests on `idle` being the last field `read_levels`
evaluates, which nothing states and nothing enforces. Multi-second stalls
in that layer are what AprilNEA#952 was about.

So both facts a reading has to be judged against are captured together,
before it is taken, as a `ReadStart`: the suspend generation in force, and
the instant `held_for` is measured from. A generation counter rather than
`since` itself, because two reads of CLOCK_MONOTONIC can return the same
value, so equal instants are not proof that no edge intervened. The
reconciler and the startup path both open a `ReadStart` before calling
`read_levels` and hand it to `discharge`, which clears nothing when the
generation no longer matches, logging the mismatch at debug so a repeated
discard is diagnosable from a user log. `held_for` is now a strict lower
bound. The pure decision functions are untouched — none of this is a
question of what the levels mean, only of which suspension they belong to.

Second, the pre-sleep margin was five seconds, which was a guess about how
fast the slowest sleep-transition client on this machine acknowledges.
IOPMLib documents the bound: a client registered for
kIOMessageSystemWillSleep has 30 s to acknowledge before power management
proceeds without it, so the machine can still be fully awake half a minute
after the WillSleep the gate closed on. The margin is now that documented
30 s.

The cost is that a stranded system sleep recovers about half a minute after
the stranding rather than a few seconds, and it is paid in the one state
where the user is at the machine producing exactly the input that ends it.
The 2026-09-11 failure this recovers from ran four minutes and stopped only
because the agent was restarted by hand.

Refs AprilNEA#1281, AprilNEA#656
@hyspacex
hyspacex force-pushed the fix/macos-resume-gate branch from 4489953 to 8b587e1 Compare September 12, 2026 04:49
@hyspacex

Copy link
Copy Markdown
Contributor Author

Rebased onto master (e846e6f) to clear the conflict. 00e9eac's shutdown routing in run_app_loop is kept as is, with the startup level read layered on top. 86e9997's WORKSPACE_NOTIFICATIONS mutex is dropped by the test-isolation commit, since each observer test now runs on its own NSNotificationCenter and there is no shared state left to lock; the commit message says so. Gate on the rebased tip: fmt, clippy, 51 agent tests x3, 1356 workspace tests excluding the GPUI crates, all green.

This branch has not been deployed

No deployments
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