Skip to content

Map a newly plugged-in keyboard's Caps Lock as soon as it arrives - #1343

Open
malpern wants to merge 14 commits into
masterfrom
feature/all-keyboards-caps
Open

malpern wants to merge 14 commits into
masterfrom
feature/all-keyboards-caps

Conversation

@malpern

@malpern malpern commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Summary

With all-keyboards Caps Lock (#1339), a newly plugged-in keyboard was mapped by the worker's 2 s poll. Now an IOKit notification brings that check forward.

  • SessionCapsKeyboardArrivalMonitor listens for IOHIDEventService first-match notifications. Services already present at startup are ignored. It classifies each new service with the lease's own serviceEmitsKeyboardUsages, on a background queue rather than the event tap's run loop, and fails open when a service's capability can't be read yet.
  • SessionCapsCheckSchedule decides when the fleet reconcile runs:
    • No check runs in the 0.25 s after the first arrival since the last check. A held key on the new keyboard isn't known until its first report has been processed.
    • Then one early check runs. If hidutil hasn't listed the keyboard yet (matched by registry ID, or another interface at the same LocationID), up to five more follow, each 0.25 s after the previous one finishes.
    • All early checks share a budget of 12 per 10 s, so a flapping device falls back to the 2 s backstop. That backstop is measured between check starts and delayed by one pause at most.
    • A failed check hands over to the backstop. Lease contention keeps the fast checks through two busy results.
  • Logging: [SessionCaps] Keyboard covered for each newly covered keyboard, and [SessionCaps] Keyboard arrival mapped in N ms, timed from that keyboard's own arrival. A keyboard covered for another reason isn't timed. A sibling interface keeps the first arrival time; a replug at the same LocationID resets it.
  • Docs: the hot-plug section of docs/architecture/all-keyboards-caps.md is rewritten. guides/driverless-caps-lock.md describes plugging in a keyboard.

Eligibility, exclusions, the all-keys-up gate and the existing-remap policy are unchanged. The monitor only schedules checks.

Testing

On the MacBook Air (Xcode 27.0):

  • ./Scripts/run-tests-safe.sh: 5,860 tests passed.
  • SessionCapsCheckScheduleTests (31 tests) cover:
    • the schedule;
    • the off-main classify and on-main delivery path, including a result that lands after stop();
    • a 20 s tick-loop simulation of a device re-registering every 0.2 s;
    • replug, sibling-interface and attribution timing;
    • backoff pruning;
    • a unit check that a high-bit LocationID reads the same from IOKit and hidutil.
  • Opt-in live tests, run with KEYPATH_LIVE_CAPS_FLEET=1, passed on the Air: the lease predicate recognises an attached keyboard, and IOKit and hidutil agree on LocationIDs.
  • Two tests were checked by mutation and fail against the bugs they guard: reporting the startup inventory as arrivals, and timing the pause from the latest arrival.
  • ./Scripts/review-gate.sh: PASS, after nine failing rounds whose findings are all addressed in this branch.

Not verified

  • No physical replug test yet. The arrival mapped in N ms time hasn't been measured. The Air is in single-keyboard mode; switch to all keyboards in Settings, then plug in an external keyboard and read the log.
  • Whether 0.25 s is long enough for macOS to report a key held on a new keyboard is unmeasured. A second keyboard arriving inside the first one's pause is checked less than 0.25 s after its own arrival. The ESP32 fixture with a key held would settle both, once the mini is back.
  • CI and the lab are unavailable while the mini is offline.

🤖 Generated with Claude Code

malpern and others added 12 commits October 25, 2026 08:24
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T18:22:01.716939Z e87589d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Oct 25, 2026

Copy link
Copy Markdown

Review of PR 1343 (IOKit arrival-triggered Caps fleet check)

I found no blocking correctness issues in the supplied diff. The diff was truncated at 60 KB, so I did not see the tail of docs/architecture/all-keyboards-caps.md or the guide change. I also couldn't build or run the tests here.

What I checked:

  • The schedule logic holds up: the settling pause, the shared early-check budget, the busy/failed hand-over to the backstop, and the arrival-generation handling all behave correctly in the cases I traced.
  • The monitor handles teardown correctly. The port and source are removed in stop(), and the self.port != nil check drops classifications that finish after stop().

Minor, non-blocking:

  1. Wall-clock time: SessionCapsCheckSchedule takes Date and compares it against lastStart, lastEnd, earlyStarts and settlingSince. A backward clock step (NTP, manual change) makes timeIntervalSince negative. That can leave settlingSince or earlyStarts entries looking fresh, which would delay checks past the settling pause or exhaust the early budget until the clock catches up. The 2 s backstop is also blocked while settlingSince is set. A monotonic source (ContinuousClock or ProcessInfo.systemUptime) would be safer. The worker already uses Date elsewhere, so this is optional.
  2. Test timing: testMonitorStartsWithoutReportingKeyboardsAlreadyConnected and testAClassificationFinishingAfterStopIsDropped rely on real RunLoop.main.run(until:) sleeps (0.5 s and 0.3 s). They can't fail spuriously, but a late delivery could pass unnoticed on a slow host.
  3. Unreadable registry IDs: drain maps an unreadable registry ID to 0. classify handles this by returning nil, so these arrivals trigger a check but are never tracked. That matches the documented fail-open intent.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e87589d303

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 <noreply@anthropic.com>
@claude

claude Bot commented Oct 25, 2026

Copy link
Copy Markdown

Review of PR 1343 (the supplied diff was truncated at 60 KB, so the rest of the docs and guide changes were not reviewed).

I found no blocking correctness issues in the reviewed part (the arrival monitor, the check schedule, the worker changes and the tests).

  • Schedule logic: The settling pause, the shared early-check budget (12 per 10 s), the generation counter for arrivals that land mid-check, and the busy/failed hand-over to the backstop are consistent with each other. The tests cover them, including a clock step back and a device that re-registers faster than the pause.
  • Monitor lifecycle: Classification runs off the main thread, and a result that lands after stop() is dropped. finish() stops the monitor.
  • Nit: startCapsArrivalMonitor() assigns capsArrivalMonitor unconditionally. This is fine if the fleet-enable path runs once, which I could not confirm from the diff. If it can run again, stop the old monitor before replacing it, or guard on capsArrivalMonitor == nil.
  • Test gap: The worker-side wiring in checkCapsFleetIfNeeded is not covered by a test. That wiring covers the resolved computation from the captured hidutil listing, the departed backoff cleanup, and the checkBusy/checkFailed routing. Only the static helpers are tested. This is acceptable given the live-hardware dependency, and the physical replug measurement is still marked as pending in the docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Oct 25, 2026

Copy link
Copy Markdown

Review (diff truncated at 60k bytes; docs/guides tail not reviewed)

Found two things worth addressing:

  1. checkFinished can clear fast checks after a failed/busy-path mismatch — in SessionRuntimeWorker.checkCapsFleetIfNeeded, the .failure branch for busy calls checkBusy() but never updates capsTimedArrivals for coverage or resolves anything; that's fine. However lastCapsCheckEnd is set in the completion handler before the guard digest == nil || digest == admittedDigest / capsOwner == owner checks pass only for the owner case. If capsOwner != owner the early return skips capsCheckInFlight = false, which is pre-existing, but the new schedule state (checkStarted already called) is left without a matching checkFinished/checkFailed. Low risk, but generationAtStart stays stale.

  2. Settling pause can starve the backstop — isDue returns false for the entire afterArrival (0.25 s) window from the first arrival, and settlingSince is only cleared in checkStarted. Since the window is anchored at the first arrival and not extended, starvation is bounded (good), but a device re-registering faster than 0.25 s while earlyBudget is exhausted still gets one check per 0.25 s window only when wantsEarly; otherwise it falls to the backstop and is fine. Test testADeviceReRegisteringFasterThanThePauseStillGetsChecked covers this. No action needed.

  3. Test coverage: SessionCapsKeyboardArrivalMonitor.start() failure path and stop() during an in-flight IOKit callback are only covered on hosts with IOKit (tests skip otherwise); CI on non-hardware runners may exercise little of the monitor.

No blocking correctness issues found in the visible portion. 🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant