Skip to content

fix(a11y): eliminate ghost keystrokes on macOS — remove probe tap + graceful shutdown#3916

Merged
louis030195 merged 2 commits into
screenpipe:mainfrom
divanshu-go:fix-phantom-keys
Jun 9, 2026
Merged

fix(a11y): eliminate ghost keystrokes on macOS — remove probe tap + graceful shutdown#3916
louis030195 merged 2 commits into
screenpipe:mainfrom
divanshu-go:fix-phantom-keys

Conversation

@divanshu-go

@divanshu-go divanshu-go commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

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 throwaway CGEventTap every 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:

  • CGEventTap removal was abrupt — removed from run loop without first disabling it, leaving events in the kernel buffer
  • AXObserver cleanup was incomplete — notifications not removed before observer was torn down
  • Stop flag raceOrdering::Relaxed reads meant callback threads might not see the stop signal during shutdown

Fix:

  • InstalledTap RAII struct: teardown() disables tap → removes run loop source → invalidates source → invalidates tap → drains run loop (modelled after KeyCastr commit 1025e8f)
  • Stop flag added to TapState; callback returns early if stop is set
  • teardown() called before final flush (not after) to close the race window
  • AXObserver notifications explicitly removed before removing from run loop
  • 3 × 50 ms run loop drain after AXObserver teardown to let Chromium process the graceful disconnect
  • Ordering::RelaxedOrdering::Acquire on all stop flag reads
  • AXManualAccessibility instead of AXEnhancedUserInterface — 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

  1. Start screenpipe and use it for a few minutes
  2. Open Discord desktop app
  3. Type some words in a Discord input box
  4. Quit screenpipe from the macOS tray menu
  5. Continue typing in Discord — no phantom keystrokes should appear

Unit tests added in crates/screenpipe-a11y:

  • test_no_ghost_keystrokes_after_stop — verifies callback drops events once stop flag is set
  • test_stop_flag_concurrent — 8 threads racing against stop flag, asserts no writes leak through

References

@louis030195 louis030195 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@divanshu-go divanshu-go changed the title fix(a11y): prevent phantom keystrokes on shutdown in Chromium/Electron apps fix(a11y): eliminate ghost keystrokes on macOS — remove probe tap + graceful shutdown Jun 9, 2026
@divanshu-go

divanshu-go commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

@louis030195

crates/screenpipe-a11y/src/platform/macos.rs:760 — @divanshu-go does run_event_tap run multiple times?

No, spawned exactly once — gated behind if perms.input_monitoring. The Box::into_raw is intentional for the C callback userdata, reclaimed via Box::from_raw at shutdown.

crates/screenpipe-a11y/src/platform/macos.rs:1062 — does try_lock fail often?

Almost never — only contention is the ~300 ms flush window. It's purely a safety valve against TAP_DISABLED_BY_TIMEOUT; dropping a captured char beats stalling the kernel callback.

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
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>
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.

macOS: a11y tree-walk + AXEnhancedUserInterface poke stalls focused Electron app → keyboard lag/stutter

2 participants