diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift new file mode 100644 index 000000000..0cd89b9ad --- /dev/null +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsKeyboardArrivalMonitor.swift @@ -0,0 +1,276 @@ +import Foundation +import IOKit + +/// A keyboard event service IOKit has just registered. +struct SessionCapsArrivedService: Hashable, Sendable { + 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. 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 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, 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. + /// 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 + /// Arrived keyboards hidutil has not listed yet. + private(set) var unlisted: Set = [] + private(set) var fastChecksLeft = 0 + private(set) var earlyStarts: [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 + 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. + /// Returns those tracked services, which are what may be timed. + @discardableResult + mutating func keyboardArrived(keyboards: [SessionCapsArrivedService], at now: Date) -> [SessionCapsArrivedService] { + arrivalPending = true + 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 } + guard !real.isEmpty else { return [] } + unlisted.formUnion(real) + fastChecksLeft = Self.fastChecks + arrivalGeneration += 1 + return real + } + + func isDue(now: Date, lastStart: Date, lastEnd: Date) -> Bool { + // No check, backstop included, in the settling pause after an arrival. + 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: { Self.isRecent($0, now: now, within: Self.budgetWindow) }) < Self.earlyBudget, + now.timeIntervalSince(lastEnd) >= Self.afterArrival + { + return true + } + 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 { !Self.isRecent($0, now: now, within: 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) + 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 + // An arrival that landed while the check ran keeps its fast checks. + guard arrivalGeneration == generationAtStart else { return } + unlisted = [] + fastChecksLeft = 0 + } + + /// 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 + } + } +} + +/// 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: (_ keyboards: [SessionCapsArrivedService]) -> Void + 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 @Sendable (UInt64) -> Bool? = SessionCapsHIDUtilTransport.serviceEmitsKeyboardUsages, + onArrival: @escaping (_ keyboards: [SessionCapsArrivedService]) -> Void) + { + self.emitsKeyboardUsages = emitsKeyboardUsages + self.onArrival = onArrival + } + + func start() -> Bool { + 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( + 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 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. + let readable = IORegistryEntryGetRegistryEntryID(service, &id) == KERN_SUCCESS + services.append(.init(registryEntryID: readable ? id : 0, locationID: Self.locationID(of: service))) + } + 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? { + 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 + /// service in a batch. Keep that off the run loop the event tap shares, and + /// drop a result that lands after `stop()`. + /// `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 { + processed?(false) + return + } + self.deliver(services, answers) + processed?(true) + } + } + 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. + /// 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 } + 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 dec591456..12fb946ca 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? + /// 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() @@ -45,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 @@ -262,6 +268,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 +625,97 @@ 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 + + /// 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 + { + backingOff.filter { !listed.contains($0) && exists($0) == false } + } + + /// 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]) + { + 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()) + lines.append("[SessionCaps] Keyboard arrival mapped in \(elapsed) ms: [\(keyboard.id)]") + for service in matches.keys { + remaining.removeValue(forKey: service) + } + } + 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), 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 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] + { + 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 { other, at in + other == service || other.locationID != location || now.timeIntervalSince(at) < replugGap + } + } + timed[service] = now + } + return timed + } + + /// 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) + } + 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(), lastStart: lastCapsCheck, lastEnd: lastCapsCheckEnd) else { return } checkCapsFleetIfNeeded() return } + guard Date().timeIntervalSince(lastCapsCheck) >= 0.5 else { return } guard let record = capsRecord else { return } capsCheckInFlight = true lastCapsCheck = Date() @@ -656,25 +747,40 @@ public final class SessionRuntimeWorker { } private func checkCapsFleetIfNeeded() { + 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() 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, departed: Set, + 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() @@ -689,7 +795,24 @@ public final class SessionRuntimeWorker { bootSessionUUID: owner.bootSessionUUID, allowNewDevices: SessionCapsRuntimeSupport.heldKeys().isEmpty ) - result = .success((coverage.records, coverage.failures, devices.count, inventory.withheld)) + // 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 + ) + // A backoff is dropped only once its keyboard is gone, not when one + // listing happens to miss it. + 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, resolved)) } catch { result = .failure(error) } @@ -697,6 +820,7 @@ public final class SessionRuntimeWorker { MainActor.assumeIsolated { guard let self, !self.finished, self.capsOwner == owner else { return } self.capsCheckInFlight = false + self.lastCapsCheckEnd = Date() guard digest == nil || digest == admittedDigest else { self.finish(.failed, reason: SessionCapsRuntimeSupport.profileChangedReason) } @@ -716,6 +840,11 @@ 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. + for id in coverage.departed where coverage.failures[id] == nil { + self.capsFleetRetryAfter.removeValue(forKey: id) + } + self.capsCheckSchedule.checkFinished(resolved: coverage.resolved) let nextCoverage = SessionRuntimeReport.CapsCoverage( eligible: coverage.eligible, covered: coverage.records.count, withheld: coverage.withheld ) @@ -725,8 +854,26 @@ public final class SessionRuntimeWorker { for (id, reason) in coverage.failures where !previous.contains(id) { AppLogger.shared.warn("[SessionCaps] Keyboard \(id) left uncovered: \(reason)") } + let added = current.subtracting(previous) + if !added.isEmpty { + AppLogger.shared.log("[SessionCaps] Keyboard covered: \(added.sorted())") + } + 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): - if case SessionCapsMappingLease.Refusal.busy = error { return } + self.capsTimedArrivals = self.capsTimedArrivals.filter { + Date().timeIntervalSince($0.value) < Self.arrivalAttributionLimit + } + if case SessionCapsMappingLease.Refusal.busy = error { + self.capsCheckSchedule.checkBusy() + return + } + self.capsCheckSchedule.checkFailed() if error is CapsInventoryFailure { self.capsFleetReadFailures += 1 if self.capsFleetReadFailures == 3 { @@ -957,6 +1104,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..40613365c --- /dev/null +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsCheckScheduleTests.swift @@ -0,0 +1,515 @@ +import Foundation +import IOKit +@testable import KeyPathAppKit +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) + + 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. + let lastEnd = start.addingTimeInterval(0.6) + + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1.9), lastStart: start, lastEnd: lastEnd)) + XCTAssertTrue(schedule.isDue(now: start.addingTimeInterval(2), lastStart: start, lastEnd: lastEnd)) + } + + func testAnArrivalsFirstCheckWaitsForItsKeysToBeKnown() { + var schedule = SessionCapsCheckSchedule() + 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, at: earlier) + 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))) + } + } + + func testAKeyboardHidutilHasNotListedYetGetsFastChecks() { + var schedule = SessionCapsCheckSchedule() + 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) + 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: back to the backstop. + let second = firstEnd.addingTimeInterval(0.25) + 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))) + } + + func testFastChecksAreCappedByCountForAServiceHidutilNeverLists() { + var schedule = SessionCapsCheckSchedule() + 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 { + _ = schedule.checkStarted(at: start, lastStart: .distantPast) + schedule.checkFinished(resolved: []) + checks += 1 + now = now.addingTimeInterval(0.3) + } + XCTAssertEqual(checks, SessionCapsCheckSchedule.fastChecks) + XCTAssertTrue(schedule.unlisted.isEmpty) + } + + func testAWakeThatReRegistersListedKeyboardsCostsOneCheck() { + var schedule = SessionCapsCheckSchedule() + let builtIn = SessionCapsArrivedService(registryEntryID: 9, locationID: 0x0200_0000) + 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 } + ) + schedule.checkFinished(resolved: resolved) + + XCTAssertFalse(schedule.isDue(now: start.addingTimeInterval(1), lastStart: start, + lastEnd: start.addingTimeInterval(0.3))) + } + + func testFastChecksAreSpacedFromTheLastCheckFinishing() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(keyboards: [usb], at: earlier) + _ = schedule.checkStarted(at: start, lastStart: .distantPast) + let lastEnd = start.addingTimeInterval(0.4) + 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 testAFailedCheckHandsOverToTheBackstop() { + var schedule = SessionCapsCheckSchedule() + schedule.keyboardArrived(keyboards: [usb], at: earlier) + _ = schedule.checkStarted(at: start, lastStart: .distantPast) + 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 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]) + } + + func testOnlyNonVirtualKeyboardsAreTracked() { + 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], at: earlier), [usb]) + XCTAssertEqual(schedule.keyboardArrived(keyboards: [virtual], at: earlier), []) + } + + 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], at: now.addingTimeInterval(-0.25)) + 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 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], 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) + 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)], 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], 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) + 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], at: earlier) + _ = 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], at: earlier) + schedule.checkFinished(resolved: []) + 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) + 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], 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], at: earlier) + schedule.checkFinished(resolved: [usb]) + + XCTAssertTrue(schedule.arrivalPending) + 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 + func testMonitorStartsWithoutReportingKeyboardsAlreadyConnected() throws { + var arrivals = 0 + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { _ in true }) { _ in arrivals += 1 } + defer { monitor.stop() } + + 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. 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") + } + + /// 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 { + let started = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + var delivered = 0 + let monitor = SessionCapsKeyboardArrivalMonitor(emitsKeyboardUsages: { _ in + started.signal() + release.wait() + return true + }) { _ in delivered += 1 } + try XCTSkipUnless(monitor.start(), "IOKit notifications unavailable on this host") + + 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() + 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 { + 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 { + 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] + 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([service(2)]) + XCTAssertEqual(arrivals, [], "a readable non-keyboard service (a mouse) is not an arrival") + 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) + } + + /// 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") } + 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 + } + } + // 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 + 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 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 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: []) + XCTAssertEqual(pending.remaining, [usb: start]) + + let expired = SessionRuntimeWorker.arrivalLog( + [usb: start], now: start.addingTimeInterval(SessionRuntimeWorker.arrivalAttributionLimit), + covered: [(id: 42, location: 0x0110_0000)] + ) + 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 a17a01c3b..b82058cec 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -1,15 +1,16 @@ # 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. 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 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 +57,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 +195,36 @@ 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) +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 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 +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 +spawns several hidutil processes, so every check ahead of the backstop draws on one +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 usually clears +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. +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'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. @@ -279,7 +308,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..bd0b8d5c6 100644 --- a/guides/driverless-caps-lock.md +++ b/guides/driverless-caps-lock.md @@ -65,10 +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. -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 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. + +Protected password fields and Secure Input still prevent dynamic remapping. ## Can a rule turn Caps Lock on?