feat: CGEvent tap diagnostics — conflict detection + live event monitor#276
feat: CGEvent tap diagnostics — conflict detection + live event monitor#276AprilNEA wants to merge 4 commits into
Conversation
Enumerate every event tap in the login session via CGGetEventTapList: owner process, tap location (HID/Session), active vs listen-only, and enabled state. Read-only and needs no Accessibility grant, so it surfaces input contention — a competing app holding an active HID tap is the classic cause of pointer lag, and it also flags OpenLogi's own tap being disabled. The latency fields CGEventTapInformation carries are deliberately not exposed: they hold uninitialised sentinel values that change between samples, so they are not a trustworthy signal. Non-macOS targets return an empty list. A list_taps example doubles as a headless diagnostic.
EventTapInfo::gates_input flags the active+enabled+HID configuration that can add system-wide latency; known_input_conflict matches the owner against a curated list of third-party mouse drivers (Logi Options+, SteerMouse, BetterMouse, Mac Mouse Fix, LinearMouse, …) so the GUI can warn about a likely pointer-lag cause without false-flagging legitimate utilities. Unit-tested: the gating predicate and case-insensitive owner matching.
Add an opt-in event monitor so the GUI can show what the mouse hook observes. A shared EventMonitor buffers button/scroll/interrupt events (pointer moves excluded as noise); the freeze-sensitive hook callback pays only one relaxed atomic load while monitoring is off. The new poll_event_monitor IPC method drains the buffer and implicitly enables monitoring; an idle janitor disables it once the GUI stops polling, so a closed panel or crashed GUI can't leave the callback buffering forever. Bumps PROTOCOL_VERSION to 4 (poll_event_monitor appended last, MonitorEvent added) and pins the new wire goldens.
Add a macOS Diagnostics settings page. In release it surfaces the curated known-conflict check: if another app holds an active HID tap (Logi Options+, SteerMouse, BetterMouse, …) it warns that the app may be causing pointer lag. In debug builds it also dumps the full event-tap list and a live event monitor that polls the agent's hook over IPC (poll_event_monitor) while the window is open. Stores polled events in AppState (debug macOS only) and adds the five release-facing strings to all 20 locale files.
Greptile SummaryAdds a macOS Diagnostics settings page that detects third-party event taps competing with OpenLogi's mouse hook (a common pointer-lag cause), backed by a new
Confidence Score: 4/5Safe to merge; all findings are in non-critical diagnostic/debug paths with no impact on the core mouse hook or IPC contract. The FFI binding, IPC extension, and wire format changes are solid. The two issues in EventMonitor (Relaxed atomic ordering and the janitor's immediate first tick) affect only the debug live-monitor feature — the worst outcome is monitoring being silently disabled for up to 3 seconds after the first poll, not a crash or data loss. The per-render CGGetEventTapList call is wasteful but the Diagnostics page is rarely visited and the call is fast. crates/openlogi-agent-core/src/event_monitor.rs (atomic ordering + janitor first-tick race) and crates/openlogi-gui/src/windows/settings.rs (CGGetEventTapList on every render). Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant GUI as GUI (SettingsView)
participant IPC as IPC Server
participant Monitor as EventMonitor
participant Hook as Hook Callback
participant Janitor as Idle Janitor
Note over GUI: Settings window opens (debug macOS)
loop every 300ms
GUI->>IPC: poll_event_monitor()
IPC->>Monitor: poll()
Monitor->>Monitor: "polled=true, enabled=true"
Monitor-->>IPC: Vec MonitorEvent
IPC-->>GUI: Vec MonitorEvent
GUI->>GUI: push_monitor_events() → refresh_windows()
end
Hook->>Monitor: record(event)
Monitor->>Monitor: enabled? load(Relaxed)
alt monitoring on
Monitor->>Monitor: buf.lock() → push_back
end
loop every 3s
Janitor->>Monitor: enabled() and polled.swap(false)
alt no poll in last 3s
Janitor->>Monitor: disable() → buf.clear()
end
end
Note over GUI: Settings window closes
GUI->>GUI: _monitor_task dropped → polling stops
Note over Janitor: Next tick: no poll → disable monitoring
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant GUI as GUI (SettingsView)
participant IPC as IPC Server
participant Monitor as EventMonitor
participant Hook as Hook Callback
participant Janitor as Idle Janitor
Note over GUI: Settings window opens (debug macOS)
loop every 300ms
GUI->>IPC: poll_event_monitor()
IPC->>Monitor: poll()
Monitor->>Monitor: "polled=true, enabled=true"
Monitor-->>IPC: Vec MonitorEvent
IPC-->>GUI: Vec MonitorEvent
GUI->>GUI: push_monitor_events() → refresh_windows()
end
Hook->>Monitor: record(event)
Monitor->>Monitor: enabled? load(Relaxed)
alt monitoring on
Monitor->>Monitor: buf.lock() → push_back
end
loop every 3s
Janitor->>Monitor: enabled() and polled.swap(false)
alt no poll in last 3s
Janitor->>Monitor: disable() → buf.clear()
end
end
Note over GUI: Settings window closes
GUI->>GUI: _monitor_task dropped → polling stops
Note over Janitor: Next tick: no poll → disable monitoring
Reviews (1): Last reviewed commit: "feat(gui): Diagnostics page with input-c..." | Re-trigger Greptile |
| /// Enable monitoring (idempotent) and drain everything buffered since the | ||
| /// last poll. Called from the IPC `poll_event_monitor` handler. | ||
| pub fn poll(&self) -> Vec<MonitorEvent> { | ||
| // Mark the poll *before* enabling so a janitor tick landing between the | ||
| // two stores can't read enabled-but-never-polled and disable instantly. | ||
| self.polled.store(true, Ordering::Relaxed); |
There was a problem hiding this comment.
Relaxed ordering undermines the intended poll-before-enable sequencing
The comment on line 78 explains the race being guarded against: a janitor tick landing between the two stores must not see enabled=true + polled=false and disable immediately. But Relaxed ordering carries no cross-thread visibility guarantee — the compiler or CPU is free to reorder the two stores, meaning the janitor can observe enabled=true while polled still reads its old false. The result is monitoring being silently disabled for up to 3 seconds right after the GUI's first poll enables it. Use Release for both stores here and Acquire for the janitor's enabled.load and polled.swap to form a proper release–acquire pair.
| /// plus (debug) the full tap list. Recomputed on each render, so it reflects the | ||
| /// live tap set whenever the window repaints. | ||
| #[cfg(target_os = "macos")] | ||
| fn input_conflict_field(pal: Palette, cx: &mut App) -> AnyElement { | ||
| let taps = Hook::list_event_taps(); | ||
|
|
There was a problem hiding this comment.
CGGetEventTapList called twice on every render frame
input_conflict_field is a render function — it runs every time GPUI repaints the Diagnostics page. Hook::list_event_taps() issues two CGGetEventTapList system calls on each invocation (probe + fill). While the call is fast, the tap list doesn't change at render frequency; the monitor task's cx.refresh_windows() alone drives ~3 repaints/sec, and any mouse interaction while the page is open adds more. Caching the snapshot in AppState (refreshed on the same 300 ms tick as push_monitor_events) and reading it here would eliminate the per-render overhead.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| loop { | ||
| ticker.tick().await; | ||
| // `swap` consumes the flag: a poll since the last tick keeps it | ||
| // alive; an untouched flag means no poll happened this interval. | ||
| if self.enabled() && !self.polled.swap(false, Ordering::Relaxed) { | ||
| self.disable(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { |
There was a problem hiding this comment.
Janitor's first tick always fires immediately
tokio::time::interval resolves its first tick instantly on creation. Since the janitor is spawned at agent startup long before any GUI connects, this is normally harmless (monitoring is off). But if the agent restarts while monitoring was enabled, the janitor's immediate first tick sees enabled=true + polled=false and disables monitoring before the reconnecting GUI has a chance to poll. Using tokio::time::interval_at(Instant::now() + IDLE_TICK, IDLE_TICK) makes the first check happen after a full idle window.
Adds an in-app way to answer "is another app intercepting my mouse / what's in the CGEvent stream?" — the productized version of hand-running
CGGetEventTapList.What's in it
Hook::list_event_taps()enumerates every event tap in the login session (owner, location, active/listen, enabled) via a hand-boundCGGetEventTapList+proc_pidpath(read-only, no Accessibility needed).EventTapInfo::gates_input()/known_input_conflict()classify the lag-relevant ones against a curated driver list (Logi Options+, SteerMouse, BetterMouse, Mac Mouse Fix, LinearMouse, …). Alist_tapsexample doubles as a headless diagnostic. Latency fields are dropped (uninitialised sentinel values).EventMonitorbuffers button/scroll/interrupt events (pointer moves excluded); the freeze-sensitive hook callback pays only one relaxed atomic load while monitoring is off. Newpoll_event_monitorIPC method drains + implicitly enables it; an idle janitor disables it once the GUI stops polling. BumpsPROTOCOL_VERSION3→4 and regenerates the wire goldens (agent + GUI ship together).Verification
list_event_taps()output matches a reference SwiftCGGetEventTapListdump byte-for-byte.-D warnings, i18n parity holds.Axis::Verticalso the wide content wraps full-width).Notes