When the Keyboard Lies: Recovering Phantom Key State After macOS Wake
For about two months, Xisper users reported that after waking their Mac from sleep, the FN key shortcut stopped working until they quit and reopened the app. The shortcut was a simple exact-set match: FN by itself toggles recording. When FN was the only key held, the tap consumed the event and triggered recording. When any other key was held with FN, the event passed through.
The bug was: after wake, the tap always had a ghost key in its pressingKeys map. The match never fired. FN alone became FN+ghost.
The root cause
Xisper's CGEventTap tracks every keyDown and keyUp to maintain a pressingKeys: [Int32: String] dictionary. This lets us do exact-set matching: when the held set matches a target set exactly, we consume the event and trigger the shortcut.
Here's the problem: during screen lock (or anytime Secure Input is active), macOS swallows keyUp events. The tap sees the keyDown and adds it to pressingKeys. It never sees the corresponding keyUp, so the key stays in the map forever. A ghost.
When you unlock and press FN, pressingKeys already contains the ghost key. The match predicate checks if the held set equals {fn} exactly. It sees {fn, ghost}, which doesn't match. The tap passes the event through. User presses FN, nothing happens.
The bug only shows up after wake, only for users who locked their screen with a key held down, and only for shortcuts using exact-set matching. This took two months to diagnose because it was non-deterministic and required a specific sequence: hold a key during lock → unlock → press FN. If the lock happened with no keys held, the bug was invisible.
Why not just clear pressingKeys on wake?
That was the first fix attempt: reset pressingKeys to empty on screen wake. This works for most keys but breaks for modifier keys that are physically held when the tap comes back.
Consider: user locks screen holding Shift (to type a capital letter, then decides to walk away). The screen locks mid-hold. Shift keyUp is swallowed. pressingKeys says Shift is down. On wake, if we blindly clear the map, we lose the fact that Shift is held. The next flagsChanged event will report Shift as "newly pressed" rather than "still held," and the original keyDown that started the hold is gone.
We can't just clear everything. We need to know which keys are physically held and which are ghosts.
The solution: CGEventSource.keyState
macOS maintains a hardware state table in the HID system that tracks the physical state of every key on every connected keyboard. This state table is independent of the event stream. It knows which keys are down in the hardware, not which keys have had their events delivered.
CGEventSource.keyState(.hidSystemState, key: keyCode) reads this table.
The key insight: Secure Input does not affect the hardware state table. It only blocks event delivery to event taps and other event monitors. The kernel still tracks physical key state because it needs to—process switching and Privileged Helper Tools rely on it.
So the fix is a reconcile function that runs at the top of every event handler, before any matching or recording:
private func reconcileWithHardwareState() {
for keyCode in pressingKeys.keys {
if keyCode == fnSyntheticKeyCode { continue } // -1 special case
if modifierKeyName(for: keyCode) != nil { continue } // modifiers reconciled via flags
if !CGEventSource.keyState(.hidSystemState, key: CGKeyCode(keyCode)) {
pressingKeys.removeValue(forKey: keyCode)
}
}
}
For each key in our map, ask the hardware: is this key physically held? If not, it's a ghost—remove it. The operation is a simple boolean read per key. No I/O, no IPC, no risk of blocking. Microsecond-level overhead.
Modifier keys (Shift, Control, Option, Command) are excluded from hardware reconciliation because they're already reconciled through flagsChanged events. The hardware state table and the modifier flags in each event come from the same source—the modifier flags are more precise for modifier state.
The synthetic FN key (sent by the Globe/Fn key on modern Macs) is excluded because it maps to a private usage page that the hardware state table doesn't track. CGEventSource.keyState for keyCode -1 would return an error, so we skip it.
Two layers: per-event + proactive
The per-event reconcile catches ghosts on the first keystroke after wake. But there's a gap: the user unlocks, presses FN immediately, and the first event is FN. The reconcile runs and purges the ghost, but the match still happens on the pre-reconcile state.
The fix: also call reconcile proactively on system transitions. didWake, screensDidWake, sessionDidBecomeActive, and a custom screenIsUnlocked notification all trigger KeyboardMonitor.reconcile(), which runs the same hardware state check under the state lock.
// HotkeySystem.swift — system transition observers
let resetHandler: (Notification) -> Void = { [weak self] _ in
self?.resetMatcher()
KeyboardMonitor.shared.reconcile() // purge ghosts before first keystroke
}
Now the reconcile runs before the user types anything. When they press FN after unlocking, pressingKeys only contains the real keys.
Side fix: exact match vs subset match
While investigating the ghost key issue, I found another bug in the matching logic. shouldConsume() was using subset matching:
return targets.contains { $0.isSubset(of: pressed) }
This means if the target was {fn} and the user held {fn, shift}, the shortcut fired. The ghost key made this survivable—ghosts are "extra" keys in the set, and subset matching tolerated them. But the correct behavior for a hotkey is exact matching: FN alone fires, FN+Shift does not.
The fix alongside the hardware reconcile:
return targets.contains { $0 == pressed }
This also let me remove the allowedCounts optimization, which was a pre-filter based on key count that subset matching needed. Exact matching doesn't need it.
Why this matters beyond a keyboard bug
The general pattern here—trust the hardware, not the event stream—applies to many macOS input problems. The event delivery pipeline can drop, reorder, or swallow events for reasons your app can't control (Secure Input, system overload, tap timeout). The hardware state table is the source of truth, and it's available to any process with CGEvent access.
If you're building anything that tracks keyboard state on macOS, CGEventSource.keyState(.hidSystemState) is your safety net. Call it before you match. Call it after wake. It'll save you months of debugging.
The two commits that fixed this are in the Xisper repository: July 3, 2026. The complete reconcileWithHardwareState() function is in KeyboardMonitor.swift.