From e5ebf3ef0e4b4e4a08bb88664e9d317cab14da1d Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 20 Oct 2026 15:39:48 -0700 Subject: [PATCH] Held-key check reads HID state and names a key it waits on Startup refused Caps activation with physicalKeysHeld three times on the mini on 2026-10-20. Combined session state reported Space held while HID state and every keyboard had released it, and the wake recovery loop waited on it silently for hours. The check now reads HID state, which was clear throughout. Because a posted press can still hold HID state, the wake recovery loop names the held key after 5 seconds and asks for it to be pressed and released. Co-Authored-By: Claude Opus 5.5 --- ...eLifecycleCoordinator+SessionRuntime.swift | 25 +++++++++ .../SessionCapsRuntimeSupport.swift | 21 +++++-- .../SessionFailureRecoveryMessageTests.swift | 8 +++ .../SessionCapsRuntimeSupportTests.swift | 11 ++++ .../stale-synthetic-key-blocks-startup.md | 55 +++++++++++++++++++ 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 docs/bugs/stale-synthetic-key-blocks-startup.md 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.