Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions crates/openlogi-agent-core/src/event_monitor.rs
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);
Comment on lines +77 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Fix in Codex Fix in Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Fix in Codex Fix in Claude Code

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");
}
}
184 changes: 96 additions & 88 deletions crates/openlogi-agent-core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use openlogi_hook::{EventDisposition, Hook, MouseEvent};
use tracing::{info, warn};

use crate::DpiCycleState;
use crate::event_monitor::SharedEventMonitor;
use crate::hardware::{toggle_smartshift_in_background, write_dpi_in_background};

/// The two button maps the OS-hook callback reads, kept behind ONE lock so a
Expand Down Expand Up @@ -101,6 +102,7 @@ pub fn start(
hooks: SharedHookMaps,
dpi_cycle: Arc<RwLock<DpiCycleState>>,
capture: CaptureChannel,
monitor: SharedEventMonitor,
) -> Option<Hook> {
if !Hook::has_accessibility() {
warn!(
Expand All @@ -112,105 +114,111 @@ pub fn start(

// The per-hold pointer accumulator lives in the thread-local `HOLD`; the
// callback must never block — see the freeze-hazard note in `macos.rs`.
let result = Hook::start(move |event| match event {
MouseEvent::Button { id, pressed } => {
// The CGEventTap only sees standard buttons 0-4. We remap
// Middle/Back/Forward; the primary L/R clicks always pass through
// (suppressing them would brick the mouse), and the DPI / thumb /
// dedicated gesture button aren't visible to the tap at all — the
// dedicated gesture button is captured separately over HID++.
if !id.is_os_hook_button() {
return EventDisposition::PassThrough;
}

// Gesture button: suppress the native click and begin a hold. The
// swipe commits mid-motion in the `Moved` arm; here, on release, we
// only fire the plain `Click` when no swipe committed. The cursor is
// free to drift via the pass-through `Moved` events during the hold.
if pressed {
let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id));
if is_gesture {
HOLD.with_borrow_mut(|h| h.begin(id));
return EventDisposition::Suppress;
let result = Hook::start(move |event| {
// Mirror the raw event to the GUI's live monitor first (a single relaxed
// atomic load while monitoring is off — see `event_monitor`), before any
// remapping decides its disposition.
monitor.record(&event);
match event {
MouseEvent::Button { id, pressed } => {
// The CGEventTap only sees standard buttons 0-4. We remap
// Middle/Back/Forward; the primary L/R clicks always pass through
// (suppressing them would brick the mouse), and the DPI / thumb /
// dedicated gesture button aren't visible to the tap at all — the
// dedicated gesture button is captured separately over HID++.
if !id.is_os_hook_button() {
return EventDisposition::PassThrough;
}
} else {
// Release: end the hold and release the `HOLD` borrow *before* any
// dispatch — the callback must stay lock-light, since a
// synthesized event could otherwise re-enter the tap and re-borrow
// `HOLD` (a RefCell double-borrow panic, freeze hazard).
let ended = HOLD.with_borrow_mut(|h| h.end(id));
if let Some(was_click) = ended {
if was_click {
// No swipe committed → fire the plain click. Resolve to an
// owned action (so no lock is held across dispatch), then
// dispatch with the guard already dropped.
let action = hooks
.read()
.ok()
.map(|m| resolve_gesture_click(&m.gestures, id));
if let Some(action) = action {
info!(button = %id, action = %action.label(), "gesture click → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);

// Gesture button: suppress the native click and begin a hold. The
// swipe commits mid-motion in the `Moved` arm; here, on release, we
// only fire the plain `Click` when no swipe committed. The cursor is
// free to drift via the pass-through `Moved` events during the hold.
if pressed {
let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id));
if is_gesture {
HOLD.with_borrow_mut(|h| h.begin(id));
return EventDisposition::Suppress;
}
} else {
// Release: end the hold and release the `HOLD` borrow *before* any
// dispatch — the callback must stay lock-light, since a
// synthesized event could otherwise re-enter the tap and re-borrow
// `HOLD` (a RefCell double-borrow panic, freeze hazard).
let ended = HOLD.with_borrow_mut(|h| h.end(id));
if let Some(was_click) = ended {
if was_click {
// No swipe committed → fire the plain click. Resolve to an
// owned action (so no lock is held across dispatch), then
// dispatch with the guard already dropped.
let action = hooks
.read()
.ok()
.map(|m| resolve_gesture_click(&m.gestures, id));
if let Some(action) = action {
info!(button = %id, action = %action.label(), "gesture click → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
}
}
return EventDisposition::Suppress;
}
return EventDisposition::Suppress;
}
}

// Single-action button.
let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned());
let Some(action) = action else {
// Unbound → leave the physical button to the OS.
return EventDisposition::PassThrough;
};
// Single-action button.
let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned());
let Some(action) = action else {
// Unbound → leave the physical button to the OS.
return EventDisposition::PassThrough;
};

// A button left on its own native click (e.g. Middle → MiddleClick)
// should just do that click; suppressing and re-synthesising it
// would be pointless churn.
if is_native_click(id, &action) {
return EventDisposition::PassThrough;
}
// A button left on its own native click (e.g. Middle → MiddleClick)
// should just do that click; suppressing and re-synthesising it
// would be pointless churn.
if is_native_click(id, &action) {
return EventDisposition::PassThrough;
}

if pressed {
info!(button = %id, action = %action.label(), "button → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
}
EventDisposition::Suppress
}
MouseEvent::Moved { delta_x, delta_y } => {
// Feed an in-progress hold; a committed swipe fires here, mid-motion.
// Always pass through so the cursor keeps moving — the swipe is read,
// not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert
// that would freeze the pointer).
let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y));
if let Some((button, dir)) = commit {
// Resolve to an owned action and drop the read guard before
// dispatch (same lock-light rule as the release arm). The button
// can leave the gesture set mid-hold (a per-app rebuild); the
// commit has already armed `fired`, so the release won't fire a
// click. Fall back to the same click action the release path uses
// so the suppressed press is never swallowed into nothing —
// symmetric with `resolve_gesture_click`.
let action = hooks.read().ok().map(|m| {
m.gestures
.get(&button)
.and_then(|dirs| dirs.get(&dir).cloned())
.unwrap_or_else(|| resolve_gesture_click(&m.gestures, button))
});
if let Some(action) = action {
info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action");
if pressed {
info!(button = %id, action = %action.label(), "button → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
}
EventDisposition::Suppress
}
EventDisposition::PassThrough
}
MouseEvent::CaptureInterrupted => {
// The OS dropped events (tap disabled); cancel any hold so a lost
// button-up can't later commit a phantom swipe off ordinary motion.
HOLD.with_borrow_mut(HoldState::cancel);
EventDisposition::PassThrough
MouseEvent::Moved { delta_x, delta_y } => {
// Feed an in-progress hold; a committed swipe fires here, mid-motion.
// Always pass through so the cursor keeps moving — the swipe is read,
// not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert
// that would freeze the pointer).
let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y));
if let Some((button, dir)) = commit {
// Resolve to an owned action and drop the read guard before
// dispatch (same lock-light rule as the release arm). The button
// can leave the gesture set mid-hold (a per-app rebuild); the
// commit has already armed `fired`, so the release won't fire a
// click. Fall back to the same click action the release path uses
// so the suppressed press is never swallowed into nothing —
// symmetric with `resolve_gesture_click`.
let action = hooks.read().ok().map(|m| {
m.gestures
.get(&button)
.and_then(|dirs| dirs.get(&dir).cloned())
.unwrap_or_else(|| resolve_gesture_click(&m.gestures, button))
});
if let Some(action) = action {
info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
}
}
EventDisposition::PassThrough
}
MouseEvent::CaptureInterrupted => {
// The OS dropped events (tap disabled); cancel any hold so a lost
// button-up can't later commit a phantom swipe off ordinary motion.
HOLD.with_borrow_mut(HoldState::cancel);
EventDisposition::PassThrough
}
MouseEvent::Scroll { .. } => EventDisposition::PassThrough,
}
MouseEvent::Scroll { .. } => EventDisposition::PassThrough,
});

match result {
Expand Down
Loading
Loading