Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,8 @@ extension ServiceLifecycleCoordinator {
onStateChanged?()
let recoveryStarted = Date()
var lastBlocker: String?
var heldSince: Date?
var namedHeldKeys: [CGKeyCode]?
while sessionStartIsCurrent(expectedGeneration) {
// Screen wake can precede system wake by tens of seconds after a
// quick lid close/open. Only a signal after this retirement counts;
Expand All @@ -533,6 +535,21 @@ extension ServiceLifecycleCoordinator {
AppLogger.shared.info("[SessionRuntime] Wake recovery gate: \(blocker ?? "ready") elapsedMs=\(Int(Date().timeIntervalSince(recoveryStarted) * 1000))")
lastBlocker = blocker
}
// A key nobody is pressing can read as held indefinitely (a posted
// press whose release never came). Past a normal keystroke, name it.
if blocker == "held-keys" {
let since = heldSince ?? Date()
heldSince = since
let held = SessionCapsRuntimeSupport.heldKeys()
if Date().timeIntervalSince(since) >= 5, !held.isEmpty, held != namedHeldKeys {
namedHeldKeys = held
AppLogger.shared.warn("[SessionRuntime] Wake recovery waiting on held key codes \(held)")
onWarning?(Self.heldKeysWarning(held))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Publish the named held-key warning

When a key remains held for five seconds, this updates lastWarning through onWarning but never calls onStateChanged. The UI is event-driven—StatePublisherService emits a new snapshot only when notifyStateChanged() is called—and the lifecycle warning callback in RuntimeCoordinator only assigns the value. Consequently, during the indefinite held-key wait the user continues seeing the generic warning, and a successful resume clears the named warning before the next state publication. Trigger onStateChanged after replacing the warning so the new actionable message is actually shown.

Useful? React with 👍 / 👎.

}
} else {
heldSince = nil
namedHeldKeys = nil
}
if blocker == nil {
AppLogger.shared.info("[SessionRuntime] Resuming after keyboard readiness")
sessionWakeRetryGeneration = nil
Expand All @@ -553,6 +570,14 @@ extension ServiceLifecycleCoordinator {
return false
}

static func heldKeysWarning(_ keys: [CGKeyCode]) -> String {
let names = keys.map { "\u{201C}\(KanataKeyCodeMap.overlayName(for: $0))\u{201D}" }
if names.count == 1 {
return "Remapping is waiting because macOS reports the \(names[0]) key as held down. Press and release it to resume."
}
return "Remapping is waiting because macOS reports these keys as held down: \(names.joined(separator: ", ")). Press and release each one to resume."
}

static func canRetryWakeStartup(_ report: SessionRuntimeReport?, nonce: String,
pid: Int32, uid: UInt32, cleanedUp: Bool) -> Bool
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,22 @@ enum SessionCapsRuntimeSupport {
return value
}

/// Combined session state is conservative: all representable keys must be up
/// excluding Caps Lock’s persistent latch. It does not attribute a key to a device.
static func requirePhysicalAllUp() throws {
guard SessionKeyMap.keyCodeToUsage.keys.filter({ $0 != 57 }).allSatisfy({ !CGEventSource.keyState(.combinedSessionState, key: $0) })
else { throw Refusal.physicalKeysHeld }
/// All representable keys must be up in HID state, excluding Caps Lock’s
/// persistent latch. It does not attribute a key to a device. Combined
/// session state also keeps a posted press whose own release never came:
/// on 2026-10-20 it reported Space held for hours while HID state and every
/// keyboard had released it. Posted presses can still reach HID state, so a
/// caller that waits on this must say which key it is waiting for.
static func requirePhysicalAllUp(isDown: (CGKeyCode) -> Bool = hidKeyDown) throws {
guard heldKeys(isDown: isDown).isEmpty else { throw Refusal.physicalKeysHeld }
}

static func heldKeys(isDown: (CGKeyCode) -> Bool = hidKeyDown) -> [CGKeyCode] {
SessionKeyMap.keyCodeToUsage.keys.filter { $0 != 57 && isDown($0) }.sorted()
}

static func hidKeyDown(_ key: CGKeyCode) -> Bool {
CGEventSource.keyState(.hidSystemState, key: key)
}

static func configSHA256(_ path: String) throws -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import XCTest

@MainActor
final class SessionFailureRecoveryMessageTests: XCTestCase {
func testHeldKeysWarningNamesTheKeyToRelease() {
XCTAssertEqual(ServiceLifecycleCoordinator.heldKeysWarning([49]),
"Remapping is waiting because macOS reports the \u{201C}space\u{201D} key as held down. Press and release it to resume.")
let several = ServiceLifecycleCoordinator.heldKeysWarning([0, 49])
XCTAssertTrue(several.contains("these keys as held down: \u{201C}a\u{201D}, \u{201C}space\u{201D}."), several)
XCTAssertTrue(several.hasSuffix("Press and release each one to resume."), several)
}

func testConfirmedDenialExplainsBothGrantsAndRelaunch() {
let message = ServiceLifecycleCoordinator.sessionFailureRecoveryMessage("missing-current-process-permission")
XCTAssertTrue(message.hasPrefix("Enable \(AccessibilityPermissionPresentation.current.name)"))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CoreGraphics
import Darwin
import Foundation
@testable import KeyPathAppKit
Expand Down Expand Up @@ -153,6 +154,16 @@ final class SessionCapsRuntimeSupportTests: XCTestCase {
XCTAssertFalse(full.managedCaps)
}

func testPhysicalAllUpRefusesAnyHeldKeyButCapsLatch() throws {
XCTAssertNoThrow(try SessionCapsRuntimeSupport.requirePhysicalAllUp(isDown: { _ in false }))
// Caps Lock's latch persists while it is not pressed; it never blocks.
XCTAssertNoThrow(try SessionCapsRuntimeSupport.requirePhysicalAllUp(isDown: { $0 == 57 }))
XCTAssertThrowsError(try SessionCapsRuntimeSupport.requirePhysicalAllUp(isDown: { $0 == 49 })) { error in
XCTAssertEqual(error as? SessionCapsRuntimeSupport.Refusal, .physicalKeysHeld)
}
XCTAssertEqual(SessionCapsRuntimeSupport.heldKeys(isDown: { [49, 57, 0].contains($0) }), [0, 49])
}

func testOrphanMutationMarkerBlocksRecoveryEvenWithoutIntent() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
Expand Down
55 changes: 55 additions & 0 deletions docs/bugs/stale-synthetic-key-blocks-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# A key nobody was pressing left remapping off with no explanation

## Symptom (mini, 2026-10-20)

KeyPath started without its engine three times in one day (11:01, 14:09,
14:18). Each start logged:

```
Managed Caps activation refused: physicalKeysHeld
Wake recovery gate: held-keys elapsedMs=0
```

and nothing after that. Port 37001 refused connections for hours, and the only
visible message was "Remapping will resume when your keyboard session is ready."

## Cause

`SessionCapsRuntimeSupport.requirePhysicalAllUp()` read
`CGEventSource.keyState(.combinedSessionState, …)`. It reported Space
(keycode 49) held while HID state did not:

```
combined: [49] hid: []
```

No keyboard was holding Space. Pressing and releasing Space on the cl80 and
unplugging the fixture and the X2A changed nothing. One Space key-up posted at
the session tap (`kCGSessionEventTap`, from a combined-state source) cleared it
immediately.

The wake recovery loop polls the same check every 250 ms and logs only when the
blocker changes, so it waited silently for as long as the stale state lasted.

How the state diverged is not established. Posted events reach both records: a
key-down posted at the session tap from a private source set HID state as well,
and a key-up from a different source cleared both. That experiment did not
reproduce a combined-only press, and which program posted the original press is
unknown.

## Fix

- The check reads `.hidSystemState`, which was clear throughout this incident.
It exists so a Caps Lock hidutil mapping never changes mid-press, and hidutil
acts on HID events.
- Because HID state can also be held by a posted press, the wake recovery loop
no longer waits silently: after 5 seconds on `held-keys` it logs the key codes
and shows "Remapping is waiting because macOS reports the “space” key as held
down. Press and release it to resume."

## Clearing it by hand

Post one key-up for the stuck key code at the session tap, for example from
Python with `ctypes`: `CGEventSourceCreate(0)`, then
`CGEventCreateKeyboardEvent(src, 49, False)` and `CGEventPost(1, event)`. A
key-up on its own types nothing. Logging out also clears it.
Loading