fix(a11y): eliminate ghost keystrokes on macOS — remove probe tap + graceful shutdown#3916
Conversation
louis030195
left a comment
There was a problem hiding this comment.
crates/screenpipe-a11y/src/platform/macos.rs:760 — @divanshu-go does run_event_tap run multiple times? leak is tiny fyi
crates/screenpipe-a11y/src/platform/macos.rs:1062 — does try_lock fail often? nice tradeoff for latency
crates/screenpipe-a11y/src/tree/macos.rs:365 — great research on axmanualaccessibility
generated by the screenpipe pr-review pipe (https://screenpi.pe), not written by a human — reply and tag @louis030195 if it got something wrong.
7213036 to
2ae8319
Compare
No, spawned exactly once — gated behind
Almost never — only contention is the ~300 ms flush window. It's purely a safety valve against |
Quitting the recorder (e.g. from the tray) could inject ghost keystrokes into the focused app — typed text coming back mangled or with garbage appended (e.g. "hello hello" → "ello ell ell"), most visibly in Chromium/Electron apps like Discord. Root causes fixed: - Probe tap on every permission poll: removed probe_listen_event_tap(). Permission checks now read INPUT_MONITORING_GROUND_TRUTH (populated by the real capture tap) falling back to CGPreflightListenEventAccess. Modelled after keycastr/keycastr@1025e8f - Shutdown race: tap callback now checks state.stop (Acquire) first and drops events immediately when teardown begins. - InstalledTap RAII guard: idempotent teardown — set_enabled(false) → remove_src → invalidate src → invalidate tap → drain run loop. Drop impl as safety net against panics. - TAP_DISABLED_BY_TIMEOUT replay burst: re-enable tap immediately via tap_ptr; use try_lock in callback so a slow flush can't stall past the kernel timeout. - Chromium AX replay: remove AXObserver notifications before teardown, clear AXManualAccessibility to signal graceful disconnect, drain run loop 3× so Chromium flushes buffered keystrokes instead of replaying. Ref: electron/electron#10305 - Memory ordering: Relaxed → Acquire on all stop-flag reads. - TapState Box::leak reclaimed on stop (was a per-session leak). Regression tests: test_no_ghost_keystrokes_after_stop, test_stop_flag_concurrent. UI input-monitoring card updated to reflect the no-probe model. Closes screenpipe#3789
2ae8319 to
778405d
Compare
Fixes the cargo fmt --all --check failure (the only red required check). Pure formatting, no logic change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Problem
When quitting screenpipe from the macOS tray menu, Chromium/Electron apps (like Discord) would receive "residue words" — phantom keystrokes that appeared as duplicated or dropped characters in text fields.
Example: Typing "hello hello" in Discord's chat box, then quitting screenpipe from the tray menu, would result in "ello ell ell" appearing in the text field.
Root Causes
Two independent issues both contributed to ghost keystrokes:
1. Probe tap on every permission poll
probe_listen_event_tap()created and immediately destroyed a throwawayCGEventTapevery 1–3 s while the permission-check loop was running. Creating a tap — even a listen-only one — injects into the kernel event pipeline; tearing it down mid-keystroke dropped or duplicated characters.Fix: Replaced with
INPUT_MONITORING_GROUND_TRUTH: AtomicI8— a process-wide cache updated once by the real tap creation. No probe tap is ever created again. Modelled after KeyCastr commit 1025e8f.2. Abrupt CGEventTap / AXObserver shutdown
Chromium/Electron apps detect when an accessibility client (like screenpipe) connects via
AXObserver. Chromium buffers keystrokes while the AT is connected. If the client disconnects abruptly — without removing notifications or draining the run loop — Chromium replays those buffered keystrokes into the focused text field.Our shutdown sequence had several issues:
Ordering::Relaxedreads meant callback threads might not see the stop signal during shutdownFix:
InstalledTapRAII struct:teardown()disables tap → removes run loop source → invalidates source → invalidates tap → drains run loop (modelled after KeyCastr commit 1025e8f)TapState; callback returns early if stop is setteardown()called before final flush (not after) to close the race windowOrdering::Relaxed→Ordering::Acquireon all stop flag readsAXManualAccessibilityinstead ofAXEnhancedUserInterface— avoids the full screen-reader semantics that carry the replay behaviour (see Electron PR #10305)Before
Kapture.2026-06-09.at.00.40.12.mp4
After
aftertt.mov
Closes #3789
Testing
Unit tests added in
crates/screenpipe-a11y:test_no_ghost_keystrokes_after_stop— verifies callback drops events once stop flag is settest_stop_flag_concurrent— 8 threads racing against stop flag, asserts no writes leak throughReferences