-
-
Notifications
You must be signed in to change notification settings - Fork 94
feat: CGEvent tap diagnostics — conflict detection + live event monitor #276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
e82f463
1feef2c
859786c
e7e4d10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| //! Live event monitor: a shared, bounded buffer that mirrors the events the OS | ||
| //! mouse hook observes to the GUI's debug monitor, on demand. | ||
| //! | ||
| //! Monitoring is **off by default**. The freeze-sensitive hook callback pays | ||
| //! only a single relaxed atomic load per event while off (see the freeze-hazard | ||
| //! note in `openlogi-hook`); it locks and pushes only once the GUI starts | ||
| //! polling. The GUI enables monitoring implicitly by polling | ||
| //! [`EventMonitor::poll`], and [`EventMonitor::run_idle_janitor`] turns it back | ||
| //! off when polls stop — so a closed panel or a crashed GUI can't leave the | ||
| //! callback doing buffer work forever. | ||
|
|
||
| use std::collections::VecDeque; | ||
| use std::sync::Mutex; | ||
| use std::sync::atomic::{AtomicBool, Ordering}; | ||
| use std::time::Duration; | ||
|
|
||
| use openlogi_hook::MouseEvent; | ||
|
|
||
| use crate::ipc::MonitorEvent; | ||
|
|
||
| /// A shared [`EventMonitor`], threaded between the hook callback (writer) and | ||
| /// the IPC server (reader/poller). | ||
| pub type SharedEventMonitor = std::sync::Arc<EventMonitor>; | ||
|
|
||
| /// How many recent events to retain between polls. A held button + a flick of | ||
| /// the scroll wheel is a handful of events; a generous cap still drops only the | ||
| /// oldest if the GUI stalls. | ||
| const CAPACITY: usize = 256; | ||
|
|
||
| /// How often the janitor checks for an idle (no-longer-polled) monitor. | ||
| const IDLE_TICK: Duration = Duration::from_secs(3); | ||
|
|
||
| /// Buffers the hook's observed events for the GUI's live monitor when enabled. | ||
| #[derive(Default)] | ||
| pub struct EventMonitor { | ||
| enabled: AtomicBool, | ||
| /// Set on every [`Self::poll`]; the janitor clears it each tick and treats a | ||
| /// tick with no intervening poll as "the GUI stopped watching". | ||
| polled: AtomicBool, | ||
| buf: Mutex<VecDeque<MonitorEvent>>, | ||
| } | ||
|
|
||
| impl EventMonitor { | ||
| /// Whether monitoring is currently on — the one check the hot hook path runs. | ||
| #[must_use] | ||
| pub fn enabled(&self) -> bool { | ||
| self.enabled.load(Ordering::Relaxed) | ||
| } | ||
|
|
||
| /// Record a hook event, if monitoring is on. Pointer moves are dropped: they | ||
| /// arrive at pointer-motion rates and would evict every button/scroll event | ||
| /// from the bounded buffer before the GUI's next poll. | ||
| pub fn record(&self, event: &MouseEvent) { | ||
| if !self.enabled() { | ||
| return; | ||
| } | ||
| let mapped = match event { | ||
| MouseEvent::Button { id, pressed } => MonitorEvent::Button { | ||
| button: id.to_string(), | ||
| pressed: *pressed, | ||
| }, | ||
| MouseEvent::Scroll { delta_x, delta_y } => MonitorEvent::Scroll { | ||
| delta_x: *delta_x, | ||
| delta_y: *delta_y, | ||
| }, | ||
| MouseEvent::CaptureInterrupted => MonitorEvent::CaptureInterrupted, | ||
| MouseEvent::Moved { .. } => return, | ||
| }; | ||
| if let Ok(mut buf) = self.buf.lock() { | ||
| if buf.len() == CAPACITY { | ||
| buf.pop_front(); | ||
| } | ||
| buf.push_back(mapped); | ||
| } | ||
| } | ||
|
|
||
| /// 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); | ||
| self.enabled.store(true, Ordering::Relaxed); | ||
| self.buf | ||
| .lock() | ||
| .map(|mut buf| buf.drain(..).collect()) | ||
| .unwrap_or_default() | ||
| } | ||
|
|
||
| /// Turn monitoring off and discard any buffered events. | ||
| fn disable(&self) { | ||
| self.enabled.store(false, Ordering::Relaxed); | ||
| if let Ok(mut buf) = self.buf.lock() { | ||
| buf.clear(); | ||
| } | ||
| } | ||
|
|
||
| /// Auto-disable monitoring when the GUI stops polling. Runs for the life of | ||
| /// the agent: each tick, if monitoring is on but no poll arrived since the | ||
| /// previous tick, the GUI is gone — disable and free the buffer. | ||
| pub async fn run_idle_janitor(self: SharedEventMonitor) { | ||
| let mut ticker = tokio::time::interval(IDLE_TICK); | ||
| 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 { | ||
|
Comment on lines
+103
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| use super::*; | ||
| use openlogi_core::binding::ButtonId; | ||
|
|
||
| #[test] | ||
| fn records_only_while_enabled_and_skips_moves() { | ||
| let m = EventMonitor::default(); | ||
| // Off by default: a press before any poll is not buffered. | ||
| m.record(&MouseEvent::Button { | ||
| id: ButtonId::Back, | ||
| pressed: true, | ||
| }); | ||
| assert!(!m.enabled()); | ||
|
|
||
| // The first poll enables monitoring and returns nothing buffered yet. | ||
| assert!(m.poll().is_empty()); | ||
| assert!(m.enabled()); | ||
|
|
||
| // Now events land — except pointer moves, which are dropped. | ||
| m.record(&MouseEvent::Moved { | ||
| delta_x: 5, | ||
| delta_y: 5, | ||
| }); | ||
| m.record(&MouseEvent::Button { | ||
| id: ButtonId::Forward, | ||
| pressed: false, | ||
| }); | ||
| assert_eq!( | ||
| m.poll(), | ||
| vec![MonitorEvent::Button { | ||
| button: ButtonId::Forward.to_string(), | ||
| pressed: false, | ||
| }] | ||
| ); | ||
| // Draining leaves the buffer empty. | ||
| assert!(m.poll().is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn bounded_buffer_drops_oldest() { | ||
| let m = EventMonitor::default(); | ||
| m.poll(); // enable | ||
| for _ in 0..(CAPACITY + 10) { | ||
| m.record(&MouseEvent::Scroll { | ||
| delta_x: 0.0, | ||
| delta_y: 1.0, | ||
| }); | ||
| } | ||
| assert_eq!(m.poll().len(), CAPACITY, "never grows past the cap"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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=falseand disable immediately. ButRelaxedordering carries no cross-thread visibility guarantee — the compiler or CPU is free to reorder the two stores, meaning the janitor can observeenabled=truewhilepolledstill reads its oldfalse. The result is monitoring being silently disabled for up to 3 seconds right after the GUI's first poll enables it. UseReleasefor both stores here andAcquirefor the janitor'senabled.loadandpolled.swapto form a proper release–acquire pair.