diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift index 497484d5a..75e42d34e 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift @@ -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; @@ -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)) + } + } else { + heldSince = nil + namedHeldKeys = nil + } if blocker == nil { AppLogger.shared.info("[SessionRuntime] Resuming after keyboard readiness") sessionWakeRetryGeneration = nil @@ -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 { diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift index 17a83a038..a6566b940 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift @@ -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 { diff --git a/Tests/KeyPathTests/Services/SessionFailureRecoveryMessageTests.swift b/Tests/KeyPathTests/Services/SessionFailureRecoveryMessageTests.swift index 64732ad0f..a4f77876a 100644 --- a/Tests/KeyPathTests/Services/SessionFailureRecoveryMessageTests.swift +++ b/Tests/KeyPathTests/Services/SessionFailureRecoveryMessageTests.swift @@ -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)")) diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsRuntimeSupportTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsRuntimeSupportTests.swift index 86d50ca17..41865916d 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsRuntimeSupportTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsRuntimeSupportTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Darwin import Foundation @testable import KeyPathAppKit @@ -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) diff --git a/docs/bugs/stale-synthetic-key-blocks-startup.md b/docs/bugs/stale-synthetic-key-blocks-startup.md new file mode 100644 index 000000000..c542ad2de --- /dev/null +++ b/docs/bugs/stale-synthetic-key-blocks-startup.md @@ -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.