From 3b4b3a51aaf219dbb0b522c835d65393c6e24513 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Mon, 27 Apr 2026 12:59:45 -0500 Subject: [PATCH] macos: re-enable CGEventTap on tap timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel disables a session-level CGEventTap when its callback runs longer than ~1 s on a single event — typical causes are heavy load, scheduler contention, or the process being briefly suspended (App Nap on a long idle, debugger pause). It is not a fatal condition: Apple's documented recovery is to call CGEventTapEnable and resume processing. Before this change the tap stayed dead until the user manually clicked Re-enable from the menubar. Stash the tap's mach port pointer in an Arc> set immediately after CGEventTap::new returns, and on TapDisabledByTimeout call CGEventTapEnable from the callback to revive the tap while preserving capture state — the user doesn't see the cursor pop back to the local screen mid-session for a transient slow callback. TapDisabledByUserInput keeps the existing teardown path: those causes (TCC Accessibility revoked mid-session, secure-input mode, explicit kill) are not safely recoverable from inside the callback, and the existing fallthrough-fix from 59d9e45 / d1e963e still applies there. Co-Authored-By: Claude Opus 4.7 (1M context) --- input-capture/src/macos.rs | 77 +++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/input-capture/src/macos.rs b/input-capture/src/macos.rs index 517127d..17ff730 100644 --- a/input-capture/src/macos.rs +++ b/input-capture/src/macos.rs @@ -2,7 +2,7 @@ use super::{Capture, CaptureError, CaptureEvent, Position, error::MacosCaptureCr use async_trait::async_trait; use bitflags::bitflags; use core_foundation::{ - base::{CFRelease, kCFAllocatorDefault}, + base::{CFRelease, TCFType, kCFAllocatorDefault}, date::CFTimeInterval, number::{CFBooleanRef, kCFBooleanTrue}, runloop::{CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes}, @@ -28,7 +28,7 @@ use std::{ collections::HashSet, ffi::{CString, c_char}, pin::Pin, - sync::Arc, + sync::{Arc, OnceLock}, task::{Context, Poll, ready}, thread::{self}, }; @@ -395,6 +395,14 @@ fn create_event_tap<'a>( notify_tx: Sender, event_tx: Sender<(Position, CaptureEvent)>, ) -> Result, MacosCaptureCreationError> { + // Shared slot for the tap's mach port pointer. Stored as `usize` + // because raw pointers aren't `Send`, but the integer + // representation is — and CGEventTapEnable is documented as + // thread-safe. Set immediately after CGEventTap::new returns; + // read by the callback to recover from a TapDisabledByTimeout. + let tap_mach_port: Arc> = Arc::new(OnceLock::new()); + let tap_mach_port_cb = Arc::clone(&tap_mach_port); + let cg_events_of_interest: Vec = vec![ CGEventType::LeftMouseDown, CGEventType::LeftMouseUp, @@ -419,21 +427,43 @@ fn create_event_tap<'a>( let mut capture_position = None; let mut res_events = vec![]; - if matches!( - event_type, - CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput - ) { - // When the tap is disabled (including the case where TCC - // Accessibility is revoked mid-session), we MUST drop - // captured state synchronously and return Keep on this - // event. Otherwise the `current_pos.is_some()` branch - // below would drop this event (and any racing callback - // still in flight) back into `CallbackResult::Drop`, - // silently eating the user's clicks and keypresses while - // the tap winds down. Clear state + show the cursor - // here, then notify the producer loop so the service - // can tear down cleanly. - log::error!("CGEventTap disabled, releasing capture state"); + if matches!(event_type, CGEventType::TapDisabledByTimeout) { + // The kernel disables the tap when our callback runs + // longer than ~1s on a single event — typical causes + // are heavy load, scheduler contention, or this + // process being briefly suspended (e.g. App Nap on a + // long idle). It is NOT a fatal condition: Apple's + // documented recovery is to call CGEventTapEnable + // and resume processing. Re-enable in place and KEEP + // existing capture state so the user doesn't see the + // cursor pop back to the local screen mid-session. + if let Some(&port) = tap_mach_port_cb.get() { + log::warn!("CGEventTap disabled by timeout — re-enabling"); + unsafe { + CGEventTapEnable(port as *mut c_void, true); + } + } else { + log::error!( + "CGEventTap disabled by timeout, but mach port not yet stored — cannot re-enable" + ); + } + return CallbackResult::Keep; + } + + if matches!(event_type, CGEventType::TapDisabledByUserInput) { + // Deliberate kill — secure-input mode (e.g. password + // field), TCC Accessibility revoked mid-session, or + // the user disabling event-monitoring. We can't + // recover from this; drop captured state synchronously + // and return Keep on this event. Otherwise the + // `current_pos.is_some()` branch below would drop this + // event (and any racing callback still in flight) back + // into `CallbackResult::Drop`, silently eating the + // user's clicks and keypresses while the tap winds + // down. Clear state + show the cursor here, then + // notify the producer loop so the service can tear + // down cleanly. + log::error!("CGEventTap disabled by user input, releasing capture state"); if state.current_pos.is_some() { let _ = CGDisplay::show_cursor(&CGDisplay::main()); state.current_pos = None; @@ -507,6 +537,13 @@ fn create_event_tap<'a>( ) .map_err(|_| MacosCaptureCreationError::EventTapCreation)?; + // Hand the mach port pointer to the callback so it can re-enable + // the tap on TapDisabledByTimeout. The pointer is valid for the + // lifetime of `tap` (which lives on the event-tap thread until + // the run loop exits). + let port_ptr = tap.mach_port().as_concrete_TypeRef() as usize; + let _ = tap_mach_port.set(port_ptr); + let tap_source: CFRunLoopSource = tap .mach_port() .create_runloop_source(0) @@ -711,6 +748,12 @@ extern "C" { seconds: CFTimeInterval, ); fn CGPreflightListenEventAccess() -> bool; + /// Re-enable an event tap that was disabled by a + /// `kCGEventTapDisabledByTimeout` event. The Apple-documented + /// recovery path: see Quartz Event Services Reference. The `tap` + /// argument is a `CFMachPortRef`; we pass the raw pointer so we + /// can store it as `usize` for cross-thread sharing. + fn CGEventTapEnable(tap: *mut c_void, enable: bool); } #[link(name = "ApplicationServices", kind = "framework")]