From a53ccb9bc2641137158777dcc07522e4d69d942a Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 08:24:26 -0700 Subject: [PATCH 01/16] Map a newly plugged-in keyboard's Caps Lock as soon as it arrives The all-keyboards fleet found new keyboards only on its two-second inventory check, so a Caps press right after plugging in could reach macOS unmapped. SessionCapsKeyboardArrivalMonitor now listens for IOHIDEventService registrations whose usage pairs include keyboard (including a board's shared NKRO interface) and makes the next fleet check due at once; checks then run every 0.25 s for three seconds while hidutil catches up. The two-second check stays as the backstop and covers everything if the notification can't be registered. The worker logs the arrival-to-mapped time. Eligibility, exclusions and the all-keys-up rule are unchanged: an arrival only schedules the same reconcile. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 107 ++++++++++++++++++ .../SessionRuntime/SessionRuntimeWorker.swift | 40 ++++++- .../SessionCapsCheckScheduleTests.swift | 58 ++++++++++ docs/architecture/all-keyboards-caps.md | 32 +++--- guides/driverless-caps-lock.md | 7 +- 5 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift create mode 100644 Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift new file mode 100644 index 000000000..f698f3f86 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -0,0 +1,107 @@ +import Foundation +import IOKit + +/// When the worker next reconciles its Caps fleet. Arrivals pull the check +/// forward: a keyboard plugged in should be mapped before its first Caps press, +/// not up to one backstop interval later. +struct SessionCapsCheckSchedule: Equatable { + /// The steady backstop while every keyboard's mapping stays verified. + static let backstop: TimeInterval = 2 + /// How often to re-read inventory right after an arrival. hidutil can list a + /// new event service a moment after IOKit reports it. + static let afterArrival: TimeInterval = 0.25 + /// How long arrival checks stay fast. + static let arrivalWindow: TimeInterval = 3 + + private(set) var fastUntil: Date = .distantPast + private(set) var arrivalPending = false + + mutating func keyboardArrived(at now: Date) { + arrivalPending = true + fastUntil = now.addingTimeInterval(Self.arrivalWindow) + } + + /// Whether a fleet check should start now, given the last one's start time. + func isDue(now: Date, lastCheck: Date) -> Bool { + if arrivalPending { return true } + let interval = now < fastUntil ? Self.afterArrival : Self.backstop + return now.timeIntervalSince(lastCheck) >= interval + } + + /// A check is starting; it covers every arrival reported so far. + mutating func checkStarted() { + arrivalPending = false + } +} + +/// Reports keyboard event services as they register, on the worker's main run +/// loop. It only schedules a check: eligibility, exclusions and all-keys-up are +/// decided by the fleet reconcile, exactly as for the backstop check. +@MainActor +final class SessionCapsKeyboardArrivalMonitor { + private let onArrival: () -> Void + private var port: IONotificationPortRef? + private var iterator: io_iterator_t = 0 + private var source: CFRunLoopSource? + + init(onArrival: @escaping () -> Void) { + self.onArrival = onArrival + } + + func start() -> Bool { + guard port == nil, let port = IONotificationPortCreate(kIOMainPortDefault), + let source = IONotificationPortGetRunLoopSource(port)?.takeUnretainedValue() + else { return false } + self.port = port + self.source = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + let status = IOServiceAddMatchingNotification( + port, kIOFirstMatchNotification, IOServiceMatching("IOHIDEventService"), + { context, iterator in + guard let context else { return } + MainActor.assumeIsolated { + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + monitor.drain(iterator, report: true) + } + }, + Unmanaged.passUnretained(self).toOpaque(), &iterator + ) + guard status == KERN_SUCCESS else { + stop() + return false + } + // The first drain lists services already present and arms the + // notification. Those are startup inventory, not arrivals. + drain(iterator, report: false) + return true + } + + func stop() { + if iterator != 0 { IOObjectRelease(iterator) } + iterator = 0 + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + source = nil + if let port { IONotificationPortDestroy(port) } + port = nil + } + + private func drain(_ iterator: io_iterator_t, report: Bool) { + var arrived = false + while case let service = IOIteratorNext(iterator), service != 0 { + defer { IOObjectRelease(service) } + if report, Self.emitsKeyboardUsages(service) { arrived = true } + } + if arrived { onArrival() } + } + + /// Any interface that can carry keyboard reports, including a board's shared + /// N-key-rollover interface whose primary usage is not keyboard. + private static func emitsKeyboardUsages(_ service: io_service_t) -> Bool { + guard let pairs = IORegistryEntryCreateCFProperty(service, "DeviceUsagePairs" as CFString, + kCFAllocatorDefault, 0)? + .takeRetainedValue() as? [[String: Any]] else { return false } + return pairs.contains { + ($0["DeviceUsagePage"] as? NSNumber)?.intValue == 1 && ($0["DeviceUsage"] as? NSNumber)?.intValue == 6 + } + } +} diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index dec591456..dbe091c1e 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -36,6 +36,10 @@ public final class SessionRuntimeWorker { private var capsFleetCoverage: SessionRuntimeReport.CapsCoverage? private var capsFleetRetryAfter: [UInt64: Date] = [:] private var capsFleetReadFailures = 0 + private var capsCheckSchedule = SessionCapsCheckSchedule() + private var capsArrivalMonitor: SessionCapsKeyboardArrivalMonitor? + /// First unanswered arrival, for the arrival-to-mapped log line. + private var capsArrivalAt: Date? private var capsOwner: SessionCapsMappingPolicy.Owner? private var capsDirectory: URL? private var capsInput = SessionCapsInputState() @@ -262,6 +266,7 @@ public final class SessionRuntimeWorker { return (coverage, devices.count, inventory.withheld) } capsFleetEnabled = true + startCapsArrivalMonitor() capsFleetRecords = result.0.records capsRecord = result.0.records.first capsFleetCoverage = .init(eligible: result.1, covered: result.0.records.count, withheld: result.2) @@ -618,13 +623,29 @@ public final class SessionRuntimeWorker { finish(.failed, reason: "output-queue-overrun") } + /// Without the monitor the backstop check still finds arrivals, only later. + private func startCapsArrivalMonitor() { + let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] in + guard let self, !self.finished else { return } + let now = Date() + self.capsCheckSchedule.keyboardArrived(at: now) + if self.capsArrivalAt == nil { self.capsArrivalAt = now } + } + if monitor.start() { + capsArrivalMonitor = monitor + } else { + AppLogger.shared.warn("[SessionCaps] Keyboard arrival notifications unavailable; relying on the 2 s check") + } + } + private func checkCapsOwnershipIfNeeded() { - guard !capsCheckInFlight, - Date().timeIntervalSince(lastCapsCheck) >= (capsFleetEnabled ? 2 : 0.5) else { return } + guard !capsCheckInFlight else { return } if capsFleetEnabled { + guard capsCheckSchedule.isDue(now: Date(), lastCheck: lastCapsCheck) else { return } checkCapsFleetIfNeeded() return } + guard Date().timeIntervalSince(lastCapsCheck) >= 0.5 else { return } guard let record = capsRecord else { return } capsCheckInFlight = true lastCapsCheck = Date() @@ -660,6 +681,7 @@ public final class SessionRuntimeWorker { let configPath = capsConfigPath, let admittedDigest = capsAdmittedDigest else { return } capsCheckInFlight = true lastCapsCheck = Date() + capsCheckSchedule.checkStarted() let previous = Set(capsFleetRecords.map(\.device.registryEntryID)) let retryAfter = capsFleetRetryAfter let usage = SessionCapsRuntimeSupport.preferredIntermediate() @@ -725,6 +747,18 @@ public final class SessionRuntimeWorker { for (id, reason) in coverage.failures where !previous.contains(id) { AppLogger.shared.warn("[SessionCaps] Keyboard \(id) left uncovered: \(reason)") } + if let arrivedAt = self.capsArrivalAt { + let added = current.subtracting(previous) + if !added.isEmpty { + let elapsed = Int(Date().timeIntervalSince(arrivedAt) * 1000) + AppLogger.shared.log("[SessionCaps] Keyboard arrival mapped in \(elapsed) ms: \(added.sorted())") + self.capsArrivalAt = nil + } else if Date() >= arrivedAt.addingTimeInterval(SessionCapsCheckSchedule.arrivalWindow) { + // Not every arrival is a new eligible keyboard (an excluded or + // withheld one, or a re-enumerated interface). + self.capsArrivalAt = nil + } + } case let .failure(error): if case SessionCapsMappingLease.Refusal.busy = error { return } if error is CapsInventoryFailure { @@ -957,6 +991,8 @@ public final class SessionRuntimeWorker { AppLogger.shared.log("🛑 [SessionWorker] Finishing: state=\(state) reason=\(reason ?? "none")") AppLogger.shared.flushBuffer() capsInput.revoke() + capsArrivalMonitor?.stop() + capsArrivalMonitor = nil var terminalState = state var terminalReason = reason if let tap { CGEvent.tapEnable(tap: tap, enable: false) } diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift new file mode 100644 index 000000000..a2d94bc9f --- /dev/null +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -0,0 +1,58 @@ +import Foundation +@testable import KeyPathAppKit +import XCTest + +final class SessionCapsCheckScheduleTests: XCTestCase { + private let start = Date(timeIntervalSince1970: 1_000_000) + + func testWithoutArrivalsTheBackstopRunsEveryTwoSeconds() { + let schedule = SessionCapsCheckSchedule() + + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1.9), lastCheck: start)) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(2), lastCheck: start)) + } + + func testAnArrivalMakesTheNextCheckDueAtOnce() { + var schedule = SessionCapsCheckSchedule() + // A check ran 10 ms ago; without the arrival the next would wait ~2 s. + let lastCheck = start + schedule.keyboardArrived(at: start.addingTimeInterval(0.01)) + + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.01), lastCheck: lastCheck)) + } + + func testChecksStayFastForTheArrivalWindowThenReturnToTheBackstop() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(at: start) + schedule.checkStarted() + + // hidutil may list the new service a moment later: re-read every 0.25 s. + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.2), lastCheck: start)) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.25), lastCheck: start)) + let late = start.addingTimeInterval(SessionCapsCheckSchedule.arrivalWindow + 0.1) + XCTAssertFalse(schedule.isDue(now: late, lastCheck: late.addingTimeInterval(-0.5))) + XCTAssertTrue(schedule.isDue(now: late, lastCheck: late.addingTimeInterval(-2))) + } + + func testAnArrivalDuringACheckIsNotLostWhenThatCheckStarted() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(at: start) + schedule.checkStarted() + // A second keyboard arrives while the first check is still in flight. + schedule.keyboardArrived(at: start.addingTimeInterval(0.05)) + + XCTAssertTrue(schedule.arrivalPending) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.06), lastCheck: start)) + } + + @MainActor + func testMonitorStartsWithoutReportingKeyboardsAlreadyConnected() { + var arrivals = 0 + let monitor = SessionCapsKeyboardArrivalMonitor { arrivals += 1 } + defer { monitor.stop() } + + XCTAssertTrue(monitor.start()) + XCTAssertFalse(monitor.start(), "a started monitor refuses a second start") + XCTAssertEqual(arrivals, 0, "services present at start are inventory, not arrivals") + } +} diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index a17a01c3b..7e6b0f15e 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -1,15 +1,14 @@ # Caps Lock rules on every keyboard **Status:** deployed on the mini and Air, 2026-10-24. The worker applies one -crash-recoverable mapping per eligible keyboard and checks for arrivals every two -seconds. Settings reports uncovered keyboards and offers per-keyboard exclusions. +crash-recoverable mapping per eligible keyboard. Since 2026-10-25 it reacts to a +keyboard arriving (an IOKit notification) instead of waiting for its two-second check. Settings reports uncovered keyboards and offers per-keyboard exclusions. The original spike and remaining questions below remain the qualification record; the opt-in `SessionCapsMappingFleetTests/testLiveAttachedKeyboardsAcquireAndRestore` passed on three attached USB keyboard services, including the physical HID fixture, with readback and exact restoration on 2026-10-24. The release build passed notarization and runtime verification, but this is not a hot-plug test. -In particular, a press immediately after plugging in a -keyboard can precede the worker's next inventory check. The passive per-press +The arrival-to-mapped time is logged but has not yet been measured on a physical replug. The passive per-press attribution described in the proposal has not been implemented. **Question:** can driverless KeyPath apply Caps Lock rules without asking the user to @@ -56,16 +55,16 @@ for users who already made a choice. ## Original proposal and shipped scope The proposal kept device-scoped mappings and applied them to every keyboard KeyPath -can target. The shipped implementation differs in two ways: it polls inventory every -two seconds rather than mapping in an arrival callback, and it reports coverage in -Settings instead of using passive per-press attribution. +can target. The shipped implementation differs in one way: it reports coverage in Settings +instead of using passive per-press attribution. (It first polled inventory every two +seconds; arrivals now trigger a check, see Hot-plug below.) 1. **Every eligible keyboard service gets the mapping** when the session starts, using the same write and readback the single-device path uses. A failed write affects only that keyboard. -2. **Hot-plug.** The worker reconciles its device inventory every two seconds. It - admits a newly arrived keyboard when all keys are up and retires a removed one. - A first press can precede the next check. +2. **Hot-plug.** A keyboard arriving triggers an immediate reconcile, with a + two-second backstop. It admits a newly arrived keyboard when all keys are up and + retires a removed one. 3. **Journal one lease per device.** Crash recovery restores each device under the existing exact-owner, same-boot, same-device and unchanged-mapping checks. A failed restoration retains its own record without discarding other keyboards' records. @@ -194,8 +193,14 @@ Acquisition, recovery and restoration use the existing exact-owner checks; one keyboard's failure does not discard other keyboards' recovery records. The old single-keyboard record remains readable for recovery. -**Hot-plug.** The worker checks inventory every two seconds while Caps rules run. -It admits a new keyboard under the same eligibility and all-keys-up checks as at +**Hot-plug.** `SessionCapsKeyboardArrivalMonitor` listens for `IOHIDEventService` +registrations (first-match notifications; services present at startup are ignored) +whose `DeviceUsagePairs` include keyboard, which also catches a board's shared NKRO +interface. An arrival makes the next fleet check due at once, then checks run every +0.25 s for three seconds, because hidutil can list a new service a moment after IOKit +reports it (`SessionCapsCheckSchedule`). The two-second check remains as a backstop, +and covers everything if the notification cannot be registered. The worker logs +`[SessionCaps] Keyboard arrival mapped in N ms`. It admits a new keyboard under the same eligibility and all-keys-up checks as at startup, and retires records for a removed service. A failed device is left uncovered and retried with backoff; an unavailable inventory leaves existing coverage intact and reports that it cannot be verified. @@ -279,7 +284,8 @@ selection step. The shipped fleet passed unit tests and an opt-in live test that acquired and restored mappings on three attached USB keyboard services. A physical unplug/replug -test is still needed to measure the first-press window and check whether macOS +test is still needed to measure the first-press window (read the `arrival mapped in N +ms` log line) and check whether macOS reapplies Modifier Keys preferences after arrival. A physical Caps press on the cl80 in NKRO mode would close the multi-interface question. The stuck-F18 VM result above narrows the investigation but does not reproduce the host incident. diff --git a/guides/driverless-caps-lock.md b/guides/driverless-caps-lock.md index e253bd6ed..521cef885 100644 --- a/guides/driverless-caps-lock.md +++ b/guides/driverless-caps-lock.md @@ -65,10 +65,9 @@ If it cannot determine the covered interfaces safely, that keyboard is left uncovered rather than guessing. Virtual keyboards are not physical Caps Lock targets. -The first Caps Lock press immediately after plugging in a keyboard can arrive -before macOS finishes presenting the device for setup. If Caps Lock behaves -normally at that moment, release all keys and try again after KeyPath reports -the keyboard as active. Protected password fields and Secure Input still +KeyPath sets up a keyboard as soon as you plug it in. A Caps Lock press in the +same instant can still arrive before macOS finishes presenting the device. If +Caps Lock behaves normally at that moment, release all keys and try again. Protected password fields and Secure Input still prevent dynamic remapping. ## Can a rule turn Caps Lock on? From 771540546b1b0d0be79c17cf06e317769033aa49 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 08:38:12 -0700 Subject: [PATCH 02/16] Address review: reuse the lease's keyboard predicate, fix arrival timing - The arrival monitor classifies services with SessionCapsHIDUtilTransport.serviceEmitsKeyboardUsages (usage pairs, then the report descriptor) and fails open: an unreadable capability still schedules a check. The predicate is injectable for tests. - Arrival attribution waits up to 30 s (e.g. keys held at plug-in), ignores keyboards recovering from failure backoff, and every newly covered keyboard is logged. - Fleet check spacing is measured from completion, so fast arrival checks leave the HID queue idle between them. - Fix a notification-port leak on an unreachable failure path; guide no longer claims an unmeasured instant setup. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 47 +++++++----- .../SessionRuntime/SessionRuntimeWorker.swift | 44 +++++++---- .../SessionCapsCheckScheduleTests.swift | 76 ++++++++++++++++++- docs/architecture/all-keyboards-caps.md | 13 +++- guides/driverless-caps-lock.md | 10 ++- 5 files changed, 150 insertions(+), 40 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index f698f3f86..3b0bf9f9d 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -7,8 +7,8 @@ import IOKit struct SessionCapsCheckSchedule: Equatable { /// The steady backstop while every keyboard's mapping stays verified. static let backstop: TimeInterval = 2 - /// How often to re-read inventory right after an arrival. hidutil can list a - /// new event service a moment after IOKit reports it. + /// The pause after each check right after an arrival. hidutil can list a new + /// event service a moment after IOKit reports it. static let afterArrival: TimeInterval = 0.25 /// How long arrival checks stay fast. static let arrivalWindow: TimeInterval = 3 @@ -21,7 +21,8 @@ struct SessionCapsCheckSchedule: Equatable { fastUntil = now.addingTimeInterval(Self.arrivalWindow) } - /// Whether a fleet check should start now, given the last one's start time. + /// Whether a fleet check should start now. `lastCheck` is when the previous + /// check finished, so fast checks still leave the HID queue idle between them. func isDue(now: Date, lastCheck: Date) -> Bool { if arrivalPending { return true } let interval = now < fastUntil ? Self.afterArrival : Self.backstop @@ -40,19 +41,27 @@ struct SessionCapsCheckSchedule: Equatable { @MainActor final class SessionCapsKeyboardArrivalMonitor { private let onArrival: () -> Void + private let emitsKeyboardUsages: (UInt64) -> Bool? private var port: IONotificationPortRef? private var iterator: io_iterator_t = 0 private var source: CFRunLoopSource? - init(onArrival: @escaping () -> Void) { + /// `emitsKeyboardUsages` is the same predicate the lease uses to group a + /// keyboard's interfaces; injectable for tests. + init(emitsKeyboardUsages: @escaping (UInt64) -> Bool? = SessionCapsHIDUtilTransport.serviceEmitsKeyboardUsages, + onArrival: @escaping () -> Void) + { + self.emitsKeyboardUsages = emitsKeyboardUsages self.onArrival = onArrival } func start() -> Bool { - guard port == nil, let port = IONotificationPortCreate(kIOMainPortDefault), - let source = IONotificationPortGetRunLoopSource(port)?.takeUnretainedValue() - else { return false } + guard port == nil, let port = IONotificationPortCreate(kIOMainPortDefault) else { return false } self.port = port + guard let source = IONotificationPortGetRunLoopSource(port)?.takeUnretainedValue() else { + stop() + return false + } self.source = source CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) let status = IOServiceAddMatchingNotification( @@ -86,22 +95,22 @@ final class SessionCapsKeyboardArrivalMonitor { } private func drain(_ iterator: io_iterator_t, report: Bool) { - var arrived = false + var registryIDs: [UInt64] = [] while case let service = IOIteratorNext(iterator), service != 0 { defer { IOObjectRelease(service) } - if report, Self.emitsKeyboardUsages(service) { arrived = true } + var id: UInt64 = 0 + // An unreadable ID still counts: the arrival is reported, and the + // reconcile decides whether there is anything to map. + registryIDs.append(IORegistryEntryGetRegistryEntryID(service, &id) == KERN_SUCCESS ? id : 0) } - if arrived { onArrival() } + if report { reportArrivals(registryIDs) } } - /// Any interface that can carry keyboard reports, including a board's shared - /// N-key-rollover interface whose primary usage is not keyboard. - private static func emitsKeyboardUsages(_ service: io_service_t) -> Bool { - guard let pairs = IORegistryEntryCreateCFProperty(service, "DeviceUsagePairs" as CFString, - kCFAllocatorDefault, 0)? - .takeRetainedValue() as? [[String: Any]] else { return false } - return pairs.contains { - ($0["DeviceUsagePage"] as? NSNumber)?.intValue == 1 && ($0["DeviceUsage"] as? NSNumber)?.intValue == 6 - } + /// Fails open: a service whose keyboard capability can't be read yet is still + /// an arrival, because one extra inventory read is cheap and a missed one + /// leaves the keyboard unmapped until the backstop. + func reportArrivals(_ registryIDs: [UInt64]) { + let arrived = registryIDs.contains { id in id == 0 || emitsKeyboardUsages(id) != false } + if arrived { onArrival() } } } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index dbe091c1e..0dfe20ba4 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -623,13 +623,33 @@ public final class SessionRuntimeWorker { finish(.failed, reason: "output-queue-overrun") } + /// How long an arrival waits to be attributed. Long enough for a keyboard + /// plugged in with keys held to be mapped once they are released. + static let arrivalAttributionLimit: TimeInterval = 30 + + /// Attributes newly covered keyboards to the pending arrival. A keyboard that + /// was backing off after a failed write is recovering, not arriving. + static func arrivalOutcome(arrivedAt: Date?, now: Date, added: Set, + recovering: Set) -> (log: String?, keepPending: Bool) + { + guard let arrivedAt else { return (nil, false) } + let arrived = added.subtracting(recovering) + if !arrived.isEmpty { + let elapsed = Int((now.timeIntervalSince(arrivedAt) * 1000).rounded()) + return ("[SessionCaps] Keyboard arrival mapped in \(elapsed) ms: \(arrived.sorted())", false) + } + // Not every arrival is a new eligible keyboard: an excluded or withheld + // one, or an interface that re-registered (for example after wake). + return (nil, now < arrivedAt.addingTimeInterval(arrivalAttributionLimit)) + } + /// Without the monitor the backstop check still finds arrivals, only later. private func startCapsArrivalMonitor() { let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] in guard let self, !self.finished else { return } let now = Date() - self.capsCheckSchedule.keyboardArrived(at: now) - if self.capsArrivalAt == nil { self.capsArrivalAt = now } + capsCheckSchedule.keyboardArrived(at: now) + if capsArrivalAt == nil { capsArrivalAt = now } } if monitor.start() { capsArrivalMonitor = monitor @@ -719,6 +739,8 @@ public final class SessionRuntimeWorker { MainActor.assumeIsolated { guard let self, !self.finished, self.capsOwner == owner else { return } self.capsCheckInFlight = false + // Spacing runs from completion, so fast arrival checks leave the HID queue idle. + self.lastCapsCheck = Date() guard digest == nil || digest == admittedDigest else { self.finish(.failed, reason: SessionCapsRuntimeSupport.profileChangedReason) } @@ -747,18 +769,14 @@ public final class SessionRuntimeWorker { for (id, reason) in coverage.failures where !previous.contains(id) { AppLogger.shared.warn("[SessionCaps] Keyboard \(id) left uncovered: \(reason)") } - if let arrivedAt = self.capsArrivalAt { - let added = current.subtracting(previous) - if !added.isEmpty { - let elapsed = Int(Date().timeIntervalSince(arrivedAt) * 1000) - AppLogger.shared.log("[SessionCaps] Keyboard arrival mapped in \(elapsed) ms: \(added.sorted())") - self.capsArrivalAt = nil - } else if Date() >= arrivedAt.addingTimeInterval(SessionCapsCheckSchedule.arrivalWindow) { - // Not every arrival is a new eligible keyboard (an excluded or - // withheld one, or a re-enumerated interface). - self.capsArrivalAt = nil - } + let added = current.subtracting(previous) + if !added.isEmpty { + AppLogger.shared.log("[SessionCaps] Keyboard covered: \(added.sorted())") } + let arrival = Self.arrivalOutcome(arrivedAt: self.capsArrivalAt, now: Date(), added: added, + recovering: Set(retryAfter.keys)) + if let line = arrival.log { AppLogger.shared.log(line) } + if !arrival.keepPending { self.capsArrivalAt = nil } case let .failure(error): if case SessionCapsMappingLease.Refusal.busy = error { return } if error is CapsInventoryFailure { diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index a2d94bc9f..9e3d0c20c 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -1,4 +1,5 @@ import Foundation +import IOKit @testable import KeyPathAppKit import XCTest @@ -48,11 +49,84 @@ final class SessionCapsCheckScheduleTests: XCTestCase { @MainActor func testMonitorStartsWithoutReportingKeyboardsAlreadyConnected() { var arrivals = 0 - let monitor = SessionCapsKeyboardArrivalMonitor { arrivals += 1 } + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { _ in true }) { arrivals += 1 } defer { monitor.stop() } XCTAssertTrue(monitor.start()) XCTAssertFalse(monitor.start(), "a started monitor refuses a second start") + // Every service counts as a keyboard here, so a zero is the startup drain at work. XCTAssertEqual(arrivals, 0, "services present at start are inventory, not arrivals") } + + @MainActor + func testArrivalClassificationFailsOpen() { + let answers: [UInt64: Bool?] = [1: true, 2: false, 3: nil] + var arrivals = 0 + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { answers[$0] ?? false }) { arrivals += 1 } + + monitor.reportArrivals([2]) + XCTAssertEqual(arrivals, 0, "a readable non-keyboard service (a mouse) is not an arrival") + monitor.reportArrivals([1]) + XCTAssertEqual(arrivals, 1) + monitor.reportArrivals([3]) + XCTAssertEqual(arrivals, 2, "an unreadable capability still schedules a check") + monitor.reportArrivals([0]) + XCTAssertEqual(arrivals, 3, "an unreadable registry ID still schedules a check") + monitor.reportArrivals([2, 1, 2]) + XCTAssertEqual(arrivals, 4, "one notification batch is one arrival") + } + + /// The live predicate on this Mac's own event services: at least one connected + /// keyboard must read as one, or the monitor would never report arrivals. + func testTheLeasePredicateRecognisesAConnectedKeyboardService() throws { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOHIDEventService"), &iterator) + == KERN_SUCCESS else { throw XCTSkip("IOKit service listing unavailable") } + defer { IOObjectRelease(iterator) } + var keyboards = 0 + while case let service = IOIteratorNext(iterator), service != 0 { + defer { IOObjectRelease(service) } + var id: UInt64 = 0 + if IORegistryEntryGetRegistryEntryID(service, &id) == KERN_SUCCESS, + SessionCapsHIDUtilTransport.serviceEmitsKeyboardUsages(id) == true + { + keyboards += 1 + } + } + try XCTSkipIf(keyboards == 0, "no keyboard attached to this machine") + XCTAssertGreaterThan(keyboards, 0) + } + + @MainActor + func testArrivalIsAttributedToANewKeyboardNotARecoveringOne() { + let arrived = start + // An unrelated keyboard coming back from its failure backoff is not the arrival. + let recovered = SessionRuntimeWorker.arrivalOutcome(arrivedAt: arrived, now: start.addingTimeInterval(0.4), + added: [7], recovering: [7]) + XCTAssertNil(recovered.log) + XCTAssertTrue(recovered.keepPending) + + let mapped = SessionRuntimeWorker.arrivalOutcome(arrivedAt: arrived, now: start.addingTimeInterval(0.4), + added: [7, 8], recovering: [7]) + XCTAssertEqual(mapped.log, "[SessionCaps] Keyboard arrival mapped in 400 ms: [8]") + XCTAssertFalse(mapped.keepPending) + } + + @MainActor + func testASlowArrivalIsStillMeasuredUntilTheAttributionLimit() { + // Plugged in with keys held: mapped only after release, well past the fast window. + let late = SessionRuntimeWorker.arrivalOutcome(arrivedAt: start, now: start.addingTimeInterval(5), + added: [], recovering: []) + XCTAssertTrue(late.keepPending) + let mapped = SessionRuntimeWorker.arrivalOutcome(arrivedAt: start, now: start.addingTimeInterval(6), + added: [9], recovering: []) + XCTAssertEqual(mapped.log, "[SessionCaps] Keyboard arrival mapped in 6000 ms: [9]") + + let expired = SessionRuntimeWorker.arrivalOutcome( + arrivedAt: start, now: start.addingTimeInterval(SessionRuntimeWorker.arrivalAttributionLimit), + added: [], recovering: [] + ) + XCTAssertFalse(expired.keepPending, "an arrival that never maps (excluded, withheld) is dropped") + XCTAssertNil(SessionRuntimeWorker.arrivalOutcome(arrivedAt: nil, now: start, added: [9], recovering: []).log) + } } diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 7e6b0f15e..9f8fd638a 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -195,12 +195,17 @@ single-keyboard record remains readable for recovery. **Hot-plug.** `SessionCapsKeyboardArrivalMonitor` listens for `IOHIDEventService` registrations (first-match notifications; services present at startup are ignored) -whose `DeviceUsagePairs` include keyboard, which also catches a board's shared NKRO -interface. An arrival makes the next fleet check due at once, then checks run every -0.25 s for three seconds, because hidutil can list a new service a moment after IOKit +and classifies each with the lease's own `serviceEmitsKeyboardUsages`, so a board's +shared NKRO interface counts. An unreadable capability still counts as an arrival: +an extra inventory read is cheap, a missed arrival is not. An arrival makes the next +fleet check due at once; for three seconds after it, checks then run 0.25 s after the +previous one finishes, because hidutil can list a new service a moment after IOKit reports it (`SessionCapsCheckSchedule`). The two-second check remains as a backstop, and covers everything if the notification cannot be registered. The worker logs -`[SessionCaps] Keyboard arrival mapped in N ms`. It admits a new keyboard under the same eligibility and all-keys-up checks as at +`[SessionCaps] Keyboard covered` for every newly covered keyboard, and +`[SessionCaps] Keyboard arrival mapped in N ms` when the newly covered keyboard +follows an arrival within 30 s (a keyboard recovering from its failure backoff is not +counted as the arrival). It admits a new keyboard under the same eligibility and all-keys-up checks as at startup, and retires records for a removed service. A failed device is left uncovered and retried with backoff; an unavailable inventory leaves existing coverage intact and reports that it cannot be verified. diff --git a/guides/driverless-caps-lock.md b/guides/driverless-caps-lock.md index 521cef885..78bd25152 100644 --- a/guides/driverless-caps-lock.md +++ b/guides/driverless-caps-lock.md @@ -65,9 +65,13 @@ If it cannot determine the covered interfaces safely, that keyboard is left uncovered rather than guessing. Virtual keyboards are not physical Caps Lock targets. -KeyPath sets up a keyboard as soon as you plug it in. A Caps Lock press in the -same instant can still arrive before macOS finishes presenting the device. If -Caps Lock behaves normally at that moment, release all keys and try again. Protected password fields and Secure Input still +KeyPath starts setting up a keyboard when you plug it in, rather than waiting +for its next periodic check. It waits until no keys are held, and a Caps Lock +press in the first moment can still arrive before macOS finishes presenting the +device. If Caps Lock behaves normally then, release all keys and try again after +**Settings → General** shows the keyboard as covered. + +Protected password fields and Secure Input still prevent dynamic remapping. ## Can a rule turn Caps Lock on? From b22349d68768b02adf20f62d1c6b3ef45eaf5912 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 08:52:25 -0700 Subject: [PATCH 03/16] Keep hot-plug checks fast only while a keyboard is waiting A wake re-registers every keyboard and now costs one check. An unreadable service gets one immediate check but no fast window. The fast window ends once no eligible keyboard is waiting, and lasts at most 30 s. The backstop is measured between check starts. Stale retry entries are pruned. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 66 +++++++++----- .../SessionRuntime/SessionRuntimeWorker.swift | 27 ++++-- .../SessionCapsCheckScheduleTests.swift | 88 +++++++++++++------ docs/architecture/all-keyboards-caps.md | 16 ++-- guides/driverless-caps-lock.md | 5 +- 5 files changed, 135 insertions(+), 67 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index 3b0bf9f9d..94af94b2b 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -1,38 +1,60 @@ import Foundation import IOKit -/// When the worker next reconciles its Caps fleet. Arrivals pull the check -/// forward: a keyboard plugged in should be mapped before its first Caps press, -/// not up to one backstop interval later. +/// When the worker next reconciles its Caps fleet. An arrival pulls one check +/// forward. Checks then stay fast only while a keyboard is waiting to be mapped, +/// so a wake that re-registers already-covered keyboards costs a single check. struct SessionCapsCheckSchedule: Equatable { - /// The steady backstop while every keyboard's mapping stays verified. + /// The steady backstop, measured between check starts. static let backstop: TimeInterval = 2 - /// The pause after each check right after an arrival. hidutil can list a new - /// event service a moment after IOKit reports it. + /// The pause after each fast check finishes. hidutil can list a new event + /// service a moment after IOKit reports it. static let afterArrival: TimeInterval = 0.25 - /// How long arrival checks stay fast. + /// Fast checks after a recognised keyboard arrives, before any check reports. static let arrivalWindow: TimeInterval = 3 + /// The longest a waiting keyboard keeps checks fast, for example while keys + /// held at plug-in block admission. + static let waitLimit: TimeInterval = 30 - private(set) var fastUntil: Date = .distantPast private(set) var arrivalPending = false + private(set) var fastUntil: Date = .distantPast + private(set) var arrivedAt: Date? - mutating func keyboardArrived(at now: Date) { + /// `recognised`: the service reads as a keyboard. An unreadable one still gets + /// its immediate check, but cannot open a fast window by itself. + mutating func keyboardArrived(at now: Date, recognised: Bool) { arrivalPending = true - fastUntil = now.addingTimeInterval(Self.arrivalWindow) + guard recognised else { return } + if arrivedAt == nil { arrivedAt = now } + fastUntil = max(fastUntil, now.addingTimeInterval(Self.arrivalWindow)) } - /// Whether a fleet check should start now. `lastCheck` is when the previous - /// check finished, so fast checks still leave the HID queue idle between them. - func isDue(now: Date, lastCheck: Date) -> Bool { + func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { if arrivalPending { return true } - let interval = now < fastUntil ? Self.afterArrival : Self.backstop - return now.timeIntervalSince(lastCheck) >= interval + if now < fastUntil { return now.timeIntervalSince(lastEnd) >= Self.afterArrival } + return now.timeIntervalSince(lastStart) >= Self.backstop } /// A check is starting; it covers every arrival reported so far. mutating func checkStarted() { arrivalPending = false } + + /// `waiting`: eligible keyboards the check left unmapped that may still be + /// admitted (not backing off after a failed write). + mutating func checkFinished(at now: Date, waiting: Int) { + guard waiting > 0 else { + fastUntil = .distantPast + arrivedAt = nil + return + } + guard let arrivedAt, now < arrivedAt.addingTimeInterval(Self.waitLimit) else { + fastUntil = .distantPast + arrivedAt = nil + return + } + fastUntil = max(fastUntil, now.addingTimeInterval(Self.afterArrival + Self.backstop)) + } } /// Reports keyboard event services as they register, on the worker's main run @@ -40,7 +62,7 @@ struct SessionCapsCheckSchedule: Equatable { /// decided by the fleet reconcile, exactly as for the backstop check. @MainActor final class SessionCapsKeyboardArrivalMonitor { - private let onArrival: () -> Void + private let onArrival: (_ recognised: Bool) -> Void private let emitsKeyboardUsages: (UInt64) -> Bool? private var port: IONotificationPortRef? private var iterator: io_iterator_t = 0 @@ -49,7 +71,7 @@ final class SessionCapsKeyboardArrivalMonitor { /// `emitsKeyboardUsages` is the same predicate the lease uses to group a /// keyboard's interfaces; injectable for tests. init(emitsKeyboardUsages: @escaping (UInt64) -> Bool? = SessionCapsHIDUtilTransport.serviceEmitsKeyboardUsages, - onArrival: @escaping () -> Void) + onArrival: @escaping (_ recognised: Bool) -> Void) { self.emitsKeyboardUsages = emitsKeyboardUsages self.onArrival = onArrival @@ -106,11 +128,11 @@ final class SessionCapsKeyboardArrivalMonitor { if report { reportArrivals(registryIDs) } } - /// Fails open: a service whose keyboard capability can't be read yet is still - /// an arrival, because one extra inventory read is cheap and a missed one - /// leaves the keyboard unmapped until the backstop. + /// Fails open: a service whose keyboard capability can't be read yet still + /// earns one pulled-forward check, so the keyboard isn't left for the backstop. func reportArrivals(_ registryIDs: [UInt64]) { - let arrived = registryIDs.contains { id in id == 0 || emitsKeyboardUsages(id) != false } - if arrived { onArrival() } + let answers = registryIDs.map { $0 == 0 ? nil : emitsKeyboardUsages($0) } + guard answers.contains(where: { $0 != false }) else { return } + onArrival(answers.contains(true)) } } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index 0dfe20ba4..557537fd2 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -49,6 +49,8 @@ public final class SessionRuntimeWorker { private var capsConfigPath: String? private var capsAdmittedDigest: String? private var lastCapsCheck = Date.distantPast + /// When the last fleet check finished; fast arrival checks are spaced from it. + private var lastCapsCheckEnd = Date.distantPast private var inputCount: UInt64 = 0 private var outputCount: UInt64 = 0 private var lastReport = Date.distantPast @@ -625,7 +627,7 @@ public final class SessionRuntimeWorker { /// How long an arrival waits to be attributed. Long enough for a keyboard /// plugged in with keys held to be mapped once they are released. - static let arrivalAttributionLimit: TimeInterval = 30 + static let arrivalAttributionLimit = SessionCapsCheckSchedule.waitLimit /// Attributes newly covered keyboards to the pending arrival. A keyboard that /// was backing off after a failed write is recovering, not arriving. @@ -645,10 +647,10 @@ public final class SessionRuntimeWorker { /// Without the monitor the backstop check still finds arrivals, only later. private func startCapsArrivalMonitor() { - let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] in + let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] recognised in guard let self, !self.finished else { return } let now = Date() - capsCheckSchedule.keyboardArrived(at: now) + capsCheckSchedule.keyboardArrived(at: now, recognised: recognised) if capsArrivalAt == nil { capsArrivalAt = now } } if monitor.start() { @@ -661,7 +663,7 @@ public final class SessionRuntimeWorker { private func checkCapsOwnershipIfNeeded() { guard !capsCheckInFlight else { return } if capsFleetEnabled { - guard capsCheckSchedule.isDue(now: Date(), lastCheck: lastCapsCheck) else { return } + guard capsCheckSchedule.isDue(now: Date(), lastStart: lastCapsCheck, lastEnd: lastCapsCheckEnd) else { return } checkCapsFleetIfNeeded() return } @@ -697,18 +699,19 @@ public final class SessionRuntimeWorker { } private func checkCapsFleetIfNeeded() { + // Before the guard: a pending arrival must not keep the check due every tick. + capsCheckSchedule.checkStarted() guard let owner = capsOwner, let directory = capsDirectory, let configPath = capsConfigPath, let admittedDigest = capsAdmittedDigest else { return } capsCheckInFlight = true lastCapsCheck = Date() - capsCheckSchedule.checkStarted() let previous = Set(capsFleetRecords.map(\.device.registryEntryID)) let retryAfter = capsFleetRetryAfter let usage = SessionCapsRuntimeSupport.preferredIntermediate() DispatchQueue.global(qos: .utility).async { [weak self] in let digest = try? SessionCapsRuntimeSupport.configSHA256(configPath) let result: Result<(records: [SessionCapsMappingPolicy.Record], failures: [UInt64: String], - eligible: Int, withheld: Int), Error> + eligible: Int, withheld: Int, present: Set, waiting: Int), Error> do { guard digest == nil || digest == admittedDigest else { throw SessionCapsRuntimeSupport.Refusal.configIdentity @@ -731,7 +734,11 @@ public final class SessionRuntimeWorker { bootSessionUUID: owner.bootSessionUUID, allowNewDevices: SessionCapsRuntimeSupport.heldKeys().isEmpty ) - result = .success((coverage.records, coverage.failures, devices.count, inventory.withheld)) + // Eligible keyboards still unmapped that a later check may admit. + let mapped = Set(coverage.records.map(\.device.registryEntryID)) + let waiting = retryable.count { !mapped.contains($0.registryEntryID) && coverage.failures[$0.registryEntryID] == nil } + result = .success((coverage.records, coverage.failures, devices.count, inventory.withheld, + Set(devices.map(\.registryEntryID)), waiting)) } catch { result = .failure(error) } @@ -739,8 +746,7 @@ public final class SessionRuntimeWorker { MainActor.assumeIsolated { guard let self, !self.finished, self.capsOwner == owner else { return } self.capsCheckInFlight = false - // Spacing runs from completion, so fast arrival checks leave the HID queue idle. - self.lastCapsCheck = Date() + self.lastCapsCheckEnd = Date() guard digest == nil || digest == admittedDigest else { self.finish(.failed, reason: SessionCapsRuntimeSupport.profileChangedReason) } @@ -760,6 +766,9 @@ public final class SessionRuntimeWorker { for id in coverage.failures.keys { self.capsFleetRetryAfter[id] = Date().addingTimeInterval(30) } + // A departed keyboard's backoff would otherwise linger forever. + self.capsFleetRetryAfter = self.capsFleetRetryAfter.filter { coverage.present.contains($0.key) } + self.capsCheckSchedule.checkFinished(at: Date(), waiting: coverage.waiting) let nextCoverage = SessionRuntimeReport.CapsCoverage( eligible: coverage.eligible, covered: coverage.records.count, withheld: coverage.withheld ) diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index 9e3d0c20c..a8f755c91 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -6,50 +6,81 @@ import XCTest final class SessionCapsCheckScheduleTests: XCTestCase { private let start = Date(timeIntervalSince1970: 1_000_000) - func testWithoutArrivalsTheBackstopRunsEveryTwoSeconds() { + func testWithoutArrivalsTheBackstopRunsTwoSecondsAfterTheLastCheckStarted() { let schedule = SessionCapsCheckSchedule() + // A slow check (0.6 s) does not stretch the backstop: it is measured between starts. + let lastEnd = start.addingTimeInterval(0.6) - XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1.9), lastCheck: start)) - XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(2), lastCheck: start)) + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1.9), lastStart: start, lastEnd: lastEnd)) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(2), lastStart: start, lastEnd: lastEnd)) } func testAnArrivalMakesTheNextCheckDueAtOnce() { var schedule = SessionCapsCheckSchedule() - // A check ran 10 ms ago; without the arrival the next would wait ~2 s. - let lastCheck = start - schedule.keyboardArrived(at: start.addingTimeInterval(0.01)) + schedule.keyboardArrived(at: start.addingTimeInterval(0.01), recognised: true) - XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.01), lastCheck: lastCheck)) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.01), lastStart: start, lastEnd: start)) } - func testChecksStayFastForTheArrivalWindowThenReturnToTheBackstop() { + func testAnUnrecognisedArrivalEarnsOneCheckButNoFastWindow() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start) + schedule.keyboardArrived(at: start, recognised: false) + XCTAssertTrue(schedule.isDue(now: start, lastStart: start, lastEnd: start)) schedule.checkStarted() - // hidutil may list the new service a moment later: re-read every 0.25 s. - XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.2), lastCheck: start)) - XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.25), lastCheck: start)) - let late = start.addingTimeInterval(SessionCapsCheckSchedule.arrivalWindow + 0.1) - XCTAssertFalse(schedule.isDue(now: late, lastCheck: late.addingTimeInterval(-0.5))) - XCTAssertTrue(schedule.isDue(now: late, lastCheck: late.addingTimeInterval(-2))) + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.5), lastStart: start, lastEnd: start.addingTimeInterval(0.1))) + } + + func testFastChecksAreSpacedFromTheLastCheckFinishing() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(at: start, recognised: true) + schedule.checkStarted() + let lastEnd = start.addingTimeInterval(0.4) + + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.6), lastStart: start, lastEnd: lastEnd)) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.65), lastStart: start, lastEnd: lastEnd)) + } + + func testAWakeThatFindsEveryKeyboardCoveredCostsOneCheck() { + var schedule = SessionCapsCheckSchedule() + // Wake re-registers every keyboard's event service. + schedule.keyboardArrived(at: start, recognised: true) + schedule.checkStarted() + schedule.checkFinished(at: start.addingTimeInterval(0.3), waiting: 0) + + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1), lastStart: start, lastEnd: start.addingTimeInterval(0.3))) + XCTAssertNil(schedule.arrivedAt) + } + + func testAWaitingKeyboardKeepsChecksFastUntilTheWaitLimit() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(at: start, recognised: true) + schedule.checkStarted() + // Keys held at plug-in: the keyboard stays unmapped past the 3 s window. + let late = start.addingTimeInterval(10) + schedule.checkFinished(at: late, waiting: 1) + XCTAssertTrue(schedule.isDue(now: late.addingTimeInterval(0.25), lastStart: late, lastEnd: late)) + + let expired = start.addingTimeInterval(SessionCapsCheckSchedule.waitLimit) + schedule.checkFinished(at: expired, waiting: 1) + XCTAssertFalse(schedule.isDue(now: expired.addingTimeInterval(0.5), lastStart: expired, lastEnd: expired)) } func testAnArrivalDuringACheckIsNotLostWhenThatCheckStarted() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start) + schedule.keyboardArrived(at: start, recognised: true) schedule.checkStarted() // A second keyboard arrives while the first check is still in flight. - schedule.keyboardArrived(at: start.addingTimeInterval(0.05)) + schedule.keyboardArrived(at: start.addingTimeInterval(0.05), recognised: true) XCTAssertTrue(schedule.arrivalPending) - XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.06), lastCheck: start)) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.06), lastStart: start, lastEnd: start)) } @MainActor func testMonitorStartsWithoutReportingKeyboardsAlreadyConnected() { var arrivals = 0 - let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { _ in true }) { arrivals += 1 } + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { _ in true }) { _ in arrivals += 1 } defer { monitor.stop() } XCTAssertTrue(monitor.start()) @@ -61,19 +92,20 @@ final class SessionCapsCheckScheduleTests: XCTestCase { @MainActor func testArrivalClassificationFailsOpen() { let answers: [UInt64: Bool?] = [1: true, 2: false, 3: nil] - var arrivals = 0 - let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { answers[$0] ?? false }) { arrivals += 1 } + var arrivals: [Bool] = [] + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { answers[$0] ?? false }) { arrivals.append($0) } monitor.reportArrivals([2]) - XCTAssertEqual(arrivals, 0, "a readable non-keyboard service (a mouse) is not an arrival") + XCTAssertEqual(arrivals, [], "a readable non-keyboard service (a mouse) is not an arrival") monitor.reportArrivals([1]) - XCTAssertEqual(arrivals, 1) + XCTAssertEqual(arrivals, [true]) monitor.reportArrivals([3]) - XCTAssertEqual(arrivals, 2, "an unreadable capability still schedules a check") + XCTAssertEqual(arrivals, [true, false], "an unreadable capability still schedules an unrecognised check") monitor.reportArrivals([0]) - XCTAssertEqual(arrivals, 3, "an unreadable registry ID still schedules a check") - monitor.reportArrivals([2, 1, 2]) - XCTAssertEqual(arrivals, 4, "one notification batch is one arrival") + XCTAssertEqual(arrivals, [true, false, false], "an unreadable registry ID still schedules a check") + monitor.reportArrivals([2, 1, 3]) + XCTAssertEqual(arrivals.last, true, "one notification batch is one arrival, recognised if any service is") + XCTAssertEqual(arrivals.count, 4) } /// The live predicate on this Mac's own event services: at least one connected @@ -93,8 +125,8 @@ final class SessionCapsCheckScheduleTests: XCTestCase { keyboards += 1 } } + // Without an attached keyboard there is nothing to recognise (a headless runner). try XCTSkipIf(keyboards == 0, "no keyboard attached to this machine") - XCTAssertGreaterThan(keyboards, 0) } @MainActor diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 9f8fd638a..9f94f66a9 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -196,12 +196,16 @@ single-keyboard record remains readable for recovery. **Hot-plug.** `SessionCapsKeyboardArrivalMonitor` listens for `IOHIDEventService` registrations (first-match notifications; services present at startup are ignored) and classifies each with the lease's own `serviceEmitsKeyboardUsages`, so a board's -shared NKRO interface counts. An unreadable capability still counts as an arrival: -an extra inventory read is cheap, a missed arrival is not. An arrival makes the next -fleet check due at once; for three seconds after it, checks then run 0.25 s after the -previous one finishes, because hidutil can list a new service a moment after IOKit -reports it (`SessionCapsCheckSchedule`). The two-second check remains as a backstop, -and covers everything if the notification cannot be registered. The worker logs +shared NKRO interface counts. An unreadable capability still earns one immediate +check: an extra inventory read is cheap, a missed arrival is not. Only a recognised +keyboard opens a fast window (`SessionCapsCheckSchedule`): checks then run 0.25 s +after the previous one finishes, because hidutil can list a new service a moment +after IOKit reports it. The window ends as soon as a check finds no eligible keyboard +waiting to be mapped. While one is waiting (for example, keys held at plug-in), it +continues for up to 30 s after the arrival. A wake re-registers every keyboard's +service, so it costs one check, and nothing more if every keyboard is still covered. +The two-second backstop is measured between check starts, so a slow check doesn't +stretch it, and covers everything if the notification cannot be registered. The worker logs `[SessionCaps] Keyboard covered` for every newly covered keyboard, and `[SessionCaps] Keyboard arrival mapped in N ms` when the newly covered keyboard follows an arrival within 30 s (a keyboard recovering from its failure backoff is not diff --git a/guides/driverless-caps-lock.md b/guides/driverless-caps-lock.md index 78bd25152..560c0a2d4 100644 --- a/guides/driverless-caps-lock.md +++ b/guides/driverless-caps-lock.md @@ -68,8 +68,9 @@ targets. KeyPath starts setting up a keyboard when you plug it in, rather than waiting for its next periodic check. It waits until no keys are held, and a Caps Lock press in the first moment can still arrive before macOS finishes presenting the -device. If Caps Lock behaves normally then, release all keys and try again after -**Settings → General** shows the keyboard as covered. +device. If Caps Lock behaves normally then, release all keys and try again. If it +still doesn't, **Settings → General** lists the keyboard as not covered, with the +reason. Protected password fields and Secure Input still prevent dynamic remapping. From b1745cb147a09479daeac82a367899fcbfc962f3 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 09:04:52 -0700 Subject: [PATCH 04/16] Keep checks fast until hidutil lists the arrived keyboard The fast window used to close on the first check of an ordinary hot-plug, because hidutil had not listed the new service yet. The monitor now passes on each arrived keyboard's registry ID and LocationID. Checks stay fast until a listing accounts for the keyboard, or until it is gone, so a wake still costs one check. Also: - a stale arrival no longer times a new one - backoffs are pruned only for keyboards that are gone - the live predicate test fails rather than skips when hidutil lists a keyboard - the monitor stops itself on deinit Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 116 +++++++++----- .../SessionRuntime/SessionRuntimeWorker.swift | 40 +++-- .../SessionCapsCheckScheduleTests.swift | 151 +++++++++++++----- docs/architecture/all-keyboards-caps.md | 19 ++- 4 files changed, 230 insertions(+), 96 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index 94af94b2b..1a393f4f9 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -1,59 +1,92 @@ import Foundation import IOKit +/// A keyboard event service IOKit has just registered. +struct SessionCapsArrivedService: Hashable { + let registryEntryID: UInt64 + /// Nil when unreadable. A LocationID of 0 marks a virtual keyboard. + let locationID: UInt32? +} + /// When the worker next reconciles its Caps fleet. An arrival pulls one check -/// forward. Checks then stay fast only while a keyboard is waiting to be mapped, -/// so a wake that re-registers already-covered keyboards costs a single check. +/// forward. Checks then stay fast while an arrived keyboard is not yet listed by +/// hidutil, or an eligible keyboard is waiting to be mapped, so a wake that +/// re-registers already-listed keyboards costs a single check. struct SessionCapsCheckSchedule: Equatable { /// The steady backstop, measured between check starts. static let backstop: TimeInterval = 2 /// The pause after each fast check finishes. hidutil can list a new event /// service a moment after IOKit reports it. static let afterArrival: TimeInterval = 0.25 - /// Fast checks after a recognised keyboard arrives, before any check reports. - static let arrivalWindow: TimeInterval = 3 - /// The longest a waiting keyboard keeps checks fast, for example while keys - /// held at plug-in block admission. + /// The longest an arrival keeps checks fast, for example while keys held at + /// plug-in block admission. static let waitLimit: TimeInterval = 30 private(set) var arrivalPending = false - private(set) var fastUntil: Date = .distantPast + /// Arrived keyboards hidutil has not listed yet. + private(set) var unlisted: Set = [] + private(set) var keyboardWaiting = false private(set) var arrivedAt: Date? - /// `recognised`: the service reads as a keyboard. An unreadable one still gets - /// its immediate check, but cannot open a fast window by itself. - mutating func keyboardArrived(at now: Date, recognised: Bool) { + /// Every arrival gets an immediate check. Only a service that reads as a + /// keyboard and is not virtual can keep checks fast until hidutil lists it. + mutating func keyboardArrived(at now: Date, keyboards: [SessionCapsArrivedService]) { arrivalPending = true - guard recognised else { return } - if arrivedAt == nil { arrivedAt = now } - fastUntil = max(fastUntil, now.addingTimeInterval(Self.arrivalWindow)) + let real = keyboards.filter { $0.locationID != 0 } + guard !real.isEmpty else { return } + if let arrivedAt, now < arrivedAt.addingTimeInterval(Self.waitLimit) {} else { + reset() + arrivedAt = now + } + unlisted.formUnion(real) } func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { if arrivalPending { return true } - if now < fastUntil { return now.timeIntervalSince(lastEnd) >= Self.afterArrival } + if isFast(now: now) { return now.timeIntervalSince(lastEnd) >= Self.afterArrival } return now.timeIntervalSince(lastStart) >= Self.backstop } - /// A check is starting; it covers every arrival reported so far. - mutating func checkStarted() { + private func isFast(now: Date) -> Bool { + guard let arrivedAt, now < arrivedAt.addingTimeInterval(Self.waitLimit) else { return false } + return !unlisted.isEmpty || keyboardWaiting + } + + /// A check is starting; it covers every arrival reported so far. Returns the + /// arrived keyboards it should look for in its listing. + mutating func checkStarted() -> Set { arrivalPending = false + return unlisted } - /// `waiting`: eligible keyboards the check left unmapped that may still be - /// admitted (not backing off after a failed write). - mutating func checkFinished(at now: Date, waiting: Int) { - guard waiting > 0 else { - fastUntil = .distantPast - arrivedAt = nil - return - } - guard let arrivedAt, now < arrivedAt.addingTimeInterval(Self.waitLimit) else { - fastUntil = .distantPast - arrivedAt = nil - return + /// `resolved`: arrived keyboards the check found listed, or found gone. + /// `waiting`: eligible keyboards it left unmapped that may still be admitted + /// (not backing off after a failed write). + mutating func checkFinished(at now: Date, resolved: Set, waiting: Int) { + unlisted.subtract(resolved) + keyboardWaiting = waiting > 0 + if !isFast(now: now) { reset() } + } + + private mutating func reset() { + unlisted = [] + keyboardWaiting = false + arrivedAt = nil + } + + /// The arrived keyboards a listing accounts for: listed under their own ID or + /// as another interface at their LocationID, or no longer registered. + static func resolved(_ arrived: Set, + listed: [SessionCapsHIDUtilTransport.ServiceRow], + exists: (UInt64) -> Bool?) -> Set + { + let ids = Set(listed.map(\.registryEntryID)) + let locations = Set(listed.compactMap(\.locationID).filter { $0 != 0 }) + return arrived.filter { service in + if ids.contains(service.registryEntryID) { return true } + if let location = service.locationID, locations.contains(location) { return true } + return exists(service.registryEntryID) == false } - fastUntil = max(fastUntil, now.addingTimeInterval(Self.afterArrival + Self.backstop)) } } @@ -62,7 +95,7 @@ struct SessionCapsCheckSchedule: Equatable { /// decided by the fleet reconcile, exactly as for the backstop check. @MainActor final class SessionCapsKeyboardArrivalMonitor { - private let onArrival: (_ recognised: Bool) -> Void + private let onArrival: (_ keyboards: [SessionCapsArrivedService]) -> Void private let emitsKeyboardUsages: (UInt64) -> Bool? private var port: IONotificationPortRef? private var iterator: io_iterator_t = 0 @@ -71,7 +104,7 @@ final class SessionCapsKeyboardArrivalMonitor { /// `emitsKeyboardUsages` is the same predicate the lease uses to group a /// keyboard's interfaces; injectable for tests. init(emitsKeyboardUsages: @escaping (UInt64) -> Bool? = SessionCapsHIDUtilTransport.serviceEmitsKeyboardUsages, - onArrival: @escaping (_ recognised: Bool) -> Void) + onArrival: @escaping (_ keyboards: [SessionCapsArrivedService]) -> Void) { self.emitsKeyboardUsages = emitsKeyboardUsages self.onArrival = onArrival @@ -117,22 +150,31 @@ final class SessionCapsKeyboardArrivalMonitor { } private func drain(_ iterator: io_iterator_t, report: Bool) { - var registryIDs: [UInt64] = [] + var services: [SessionCapsArrivedService] = [] while case let service = IOIteratorNext(iterator), service != 0 { defer { IOObjectRelease(service) } var id: UInt64 = 0 // An unreadable ID still counts: the arrival is reported, and the // reconcile decides whether there is anything to map. - registryIDs.append(IORegistryEntryGetRegistryEntryID(service, &id) == KERN_SUCCESS ? id : 0) + let readable = IORegistryEntryGetRegistryEntryID(service, &id) == KERN_SUCCESS + let location = IORegistryEntryCreateCFProperty(service, "LocationID" as CFString, kCFAllocatorDefault, 0)? + .takeRetainedValue() as? NSNumber + services.append(.init(registryEntryID: readable ? id : 0, + locationID: location.flatMap { UInt32(exactly: $0.uint64Value) })) } - if report { reportArrivals(registryIDs) } + if report { reportArrivals(services) } } /// Fails open: a service whose keyboard capability can't be read yet still /// earns one pulled-forward check, so the keyboard isn't left for the backstop. - func reportArrivals(_ registryIDs: [UInt64]) { - let answers = registryIDs.map { $0 == 0 ? nil : emitsKeyboardUsages($0) } + /// Only services that read as keyboards are passed on, to be looked for. + func reportArrivals(_ services: [SessionCapsArrivedService]) { + let answers = services.map { $0.registryEntryID == 0 ? nil : emitsKeyboardUsages($0.registryEntryID) } guard answers.contains(where: { $0 != false }) else { return } - onArrival(answers.contains(true)) + onArrival(zip(services, answers).filter { $0.1 == true }.map(\.0)) + } + + isolated deinit { + stop() } } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index 557537fd2..c3fd9d7d7 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -647,11 +647,14 @@ public final class SessionRuntimeWorker { /// Without the monitor the backstop check still finds arrivals, only later. private func startCapsArrivalMonitor() { - let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] recognised in + let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] keyboards in guard let self, !self.finished else { return } let now = Date() - capsCheckSchedule.keyboardArrived(at: now, recognised: recognised) - if capsArrivalAt == nil { capsArrivalAt = now } + capsCheckSchedule.keyboardArrived(at: now, keyboards: keyboards) + // A stale arrival (no check succeeded since) must not time a new one. + if !keyboards.isEmpty, capsArrivalAt.map({ now >= $0.addingTimeInterval(Self.arrivalAttributionLimit) }) ?? true { + capsArrivalAt = now + } } if monitor.start() { capsArrivalMonitor = monitor @@ -700,7 +703,7 @@ public final class SessionRuntimeWorker { private func checkCapsFleetIfNeeded() { // Before the guard: a pending arrival must not keep the check due every tick. - capsCheckSchedule.checkStarted() + let arrived = capsCheckSchedule.checkStarted() guard let owner = capsOwner, let directory = capsDirectory, let configPath = capsConfigPath, let admittedDigest = capsAdmittedDigest else { return } capsCheckInFlight = true @@ -711,15 +714,21 @@ public final class SessionRuntimeWorker { DispatchQueue.global(qos: .utility).async { [weak self] in let digest = try? SessionCapsRuntimeSupport.configSHA256(configPath) let result: Result<(records: [SessionCapsMappingPolicy.Record], failures: [UInt64: String], - eligible: Int, withheld: Int, present: Set, waiting: Int), Error> + eligible: Int, withheld: Int, departed: Set, waiting: Int, + resolved: Set), Error> do { guard digest == nil || digest == admittedDigest else { throw SessionCapsRuntimeSupport.Refusal.configIdentity } let excluded = SessionCapsRuntimeSupport.excludedKeyboardIDs() let inventory: (keyboards: [SessionCapsHIDUtilTransport.Keyboard], withheld: Int) - do { inventory = try SessionCapsHIDUtilTransport.keyboards() } - catch { throw CapsInventoryFailure.unavailable } + var listing = "" + do { + inventory = try SessionCapsHIDUtilTransport.keyboards(run: { + listing = try SessionCapsHIDUtilTransport.runHIDUtil($0) + return listing + }) + } catch { throw CapsInventoryFailure.unavailable } let devices = inventory.keyboards.map(\.device) .filter { !excluded.contains(SessionCapsRuntimeSupport.preferenceID($0)) } let now = Date() @@ -737,8 +746,16 @@ public final class SessionRuntimeWorker { // Eligible keyboards still unmapped that a later check may admit. let mapped = Set(coverage.records.map(\.device.registryEntryID)) let waiting = retryable.count { !mapped.contains($0.registryEntryID) && coverage.failures[$0.registryEntryID] == nil } + let exists = { (id: UInt64) in try? SessionCapsHIDUtilTransport.registryEntryExists(id) } + let resolved = SessionCapsCheckSchedule.resolved( + arrived, listed: (try? SessionCapsHIDUtilTransport.parseServiceRows(listing)) ?? [], exists: exists + ) + // A backoff is dropped only once its keyboard is gone, not when one + // listing happens to miss it. + let listed = Set(inventory.keyboards.map(\.device.registryEntryID)) + let departed = Set(retryAfter.keys.filter { !listed.contains($0) && exists($0) == false }) result = .success((coverage.records, coverage.failures, devices.count, inventory.withheld, - Set(devices.map(\.registryEntryID)), waiting)) + departed, waiting, resolved)) } catch { result = .failure(error) } @@ -767,8 +784,11 @@ public final class SessionRuntimeWorker { self.capsFleetRetryAfter[id] = Date().addingTimeInterval(30) } // A departed keyboard's backoff would otherwise linger forever. - self.capsFleetRetryAfter = self.capsFleetRetryAfter.filter { coverage.present.contains($0.key) } - self.capsCheckSchedule.checkFinished(at: Date(), waiting: coverage.waiting) + for id in coverage.departed where coverage.failures[id] == nil { + self.capsFleetRetryAfter.removeValue(forKey: id) + } + self.capsCheckSchedule.checkFinished(at: Date(), resolved: coverage.resolved, + waiting: coverage.waiting) let nextCoverage = SessionRuntimeReport.CapsCoverage( eligible: coverage.eligible, covered: coverage.records.count, withheld: coverage.withheld ) diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index a8f755c91..4a2e8afca 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -6,6 +6,13 @@ import XCTest final class SessionCapsCheckScheduleTests: XCTestCase { private let start = Date(timeIntervalSince1970: 1_000_000) + private let usb = SessionCapsArrivedService(registryEntryID: 42, locationID: 0x0110_0000) + + private func row(_ id: UInt64, location: UInt32?) -> SessionCapsHIDUtilTransport.ServiceRow { + .init(registryEntryID: id, vendorID: 0x1234, productID: 1, locationID: location, serialNumber: nil, + primaryUsagePage: 1, primaryUsage: 6) + } + func testWithoutArrivalsTheBackstopRunsTwoSecondsAfterTheLastCheckStarted() { let schedule = SessionCapsCheckSchedule() // A slow check (0.6 s) does not stretch the backstop: it is measured between starts. @@ -17,64 +24,118 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testAnArrivalMakesTheNextCheckDueAtOnce() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start.addingTimeInterval(0.01), recognised: true) + schedule.keyboardArrived(at: start.addingTimeInterval(0.01), keyboards: [usb]) XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.01), lastStart: start, lastEnd: start)) } - func testAnUnrecognisedArrivalEarnsOneCheckButNoFastWindow() { + func testAnUnrecognisedOrVirtualArrivalEarnsOneCheckButNoFastWindow() { + for keyboards in [[], [SessionCapsArrivedService(registryEntryID: 7, locationID: 0)]] { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(at: start, keyboards: keyboards) + XCTAssertTrue(schedule.isDue(now: start, lastStart: start, lastEnd: start)) + XCTAssertTrue(schedule.checkStarted().isEmpty) + + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.5), lastStart: start, + lastEnd: start.addingTimeInterval(0.1))) + } + } + + func testAKeyboardHidutilHasNotListedYetKeepsChecksFast() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, recognised: false) - XCTAssertTrue(schedule.isDue(now: start, lastStart: start, lastEnd: start)) - schedule.checkStarted() + schedule.keyboardArrived(at: start, keyboards: [usb]) + XCTAssertEqual(schedule.checkStarted(), [usb]) + // The first listing does not include the new service yet. + let firstEnd = start.addingTimeInterval(0.1) + schedule.checkFinished(at: firstEnd, resolved: [], waiting: 0) + XCTAssertFalse(schedule.isDue(now: firstEnd.addingTimeInterval(0.2), lastStart: start, lastEnd: firstEnd)) + XCTAssertTrue(schedule.isDue(now: firstEnd.addingTimeInterval(0.25), lastStart: start, lastEnd: firstEnd)) + + // The next listing includes it and the keyboard is mapped: back to the backstop. + let second = firstEnd.addingTimeInterval(0.25) + XCTAssertEqual(schedule.checkStarted(), [usb]) + schedule.checkFinished(at: second.addingTimeInterval(0.1), resolved: [usb], waiting: 0) + XCTAssertFalse(schedule.isDue(now: second.addingTimeInterval(1), lastStart: second, + lastEnd: second.addingTimeInterval(0.1))) + XCTAssertNil(schedule.arrivedAt) + } + + func testAWakeThatReRegistersListedKeyboardsCostsOneCheck() { + var schedule = SessionCapsCheckSchedule() + let builtIn = SessionCapsArrivedService(registryEntryID: 9, locationID: 0x0200_0000) + schedule.keyboardArrived(at: start, keyboards: [builtIn, usb]) + let arrived = schedule.checkStarted() + let resolved = SessionCapsCheckSchedule.resolved( + arrived, listed: [row(9, location: 0x0200_0000), row(42, location: 0x0110_0000)], exists: { _ in true } + ) + schedule.checkFinished(at: start.addingTimeInterval(0.3), resolved: resolved, waiting: 0) - XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.5), lastStart: start, lastEnd: start.addingTimeInterval(0.1))) + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1), lastStart: start, + lastEnd: start.addingTimeInterval(0.3))) } func testFastChecksAreSpacedFromTheLastCheckFinishing() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, recognised: true) - schedule.checkStarted() + schedule.keyboardArrived(at: start, keyboards: [usb]) + _ = schedule.checkStarted() let lastEnd = start.addingTimeInterval(0.4) + schedule.checkFinished(at: lastEnd, resolved: [], waiting: 0) XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.6), lastStart: start, lastEnd: lastEnd)) XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.65), lastStart: start, lastEnd: lastEnd)) } - func testAWakeThatFindsEveryKeyboardCoveredCostsOneCheck() { - var schedule = SessionCapsCheckSchedule() - // Wake re-registers every keyboard's event service. - schedule.keyboardArrived(at: start, recognised: true) - schedule.checkStarted() - schedule.checkFinished(at: start.addingTimeInterval(0.3), waiting: 0) - - XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1), lastStart: start, lastEnd: start.addingTimeInterval(0.3))) - XCTAssertNil(schedule.arrivedAt) - } - func testAWaitingKeyboardKeepsChecksFastUntilTheWaitLimit() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, recognised: true) - schedule.checkStarted() - // Keys held at plug-in: the keyboard stays unmapped past the 3 s window. + schedule.keyboardArrived(at: start, keyboards: [usb]) + _ = schedule.checkStarted() + // Keys held at plug-in: listed, but not admitted yet. let late = start.addingTimeInterval(10) - schedule.checkFinished(at: late, waiting: 1) + schedule.checkFinished(at: late, resolved: [usb], waiting: 1) XCTAssertTrue(schedule.isDue(now: late.addingTimeInterval(0.25), lastStart: late, lastEnd: late)) let expired = start.addingTimeInterval(SessionCapsCheckSchedule.waitLimit) - schedule.checkFinished(at: expired, waiting: 1) + schedule.checkFinished(at: expired, resolved: [], waiting: 1) XCTAssertFalse(schedule.isDue(now: expired.addingTimeInterval(0.5), lastStart: expired, lastEnd: expired)) } + func testAStaleArrivalDoesNotShortenANewOnesWindow() { + var schedule = SessionCapsCheckSchedule() + // No check succeeded after this arrival (hidutil unavailable). + schedule.keyboardArrived(at: start, keyboards: [SessionCapsArrivedService(registryEntryID: 5, locationID: 1)]) + let later = start.addingTimeInterval(120) + schedule.keyboardArrived(at: later, keyboards: [usb]) + + XCTAssertEqual(schedule.arrivedAt, later) + XCTAssertEqual(schedule.unlisted, [usb]) + } + func testAnArrivalDuringACheckIsNotLostWhenThatCheckStarted() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, recognised: true) - schedule.checkStarted() + schedule.keyboardArrived(at: start, keyboards: [usb]) + _ = schedule.checkStarted() // A second keyboard arrives while the first check is still in flight. - schedule.keyboardArrived(at: start.addingTimeInterval(0.05), recognised: true) + let second = SessionCapsArrivedService(registryEntryID: 43, locationID: 0x0120_0000) + schedule.keyboardArrived(at: start.addingTimeInterval(0.05), keyboards: [second]) + schedule.checkFinished(at: start.addingTimeInterval(0.1), resolved: [usb], waiting: 0) XCTAssertTrue(schedule.arrivalPending) - XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.06), lastStart: start, lastEnd: start)) + XCTAssertEqual(schedule.unlisted, [second]) + } + + func testAnArrivedServiceIsResolvedByItsIDItsLocationOrItsDeparture() { + let secondInterface = SessionCapsArrivedService(registryEntryID: 50, locationID: 0x0300_0000) + let unplugged = SessionCapsArrivedService(registryEntryID: 60, locationID: 0x0400_0000) + let lagging = SessionCapsArrivedService(registryEntryID: 70, locationID: 0x0500_0000) + let unreadable = SessionCapsArrivedService(registryEntryID: 80, locationID: nil) + + let resolved = SessionCapsCheckSchedule.resolved( + [usb, secondInterface, unplugged, lagging, unreadable], + // The shared NKRO interface is not listed itself, but its board's primary one is. + listed: [row(42, location: 0x0110_0000), row(49, location: 0x0300_0000), row(1, location: nil)], + exists: { $0 != 60 && $0 != 80 ? true : ($0 == 60 ? false : nil) } + ) + XCTAssertEqual(resolved, [usb, secondInterface, unplugged]) } @MainActor @@ -92,19 +153,24 @@ final class SessionCapsCheckScheduleTests: XCTestCase { @MainActor func testArrivalClassificationFailsOpen() { let answers: [UInt64: Bool?] = [1: true, 2: false, 3: nil] - var arrivals: [Bool] = [] - let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { answers[$0] ?? false }) { arrivals.append($0) } + var arrivals: [[UInt64]] = [] + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { answers[$0] ?? false }) { + arrivals.append($0.map(\.registryEntryID)) + } + func service(_ id: UInt64) -> SessionCapsArrivedService { + .init(registryEntryID: id, locationID: 1) + } - monitor.reportArrivals([2]) + monitor.reportArrivals([service(2)]) XCTAssertEqual(arrivals, [], "a readable non-keyboard service (a mouse) is not an arrival") - monitor.reportArrivals([1]) - XCTAssertEqual(arrivals, [true]) - monitor.reportArrivals([3]) - XCTAssertEqual(arrivals, [true, false], "an unreadable capability still schedules an unrecognised check") - monitor.reportArrivals([0]) - XCTAssertEqual(arrivals, [true, false, false], "an unreadable registry ID still schedules a check") - monitor.reportArrivals([2, 1, 3]) - XCTAssertEqual(arrivals.last, true, "one notification batch is one arrival, recognised if any service is") + monitor.reportArrivals([service(1)]) + XCTAssertEqual(arrivals, [[1]]) + monitor.reportArrivals([service(3)]) + XCTAssertEqual(arrivals.last, [], "an unreadable capability still schedules a check, with nothing to look for") + monitor.reportArrivals([service(0)]) + XCTAssertEqual(arrivals.last, [], "an unreadable registry ID still schedules a check") + monitor.reportArrivals([service(2), service(1), service(3)]) + XCTAssertEqual(arrivals.last, [1], "one notification batch is one arrival; only keyboards are looked for") XCTAssertEqual(arrivals.count, 4) } @@ -125,8 +191,11 @@ final class SessionCapsCheckScheduleTests: XCTestCase { keyboards += 1 } } - // Without an attached keyboard there is nothing to recognise (a headless runner). - try XCTSkipIf(keyboards == 0, "no keyboard attached to this machine") + // hidutil's own listing decides whether a keyboard is attached, so a + // predicate that stops recognising keyboards fails instead of skipping. + let attached = (try? SessionCapsHIDUtilTransport.keyboards().keyboards.count) ?? 0 + try XCTSkipIf(attached == 0, "no keyboard attached to this machine (a headless runner)") + XCTAssertGreaterThan(keyboards, 0, "hidutil lists \(attached) keyboard(s), but none reads as one") } @MainActor diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 9f94f66a9..771f595a0 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -196,14 +196,17 @@ single-keyboard record remains readable for recovery. **Hot-plug.** `SessionCapsKeyboardArrivalMonitor` listens for `IOHIDEventService` registrations (first-match notifications; services present at startup are ignored) and classifies each with the lease's own `serviceEmitsKeyboardUsages`, so a board's -shared NKRO interface counts. An unreadable capability still earns one immediate -check: an extra inventory read is cheap, a missed arrival is not. Only a recognised -keyboard opens a fast window (`SessionCapsCheckSchedule`): checks then run 0.25 s -after the previous one finishes, because hidutil can list a new service a moment -after IOKit reports it. The window ends as soon as a check finds no eligible keyboard -waiting to be mapped. While one is waiting (for example, keys held at plug-in), it -continues for up to 30 s after the arrival. A wake re-registers every keyboard's -service, so it costs one check, and nothing more if every keyboard is still covered. +shared NKRO interface counts. Every arrival earns one immediate check: an +unreadable capability counts too, since an extra inventory read is cheap and a missed +arrival is not. A recognised, non-virtual keyboard also opens a fast window +(`SessionCapsCheckSchedule`): checks run 0.25 s after the previous one finishes until +hidutil lists the arrived service (under its own registry ID, or as another interface +at the same LocationID) or the service is gone, because hidutil can list a new service +a moment after IOKit reports it. Checks also stay fast while an eligible keyboard is +waiting to be mapped (for example, keys held at plug-in). Either way the window closes +30 s after the arrival. A wake re-registers services hidutil already lists, so it costs +one check. A press immediately after plugging in can still come before the keyboard is +mapped; the `arrival mapped in N ms` log measures that window. The two-second backstop is measured between check starts, so a slow check doesn't stretch it, and covers everything if the notification cannot be registered. The worker logs `[SessionCaps] Keyboard covered` for every newly covered keyboard, and From afe4e4f13a3692f2e73e42a0cdcc7afb523fb379 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 09:20:31 -0700 Subject: [PATCH 05/16] Time only tracked arrivals; classify arrivals off the tap's run loop A virtual keyboard no longer starts the arrival-to-mapped clock, so the log a replug test reads is not inflated. Classification walks the registry and can parse report descriptors, so it now runs on a background queue rather than on the run loop the event tap uses. A failed check hands over to the backstop instead of retrying hidutil every 0.25 s. The design doc no longer describes the arrival path as deployed. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 48 +++++++++++++++---- .../SessionRuntime/SessionRuntimeWorker.swift | 17 +++++-- .../SessionCapsCheckScheduleTests.swift | 36 ++++++++++++++ docs/architecture/all-keyboards-caps.md | 9 ++-- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index 1a393f4f9..b05ba8be8 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -2,7 +2,7 @@ import Foundation import IOKit /// A keyboard event service IOKit has just registered. -struct SessionCapsArrivedService: Hashable { +struct SessionCapsArrivedService: Hashable, Sendable { let registryEntryID: UInt64 /// Nil when unreadable. A LocationID of 0 marks a virtual keyboard. let locationID: UInt32? @@ -30,15 +30,18 @@ struct SessionCapsCheckSchedule: Equatable { /// Every arrival gets an immediate check. Only a service that reads as a /// keyboard and is not virtual can keep checks fast until hidutil lists it. - mutating func keyboardArrived(at now: Date, keyboards: [SessionCapsArrivedService]) { + /// Returns whether the arrival is tracked, which is what may be timed. + @discardableResult + mutating func keyboardArrived(at now: Date, keyboards: [SessionCapsArrivedService]) -> Bool { arrivalPending = true let real = keyboards.filter { $0.locationID != 0 } - guard !real.isEmpty else { return } + guard !real.isEmpty else { return false } if let arrivedAt, now < arrivedAt.addingTimeInterval(Self.waitLimit) {} else { reset() arrivedAt = now } unlisted.formUnion(real) + return true } func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { @@ -68,6 +71,12 @@ struct SessionCapsCheckSchedule: Equatable { if !isFast(now: now) { reset() } } + /// A check that produced no answer neither confirms nor refutes an arrival. + /// Fast retries would only spawn hidutil again, so the backstop takes over. + mutating func checkFailed() { + reset() + } + private mutating func reset() { unlisted = [] keyboardWaiting = false @@ -96,14 +105,14 @@ struct SessionCapsCheckSchedule: Equatable { @MainActor final class SessionCapsKeyboardArrivalMonitor { private let onArrival: (_ keyboards: [SessionCapsArrivedService]) -> Void - private let emitsKeyboardUsages: (UInt64) -> Bool? + private let emitsKeyboardUsages: @Sendable (UInt64) -> Bool? private var port: IONotificationPortRef? private var iterator: io_iterator_t = 0 private var source: CFRunLoopSource? /// `emitsKeyboardUsages` is the same predicate the lease uses to group a /// keyboard's interfaces; injectable for tests. - init(emitsKeyboardUsages: @escaping (UInt64) -> Bool? = SessionCapsHIDUtilTransport.serviceEmitsKeyboardUsages, + init(emitsKeyboardUsages: @escaping @Sendable (UInt64) -> Bool? = SessionCapsHIDUtilTransport.serviceEmitsKeyboardUsages, onArrival: @escaping (_ keyboards: [SessionCapsArrivedService]) -> Void) { self.emitsKeyboardUsages = emitsKeyboardUsages @@ -162,14 +171,37 @@ final class SessionCapsKeyboardArrivalMonitor { services.append(.init(registryEntryID: readable ? id : 0, locationID: location.flatMap { UInt32(exactly: $0.uint64Value) })) } - if report { reportArrivals(services) } + guard report, !services.isEmpty else { return } + // Classifying can walk the registry and parse report descriptors, once per + // service in a batch. Keep that off the run loop the event tap shares. + let classify = emitsKeyboardUsages + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let answers = Self.classify(services, with: classify) + CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) { [weak self] in + MainActor.assumeIsolated { + guard let self, self.port != nil else { return } + self.deliver(services, answers) + } + } + CFRunLoopWakeUp(CFRunLoopGetMain()) + } + } + + private nonisolated static func classify(_ services: [SessionCapsArrivedService], + with emitsKeyboardUsages: (UInt64) -> Bool?) -> [Bool?] + { + services.map { $0.registryEntryID == 0 ? nil : emitsKeyboardUsages($0.registryEntryID) } + } + + /// Classifies on the caller's thread; for tests. + func reportArrivals(_ services: [SessionCapsArrivedService]) { + deliver(services, Self.classify(services, with: emitsKeyboardUsages)) } /// Fails open: a service whose keyboard capability can't be read yet still /// earns one pulled-forward check, so the keyboard isn't left for the backstop. /// Only services that read as keyboards are passed on, to be looked for. - func reportArrivals(_ services: [SessionCapsArrivedService]) { - let answers = services.map { $0.registryEntryID == 0 ? nil : emitsKeyboardUsages($0.registryEntryID) } + private func deliver(_ services: [SessionCapsArrivedService], _ answers: [Bool?]) { guard answers.contains(where: { $0 != false }) else { return } onArrival(zip(services, answers).filter { $0.1 == true }.map(\.0)) } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index c3fd9d7d7..f869f8ffc 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -629,6 +629,15 @@ public final class SessionRuntimeWorker { /// plugged in with keys held to be mapped once they are released. static let arrivalAttributionLimit = SessionCapsCheckSchedule.waitLimit + /// The arrival a later mapping is timed from. Only an arrival the schedule + /// tracks starts the clock (a virtual keyboard does not), and a stale one, + /// never followed by a successful check, does not time a new one. + static func arrivalStamp(current: Date?, now: Date, tracked: Bool) -> Date? { + guard tracked else { return current } + guard let current, now < current.addingTimeInterval(arrivalAttributionLimit) else { return now } + return current + } + /// Attributes newly covered keyboards to the pending arrival. A keyboard that /// was backing off after a failed write is recovering, not arriving. static func arrivalOutcome(arrivedAt: Date?, now: Date, added: Set, @@ -650,11 +659,8 @@ public final class SessionRuntimeWorker { let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] keyboards in guard let self, !self.finished else { return } let now = Date() - capsCheckSchedule.keyboardArrived(at: now, keyboards: keyboards) - // A stale arrival (no check succeeded since) must not time a new one. - if !keyboards.isEmpty, capsArrivalAt.map({ now >= $0.addingTimeInterval(Self.arrivalAttributionLimit) }) ?? true { - capsArrivalAt = now - } + let tracked = capsCheckSchedule.keyboardArrived(at: now, keyboards: keyboards) + capsArrivalAt = Self.arrivalStamp(current: capsArrivalAt, now: now, tracked: tracked) } if monitor.start() { capsArrivalMonitor = monitor @@ -807,6 +813,7 @@ public final class SessionRuntimeWorker { if let line = arrival.log { AppLogger.shared.log(line) } if !arrival.keepPending { self.capsArrivalAt = nil } case let .failure(error): + self.capsCheckSchedule.checkFailed() if case SessionCapsMappingLease.Refusal.busy = error { return } if error is CapsInventoryFailure { self.capsFleetReadFailures += 1 diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index 4a2e8afca..a70d757fc 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -110,6 +110,42 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertEqual(schedule.unlisted, [usb]) } + func testAFailedCheckHandsOverToTheBackstop() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(at: start, keyboards: [usb]) + _ = schedule.checkStarted() + schedule.checkFailed() + + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1), lastStart: start, + lastEnd: start.addingTimeInterval(0.1))) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(2), lastStart: start, + lastEnd: start.addingTimeInterval(0.1))) + } + + @MainActor + func testOnlyATrackedArrivalStartsTheMappingClock() { + var schedule = SessionCapsCheckSchedule() + let virtual = SessionCapsArrivedService(registryEntryID: 7, locationID: 0) + // A virtual keyboard (Karabiner, a VM's platform keyboard) is not timed. + let afterVirtual = SessionRuntimeWorker.arrivalStamp( + current: nil, now: start, tracked: schedule.keyboardArrived(at: start, keyboards: [virtual]) + ) + XCTAssertNil(afterVirtual) + + let plugged = start.addingTimeInterval(5) + let afterUSB = SessionRuntimeWorker.arrivalStamp( + current: afterVirtual, now: plugged, tracked: schedule.keyboardArrived(at: plugged, keyboards: [usb]) + ) + XCTAssertEqual(afterUSB, plugged) + + // A second keyboard during the same window keeps the first arrival's clock. + let next = plugged.addingTimeInterval(1) + XCTAssertEqual(SessionRuntimeWorker.arrivalStamp(current: afterUSB, now: next, tracked: true), plugged) + // A stale stamp, never followed by a successful check, is replaced. + let much = plugged.addingTimeInterval(SessionRuntimeWorker.arrivalAttributionLimit) + XCTAssertEqual(SessionRuntimeWorker.arrivalStamp(current: afterUSB, now: much, tracked: true), much) + } + func testAnArrivalDuringACheckIsNotLostWhenThatCheckStarted() { var schedule = SessionCapsCheckSchedule() schedule.keyboardArrived(at: start, keyboards: [usb]) diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 771f595a0..7e29b1852 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -1,8 +1,10 @@ # Caps Lock rules on every keyboard **Status:** deployed on the mini and Air, 2026-10-24. The worker applies one -crash-recoverable mapping per eligible keyboard. Since 2026-10-25 it reacts to a -keyboard arriving (an IOKit notification) instead of waiting for its two-second check. Settings reports uncovered keyboards and offers per-keyboard exclusions. +crash-recoverable mapping per eligible keyboard. Settings reports uncovered keyboards +and offers per-keyboard exclusions. Reacting to a keyboard arriving (an IOKit +notification) instead of waiting for the two-second check is in review and not yet +deployed (see Hot-plug below). The original spike and remaining questions below remain the qualification record; the opt-in `SessionCapsMappingFleetTests/testLiveAttachedKeyboardsAcquireAndRestore` passed on three attached USB keyboard services, including the physical HID @@ -204,7 +206,8 @@ hidutil lists the arrived service (under its own registry ID, or as another inte at the same LocationID) or the service is gone, because hidutil can list a new service a moment after IOKit reports it. Checks also stay fast while an eligible keyboard is waiting to be mapped (for example, keys held at plug-in). Either way the window closes -30 s after the arrival. A wake re-registers services hidutil already lists, so it costs +30 s after the arrival, and a check that fails hands over to the backstop rather than +retrying hidutil quickly. Classification runs off the event tap's run loop. A wake re-registers services hidutil already lists, so it costs one check. A press immediately after plugging in can still come before the keyboard is mapped; the `arrival mapped in N ms` log measures that window. The two-second backstop is measured between check starts, so a slow check doesn't From 6b567200a7233ef8c3dc123c017f0791cdf7bf9d Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 09:38:45 -0700 Subject: [PATCH 06/16] Cap fast checks after an arrival by count, not by time Each check spawns several hidutil processes. A 30 s fast window could keep that near-continuous, and a service hidutil never lists held it open the whole time. An arrival now earns up to six fast checks while its keyboard is unlisted, then the backstop takes over. Checks no longer stay fast while keys are held. Also: - the backoff-pruning rule is extracted and tested - the live predicate test is opt-in (KEYPATH_LIVE_CAPS_FLEET=1) - the guide no longer claims Settings shows a reason Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 46 ++++-------- .../SessionRuntime/SessionRuntimeWorker.swift | 25 ++++--- .../SessionCapsCheckScheduleTests.swift | 70 ++++++++++--------- docs/architecture/all-keyboards-caps.md | 16 ++--- guides/driverless-caps-lock.md | 3 +- 5 files changed, 75 insertions(+), 85 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index b05ba8be8..cd680796a 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -9,52 +9,43 @@ struct SessionCapsArrivedService: Hashable, Sendable { } /// When the worker next reconciles its Caps fleet. An arrival pulls one check -/// forward. Checks then stay fast while an arrived keyboard is not yet listed by -/// hidutil, or an eligible keyboard is waiting to be mapped, so a wake that -/// re-registers already-listed keyboards costs a single check. +/// forward. If that check's listing doesn't show the arrived keyboard yet, a few +/// more follow quickly, then the backstop takes over. A wake that re-registers +/// already-listed keyboards costs a single check. struct SessionCapsCheckSchedule: Equatable { /// The steady backstop, measured between check starts. static let backstop: TimeInterval = 2 /// The pause after each fast check finishes. hidutil can list a new event /// service a moment after IOKit reports it. static let afterArrival: TimeInterval = 0.25 - /// The longest an arrival keeps checks fast, for example while keys held at - /// plug-in block admission. - static let waitLimit: TimeInterval = 30 + /// Fast checks an arrival may use while its keyboard is unlisted. Each check + /// spawns several hidutil processes, so this is a count, not a duration. + static let fastChecks = 6 private(set) var arrivalPending = false /// Arrived keyboards hidutil has not listed yet. private(set) var unlisted: Set = [] - private(set) var keyboardWaiting = false - private(set) var arrivedAt: Date? + private(set) var fastChecksLeft = 0 /// Every arrival gets an immediate check. Only a service that reads as a - /// keyboard and is not virtual can keep checks fast until hidutil lists it. + /// keyboard and is not virtual can earn fast checks until hidutil lists it. /// Returns whether the arrival is tracked, which is what may be timed. @discardableResult - mutating func keyboardArrived(at now: Date, keyboards: [SessionCapsArrivedService]) -> Bool { + mutating func keyboardArrived(at _: Date, keyboards: [SessionCapsArrivedService]) -> Bool { arrivalPending = true let real = keyboards.filter { $0.locationID != 0 } guard !real.isEmpty else { return false } - if let arrivedAt, now < arrivedAt.addingTimeInterval(Self.waitLimit) {} else { - reset() - arrivedAt = now - } unlisted.formUnion(real) + fastChecksLeft = Self.fastChecks return true } func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { if arrivalPending { return true } - if isFast(now: now) { return now.timeIntervalSince(lastEnd) >= Self.afterArrival } + if !unlisted.isEmpty, fastChecksLeft > 0 { return now.timeIntervalSince(lastEnd) >= Self.afterArrival } return now.timeIntervalSince(lastStart) >= Self.backstop } - private func isFast(now: Date) -> Bool { - guard let arrivedAt, now < arrivedAt.addingTimeInterval(Self.waitLimit) else { return false } - return !unlisted.isEmpty || keyboardWaiting - } - /// A check is starting; it covers every arrival reported so far. Returns the /// arrived keyboards it should look for in its listing. mutating func checkStarted() -> Set { @@ -63,24 +54,17 @@ struct SessionCapsCheckSchedule: Equatable { } /// `resolved`: arrived keyboards the check found listed, or found gone. - /// `waiting`: eligible keyboards it left unmapped that may still be admitted - /// (not backing off after a failed write). - mutating func checkFinished(at now: Date, resolved: Set, waiting: Int) { + mutating func checkFinished(resolved: Set) { unlisted.subtract(resolved) - keyboardWaiting = waiting > 0 - if !isFast(now: now) { reset() } + fastChecksLeft = max(0, fastChecksLeft - 1) + if fastChecksLeft == 0 { unlisted = [] } } /// A check that produced no answer neither confirms nor refutes an arrival. /// Fast retries would only spawn hidutil again, so the backstop takes over. mutating func checkFailed() { - reset() - } - - private mutating func reset() { unlisted = [] - keyboardWaiting = false - arrivedAt = nil + fastChecksLeft = 0 } /// The arrived keyboards a listing accounts for: listed under their own ID or diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index f869f8ffc..f5f23e973 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -627,7 +627,15 @@ public final class SessionRuntimeWorker { /// How long an arrival waits to be attributed. Long enough for a keyboard /// plugged in with keys held to be mapped once they are released. - static let arrivalAttributionLimit = SessionCapsCheckSchedule.waitLimit + static let arrivalAttributionLimit: TimeInterval = 30 + + /// Keyboards whose failure backoff can be dropped: gone from the registry, + /// not merely missing from one listing. + static func departedBackoffs(_ backingOff: Set, listed: Set, + exists: (UInt64) -> Bool?) -> Set + { + backingOff.filter { !listed.contains($0) && exists($0) == false } + } /// The arrival a later mapping is timed from. Only an arrival the schedule /// tracks starts the clock (a virtual keyboard does not), and a stale one, @@ -720,7 +728,7 @@ public final class SessionRuntimeWorker { DispatchQueue.global(qos: .utility).async { [weak self] in let digest = try? SessionCapsRuntimeSupport.configSHA256(configPath) let result: Result<(records: [SessionCapsMappingPolicy.Record], failures: [UInt64: String], - eligible: Int, withheld: Int, departed: Set, waiting: Int, + eligible: Int, withheld: Int, departed: Set, resolved: Set), Error> do { guard digest == nil || digest == admittedDigest else { @@ -749,19 +757,17 @@ public final class SessionRuntimeWorker { bootSessionUUID: owner.bootSessionUUID, allowNewDevices: SessionCapsRuntimeSupport.heldKeys().isEmpty ) - // Eligible keyboards still unmapped that a later check may admit. - let mapped = Set(coverage.records.map(\.device.registryEntryID)) - let waiting = retryable.count { !mapped.contains($0.registryEntryID) && coverage.failures[$0.registryEntryID] == nil } let exists = { (id: UInt64) in try? SessionCapsHIDUtilTransport.registryEntryExists(id) } let resolved = SessionCapsCheckSchedule.resolved( arrived, listed: (try? SessionCapsHIDUtilTransport.parseServiceRows(listing)) ?? [], exists: exists ) // A backoff is dropped only once its keyboard is gone, not when one // listing happens to miss it. - let listed = Set(inventory.keyboards.map(\.device.registryEntryID)) - let departed = Set(retryAfter.keys.filter { !listed.contains($0) && exists($0) == false }) + let departed = Self.departedBackoffs( + Set(retryAfter.keys), listed: Set(inventory.keyboards.map(\.device.registryEntryID)), exists: exists + ) result = .success((coverage.records, coverage.failures, devices.count, inventory.withheld, - departed, waiting, resolved)) + departed, resolved)) } catch { result = .failure(error) } @@ -793,8 +799,7 @@ public final class SessionRuntimeWorker { for id in coverage.departed where coverage.failures[id] == nil { self.capsFleetRetryAfter.removeValue(forKey: id) } - self.capsCheckSchedule.checkFinished(at: Date(), resolved: coverage.resolved, - waiting: coverage.waiting) + self.capsCheckSchedule.checkFinished(resolved: coverage.resolved) let nextCoverage = SessionRuntimeReport.CapsCoverage( eligible: coverage.eligible, covered: coverage.records.count, withheld: coverage.withheld ) diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index a70d757fc..651fea22b 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -29,7 +29,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.01), lastStart: start, lastEnd: start)) } - func testAnUnrecognisedOrVirtualArrivalEarnsOneCheckButNoFastWindow() { + func testAnUnrecognisedOrVirtualArrivalEarnsOneCheckButNoFastChecks() { for keyboards in [[], [SessionCapsArrivedService(registryEntryID: 7, locationID: 0)]] { var schedule = SessionCapsCheckSchedule() schedule.keyboardArrived(at: start, keyboards: keyboards) @@ -41,23 +41,37 @@ final class SessionCapsCheckScheduleTests: XCTestCase { } } - func testAKeyboardHidutilHasNotListedYetKeepsChecksFast() { + func testAKeyboardHidutilHasNotListedYetGetsFastChecks() { var schedule = SessionCapsCheckSchedule() schedule.keyboardArrived(at: start, keyboards: [usb]) XCTAssertEqual(schedule.checkStarted(), [usb]) // The first listing does not include the new service yet. let firstEnd = start.addingTimeInterval(0.1) - schedule.checkFinished(at: firstEnd, resolved: [], waiting: 0) + schedule.checkFinished(resolved: []) XCTAssertFalse(schedule.isDue(now: firstEnd.addingTimeInterval(0.2), lastStart: start, lastEnd: firstEnd)) XCTAssertTrue(schedule.isDue(now: firstEnd.addingTimeInterval(0.25), lastStart: start, lastEnd: firstEnd)) - // The next listing includes it and the keyboard is mapped: back to the backstop. + // The next listing includes it: back to the backstop. let second = firstEnd.addingTimeInterval(0.25) XCTAssertEqual(schedule.checkStarted(), [usb]) - schedule.checkFinished(at: second.addingTimeInterval(0.1), resolved: [usb], waiting: 0) + schedule.checkFinished(resolved: [usb]) XCTAssertFalse(schedule.isDue(now: second.addingTimeInterval(1), lastStart: second, lastEnd: second.addingTimeInterval(0.1))) - XCTAssertNil(schedule.arrivedAt) + } + + func testFastChecksAreCappedByCountForAServiceHidutilNeverLists() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(at: start, keyboards: [usb]) + var now = start + var checks = 0 + while schedule.isDue(now: now, lastStart: now, lastEnd: now.addingTimeInterval(-1)), checks < 100 { + _ = schedule.checkStarted() + schedule.checkFinished(resolved: []) + checks += 1 + now = now.addingTimeInterval(0.3) + } + XCTAssertEqual(checks, SessionCapsCheckSchedule.fastChecks) + XCTAssertTrue(schedule.unlisted.isEmpty) } func testAWakeThatReRegistersListedKeyboardsCostsOneCheck() { @@ -68,7 +82,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { let resolved = SessionCapsCheckSchedule.resolved( arrived, listed: [row(9, location: 0x0200_0000), row(42, location: 0x0110_0000)], exists: { _ in true } ) - schedule.checkFinished(at: start.addingTimeInterval(0.3), resolved: resolved, waiting: 0) + schedule.checkFinished(resolved: resolved) XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1), lastStart: start, lastEnd: start.addingTimeInterval(0.3))) @@ -79,37 +93,12 @@ final class SessionCapsCheckScheduleTests: XCTestCase { schedule.keyboardArrived(at: start, keyboards: [usb]) _ = schedule.checkStarted() let lastEnd = start.addingTimeInterval(0.4) - schedule.checkFinished(at: lastEnd, resolved: [], waiting: 0) + schedule.checkFinished(resolved: []) XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.6), lastStart: start, lastEnd: lastEnd)) XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.65), lastStart: start, lastEnd: lastEnd)) } - func testAWaitingKeyboardKeepsChecksFastUntilTheWaitLimit() { - var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, keyboards: [usb]) - _ = schedule.checkStarted() - // Keys held at plug-in: listed, but not admitted yet. - let late = start.addingTimeInterval(10) - schedule.checkFinished(at: late, resolved: [usb], waiting: 1) - XCTAssertTrue(schedule.isDue(now: late.addingTimeInterval(0.25), lastStart: late, lastEnd: late)) - - let expired = start.addingTimeInterval(SessionCapsCheckSchedule.waitLimit) - schedule.checkFinished(at: expired, resolved: [], waiting: 1) - XCTAssertFalse(schedule.isDue(now: expired.addingTimeInterval(0.5), lastStart: expired, lastEnd: expired)) - } - - func testAStaleArrivalDoesNotShortenANewOnesWindow() { - var schedule = SessionCapsCheckSchedule() - // No check succeeded after this arrival (hidutil unavailable). - schedule.keyboardArrived(at: start, keyboards: [SessionCapsArrivedService(registryEntryID: 5, locationID: 1)]) - let later = start.addingTimeInterval(120) - schedule.keyboardArrived(at: later, keyboards: [usb]) - - XCTAssertEqual(schedule.arrivedAt, later) - XCTAssertEqual(schedule.unlisted, [usb]) - } - func testAFailedCheckHandsOverToTheBackstop() { var schedule = SessionCapsCheckSchedule() schedule.keyboardArrived(at: start, keyboards: [usb]) @@ -122,6 +111,16 @@ final class SessionCapsCheckScheduleTests: XCTestCase { lastEnd: start.addingTimeInterval(0.1))) } + @MainActor + func testABackoffIsDroppedOnlyOnceItsKeyboardIsGone() { + // 1 is listed, 2 is missing from this listing but still registered, 3 is gone, + // 4's existence can't be read. + let departed = SessionRuntimeWorker.departedBackoffs( + [1, 2, 3, 4], listed: [1], exists: { id in id == 3 ? false : (id == 4 ? nil : true) } + ) + XCTAssertEqual(departed, [3]) + } + @MainActor func testOnlyATrackedArrivalStartsTheMappingClock() { var schedule = SessionCapsCheckSchedule() @@ -153,7 +152,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { // A second keyboard arrives while the first check is still in flight. let second = SessionCapsArrivedService(registryEntryID: 43, locationID: 0x0120_0000) schedule.keyboardArrived(at: start.addingTimeInterval(0.05), keyboards: [second]) - schedule.checkFinished(at: start.addingTimeInterval(0.1), resolved: [usb], waiting: 0) + schedule.checkFinished(resolved: [usb]) XCTAssertTrue(schedule.arrivalPending) XCTAssertEqual(schedule.unlisted, [second]) @@ -213,6 +212,9 @@ final class SessionCapsCheckScheduleTests: XCTestCase { /// The live predicate on this Mac's own event services: at least one connected /// keyboard must read as one, or the monitor would never report arrivals. func testTheLeasePredicateRecognisesAConnectedKeyboardService() throws { + // Reads this Mac's hardware, so it is opt-in like the fleet's live test. + try XCTSkipUnless(ProcessInfo.processInfo.environment["KEYPATH_LIVE_CAPS_FLEET"] == "1", + "set KEYPATH_LIVE_CAPS_FLEET=1 to check attached keyboards") var iterator: io_iterator_t = 0 guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOHIDEventService"), &iterator) == KERN_SUCCESS else { throw XCTSkip("IOKit service listing unavailable") } diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 7e29b1852..68c3fd4eb 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -200,14 +200,14 @@ registrations (first-match notifications; services present at startup are ignore and classifies each with the lease's own `serviceEmitsKeyboardUsages`, so a board's shared NKRO interface counts. Every arrival earns one immediate check: an unreadable capability counts too, since an extra inventory read is cheap and a missed -arrival is not. A recognised, non-virtual keyboard also opens a fast window -(`SessionCapsCheckSchedule`): checks run 0.25 s after the previous one finishes until -hidutil lists the arrived service (under its own registry ID, or as another interface -at the same LocationID) or the service is gone, because hidutil can list a new service -a moment after IOKit reports it. Checks also stay fast while an eligible keyboard is -waiting to be mapped (for example, keys held at plug-in). Either way the window closes -30 s after the arrival, and a check that fails hands over to the backstop rather than -retrying hidutil quickly. Classification runs off the event tap's run loop. A wake re-registers services hidutil already lists, so it costs +arrival is not. If that check's listing doesn't show a recognised, non-virtual arrived +service yet (under its own registry ID, or as another interface at the same +LocationID), up to six fast checks follow, each 0.25 s after the previous one +finishes, because hidutil can list a new service a moment after IOKit reports it. +The cap is a count because each check spawns several hidutil processes. A keyboard +plugged in with keys held, or one still not listed, is left to the backstop, and a +check that fails hands over to the backstop at once. Classification runs off the +event tap's run loop. A wake re-registers services hidutil already lists, so it costs one check. A press immediately after plugging in can still come before the keyboard is mapped; the `arrival mapped in N ms` log measures that window. The two-second backstop is measured between check starts, so a slow check doesn't diff --git a/guides/driverless-caps-lock.md b/guides/driverless-caps-lock.md index 560c0a2d4..42eab05ec 100644 --- a/guides/driverless-caps-lock.md +++ b/guides/driverless-caps-lock.md @@ -69,8 +69,7 @@ KeyPath starts setting up a keyboard when you plug it in, rather than waiting for its next periodic check. It waits until no keys are held, and a Caps Lock press in the first moment can still arrive before macOS finishes presenting the device. If Caps Lock behaves normally then, release all keys and try again. If it -still doesn't, **Settings → General** lists the keyboard as not covered, with the -reason. +still doesn't, **Settings → General** lists the keyboards that aren't covered. Protected password fields and Secure Input still prevent dynamic remapping. From 7d5507c0bd7599ac3e87a8c14693e768358e3de2 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 09:58:21 -0700 Subject: [PATCH 07/16] Share one budget for early checks; time each arrival separately Every check ahead of the backstop, including the immediate check an arrival asks for, now draws on one budget of six per 10 s and waits 0.25 s after the previous check. A device that keeps re-registering falls back to the backstop instead of spawning hidutil continuously. The arrival log now times each newly covered keyboard from its own service's arrival, matched by registry ID or LocationID. A keyboard covered for another reason is no longer timed. Also: - a check that can't run counts as failed - registry lookups are memoised per check - the doc's fast-check count is corrected - the guide hedges for when notifications are unavailable Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 37 +++-- .../SessionRuntime/SessionRuntimeWorker.swift | 84 ++++++---- .../SessionCapsCheckScheduleTests.swift | 146 ++++++++++-------- docs/architecture/all-keyboards-caps.md | 14 +- guides/driverless-caps-lock.md | 4 +- 5 files changed, 168 insertions(+), 117 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index cd680796a..95ef06040 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -15,41 +15,54 @@ struct SessionCapsArrivedService: Hashable, Sendable { struct SessionCapsCheckSchedule: Equatable { /// The steady backstop, measured between check starts. static let backstop: TimeInterval = 2 - /// The pause after each fast check finishes. hidutil can list a new event + /// The pause after each early check finishes. hidutil can list a new event /// service a moment after IOKit reports it. static let afterArrival: TimeInterval = 0.25 - /// Fast checks an arrival may use while its keyboard is unlisted. Each check - /// spawns several hidutil processes, so this is a count, not a duration. + /// Fast checks an arrival may use while its keyboard is unlisted, the + /// immediate one included. static let fastChecks = 6 + /// Each check spawns several hidutil processes, so checks ahead of the + /// backstop share one budget however many arrivals there are: a device that + /// re-registers over and over gets the backstop, not a continuous loop. + static let earlyBudget = 6 + static let budgetWindow: TimeInterval = 10 private(set) var arrivalPending = false /// Arrived keyboards hidutil has not listed yet. private(set) var unlisted: Set = [] private(set) var fastChecksLeft = 0 + private(set) var earlyStarts: [Date] = [] - /// Every arrival gets an immediate check. Only a service that reads as a + /// Every arrival asks for an immediate check. Only a service that reads as a /// keyboard and is not virtual can earn fast checks until hidutil lists it. - /// Returns whether the arrival is tracked, which is what may be timed. + /// Returns those tracked services, which are what may be timed. @discardableResult - mutating func keyboardArrived(at _: Date, keyboards: [SessionCapsArrivedService]) -> Bool { + mutating func keyboardArrived(keyboards: [SessionCapsArrivedService]) -> [SessionCapsArrivedService] { arrivalPending = true let real = keyboards.filter { $0.locationID != 0 } - guard !real.isEmpty else { return false } + guard !real.isEmpty else { return [] } unlisted.formUnion(real) fastChecksLeft = Self.fastChecks - return true + return real } func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { - if arrivalPending { return true } - if !unlisted.isEmpty, fastChecksLeft > 0 { return now.timeIntervalSince(lastEnd) >= Self.afterArrival } + let wantsEarly = arrivalPending || (!unlisted.isEmpty && fastChecksLeft > 0) + if wantsEarly, earlyStarts.count(where: { now.timeIntervalSince($0) < Self.budgetWindow }) < Self.earlyBudget, + now.timeIntervalSince(lastEnd) >= Self.afterArrival + { + return true + } return now.timeIntervalSince(lastStart) >= Self.backstop } /// A check is starting; it covers every arrival reported so far. Returns the - /// arrived keyboards it should look for in its listing. - mutating func checkStarted() -> Set { + /// arrived keyboards it should look for in its listing. `lastStart` is the + /// previous check's start, which tells an early check from a backstop one. + mutating func checkStarted(at now: Date, lastStart: Date) -> Set { arrivalPending = false + earlyStarts.removeAll { now.timeIntervalSince($0) >= Self.budgetWindow } + if now.timeIntervalSince(lastStart) < Self.backstop { earlyStarts.append(now) } return unlisted } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index f5f23e973..cc9920dda 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -39,7 +39,8 @@ public final class SessionRuntimeWorker { private var capsCheckSchedule = SessionCapsCheckSchedule() private var capsArrivalMonitor: SessionCapsKeyboardArrivalMonitor? /// First unanswered arrival, for the arrival-to-mapped log line. - private var capsArrivalAt: Date? + /// Tracked arrivals not yet mapped, each with when it arrived. + private var capsTimedArrivals: [SessionCapsArrivedService: Date] = [:] private var capsOwner: SessionCapsMappingPolicy.Owner? private var capsDirectory: URL? private var capsInput = SessionCapsInputState() @@ -637,29 +638,28 @@ public final class SessionRuntimeWorker { backingOff.filter { !listed.contains($0) && exists($0) == false } } - /// The arrival a later mapping is timed from. Only an arrival the schedule - /// tracks starts the clock (a virtual keyboard does not), and a stale one, - /// never followed by a successful check, does not time a new one. - static func arrivalStamp(current: Date?, now: Date, tracked: Bool) -> Date? { - guard tracked else { return current } - guard let current, now < current.addingTimeInterval(arrivalAttributionLimit) else { return now } - return current - } - - /// Attributes newly covered keyboards to the pending arrival. A keyboard that - /// was backing off after a failed write is recovering, not arriving. - static func arrivalOutcome(arrivedAt: Date?, now: Date, added: Set, - recovering: Set) -> (log: String?, keepPending: Bool) + /// Times each newly covered keyboard from the arrival of its own service (by + /// registry ID, or another interface at its LocationID). A keyboard covered + /// for any other reason, such as an exclusion removed, is not an arrival. + static func arrivalLog(_ timed: [SessionCapsArrivedService: Date], now: Date, + covered: [(id: UInt64, location: UInt32?)]) + -> (lines: [String], remaining: [SessionCapsArrivedService: Date]) { - guard let arrivedAt else { return (nil, false) } - let arrived = added.subtracting(recovering) - if !arrived.isEmpty { + var remaining = timed.filter { now.timeIntervalSince($0.value) < arrivalAttributionLimit } + var lines: [String] = [] + for keyboard in covered.sorted(by: { $0.id < $1.id }) { + let matches = remaining.filter { service, _ in + service.registryEntryID == keyboard.id + || (service.locationID != nil && service.locationID == keyboard.location) + } + guard let arrivedAt = matches.values.min() else { continue } let elapsed = Int((now.timeIntervalSince(arrivedAt) * 1000).rounded()) - return ("[SessionCaps] Keyboard arrival mapped in \(elapsed) ms: \(arrived.sorted())", false) + lines.append("[SessionCaps] Keyboard arrival mapped in \(elapsed) ms: [\(keyboard.id)]") + for service in matches.keys { + remaining.removeValue(forKey: service) + } } - // Not every arrival is a new eligible keyboard: an excluded or withheld - // one, or an interface that re-registered (for example after wake). - return (nil, now < arrivedAt.addingTimeInterval(arrivalAttributionLimit)) + return (lines, remaining) } /// Without the monitor the backstop check still finds arrivals, only later. @@ -667,8 +667,11 @@ public final class SessionRuntimeWorker { let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] keyboards in guard let self, !self.finished else { return } let now = Date() - let tracked = capsCheckSchedule.keyboardArrived(at: now, keyboards: keyboards) - capsArrivalAt = Self.arrivalStamp(current: capsArrivalAt, now: now, tracked: tracked) + for service in capsCheckSchedule.keyboardArrived(keyboards: keyboards) + where capsTimedArrivals[service].map({ now.timeIntervalSince($0) >= Self.arrivalAttributionLimit }) ?? true + { + capsTimedArrivals[service] = now + } } if monitor.start() { capsArrivalMonitor = monitor @@ -716,12 +719,19 @@ public final class SessionRuntimeWorker { } private func checkCapsFleetIfNeeded() { - // Before the guard: a pending arrival must not keep the check due every tick. - let arrived = capsCheckSchedule.checkStarted() + let now = Date() + let arrived = capsCheckSchedule.checkStarted(at: now, lastStart: lastCapsCheck) + lastCapsCheck = now guard let owner = capsOwner, let directory = capsDirectory, - let configPath = capsConfigPath, let admittedDigest = capsAdmittedDigest else { return } + let configPath = capsConfigPath, let admittedDigest = capsAdmittedDigest + else { + // Unreachable today (all four are set before the tick timer starts). + // Counted as a failed check so a pending arrival can't spin the tick. + lastCapsCheckEnd = now + capsCheckSchedule.checkFailed() + return + } capsCheckInFlight = true - lastCapsCheck = Date() let previous = Set(capsFleetRecords.map(\.device.registryEntryID)) let retryAfter = capsFleetRetryAfter let usage = SessionCapsRuntimeSupport.preferredIntermediate() @@ -757,7 +767,14 @@ public final class SessionRuntimeWorker { bootSessionUUID: owner.bootSessionUUID, allowNewDevices: SessionCapsRuntimeSupport.heldKeys().isEmpty ) - let exists = { (id: UInt64) in try? SessionCapsHIDUtilTransport.registryEntryExists(id) } + // A registry walk can be slow; ask once per keyboard per check. + var existence: [UInt64: Bool?] = [:] + let exists = { (id: UInt64) -> Bool? in + if let known = existence[id] { return known } + let answer = try? SessionCapsHIDUtilTransport.registryEntryExists(id) + existence[id] = answer + return answer + } let resolved = SessionCapsCheckSchedule.resolved( arrived, listed: (try? SessionCapsHIDUtilTransport.parseServiceRows(listing)) ?? [], exists: exists ) @@ -813,10 +830,13 @@ public final class SessionRuntimeWorker { if !added.isEmpty { AppLogger.shared.log("[SessionCaps] Keyboard covered: \(added.sorted())") } - let arrival = Self.arrivalOutcome(arrivedAt: self.capsArrivalAt, now: Date(), added: added, - recovering: Set(retryAfter.keys)) - if let line = arrival.log { AppLogger.shared.log(line) } - if !arrival.keepPending { self.capsArrivalAt = nil } + let arrival = Self.arrivalLog( + self.capsTimedArrivals, now: Date(), + covered: coverage.records.filter { added.contains($0.device.registryEntryID) } + .map { ($0.device.registryEntryID, $0.device.locationID) } + ) + arrival.lines.forEach { AppLogger.shared.log($0) } + self.capsTimedArrivals = arrival.remaining case let .failure(error): self.capsCheckSchedule.checkFailed() if case SessionCapsMappingLease.Refusal.busy = error { return } diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index 651fea22b..f3e78dfda 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -24,17 +24,19 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testAnArrivalMakesTheNextCheckDueAtOnce() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start.addingTimeInterval(0.01), keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb]) - XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.01), lastStart: start, lastEnd: start)) + // The previous check finished a second ago, well past the pause. + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.01), lastStart: start.addingTimeInterval(-1.5), + lastEnd: start.addingTimeInterval(-1))) } func testAnUnrecognisedOrVirtualArrivalEarnsOneCheckButNoFastChecks() { for keyboards in [[], [SessionCapsArrivedService(registryEntryID: 7, locationID: 0)]] { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, keyboards: keyboards) - XCTAssertTrue(schedule.isDue(now: start, lastStart: start, lastEnd: start)) - XCTAssertTrue(schedule.checkStarted().isEmpty) + schedule.keyboardArrived(keyboards: keyboards) + XCTAssertTrue(schedule.isDue(now: start, lastStart: .distantPast, lastEnd: .distantPast)) + XCTAssertTrue(schedule.checkStarted(at: start, lastStart: .distantPast).isEmpty) XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.5), lastStart: start, lastEnd: start.addingTimeInterval(0.1))) @@ -43,8 +45,8 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testAKeyboardHidutilHasNotListedYetGetsFastChecks() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, keyboards: [usb]) - XCTAssertEqual(schedule.checkStarted(), [usb]) + schedule.keyboardArrived(keyboards: [usb]) + XCTAssertEqual(schedule.checkStarted(at: start, lastStart: .distantPast), [usb]) // The first listing does not include the new service yet. let firstEnd = start.addingTimeInterval(0.1) schedule.checkFinished(resolved: []) @@ -53,7 +55,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { // The next listing includes it: back to the backstop. let second = firstEnd.addingTimeInterval(0.25) - XCTAssertEqual(schedule.checkStarted(), [usb]) + XCTAssertEqual(schedule.checkStarted(at: start, lastStart: .distantPast), [usb]) schedule.checkFinished(resolved: [usb]) XCTAssertFalse(schedule.isDue(now: second.addingTimeInterval(1), lastStart: second, lastEnd: second.addingTimeInterval(0.1))) @@ -61,11 +63,11 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testFastChecksAreCappedByCountForAServiceHidutilNeverLists() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb]) var now = start var checks = 0 while schedule.isDue(now: now, lastStart: now, lastEnd: now.addingTimeInterval(-1)), checks < 100 { - _ = schedule.checkStarted() + _ = schedule.checkStarted(at: start, lastStart: .distantPast) schedule.checkFinished(resolved: []) checks += 1 now = now.addingTimeInterval(0.3) @@ -77,8 +79,8 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testAWakeThatReRegistersListedKeyboardsCostsOneCheck() { var schedule = SessionCapsCheckSchedule() let builtIn = SessionCapsArrivedService(registryEntryID: 9, locationID: 0x0200_0000) - schedule.keyboardArrived(at: start, keyboards: [builtIn, usb]) - let arrived = schedule.checkStarted() + schedule.keyboardArrived(keyboards: [builtIn, usb]) + let arrived = schedule.checkStarted(at: start, lastStart: .distantPast) let resolved = SessionCapsCheckSchedule.resolved( arrived, listed: [row(9, location: 0x0200_0000), row(42, location: 0x0110_0000)], exists: { _ in true } ) @@ -90,8 +92,8 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testFastChecksAreSpacedFromTheLastCheckFinishing() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, keyboards: [usb]) - _ = schedule.checkStarted() + schedule.keyboardArrived(keyboards: [usb]) + _ = schedule.checkStarted(at: start, lastStart: .distantPast) let lastEnd = start.addingTimeInterval(0.4) schedule.checkFinished(resolved: []) @@ -101,8 +103,8 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testAFailedCheckHandsOverToTheBackstop() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, keyboards: [usb]) - _ = schedule.checkStarted() + schedule.keyboardArrived(keyboards: [usb]) + _ = schedule.checkStarted(at: start, lastStart: .distantPast) schedule.checkFailed() XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1), lastStart: start, @@ -121,37 +123,54 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertEqual(departed, [3]) } - @MainActor - func testOnlyATrackedArrivalStartsTheMappingClock() { + func testOnlyNonVirtualKeyboardsAreTracked() { var schedule = SessionCapsCheckSchedule() let virtual = SessionCapsArrivedService(registryEntryID: 7, locationID: 0) // A virtual keyboard (Karabiner, a VM's platform keyboard) is not timed. - let afterVirtual = SessionRuntimeWorker.arrivalStamp( - current: nil, now: start, tracked: schedule.keyboardArrived(at: start, keyboards: [virtual]) - ) - XCTAssertNil(afterVirtual) + XCTAssertEqual(schedule.keyboardArrived(keyboards: [virtual, usb]), [usb]) + XCTAssertEqual(schedule.keyboardArrived(keyboards: [virtual]), []) + } - let plugged = start.addingTimeInterval(5) - let afterUSB = SessionRuntimeWorker.arrivalStamp( - current: afterVirtual, now: plugged, tracked: schedule.keyboardArrived(at: plugged, keyboards: [usb]) - ) - XCTAssertEqual(afterUSB, plugged) - - // A second keyboard during the same window keeps the first arrival's clock. - let next = plugged.addingTimeInterval(1) - XCTAssertEqual(SessionRuntimeWorker.arrivalStamp(current: afterUSB, now: next, tracked: true), plugged) - // A stale stamp, never followed by a successful check, is replaced. - let much = plugged.addingTimeInterval(SessionRuntimeWorker.arrivalAttributionLimit) - XCTAssertEqual(SessionRuntimeWorker.arrivalStamp(current: afterUSB, now: much, tracked: true), much) + func testRepeatedArrivalsShareOneEarlyCheckBudget() { + var schedule = SessionCapsCheckSchedule() + var now = start + var lastStart = Date.distantPast + var early = 0 + // A flapping device re-registers every 0.3 s, and every listing shows it. + for _ in 0 ..< 30 { + schedule.keyboardArrived(keyboards: [usb]) + if schedule.isDue(now: now, lastStart: lastStart, lastEnd: now.addingTimeInterval(-0.3)), + now.timeIntervalSince(lastStart) < SessionCapsCheckSchedule.backstop || lastStart == .distantPast + { + let arrived = schedule.checkStarted(at: now, lastStart: lastStart) + schedule.checkFinished(resolved: arrived) + if now.timeIntervalSince(lastStart) < SessionCapsCheckSchedule.backstop { early += 1 } + lastStart = now + } + now = now.addingTimeInterval(0.3) + } + // 9 s of flapping: the first check (after idle) plus the budget's early ones, + // then nothing early until the window slides. + XCTAssertEqual(early, SessionCapsCheckSchedule.earlyBudget) + XCTAssertFalse(schedule.isDue(now: now, lastStart: now.addingTimeInterval(-1), lastEnd: now.addingTimeInterval(-1))) + XCTAssertTrue(schedule.isDue(now: now, lastStart: now.addingTimeInterval(-2), lastEnd: now.addingTimeInterval(-1)), + "the backstop still runs") + } + + func testAnEarlyCheckWaitsForThePauseAfterTheLastOneFinished() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(keyboards: [usb]) + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.1), lastStart: start, lastEnd: start)) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.25), lastStart: start, lastEnd: start)) } func testAnArrivalDuringACheckIsNotLostWhenThatCheckStarted() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(at: start, keyboards: [usb]) - _ = schedule.checkStarted() + schedule.keyboardArrived(keyboards: [usb]) + _ = schedule.checkStarted(at: start, lastStart: .distantPast) // A second keyboard arrives while the first check is still in flight. let second = SessionCapsArrivedService(registryEntryID: 43, locationID: 0x0120_0000) - schedule.keyboardArrived(at: start.addingTimeInterval(0.05), keyboards: [second]) + schedule.keyboardArrived(keyboards: [second]) schedule.checkFinished(resolved: [usb]) XCTAssertTrue(schedule.arrivalPending) @@ -174,12 +193,12 @@ final class SessionCapsCheckScheduleTests: XCTestCase { } @MainActor - func testMonitorStartsWithoutReportingKeyboardsAlreadyConnected() { + func testMonitorStartsWithoutReportingKeyboardsAlreadyConnected() throws { var arrivals = 0 let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { _ in true }) { _ in arrivals += 1 } defer { monitor.stop() } - XCTAssertTrue(monitor.start()) + try XCTSkipUnless(monitor.start(), "IOKit notifications unavailable on this host") XCTAssertFalse(monitor.start(), "a started monitor refuses a second start") // Every service counts as a keyboard here, so a zero is the startup drain at work. XCTAssertEqual(arrivals, 0, "services present at start are inventory, not arrivals") @@ -237,35 +256,32 @@ final class SessionCapsCheckScheduleTests: XCTestCase { } @MainActor - func testArrivalIsAttributedToANewKeyboardNotARecoveringOne() { - let arrived = start - // An unrelated keyboard coming back from its failure backoff is not the arrival. - let recovered = SessionRuntimeWorker.arrivalOutcome(arrivedAt: arrived, now: start.addingTimeInterval(0.4), - added: [7], recovering: [7]) - XCTAssertNil(recovered.log) - XCTAssertTrue(recovered.keepPending) - - let mapped = SessionRuntimeWorker.arrivalOutcome(arrivedAt: arrived, now: start.addingTimeInterval(0.4), - added: [7, 8], recovering: [7]) - XCTAssertEqual(mapped.log, "[SessionCaps] Keyboard arrival mapped in 400 ms: [8]") - XCTAssertFalse(mapped.keepPending) + func testEachCoveredKeyboardIsTimedFromItsOwnArrival() { + let secondary = SessionCapsArrivedService(registryEntryID: 50, locationID: 0x0300_0000) + let timed: [SessionCapsArrivedService: Date] = [usb: start, secondary: start.addingTimeInterval(1)] + let result = SessionRuntimeWorker.arrivalLog( + timed, now: start.addingTimeInterval(1.4), + // 42 by its own ID; 49 by the LocationID its NKRO interface 50 shares; 7 was + // covered for another reason (an exclusion removed) and is not an arrival. + covered: [(id: 42, location: 0x0110_0000), (id: 49, location: 0x0300_0000), (id: 7, location: 0x0900_0000)] + ) + XCTAssertEqual(result.lines, [ + "[SessionCaps] Keyboard arrival mapped in 1400 ms: [42]", + "[SessionCaps] Keyboard arrival mapped in 400 ms: [49]", + ]) + XCTAssertTrue(result.remaining.isEmpty) } @MainActor - func testASlowArrivalIsStillMeasuredUntilTheAttributionLimit() { - // Plugged in with keys held: mapped only after release, well past the fast window. - let late = SessionRuntimeWorker.arrivalOutcome(arrivedAt: start, now: start.addingTimeInterval(5), - added: [], recovering: []) - XCTAssertTrue(late.keepPending) - let mapped = SessionRuntimeWorker.arrivalOutcome(arrivedAt: start, now: start.addingTimeInterval(6), - added: [9], recovering: []) - XCTAssertEqual(mapped.log, "[SessionCaps] Keyboard arrival mapped in 6000 ms: [9]") - - let expired = SessionRuntimeWorker.arrivalOutcome( - arrivedAt: start, now: start.addingTimeInterval(SessionRuntimeWorker.arrivalAttributionLimit), - added: [], recovering: [] + func testAnArrivalThatNeverMapsIsDroppedAtTheAttributionLimit() { + let pending = SessionRuntimeWorker.arrivalLog([usb: start], now: start.addingTimeInterval(5), covered: []) + XCTAssertEqual(pending.remaining, [usb: start]) + + let expired = SessionRuntimeWorker.arrivalLog( + [usb: start], now: start.addingTimeInterval(SessionRuntimeWorker.arrivalAttributionLimit), + covered: [(id: 42, location: 0x0110_0000)] ) - XCTAssertFalse(expired.keepPending, "an arrival that never maps (excluded, withheld) is dropped") - XCTAssertNil(SessionRuntimeWorker.arrivalOutcome(arrivedAt: nil, now: start, added: [9], recovering: []).log) + XCTAssertEqual(expired.lines, [], "an excluded or withheld arrival is not timed by a later mapping") + XCTAssertTrue(expired.remaining.isEmpty) } } diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 68c3fd4eb..524375e12 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -202,9 +202,11 @@ shared NKRO interface counts. Every arrival earns one immediate check: an unreadable capability counts too, since an extra inventory read is cheap and a missed arrival is not. If that check's listing doesn't show a recognised, non-virtual arrived service yet (under its own registry ID, or as another interface at the same -LocationID), up to six fast checks follow, each 0.25 s after the previous one -finishes, because hidutil can list a new service a moment after IOKit reports it. -The cap is a count because each check spawns several hidutil processes. A keyboard +LocationID), up to five more follow, each 0.25 s after the previous one finishes, +because hidutil can list a new service a moment after IOKit reports it. Each check +spawns several hidutil processes, so every check ahead of the backstop draws on one +shared budget of six per 10 s, however many arrivals there are: a device that keeps +re-registering gets the backstop, not a continuous loop. A keyboard plugged in with keys held, or one still not listed, is left to the backstop, and a check that fails hands over to the backstop at once. Classification runs off the event tap's run loop. A wake re-registers services hidutil already lists, so it costs @@ -213,9 +215,9 @@ mapped; the `arrival mapped in N ms` log measures that window. The two-second backstop is measured between check starts, so a slow check doesn't stretch it, and covers everything if the notification cannot be registered. The worker logs `[SessionCaps] Keyboard covered` for every newly covered keyboard, and -`[SessionCaps] Keyboard arrival mapped in N ms` when the newly covered keyboard -follows an arrival within 30 s (a keyboard recovering from its failure backoff is not -counted as the arrival). It admits a new keyboard under the same eligibility and all-keys-up checks as at +`[SessionCaps] Keyboard arrival mapped in N ms` when the newly covered keyboard's own +service (by registry ID, or another interface at its LocationID) arrived within the +last 30 s; a keyboard covered for another reason is not timed. It admits a new keyboard under the same eligibility and all-keys-up checks as at startup, and retires records for a removed service. A failed device is left uncovered and retried with backoff; an unavailable inventory leaves existing coverage intact and reports that it cannot be verified. diff --git a/guides/driverless-caps-lock.md b/guides/driverless-caps-lock.md index 42eab05ec..bd0b8d5c6 100644 --- a/guides/driverless-caps-lock.md +++ b/guides/driverless-caps-lock.md @@ -65,8 +65,8 @@ If it cannot determine the covered interfaces safely, that keyboard is left uncovered rather than guessing. Virtual keyboards are not physical Caps Lock targets. -KeyPath starts setting up a keyboard when you plug it in, rather than waiting -for its next periodic check. It waits until no keys are held, and a Caps Lock +KeyPath normally starts setting up a keyboard as soon as you plug it in, and +otherwise within a couple of seconds. It waits until no keys are held, and a Caps Lock press in the first moment can still arrive before macOS finishes presenting the device. If Caps Lock behaves normally then, release all keys and try again. If it still doesn't, **Settings → General** lists the keyboards that aren't covered. From d65aa7201955b228a73f9aae4bd336bef99340d7 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 10:13:14 -0700 Subject: [PATCH 08/16] Test the real arrival delivery path; widen the early-check budget - The off-main classification and its delivery on the main run loop are now tested, along with a result that lands after stop(). - The early-check budget is now twelve per 10 s, so a second keyboard plugged in just after the first isn't left for the backstop. - Lease contention keeps an arrival's fast checks. - Arrival timestamps are pruned on failed checks too. - LocationID is read as hidutil prints it, and an opt-in live test checks the two readings agree (they do on the Air). Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 32 ++++++--- .../SessionRuntime/SessionRuntimeWorker.swift | 7 +- .../SessionCapsCheckScheduleTests.swift | 71 +++++++++++++++++++ docs/architecture/all-keyboards-caps.md | 5 +- 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index 95ef06040..ef4ff9175 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -24,7 +24,9 @@ struct SessionCapsCheckSchedule: Equatable { /// Each check spawns several hidutil processes, so checks ahead of the /// backstop share one budget however many arrivals there are: a device that /// re-registers over and over gets the backstop, not a continuous loop. - static let earlyBudget = 6 + /// Room for two keyboards' fast checks, so a second keyboard plugged in + /// moments after the first is not left for the backstop. + static let earlyBudget = 12 static let budgetWindow: TimeInterval = 10 private(set) var arrivalPending = false @@ -39,6 +41,8 @@ struct SessionCapsCheckSchedule: Equatable { @discardableResult mutating func keyboardArrived(keyboards: [SessionCapsArrivedService]) -> [SessionCapsArrivedService] { arrivalPending = true + // Unlike the rest of this subsystem, an unreadable (nil) LocationID counts + // as real here: failing open costs a few checks, failing closed a keyboard. let real = keyboards.filter { $0.locationID != 0 } guard !real.isEmpty else { return [] } unlisted.formUnion(real) @@ -163,14 +167,26 @@ final class SessionCapsKeyboardArrivalMonitor { // An unreadable ID still counts: the arrival is reported, and the // reconcile decides whether there is anything to map. let readable = IORegistryEntryGetRegistryEntryID(service, &id) == KERN_SUCCESS - let location = IORegistryEntryCreateCFProperty(service, "LocationID" as CFString, kCFAllocatorDefault, 0)? - .takeRetainedValue() as? NSNumber - services.append(.init(registryEntryID: readable ? id : 0, - locationID: location.flatMap { UInt32(exactly: $0.uint64Value) })) + services.append(.init(registryEntryID: readable ? id : 0, locationID: Self.locationID(of: service))) } - guard report, !services.isEmpty else { return } - // Classifying can walk the registry and parse report descriptors, once per - // service in a batch. Keep that off the run loop the event tap shares. + guard report else { return } + classifyAndDeliver(services) + } + + /// The registry's LocationID as the 32-bit pattern hidutil prints. It may be + /// stored as a signed CFNumber, so take the low 32 bits rather than refusing + /// a value with the high bit set. + nonisolated static func locationID(of service: io_service_t) -> UInt32? { + let value = IORegistryEntryCreateCFProperty(service, "LocationID" as CFString, kCFAllocatorDefault, 0)? + .takeRetainedValue() as? NSNumber + return value.map { UInt32(truncatingIfNeeded: $0.int64Value) } + } + + /// Classifying can walk the registry and parse report descriptors, once per + /// service in a batch. Keep that off the run loop the event tap shares, and + /// drop a result that lands after `stop()`. + func classifyAndDeliver(_ services: [SessionCapsArrivedService]) { + guard !services.isEmpty else { return } let classify = emitsKeyboardUsages DispatchQueue.global(qos: .userInitiated).async { [weak self] in let answers = Self.classify(services, with: classify) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index cc9920dda..e88bee789 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -38,7 +38,6 @@ public final class SessionRuntimeWorker { private var capsFleetReadFailures = 0 private var capsCheckSchedule = SessionCapsCheckSchedule() private var capsArrivalMonitor: SessionCapsKeyboardArrivalMonitor? - /// First unanswered arrival, for the arrival-to-mapped log line. /// Tracked arrivals not yet mapped, each with when it arrived. private var capsTimedArrivals: [SessionCapsArrivedService: Date] = [:] private var capsOwner: SessionCapsMappingPolicy.Owner? @@ -838,8 +837,12 @@ public final class SessionRuntimeWorker { arrival.lines.forEach { AppLogger.shared.log($0) } self.capsTimedArrivals = arrival.remaining case let .failure(error): - self.capsCheckSchedule.checkFailed() + self.capsTimedArrivals = self.capsTimedArrivals.filter { + Date().timeIntervalSince($0.value) < Self.arrivalAttributionLimit + } + // Lease contention clears in moments; keep the arrival's fast checks. if case SessionCapsMappingLease.Refusal.busy = error { return } + self.capsCheckSchedule.checkFailed() if error is CapsInventoryFailure { self.capsFleetReadFailures += 1 if self.capsFleetReadFailures == 3 { diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index f3e78dfda..9eb5070d4 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -157,6 +157,24 @@ final class SessionCapsCheckScheduleTests: XCTestCase { "the backstop still runs") } + func testASecondKeyboardSoonAfterTheFirstStillGetsEarlyChecks() { + var schedule = SessionCapsCheckSchedule() + var now = start + var lastStart = Date.distantPast + // The first keyboard never shows up in hidutil's list and uses all its fast checks. + schedule.keyboardArrived(keyboards: [usb]) + for _ in 0 ..< SessionCapsCheckSchedule.fastChecks { + XCTAssertTrue(schedule.isDue(now: now, lastStart: lastStart, lastEnd: now.addingTimeInterval(-0.3))) + _ = schedule.checkStarted(at: now, lastStart: lastStart) + schedule.checkFinished(resolved: []) + lastStart = now + now = now.addingTimeInterval(0.3) + } + // A second keyboard is plugged in a moment later. + schedule.keyboardArrived(keyboards: [SessionCapsArrivedService(registryEntryID: 43, locationID: 0x0120_0000)]) + XCTAssertTrue(schedule.isDue(now: now, lastStart: lastStart, lastEnd: now.addingTimeInterval(-0.3))) + } + func testAnEarlyCheckWaitsForThePauseAfterTheLastOneFinished() { var schedule = SessionCapsCheckSchedule() schedule.keyboardArrived(keyboards: [usb]) @@ -204,6 +222,59 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertEqual(arrivals, 0, "services present at start are inventory, not arrivals") } + /// The production path: classify off the main thread, deliver on the main run loop. + @MainActor + func testArrivalsAreClassifiedOffTheMainThreadAndDeliveredOnIt() throws { + var delivered: [[UInt64]] = [] + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { id in + XCTAssertFalse(Thread.isMainThread, "classification must stay off the event tap's run loop") + return id == 1 + }) { delivered.append($0.map(\.registryEntryID)) } + defer { monitor.stop() } + try XCTSkipUnless(monitor.start(), "IOKit notifications unavailable on this host") + + monitor.classifyAndDeliver([.init(registryEntryID: 1, locationID: 1), .init(registryEntryID: 2, locationID: 2)]) + let deadline = Date().addingTimeInterval(5) + while delivered.isEmpty, Date() < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + } + XCTAssertEqual(delivered, [[1]]) + } + + @MainActor + func testAClassificationFinishingAfterStopIsDropped() throws { + var delivered = 0 + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { _ in + Thread.sleep(forTimeInterval: 0.05) + return true + }) { _ in delivered += 1 } + try XCTSkipUnless(monitor.start(), "IOKit notifications unavailable on this host") + + monitor.classifyAndDeliver([.init(registryEntryID: 1, locationID: 1)]) + monitor.stop() + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + XCTAssertEqual(delivered, 0) + } + + /// The arrival side reads LocationID from IOKit, the listing side from hidutil's + /// JSON; matching by location needs the two to agree. + func testIOKitAndHidutilAgreeOnLocationIDs() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["KEYPATH_LIVE_CAPS_FLEET"] == "1", + "set KEYPATH_LIVE_CAPS_FLEET=1 to check attached keyboards") + let rows = try SessionCapsHIDUtilTransport.parseServiceRows( + SessionCapsHIDUtilTransport.runHIDUtil(["list", "--ndjson", "--matching", "keyboard"]) + ).filter { ($0.locationID ?? 0) != 0 } + try XCTSkipIf(rows.isEmpty, "no located keyboard attached") + for row in rows { + guard let matching = IORegistryEntryIDMatching(row.registryEntryID) else { continue } + let service = IOServiceGetMatchingService(kIOMainPortDefault, matching) + guard service != 0 else { continue } + defer { IOObjectRelease(service) } + XCTAssertEqual(SessionCapsKeyboardArrivalMonitor.locationID(of: service), row.locationID, + "registry ID \(row.registryEntryID)") + } + } + @MainActor func testArrivalClassificationFailsOpen() { let answers: [UInt64: Bool?] = [1: true, 2: false, 3: nil] diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 524375e12..8d5311240 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -205,10 +205,11 @@ service yet (under its own registry ID, or as another interface at the same LocationID), up to five more follow, each 0.25 s after the previous one finishes, because hidutil can list a new service a moment after IOKit reports it. Each check spawns several hidutil processes, so every check ahead of the backstop draws on one -shared budget of six per 10 s, however many arrivals there are: a device that keeps +shared budget of twelve per 10 s (two keyboards' worth), however many arrivals there are: a device that keeps re-registering gets the backstop, not a continuous loop. A keyboard plugged in with keys held, or one still not listed, is left to the backstop, and a -check that fails hands over to the backstop at once. Classification runs off the +check that fails hands over to the backstop at once (lease contention, which clears in +moments, keeps the arrival's fast checks). Classification runs off the event tap's run loop. A wake re-registers services hidutil already lists, so it costs one check. A press immediately after plugging in can still come before the keyboard is mapped; the `arrival mapped in N ms` log measures that window. From a4ae29f48bfc9f0e5225253470347acedd9a9f42 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 10:25:42 -0700 Subject: [PATCH 09/16] Make the startup-inventory test able to fail; bound busy retries - The startup test now pumps the run loop before asserting. Reporting the startup inventory as arrivals makes it fail (checked by mutation). - A unit test checks that a high-bit LocationID reads the same from IOKit's signed CFNumber and from hidutil's unsigned JSON. - Lease contention keeps an arrival's fast checks for at most three busy results. - A replug at the same LocationID replaces an earlier arrival that was never mapped, so it is timed from the replug. - The docs state what "fails open" covers. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 11 ++++-- .../SessionRuntime/SessionRuntimeWorker.swift | 39 +++++++++++++++---- .../SessionCapsCheckScheduleTests.swift | 37 +++++++++++++++++- docs/architecture/all-keyboards-caps.md | 12 +++--- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index ef4ff9175..37a5ad117 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -177,9 +177,12 @@ final class SessionCapsKeyboardArrivalMonitor { /// stored as a signed CFNumber, so take the low 32 bits rather than refusing /// a value with the high bit set. nonisolated static func locationID(of service: io_service_t) -> UInt32? { - let value = IORegistryEntryCreateCFProperty(service, "LocationID" as CFString, kCFAllocatorDefault, 0)? - .takeRetainedValue() as? NSNumber - return value.map { UInt32(truncatingIfNeeded: $0.int64Value) } + locationID(from: IORegistryEntryCreateCFProperty(service, "LocationID" as CFString, kCFAllocatorDefault, 0)? + .takeRetainedValue() as? NSNumber) + } + + nonisolated static func locationID(from value: NSNumber?) -> UInt32? { + value.map { UInt32(truncatingIfNeeded: $0.int64Value) } } /// Classifying can walk the registry and parse report descriptors, once per @@ -213,6 +216,8 @@ final class SessionCapsKeyboardArrivalMonitor { /// Fails open: a service whose keyboard capability can't be read yet still /// earns one pulled-forward check, so the keyboard isn't left for the backstop. + /// One that reads as definitely not a keyboard (a mouse, or a keyboard whose + /// properties are only partly published) is left to the backstop. /// Only services that read as keyboards are passed on, to be looked for. private func deliver(_ services: [SessionCapsArrivedService], _ answers: [Bool?]) { guard answers.contains(where: { $0 != false }) else { return } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index e88bee789..c83f01cfb 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -36,6 +36,7 @@ public final class SessionRuntimeWorker { private var capsFleetCoverage: SessionRuntimeReport.CapsCoverage? private var capsFleetRetryAfter: [UInt64: Date] = [:] private var capsFleetReadFailures = 0 + private var capsFleetBusyStreak = 0 private var capsCheckSchedule = SessionCapsCheckSchedule() private var capsArrivalMonitor: SessionCapsKeyboardArrivalMonitor? /// Tracked arrivals not yet mapped, each with when it arrived. @@ -628,6 +629,8 @@ public final class SessionRuntimeWorker { /// How long an arrival waits to be attributed. Long enough for a keyboard /// plugged in with keys held to be mapped once they are released. static let arrivalAttributionLimit: TimeInterval = 30 + /// Busy results an arrival's fast checks survive before the backstop takes over. + static let busyRetries = 3 /// Keyboards whose failure backoff can be dropped: gone from the registry, /// not merely missing from one listing. @@ -661,16 +664,29 @@ public final class SessionRuntimeWorker { return (lines, remaining) } + /// Records when each tracked service arrived. A service re-reported within the + /// attribution limit keeps its first arrival; a new service at the same + /// LocationID (a replug) replaces an earlier arrival that was never mapped. + static func recordArrivals(_ services: [SessionCapsArrivedService], at now: Date, + into timed: [SessionCapsArrivedService: Date]) -> [SessionCapsArrivedService: Date] + { + var timed = timed + for service in services { + if let at = timed[service], now.timeIntervalSince(at) < arrivalAttributionLimit { continue } + if let location = service.locationID { + timed = timed.filter { $0.key == service || $0.key.locationID != location } + } + timed[service] = now + } + return timed + } + /// Without the monitor the backstop check still finds arrivals, only later. private func startCapsArrivalMonitor() { let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] keyboards in guard let self, !self.finished else { return } - let now = Date() - for service in capsCheckSchedule.keyboardArrived(keyboards: keyboards) - where capsTimedArrivals[service].map({ now.timeIntervalSince($0) >= Self.arrivalAttributionLimit }) ?? true - { - capsTimedArrivals[service] = now - } + capsTimedArrivals = Self.recordArrivals(capsCheckSchedule.keyboardArrived(keyboards: keyboards), + at: Date(), into: capsTimedArrivals) } if monitor.start() { capsArrivalMonitor = monitor @@ -798,6 +814,7 @@ public final class SessionRuntimeWorker { switch result { case let .success(coverage): self.capsFleetReadFailures = 0 + self.capsFleetBusyStreak = 0 let current = Set(coverage.records.map(\.device.registryEntryID)) if previous.isEmpty, !current.isEmpty { self.capsInput.activate(generation: owner.generation, intermediateUsage: usage) @@ -840,8 +857,14 @@ public final class SessionRuntimeWorker { self.capsTimedArrivals = self.capsTimedArrivals.filter { Date().timeIntervalSince($0.value) < Self.arrivalAttributionLimit } - // Lease contention clears in moments; keep the arrival's fast checks. - if case SessionCapsMappingLease.Refusal.busy = error { return } + // Lease contention usually clears in moments, so an arrival keeps + // its fast checks for a few busy results, not indefinitely. + if case SessionCapsMappingLease.Refusal.busy = error { + self.capsFleetBusyStreak += 1 + if self.capsFleetBusyStreak >= Self.busyRetries { self.capsCheckSchedule.checkFailed() } + return + } + self.capsFleetBusyStreak = 0 self.capsCheckSchedule.checkFailed() if error is CapsInventoryFailure { self.capsFleetReadFailures += 1 diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index 9eb5070d4..f4058b015 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -55,7 +55,8 @@ final class SessionCapsCheckScheduleTests: XCTestCase { // The next listing includes it: back to the backstop. let second = firstEnd.addingTimeInterval(0.25) - XCTAssertEqual(schedule.checkStarted(at: start, lastStart: .distantPast), [usb]) + XCTAssertEqual(schedule.checkStarted(at: second, lastStart: start), [usb]) + XCTAssertEqual(schedule.earlyStarts, [second], "a check 0.35 s after the last one is early") schedule.checkFinished(resolved: [usb]) XCTAssertFalse(schedule.isDue(now: second.addingTimeInterval(1), lastStart: second, lastEnd: second.addingTimeInterval(0.1))) @@ -218,7 +219,9 @@ final class SessionCapsCheckScheduleTests: XCTestCase { try XCTSkipUnless(monitor.start(), "IOKit notifications unavailable on this host") XCTAssertFalse(monitor.start(), "a started monitor refuses a second start") - // Every service counts as a keyboard here, so a zero is the startup drain at work. + // Every service counts as a keyboard here, so a zero is the startup drain at + // work. Delivery is asynchronous, so give a wrongly reported batch time to land. + RunLoop.main.run(until: Date().addingTimeInterval(0.5)) XCTAssertEqual(arrivals, 0, "services present at start are inventory, not arrivals") } @@ -256,6 +259,20 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertEqual(delivered, 0) } + /// A LocationID with the high bit set, stored by IOKit as a signed CFNumber, + /// must match the unsigned value hidutil prints. + func testAHighBitLocationIDReadsTheSameFromIOKitAndHidutil() throws { + let signed = NSNumber(value: Int32(bitPattern: 0xF010_0000)) + XCTAssertEqual(SessionCapsKeyboardArrivalMonitor.locationID(from: signed), 0xF010_0000) + let rows = try SessionCapsHIDUtilTransport.parseServiceRows( + #"{"type":"service","IORegistryEntryID":42,"VendorID":4660,"ProductID":1,"LocationID":4027580416,"PrimaryUsagePage":1,"PrimaryUsage":6}"# + ) + XCTAssertEqual(rows.first?.locationID, 0xF010_0000) + let arrived = SessionCapsArrivedService(registryEntryID: 50, + locationID: SessionCapsKeyboardArrivalMonitor.locationID(from: signed)) + XCTAssertEqual(SessionCapsCheckSchedule.resolved([arrived], listed: rows, exists: { _ in true }), [arrived]) + } + /// The arrival side reads LocationID from IOKit, the listing side from hidutil's /// JSON; matching by location needs the two to agree. func testIOKitAndHidutilAgreeOnLocationIDs() throws { @@ -343,6 +360,22 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertTrue(result.remaining.isEmpty) } + @MainActor + func testAReplugAtTheSamePortReplacesTheEarlierArrival() { + let first = SessionCapsArrivedService(registryEntryID: 42, locationID: 0x0110_0000) + let replug = SessionCapsArrivedService(registryEntryID: 77, locationID: 0x0110_0000) + let later = start.addingTimeInterval(10) + // The same service re-reported keeps its original arrival. + XCTAssertEqual(SessionRuntimeWorker.recordArrivals([first], at: start.addingTimeInterval(5), into: [first: start]), + [first: start]) + // The first plug-in was never mapped (keys held); it was replugged 10 s later. + let timed = SessionRuntimeWorker.recordArrivals([replug], at: later, into: [first: start]) + XCTAssertEqual(timed, [replug: later]) + let log = SessionRuntimeWorker.arrivalLog(timed, now: later.addingTimeInterval(0.3), + covered: [(id: 77, location: 0x0110_0000)]) + XCTAssertEqual(log.lines, ["[SessionCaps] Keyboard arrival mapped in 300 ms: [77]"]) + } + @MainActor func testAnArrivalThatNeverMapsIsDroppedAtTheAttributionLimit() { let pending = SessionRuntimeWorker.arrivalLog([usb: start], now: start.addingTimeInterval(5), covered: []) diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 8d5311240..34770562c 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -198,9 +198,11 @@ single-keyboard record remains readable for recovery. **Hot-plug.** `SessionCapsKeyboardArrivalMonitor` listens for `IOHIDEventService` registrations (first-match notifications; services present at startup are ignored) and classifies each with the lease's own `serviceEmitsKeyboardUsages`, so a board's -shared NKRO interface counts. Every arrival earns one immediate check: an -unreadable capability counts too, since an extra inventory read is cheap and a missed -arrival is not. If that check's listing doesn't show a recognised, non-virtual arrived +shared NKRO interface counts. Every arrival earns one immediate check: a +service whose capability can't be read yet counts too, since an extra inventory read +is cheap and a missed arrival is not. One that reads as definitely not a keyboard +(including a keyboard whose properties are only partly published) is left to the +backstop. If that check's listing doesn't show a recognised, non-virtual arrived service yet (under its own registry ID, or as another interface at the same LocationID), up to five more follow, each 0.25 s after the previous one finishes, because hidutil can list a new service a moment after IOKit reports it. Each check @@ -208,8 +210,8 @@ spawns several hidutil processes, so every check ahead of the backstop draws on shared budget of twelve per 10 s (two keyboards' worth), however many arrivals there are: a device that keeps re-registering gets the backstop, not a continuous loop. A keyboard plugged in with keys held, or one still not listed, is left to the backstop, and a -check that fails hands over to the backstop at once (lease contention, which clears in -moments, keeps the arrival's fast checks). Classification runs off the +check that fails hands over to the backstop at once (lease contention, which usually clears +in moments, keeps the fast checks for up to three busy results). Classification runs off the event tap's run loop. A wake re-registers services hidutil already lists, so it costs one check. A press immediately after plugging in can still come before the keyboard is mapped; the `arrival mapped in N ms` log measures that window. From 76ab978db471c015a2e9ae600c122a0714aec483 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 10:39:26 -0700 Subject: [PATCH 10/16] Test the busy and arrival wiring; tell sibling interfaces from replugs - Busy handling moves into the schedule (checkBusy) and is tested. - The monitor-to-schedule-and-timing wiring is one tested function. - An arrival that lands during a check keeps all six fast checks. - A sibling interface registering moments later keeps the keyboard's first arrival. Only a new service at that LocationID 2 s or more later counts as a replug. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 20 ++++++- .../SessionRuntime/SessionRuntimeWorker.swift | 34 +++++++----- .../SessionCapsCheckScheduleTests.swift | 53 +++++++++++++++++++ docs/architecture/all-keyboards-caps.md | 2 +- 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index 37a5ad117..21d11b2a2 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -34,6 +34,13 @@ struct SessionCapsCheckSchedule: Equatable { private(set) var unlisted: Set = [] private(set) var fastChecksLeft = 0 private(set) var earlyStarts: [Date] = [] + /// Bumped by each tracked arrival, so a check knows whether one landed while + /// it ran (that arrival's fast checks start after it, not with it). + private var arrivalGeneration = 0 + private var generationAtStart = 0 + /// Busy results an arrival's fast checks survive; the next one hands over. + static let busyRetries = 2 + private(set) var busyStreak = 0 /// Every arrival asks for an immediate check. Only a service that reads as a /// keyboard and is not virtual can earn fast checks until hidutil lists it. @@ -47,6 +54,7 @@ struct SessionCapsCheckSchedule: Equatable { guard !real.isEmpty else { return [] } unlisted.formUnion(real) fastChecksLeft = Self.fastChecks + arrivalGeneration += 1 return real } @@ -67,19 +75,29 @@ struct SessionCapsCheckSchedule: Equatable { arrivalPending = false earlyStarts.removeAll { now.timeIntervalSince($0) >= Self.budgetWindow } if now.timeIntervalSince(lastStart) < Self.backstop { earlyStarts.append(now) } + generationAtStart = arrivalGeneration return unlisted } /// `resolved`: arrived keyboards the check found listed, or found gone. mutating func checkFinished(resolved: Set) { + busyStreak = 0 unlisted.subtract(resolved) - fastChecksLeft = max(0, fastChecksLeft - 1) + if arrivalGeneration == generationAtStart { fastChecksLeft = max(0, fastChecksLeft - 1) } if fastChecksLeft == 0 { unlisted = [] } } + /// Lease contention usually clears in moments, so an arrival keeps its fast + /// checks through a couple of busy results, not indefinitely. + mutating func checkBusy() { + busyStreak += 1 + if busyStreak > Self.busyRetries { checkFailed() } + } + /// A check that produced no answer neither confirms nor refutes an arrival. /// Fast retries would only spawn hidutil again, so the backstop takes over. mutating func checkFailed() { + busyStreak = 0 unlisted = [] fastChecksLeft = 0 } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index c83f01cfb..8e24a9621 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -36,7 +36,6 @@ public final class SessionRuntimeWorker { private var capsFleetCoverage: SessionRuntimeReport.CapsCoverage? private var capsFleetRetryAfter: [UInt64: Date] = [:] private var capsFleetReadFailures = 0 - private var capsFleetBusyStreak = 0 private var capsCheckSchedule = SessionCapsCheckSchedule() private var capsArrivalMonitor: SessionCapsKeyboardArrivalMonitor? /// Tracked arrivals not yet mapped, each with when it arrived. @@ -629,8 +628,6 @@ public final class SessionRuntimeWorker { /// How long an arrival waits to be attributed. Long enough for a keyboard /// plugged in with keys held to be mapped once they are released. static let arrivalAttributionLimit: TimeInterval = 30 - /// Busy results an arrival's fast checks survive before the backstop takes over. - static let busyRetries = 3 /// Keyboards whose failure backoff can be dropped: gone from the registry, /// not merely missing from one listing. @@ -664,9 +661,22 @@ public final class SessionRuntimeWorker { return (lines, remaining) } + /// An arrival reaches both the schedule (an early check) and the timing map. + static func noteArrival(_ keyboards: [SessionCapsArrivedService], at now: Date, + schedule: inout SessionCapsCheckSchedule, + timed: inout [SessionCapsArrivedService: Date]) + { + timed = recordArrivals(schedule.keyboardArrived(keyboards: keyboards), at: now, into: timed) + } + + /// Interfaces of one keyboard register within moments of each other; a new + /// service at the same LocationID this much later is a replug. + static let replugGap: TimeInterval = 2 + /// Records when each tracked service arrived. A service re-reported within the - /// attribution limit keeps its first arrival; a new service at the same - /// LocationID (a replug) replaces an earlier arrival that was never mapped. + /// attribution limit keeps its first arrival. A new service at the same + /// LocationID replaces an earlier, never-mapped arrival there only when it + /// comes `replugGap` later (a replug); a sibling interface keeps the first. static func recordArrivals(_ services: [SessionCapsArrivedService], at now: Date, into timed: [SessionCapsArrivedService: Date]) -> [SessionCapsArrivedService: Date] { @@ -674,7 +684,9 @@ public final class SessionRuntimeWorker { for service in services { if let at = timed[service], now.timeIntervalSince(at) < arrivalAttributionLimit { continue } if let location = service.locationID { - timed = timed.filter { $0.key == service || $0.key.locationID != location } + timed = timed.filter { other, at in + other == service || other.locationID != location || now.timeIntervalSince(at) < replugGap + } } timed[service] = now } @@ -685,8 +697,7 @@ public final class SessionRuntimeWorker { private func startCapsArrivalMonitor() { let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] keyboards in guard let self, !self.finished else { return } - capsTimedArrivals = Self.recordArrivals(capsCheckSchedule.keyboardArrived(keyboards: keyboards), - at: Date(), into: capsTimedArrivals) + Self.noteArrival(keyboards, at: Date(), schedule: &capsCheckSchedule, timed: &capsTimedArrivals) } if monitor.start() { capsArrivalMonitor = monitor @@ -814,7 +825,6 @@ public final class SessionRuntimeWorker { switch result { case let .success(coverage): self.capsFleetReadFailures = 0 - self.capsFleetBusyStreak = 0 let current = Set(coverage.records.map(\.device.registryEntryID)) if previous.isEmpty, !current.isEmpty { self.capsInput.activate(generation: owner.generation, intermediateUsage: usage) @@ -857,14 +867,10 @@ public final class SessionRuntimeWorker { self.capsTimedArrivals = self.capsTimedArrivals.filter { Date().timeIntervalSince($0.value) < Self.arrivalAttributionLimit } - // Lease contention usually clears in moments, so an arrival keeps - // its fast checks for a few busy results, not indefinitely. if case SessionCapsMappingLease.Refusal.busy = error { - self.capsFleetBusyStreak += 1 - if self.capsFleetBusyStreak >= Self.busyRetries { self.capsCheckSchedule.checkFailed() } + self.capsCheckSchedule.checkBusy() return } - self.capsFleetBusyStreak = 0 self.capsCheckSchedule.checkFailed() if error is CapsInventoryFailure { self.capsFleetReadFailures += 1 diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index f4058b015..1f5b5f089 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -176,6 +176,37 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertTrue(schedule.isDue(now: now, lastStart: lastStart, lastEnd: now.addingTimeInterval(-0.3))) } + func testBusyResultsKeepFastChecksBrieflyThenHandOver() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(keyboards: [usb]) + let due = { schedule.isDue(now: self.start, lastStart: self.start, lastEnd: self.start.addingTimeInterval(-1)) } + for _ in 0 ..< SessionCapsCheckSchedule.busyRetries { + _ = schedule.checkStarted(at: start, lastStart: start) + schedule.checkBusy() + XCTAssertTrue(due(), "fast checks survive a busy result") + } + _ = schedule.checkStarted(at: start, lastStart: start) + schedule.checkBusy() + XCTAssertFalse(due(), "the next busy result hands over to the backstop") + + // A finished check resets the streak. + schedule.keyboardArrived(keyboards: [usb]) + _ = schedule.checkStarted(at: start, lastStart: start) + schedule.checkBusy() + _ = schedule.checkStarted(at: start, lastStart: start) + schedule.checkFinished(resolved: []) + XCTAssertEqual(schedule.busyStreak, 0) + } + + func testAnArrivalDuringACheckKeepsAllItsFastChecks() { + var schedule = SessionCapsCheckSchedule() + _ = schedule.checkStarted(at: start, lastStart: .distantPast) + // A keyboard registers while the backstop check is in flight. + schedule.keyboardArrived(keyboards: [usb]) + schedule.checkFinished(resolved: []) + XCTAssertEqual(schedule.fastChecksLeft, SessionCapsCheckSchedule.fastChecks) + } + func testAnEarlyCheckWaitsForThePauseAfterTheLastOneFinished() { var schedule = SessionCapsCheckSchedule() schedule.keyboardArrived(keyboards: [usb]) @@ -376,6 +407,28 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertEqual(log.lines, ["[SessionCaps] Keyboard arrival mapped in 300 ms: [77]"]) } + @MainActor + func testASiblingInterfaceKeepsTheKeyboardsFirstArrival() { + let sibling = SessionCapsArrivedService(registryEntryID: 43, locationID: 0x0110_0000) + // A second interface of the same keyboard registers 0.4 s later, in its own batch. + let timed = SessionRuntimeWorker.recordArrivals([sibling], at: start.addingTimeInterval(0.4), into: [usb: start]) + let log = SessionRuntimeWorker.arrivalLog(timed, now: start.addingTimeInterval(0.45), + covered: [(id: 42, location: 0x0110_0000)]) + XCTAssertEqual(log.lines, ["[SessionCaps] Keyboard arrival mapped in 450 ms: [42]"]) + } + + @MainActor + func testAnArrivalReachesBothTheScheduleAndTheTimingMap() { + var schedule = SessionCapsCheckSchedule() + var timed: [SessionCapsArrivedService: Date] = [:] + let virtual = SessionCapsArrivedService(registryEntryID: 7, locationID: 0) + SessionRuntimeWorker.noteArrival([virtual, usb], at: start, schedule: &schedule, timed: &timed) + + XCTAssertTrue(schedule.arrivalPending) + XCTAssertEqual(schedule.unlisted, [usb]) + XCTAssertEqual(timed, [usb: start], "only tracked keyboards are timed") + } + @MainActor func testAnArrivalThatNeverMapsIsDroppedAtTheAttributionLimit() { let pending = SessionRuntimeWorker.arrivalLog([usb: start], now: start.addingTimeInterval(5), covered: []) diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 34770562c..c050ed08e 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -211,7 +211,7 @@ shared budget of twelve per 10 s (two keyboards' worth), however many arrivals t re-registering gets the backstop, not a continuous loop. A keyboard plugged in with keys held, or one still not listed, is left to the backstop, and a check that fails hands over to the backstop at once (lease contention, which usually clears -in moments, keeps the fast checks for up to three busy results). Classification runs off the +in moments, keeps the fast checks through two busy results; a third hands over). Classification runs off the event tap's run loop. A wake re-registers services hidutil already lists, so it costs one check. A press immediately after plugging in can still come before the keyboard is mapped; the `arrival mapped in N ms` log measures that window. From 33e0dabaf82e57488c751768b1479f5e05130720 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 10:59:15 -0700 Subject: [PATCH 11/16] Wait 0.25 s after an arrival before any check A held key on a newly plugged-in keyboard is known only after its first report has been processed. A check right after the IOKit notification could read all keys as up and map the keyboard while one is held. Every check, the backstop included, now waits out a 0.25 s pause after the latest arrival. This is unmeasured; a fixture test with a held key would settle it. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 9 +++- .../SessionRuntime/SessionRuntimeWorker.swift | 2 +- .../SessionCapsCheckScheduleTests.swift | 51 ++++++++++--------- docs/architecture/all-keyboards-caps.md | 5 +- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index 21d11b2a2..9b13d47b0 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -34,6 +34,10 @@ struct SessionCapsCheckSchedule: Equatable { private(set) var unlisted: Set = [] private(set) var fastChecksLeft = 0 private(set) var earlyStarts: [Date] = [] + /// The latest arrival. No check runs within `afterArrival` of it: a key held + /// on a new keyboard is known only once its first report has been processed, + /// and the all-keys-up gate must not read it before then. + private(set) var lastArrivalAt: Date? /// Bumped by each tracked arrival, so a check knows whether one landed while /// it ran (that arrival's fast checks start after it, not with it). private var arrivalGeneration = 0 @@ -46,8 +50,9 @@ struct SessionCapsCheckSchedule: Equatable { /// keyboard and is not virtual can earn fast checks until hidutil lists it. /// Returns those tracked services, which are what may be timed. @discardableResult - mutating func keyboardArrived(keyboards: [SessionCapsArrivedService]) -> [SessionCapsArrivedService] { + mutating func keyboardArrived(keyboards: [SessionCapsArrivedService], at now: Date) -> [SessionCapsArrivedService] { arrivalPending = true + lastArrivalAt = now // Unlike the rest of this subsystem, an unreadable (nil) LocationID counts // as real here: failing open costs a few checks, failing closed a keyboard. let real = keyboards.filter { $0.locationID != 0 } @@ -59,6 +64,8 @@ struct SessionCapsCheckSchedule: Equatable { } func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { + // No check, backstop included, in the settling pause after an arrival. + if let lastArrivalAt, now.timeIntervalSince(lastArrivalAt) < Self.afterArrival { return false } let wantsEarly = arrivalPending || (!unlisted.isEmpty && fastChecksLeft > 0) if wantsEarly, earlyStarts.count(where: { now.timeIntervalSince($0) < Self.budgetWindow }) < Self.earlyBudget, now.timeIntervalSince(lastEnd) >= Self.afterArrival diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index 8e24a9621..e83b9371b 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -666,7 +666,7 @@ public final class SessionRuntimeWorker { schedule: inout SessionCapsCheckSchedule, timed: inout [SessionCapsArrivedService: Date]) { - timed = recordArrivals(schedule.keyboardArrived(keyboards: keyboards), at: now, into: timed) + timed = recordArrivals(schedule.keyboardArrived(keyboards: keyboards, at: now), at: now, into: timed) } /// Interfaces of one keyboard register within moments of each other; a new diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index 1f5b5f089..90f97ab2b 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -5,6 +5,10 @@ import XCTest final class SessionCapsCheckScheduleTests: XCTestCase { private let start = Date(timeIntervalSince1970: 1_000_000) + /// Arrivals in most tests land a second before `start`, past the settling pause. + private var earlier: Date { + start.addingTimeInterval(-1) + } private let usb = SessionCapsArrivedService(registryEntryID: 42, locationID: 0x0110_0000) @@ -22,19 +26,20 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(2), lastStart: start, lastEnd: lastEnd)) } - func testAnArrivalMakesTheNextCheckDueAtOnce() { + func testAnArrivalsFirstCheckWaitsForItsKeysToBeKnown() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: [usb]) - - // The previous check finished a second ago, well past the pause. - XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.01), lastStart: start.addingTimeInterval(-1.5), - lastEnd: start.addingTimeInterval(-1))) + schedule.keyboardArrived(keyboards: [usb], at: start) + // The previous check finished long ago; only the new keyboard's settling pause applies. + let longAgo = start.addingTimeInterval(-5) + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.1), lastStart: longAgo, lastEnd: longAgo), + "a held key on the new keyboard may not be known yet") + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.25), lastStart: longAgo, lastEnd: longAgo)) } func testAnUnrecognisedOrVirtualArrivalEarnsOneCheckButNoFastChecks() { for keyboards in [[], [SessionCapsArrivedService(registryEntryID: 7, locationID: 0)]] { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: keyboards) + schedule.keyboardArrived(keyboards: keyboards, at: earlier) XCTAssertTrue(schedule.isDue(now: start, lastStart: .distantPast, lastEnd: .distantPast)) XCTAssertTrue(schedule.checkStarted(at: start, lastStart: .distantPast).isEmpty) @@ -45,7 +50,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testAKeyboardHidutilHasNotListedYetGetsFastChecks() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) XCTAssertEqual(schedule.checkStarted(at: start, lastStart: .distantPast), [usb]) // The first listing does not include the new service yet. let firstEnd = start.addingTimeInterval(0.1) @@ -64,7 +69,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testFastChecksAreCappedByCountForAServiceHidutilNeverLists() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) var now = start var checks = 0 while schedule.isDue(now: now, lastStart: now, lastEnd: now.addingTimeInterval(-1)), checks < 100 { @@ -80,7 +85,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testAWakeThatReRegistersListedKeyboardsCostsOneCheck() { var schedule = SessionCapsCheckSchedule() let builtIn = SessionCapsArrivedService(registryEntryID: 9, locationID: 0x0200_0000) - schedule.keyboardArrived(keyboards: [builtIn, usb]) + schedule.keyboardArrived(keyboards: [builtIn, usb], at: earlier) let arrived = schedule.checkStarted(at: start, lastStart: .distantPast) let resolved = SessionCapsCheckSchedule.resolved( arrived, listed: [row(9, location: 0x0200_0000), row(42, location: 0x0110_0000)], exists: { _ in true } @@ -93,7 +98,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testFastChecksAreSpacedFromTheLastCheckFinishing() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) _ = schedule.checkStarted(at: start, lastStart: .distantPast) let lastEnd = start.addingTimeInterval(0.4) schedule.checkFinished(resolved: []) @@ -104,7 +109,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { func testAFailedCheckHandsOverToTheBackstop() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) _ = schedule.checkStarted(at: start, lastStart: .distantPast) schedule.checkFailed() @@ -128,8 +133,8 @@ final class SessionCapsCheckScheduleTests: XCTestCase { var schedule = SessionCapsCheckSchedule() let virtual = SessionCapsArrivedService(registryEntryID: 7, locationID: 0) // A virtual keyboard (Karabiner, a VM's platform keyboard) is not timed. - XCTAssertEqual(schedule.keyboardArrived(keyboards: [virtual, usb]), [usb]) - XCTAssertEqual(schedule.keyboardArrived(keyboards: [virtual]), []) + XCTAssertEqual(schedule.keyboardArrived(keyboards: [virtual, usb], at: earlier), [usb]) + XCTAssertEqual(schedule.keyboardArrived(keyboards: [virtual], at: earlier), []) } func testRepeatedArrivalsShareOneEarlyCheckBudget() { @@ -139,7 +144,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { var early = 0 // A flapping device re-registers every 0.3 s, and every listing shows it. for _ in 0 ..< 30 { - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) if schedule.isDue(now: now, lastStart: lastStart, lastEnd: now.addingTimeInterval(-0.3)), now.timeIntervalSince(lastStart) < SessionCapsCheckSchedule.backstop || lastStart == .distantPast { @@ -163,7 +168,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { var now = start var lastStart = Date.distantPast // The first keyboard never shows up in hidutil's list and uses all its fast checks. - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) for _ in 0 ..< SessionCapsCheckSchedule.fastChecks { XCTAssertTrue(schedule.isDue(now: now, lastStart: lastStart, lastEnd: now.addingTimeInterval(-0.3))) _ = schedule.checkStarted(at: now, lastStart: lastStart) @@ -172,13 +177,13 @@ final class SessionCapsCheckScheduleTests: XCTestCase { now = now.addingTimeInterval(0.3) } // A second keyboard is plugged in a moment later. - schedule.keyboardArrived(keyboards: [SessionCapsArrivedService(registryEntryID: 43, locationID: 0x0120_0000)]) + schedule.keyboardArrived(keyboards: [SessionCapsArrivedService(registryEntryID: 43, locationID: 0x0120_0000)], at: now.addingTimeInterval(-0.25)) XCTAssertTrue(schedule.isDue(now: now, lastStart: lastStart, lastEnd: now.addingTimeInterval(-0.3))) } func testBusyResultsKeepFastChecksBrieflyThenHandOver() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) let due = { schedule.isDue(now: self.start, lastStart: self.start, lastEnd: self.start.addingTimeInterval(-1)) } for _ in 0 ..< SessionCapsCheckSchedule.busyRetries { _ = schedule.checkStarted(at: start, lastStart: start) @@ -190,7 +195,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertFalse(due(), "the next busy result hands over to the backstop") // A finished check resets the streak. - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) _ = schedule.checkStarted(at: start, lastStart: start) schedule.checkBusy() _ = schedule.checkStarted(at: start, lastStart: start) @@ -202,25 +207,25 @@ final class SessionCapsCheckScheduleTests: XCTestCase { var schedule = SessionCapsCheckSchedule() _ = schedule.checkStarted(at: start, lastStart: .distantPast) // A keyboard registers while the backstop check is in flight. - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) schedule.checkFinished(resolved: []) XCTAssertEqual(schedule.fastChecksLeft, SessionCapsCheckSchedule.fastChecks) } func testAnEarlyCheckWaitsForThePauseAfterTheLastOneFinished() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(0.1), lastStart: start, lastEnd: start)) XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(0.25), lastStart: start, lastEnd: start)) } func testAnArrivalDuringACheckIsNotLostWhenThatCheckStarted() { var schedule = SessionCapsCheckSchedule() - schedule.keyboardArrived(keyboards: [usb]) + schedule.keyboardArrived(keyboards: [usb], at: earlier) _ = schedule.checkStarted(at: start, lastStart: .distantPast) // A second keyboard arrives while the first check is still in flight. let second = SessionCapsArrivedService(registryEntryID: 43, locationID: 0x0120_0000) - schedule.keyboardArrived(keyboards: [second]) + schedule.keyboardArrived(keyboards: [second], at: earlier) schedule.checkFinished(resolved: [usb]) XCTAssertTrue(schedule.arrivalPending) diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index c050ed08e..a6b81dca9 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -198,7 +198,10 @@ single-keyboard record remains readable for recovery. **Hot-plug.** `SessionCapsKeyboardArrivalMonitor` listens for `IOHIDEventService` registrations (first-match notifications; services present at startup are ignored) and classifies each with the lease's own `serviceEmitsKeyboardUsages`, so a board's -shared NKRO interface counts. Every arrival earns one immediate check: a +shared NKRO interface counts. No check runs in the 0.25 s after an arrival: a key held +on the new keyboard is known only once its first report has been processed, and the +all-keys-up gate must not read it earlier (unmeasured; a fixture test with a held key +would settle it). Every arrival then earns one early check: a service whose capability can't be read yet counts too, since an extra inventory read is cheap and a missed arrival is not. One that reads as definitely not a keyboard (including a keyboard whose properties are only partly published) is left to the From e87589d3035291533cadd8f4d32048250602f598 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 11:11:16 -0700 Subject: [PATCH 12/16] Time the settling pause from the first arrival of a burst Timing it from the latest arrival let a device that re-registers faster than every 0.25 s block every check, the backstop included. The pause now runs from the first arrival since the last check started, so it delays a check by one pause at most. A tick-loop test at a 0.2 s re-registration rate pins this; it fails against the old rule. An arrival during a failed check now keeps its fast checks, as it already did on success. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 17 ++++--- .../SessionCapsCheckScheduleTests.swift | 46 ++++++++++++++++++- docs/architecture/all-keyboards-caps.md | 3 +- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index 9b13d47b0..b30ecbf51 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -34,10 +34,12 @@ struct SessionCapsCheckSchedule: Equatable { private(set) var unlisted: Set = [] private(set) var fastChecksLeft = 0 private(set) var earlyStarts: [Date] = [] - /// The latest arrival. No check runs within `afterArrival` of it: a key held - /// on a new keyboard is known only once its first report has been processed, - /// and the all-keys-up gate must not read it before then. - private(set) var lastArrivalAt: Date? + /// The first arrival since the last check started. No check runs within + /// `afterArrival` of it: a key held on a new keyboard is known only once its + /// first report has been processed, and the all-keys-up gate must not read + /// it before then. Timing from the first arrival, not the latest, means a + /// device that keeps re-registering delays a check by one pause at most. + private(set) var settlingSince: Date? /// Bumped by each tracked arrival, so a check knows whether one landed while /// it ran (that arrival's fast checks start after it, not with it). private var arrivalGeneration = 0 @@ -52,7 +54,7 @@ struct SessionCapsCheckSchedule: Equatable { @discardableResult mutating func keyboardArrived(keyboards: [SessionCapsArrivedService], at now: Date) -> [SessionCapsArrivedService] { arrivalPending = true - lastArrivalAt = now + if settlingSince == nil { settlingSince = now } // Unlike the rest of this subsystem, an unreadable (nil) LocationID counts // as real here: failing open costs a few checks, failing closed a keyboard. let real = keyboards.filter { $0.locationID != 0 } @@ -65,7 +67,7 @@ struct SessionCapsCheckSchedule: Equatable { func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { // No check, backstop included, in the settling pause after an arrival. - if let lastArrivalAt, now.timeIntervalSince(lastArrivalAt) < Self.afterArrival { return false } + if let settlingSince, now.timeIntervalSince(settlingSince) < Self.afterArrival { return false } let wantsEarly = arrivalPending || (!unlisted.isEmpty && fastChecksLeft > 0) if wantsEarly, earlyStarts.count(where: { now.timeIntervalSince($0) < Self.budgetWindow }) < Self.earlyBudget, now.timeIntervalSince(lastEnd) >= Self.afterArrival @@ -80,6 +82,7 @@ struct SessionCapsCheckSchedule: Equatable { /// previous check's start, which tells an early check from a backstop one. mutating func checkStarted(at now: Date, lastStart: Date) -> Set { arrivalPending = false + settlingSince = nil earlyStarts.removeAll { now.timeIntervalSince($0) >= Self.budgetWindow } if now.timeIntervalSince(lastStart) < Self.backstop { earlyStarts.append(now) } generationAtStart = arrivalGeneration @@ -105,6 +108,8 @@ struct SessionCapsCheckSchedule: Equatable { /// Fast retries would only spawn hidutil again, so the backstop takes over. mutating func checkFailed() { busyStreak = 0 + // An arrival that landed while the check ran keeps its fast checks. + guard arrivalGeneration == generationAtStart else { return } unlisted = [] fastChecksLeft = 0 } diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index 90f97ab2b..e9c0dd880 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -144,7 +144,7 @@ final class SessionCapsCheckScheduleTests: XCTestCase { var early = 0 // A flapping device re-registers every 0.3 s, and every listing shows it. for _ in 0 ..< 30 { - schedule.keyboardArrived(keyboards: [usb], at: earlier) + schedule.keyboardArrived(keyboards: [usb], at: now.addingTimeInterval(-0.25)) if schedule.isDue(now: now, lastStart: lastStart, lastEnd: now.addingTimeInterval(-0.3)), now.timeIntervalSince(lastStart) < SessionCapsCheckSchedule.backstop || lastStart == .distantPast { @@ -212,6 +212,50 @@ final class SessionCapsCheckScheduleTests: XCTestCase { XCTAssertEqual(schedule.fastChecksLeft, SessionCapsCheckSchedule.fastChecks) } + func testADeviceReRegisteringFasterThanThePauseStillGetsChecked() { + var schedule = SessionCapsCheckSchedule() + var lastStart = start.addingTimeInterval(-5) + var lastEnd = lastStart + var starts: [Date] = [] + // Re-registers every 0.2 s for 20 s, on a 5 ms tick; each check takes 50 ms. + var nextArrival = start + var inFlightUntil: Date? + var now = start + while now < start.addingTimeInterval(20) { + if now >= nextArrival { + schedule.keyboardArrived(keyboards: [usb], at: now) + nextArrival = now.addingTimeInterval(0.2) + } + if let done = inFlightUntil, now >= done { + schedule.checkFinished(resolved: [usb]) + lastEnd = now + inFlightUntil = nil + } + if inFlightUntil == nil, schedule.isDue(now: now, lastStart: lastStart, lastEnd: lastEnd) { + _ = schedule.checkStarted(at: now, lastStart: lastStart) + starts.append(now) + lastStart = now + inFlightUntil = now.addingTimeInterval(0.05) + } + now = now.addingTimeInterval(0.005) + } + let gaps = zip(starts.dropFirst(), starts).map { $0.timeIntervalSince($1) } + XCTAssertFalse(starts.isEmpty) + XCTAssertLessThanOrEqual(gaps.max() ?? 0, SessionCapsCheckSchedule.backstop + SessionCapsCheckSchedule.afterArrival + 0.01, + "the backstop is delayed by one pause at most") + // Early checks stay within the shared budget: at most 12 per 10 s plus one backstop per 2 s. + XCTAssertLessThanOrEqual(starts.count, 2 * SessionCapsCheckSchedule.earlyBudget + 10 + 1) + } + + func testAnArrivalDuringAFailedCheckKeepsItsFastChecks() { + var schedule = SessionCapsCheckSchedule() + _ = schedule.checkStarted(at: start, lastStart: .distantPast) + schedule.keyboardArrived(keyboards: [usb], at: start.addingTimeInterval(0.01)) + schedule.checkFailed() + XCTAssertEqual(schedule.unlisted, [usb]) + XCTAssertEqual(schedule.fastChecksLeft, SessionCapsCheckSchedule.fastChecks) + } + func testAnEarlyCheckWaitsForThePauseAfterTheLastOneFinished() { var schedule = SessionCapsCheckSchedule() schedule.keyboardArrived(keyboards: [usb], at: earlier) diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index a6b81dca9..b82058cec 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -198,7 +198,8 @@ single-keyboard record remains readable for recovery. **Hot-plug.** `SessionCapsKeyboardArrivalMonitor` listens for `IOHIDEventService` registrations (first-match notifications; services present at startup are ignored) and classifies each with the lease's own `serviceEmitsKeyboardUsages`, so a board's -shared NKRO interface counts. No check runs in the 0.25 s after an arrival: a key held +shared NKRO interface counts. No check runs in the 0.25 s after the first arrival since the last check (so a +device that keeps re-registering delays a check by one pause at most): a key held on the new keyboard is known only once its first report has been processed, and the all-keys-up gate must not read it earlier (unmeasured; a fixture test with a held key would settle it). Every arrival then earns one early check: a From dfbccee055968bb08971d12d45e1048de764fe00 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 11:26:59 -0700 Subject: [PATCH 13/16] Drive the stop test with semaphores; ignore future timestamps The post-stop test no longer sleeps. It blocks classification, stops the monitor, releases classification, and waits for its result to be handled. It fails if the stop guard is removed. A wall-clock step backwards no longer holds off checks or uses up the early budget. Co-Authored-By: Claude Opus 5.5 --- .../SessionCapsKeyboardArrivalMonitor.swift | 26 +++++++++++++---- .../SessionCapsCheckScheduleTests.swift | 28 +++++++++++++++++-- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift index b30ecbf51..0cd89b9ad 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -67,9 +67,9 @@ struct SessionCapsCheckSchedule: Equatable { func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { // No check, backstop included, in the settling pause after an arrival. - if let settlingSince, now.timeIntervalSince(settlingSince) < Self.afterArrival { return false } + if let settlingSince, Self.isRecent(settlingSince, now: now, within: Self.afterArrival) { return false } let wantsEarly = arrivalPending || (!unlisted.isEmpty && fastChecksLeft > 0) - if wantsEarly, earlyStarts.count(where: { now.timeIntervalSince($0) < Self.budgetWindow }) < Self.earlyBudget, + if wantsEarly, earlyStarts.count(where: { Self.isRecent($0, now: now, within: Self.budgetWindow) }) < Self.earlyBudget, now.timeIntervalSince(lastEnd) >= Self.afterArrival { return true @@ -77,13 +77,21 @@ struct SessionCapsCheckSchedule: Equatable { return now.timeIntervalSince(lastStart) >= Self.backstop } + /// Wall-clock time can step backwards (NTP, a manual change). A timestamp + /// from the "future" counts as old, so a step back can't hold off checks or + /// exhaust the early budget until the clock catches up. + private static func isRecent(_ date: Date, now: Date, within interval: TimeInterval) -> Bool { + let age = now.timeIntervalSince(date) + return age >= 0 && age < interval + } + /// A check is starting; it covers every arrival reported so far. Returns the /// arrived keyboards it should look for in its listing. `lastStart` is the /// previous check's start, which tells an early check from a backstop one. mutating func checkStarted(at now: Date, lastStart: Date) -> Set { arrivalPending = false settlingSince = nil - earlyStarts.removeAll { now.timeIntervalSince($0) >= Self.budgetWindow } + earlyStarts.removeAll { !Self.isRecent($0, now: now, within: Self.budgetWindow) } if now.timeIntervalSince(lastStart) < Self.backstop { earlyStarts.append(now) } generationAtStart = arrivalGeneration return unlisted @@ -218,15 +226,23 @@ final class SessionCapsKeyboardArrivalMonitor { /// Classifying can walk the registry and parse report descriptors, once per /// service in a batch. Keep that off the run loop the event tap shares, and /// drop a result that lands after `stop()`. - func classifyAndDeliver(_ services: [SessionCapsArrivedService]) { + /// `processed` runs on the main run loop once the result has been handled + /// (delivered, or dropped after `stop()`); for tests. + func classifyAndDeliver(_ services: [SessionCapsArrivedService], + processed: (@MainActor (_ delivered: Bool) -> Void)? = nil) + { guard !services.isEmpty else { return } let classify = emitsKeyboardUsages DispatchQueue.global(qos: .userInitiated).async { [weak self] in let answers = Self.classify(services, with: classify) CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) { [weak self] in MainActor.assumeIsolated { - guard let self, self.port != nil else { return } + guard let self, self.port != nil else { + processed?(false) + return + } self.deliver(services, answers) + processed?(true) } } CFRunLoopWakeUp(CFRunLoopGetMain()) diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift index e9c0dd880..40613365c 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -326,19 +326,41 @@ final class SessionCapsCheckScheduleTests: XCTestCase { @MainActor func testAClassificationFinishingAfterStopIsDropped() throws { + let started = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) var delivered = 0 let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { _ in - Thread.sleep(forTimeInterval: 0.05) + started.signal() + release.wait() return true }) { _ in delivered += 1 } try XCTSkipUnless(monitor.start(), "IOKit notifications unavailable on this host") - monitor.classifyAndDeliver([.init(registryEntryID: 1, locationID: 1)]) + let handled = expectation(description: "the classification's result was handled") + var wasDelivered: Bool? + monitor.classifyAndDeliver([.init(registryEntryID: 1, locationID: 1)]) { + wasDelivered = $0 + handled.fulfill() + } + // Classification is in progress; stop, then let it finish. + XCTAssertEqual(started.wait(timeout: .now() + 5), .success) monitor.stop() - RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + release.signal() + wait(for: [handled], timeout: 5) + + XCTAssertEqual(wasDelivered, false) XCTAssertEqual(delivered, 0) } + func testAClockStepBackDoesNotHoldOffChecks() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(keyboards: [usb], at: start) + // The clock steps back an hour right after the arrival. + let stepped = start.addingTimeInterval(-3600) + XCTAssertTrue(schedule.isDue(now: stepped, lastStart: stepped.addingTimeInterval(-5), + lastEnd: stepped.addingTimeInterval(-5))) + } + /// A LocationID with the high bit set, stored by IOKit as a signed CFNumber, /// must match the unsigned value hidutil prints. func testAHighBitLocationIDReadsTheSameFromIOKitAndHidutil() throws { From 5f90d7a8103307c888c02bfdaf55cd60485f9e8e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 11:29:18 -0700 Subject: [PATCH 14/16] Start the arrival monitor at most once per session Co-Authored-By: Claude Opus 5.5 --- .../Services/SessionRuntime/SessionRuntimeWorker.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index e83b9371b..53f4d0866 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -695,6 +695,8 @@ public final class SessionRuntimeWorker { /// Without the monitor the backstop check still finds arrivals, only later. private func startCapsArrivalMonitor() { + // Once per session: a second monitor would report every arrival twice. + guard capsArrivalMonitor == nil else { return } let monitor = SessionCapsKeyboardArrivalMonitor { [weak self] keyboards in guard let self, !self.finished else { return } Self.noteArrival(keyboards, at: Date(), schedule: &capsCheckSchedule, timed: &capsTimedArrivals) From 8a97f7da168a865106ea5ffca3019766851459b0 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 30 Oct 2026 21:33:44 -0700 Subject: [PATCH 15/16] Call the backoff rule off the main actor without a warning departedBackoffs runs on the check's background queue. It's pure, so it needn't be main-actor isolated. CI allows no build warnings. Co-Authored-By: Claude Opus 5.5 --- .../Services/SessionRuntime/SessionRuntimeWorker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index 53f4d0866..0dc406d14 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -631,7 +631,7 @@ public final class SessionRuntimeWorker { /// Keyboards whose failure backoff can be dropped: gone from the registry, /// not merely missing from one listing. - static func departedBackoffs(_ backingOff: Set, listed: Set, + nonisolated static func departedBackoffs(_ backingOff: Set, listed: Set, exists: (UInt64) -> Bool?) -> Set { backingOff.filter { !listed.contains($0) && exists($0) == false } From 68866c640bf7663aa533f89e49a860394b087b0b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 30 Oct 2026 21:47:00 -0700 Subject: [PATCH 16/16] Format the backoff rule's signature Co-Authored-By: Claude Opus 5.5 --- .../Services/SessionRuntime/SessionRuntimeWorker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index 0dc406d14..12fb946ca 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -632,7 +632,7 @@ public final class SessionRuntimeWorker { /// Keyboards whose failure backoff can be dropped: gone from the registry, /// not merely missing from one listing. nonisolated static func departedBackoffs(_ backingOff: Set, listed: Set, - exists: (UInt64) -> Bool?) -> Set + exists: (UInt64) -> Bool?) -> Set { backingOff.filter { !listed.contains($0) && exists($0) == false } }