macOS: key gate — hold the user's keystrokes during a correction (#8) - #22
Conversation
|
Thank you — this is the piece of macOS support I expected to be hardest, and you brought it back with the one diagnosis nobody could have made from reading the docs: that
CI is green on all three OSes and there are no conflicts. Three things I'd like sorted before it goes in, and only one of them is a real decision. 1. The default — this is the one I want your read onThe gate being on by default is the part I'm not ready to merge as-is, and the reason changed under you four days ago. And that cost is not Windows-specific — it comes from the engine, not the backend: So: did you feel it? You've been typing on this build on real hardware, which is more than we have for Windows. If the post-correction lag is not perceptible on your machine I want to know that, because it would mean the Windows measurement is not the whole story. If it is perceptible, I'd rather ship it opt-in on macOS too and revisit both platforms together — the mechanism stays exactly as you wrote it, only 2. A hole on our side that your PR makes live — worth closing hereNot your bug, but this PR is what makes it reachable. In if !last.is_empty() {
debug!(count = last.len(), "typing out the last held keystrokes");
let sent = self.key_emitter.send_keys(&last);
if let Err(e) = self.emit_held_keys(&last, to) {
warn!(?e, "flushing the last held keystrokes failed");
}The window is narrow — keys arriving between the last drain and the release — but it opens exactly for the fast typist the gate exists to protect, and it loses characters outright rather than scrambling them. Since you're the only one who can test it on hardware, I'd rather it rode along with this PR. 3. Docs and changelogThe gate landing on macOS makes four places untrue, and none of them are in code:
Docs tracking code is a release blocker here rather than a nicety, so it has to happen either way — but it's four short edits and you're the one who knows what's now true. If you'd rather not, say so and I'll take them. Nits — no action needed unless you feel like it
And two things I looked at and decided are fine, so nobody re-opens them later: the per-keystroke Housekeeping: the branch is one commit behind |
The tap moves from listen-only to active (when the gate is on), and the callback consults the gate on every KeyDown/KeyUp: held keys are still forwarded to the engine (it replays them behind the correction) but return NULL to the window server. Our own emissions bypass the gate via the EMITTER_TAG stamp; FlagsChanged is never swallowed (a held modifier edge without its counterpart would stick the system modifier state). - HoldState moves from windows/ to a shared crate::hold — the swallow decision is identical on both platforms and its tests must keep running on every host. - MacosGate is on by default with POLTERTYPE_HOLD_KEYS=0 as the escape hatch, and reports available() only while the tap is actually running — a gate that claims to hold when nothing is listening would make corrections skip compensation and lose text. - The callback re-enables the tap on kCGEventTapDisabledByTimeout / ByUserInput instead of staying deaf. Validated on macOS 15 (Intel): corrections no longer interleave with fast typing.
0.24's cg_event_tap_callback_internal maps a callback's None back to the ORIGINAL event, so an active tap returned the event either way and the gate 'swallowed' nothing — held keys reached the app directly *and* were replayed by the flush, doubling them. 0.25's CallbackResult::Drop returns NULL, which is the actual swallow. Migrates the callback to the new API (Keep/Drop, mach_port()).
- Default flips to opt-in (POLTERTYPE_HOLD_KEYS=1), same as Windows: the flush latency after every correction is an engine-side cost, not a backend one, so it is not a trade to make on everybody's behalf. Doc comment updated to match. - correction.rs: the post-release sweep emitted held keystrokes via send_keys, which is Unsupported on macOS/Windows — they were swallowed from the app and then dropped. Now goes through emit_held_keys like the main flush path. - Docs: CHANGELOG [Unreleased] entry, README hold-back paragraph, PERMISSIONS.md macOS gate section, PLAN.md status. - TAP_PORT: document the one-tap-per-process construction and the enable-then-set ordering being the safe direction. - Unit tests for MacosGate: unavailable until the tap runs, env=0 path, opt-in default (run on the macOS CI job).
0d01861 to
86756d5
Compare
|
All three sorted in the force-push ( 1. The default. Flipped to opt-in, same knob and semantics as Windows. On the latency question — honest answer: my validation was scripted (a key poster racing a correction), not perceptual typing, so I can't give you a felt-sense verdict. What the trace shows from the hardware run: the four racer keys were flushed inside the burst window (last flush ~110 ms before 2. The final sweep. Closed — 3. Docs. All four: Nits: TAP_PORT now documents one-tap-per-process and the enable-then-set ordering being the safe direction. The Verification on this push: |
audio: release the cached output stream when idle — HDMI output blocks macOS sleep. Merged here rather than on GitHub: #22 landed first and both PRs opened a `## [Unreleased]` section, so the changelog conflicted. Both entries are kept verbatim, split into `### Added — macOS: the key gate (opt-in)` and `### Fixed — macOS` under the one heading; nothing else in the merge needed a decision.
The three `MacosGate` tests each drive the gate through `POLTERTYPE_HOLD_KEYS`, a process-global, and the harness runs them on separate threads. Nothing stopped `default_is_opt_in`'s `remove_var` from landing between another test's `set_var` and its `MacosGate::new()` — or its own gate from being built while `=1` was briefly in force. Either way the wrong gate gets constructed and an assertion fails on a machine nobody is watching: these are `cfg(target_os = "macos")`, so the flake would only ever appear on the macOS CI job, intermittently, in a PR that had nothing to do with it. A mutex around the env-mutating section fixes it. Poisoning is stepped over with `into_inner` on purpose: when one test fails while holding the lock, the others should still report their own verdict instead of turning one real failure into three panics. Found while reviewing #22; the gate itself is unchanged.
Step 2 of the release, and it caught what step 2 exists to catch: #22 updated the README's hold-back paragraph to cover macOS, then left a sentence forty lines below saying "the keystroke hold-back remains Linux-only there, as on Windows." Both were in the same file, and the true one made the stale one look reviewed. The same paragraph's other half was stale in the other direction: it said the 0.7.0 macOS input changes "have still not been run on a Mac by anyone". Validating the key gate ran the tap and the emitter on Intel hardware, so that is no longer true — but the held-modifier case those changes exist for still has nobody's report behind it, and Apple Silicon none at all, so the warning is narrowed rather than deleted. PLAN.md gets its `Last updated` stamp moved to v0.13.0; its body was already updated in #22.
Implements #8 on real hardware (macOS 15.7, Intel Mac Pro).
What changes
The event tap moves from listen-only to active (when the gate is on), and the callback consults the gate on every KeyDown/KeyUp: a held keystroke is still forwarded to the engine — which replays it behind the correction — but the callback drops it for the window server. Engine side needed nothing:
HeldKeys/ the flush path were already platform-ready.HoldStatemoveswindows/hold.rs→crate::hold. The swallow decision is identical on both platforms, and its tests ("the user's keyboard always comes back") keep running on every host.MacosGatemirrorsWindowsGate: on by default,POLTERTYPE_HOLD_KEYS=0as the escape hatch (inverse default from Windows, which has never run on hardware — this one has). `available()) additionally requires the tap to be running: a gate that claims to hold when nothing is listening would make corrections skip their compensation path and lose text.EMITTER_TAGstamp (kCGEventSourceUserData), the same self-deadlock guard the evdev gate needed.FlagsChangedis never swallowed — a held modifier edge without its counterpart would stick the system modifier state.kCGEventTapDisabledByTimeout/ByUserInputinstead of staying deaf.The dependency bump is the load-bearing part
core-graphics 0.24's tap trampoline cannot swallow:cg_event_tap_callback_internalmaps a callback'sNoneback to the original event, so an active tap returned the event either way. The gate 'held' keys, macOS delivered them anyway, and the flush replayed them — the user's characters landed twice. 0.25'sCallbackResult::Dropreturns NULL, which is the actual swallow. Migrating the callback to the new API is most of the diff.Reproduced and fixed on hardware: a 4-key racer burst fired mid-correction went from
привет ффыывава(doubled, 0.24) toпривет фыва(0.25) — held keys re-emitted exactly once, in order, in the freshly switched layout (typed-in-new-layout semantics, as on evdev).Test plan
holds_keys=trueat startupcargo fmt --all --checkcleancargo clippy --workspace --all-targets --locked -- -D warningscleancargo test --workspace --locked— same result as main on a macOS host (112 passed; 5 pre-existingplugins::menufailures that need the main thread, unrelated — they fail identically on unmodified main)Known bounds, same as the Linux gate: a mid-correction chord (shortcut) is unreproducible and documented as lost;
Enter/Tabheld mid-burst are dropped by design (submission keys are never re-emitted).