macos: re-enable CGEventTap on tap timeout

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<OnceLock<usize>> 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) <noreply@anthropic.com>
This commit is contained in:
Jon Kinney
2026-04-27 12:59:45 -05:00
committed by Ferdinand Schober
parent 373e382152
commit 3b4b3a51aa

View File

@@ -2,7 +2,7 @@ use super::{Capture, CaptureError, CaptureEvent, Position, error::MacosCaptureCr
use async_trait::async_trait; use async_trait::async_trait;
use bitflags::bitflags; use bitflags::bitflags;
use core_foundation::{ use core_foundation::{
base::{CFRelease, kCFAllocatorDefault}, base::{CFRelease, TCFType, kCFAllocatorDefault},
date::CFTimeInterval, date::CFTimeInterval,
number::{CFBooleanRef, kCFBooleanTrue}, number::{CFBooleanRef, kCFBooleanTrue},
runloop::{CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes}, runloop::{CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes},
@@ -28,7 +28,7 @@ use std::{
collections::HashSet, collections::HashSet,
ffi::{CString, c_char}, ffi::{CString, c_char},
pin::Pin, pin::Pin,
sync::Arc, sync::{Arc, OnceLock},
task::{Context, Poll, ready}, task::{Context, Poll, ready},
thread::{self}, thread::{self},
}; };
@@ -395,6 +395,14 @@ fn create_event_tap<'a>(
notify_tx: Sender<ProducerEvent>, notify_tx: Sender<ProducerEvent>,
event_tx: Sender<(Position, CaptureEvent)>, event_tx: Sender<(Position, CaptureEvent)>,
) -> Result<CGEventTap<'a>, MacosCaptureCreationError> { ) -> Result<CGEventTap<'a>, 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<OnceLock<usize>> = Arc::new(OnceLock::new());
let tap_mach_port_cb = Arc::clone(&tap_mach_port);
let cg_events_of_interest: Vec<CGEventType> = vec![ let cg_events_of_interest: Vec<CGEventType> = vec![
CGEventType::LeftMouseDown, CGEventType::LeftMouseDown,
CGEventType::LeftMouseUp, CGEventType::LeftMouseUp,
@@ -419,21 +427,43 @@ fn create_event_tap<'a>(
let mut capture_position = None; let mut capture_position = None;
let mut res_events = vec![]; let mut res_events = vec![];
if matches!( if matches!(event_type, CGEventType::TapDisabledByTimeout) {
event_type, // The kernel disables the tap when our callback runs
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput // longer than ~1s on a single event — typical causes
) { // are heavy load, scheduler contention, or this
// When the tap is disabled (including the case where TCC // process being briefly suspended (e.g. App Nap on a
// Accessibility is revoked mid-session), we MUST drop // long idle). It is NOT a fatal condition: Apple's
// captured state synchronously and return Keep on this // documented recovery is to call CGEventTapEnable
// event. Otherwise the `current_pos.is_some()` branch // and resume processing. Re-enable in place and KEEP
// below would drop this event (and any racing callback // existing capture state so the user doesn't see the
// still in flight) back into `CallbackResult::Drop`, // cursor pop back to the local screen mid-session.
// silently eating the user's clicks and keypresses while if let Some(&port) = tap_mach_port_cb.get() {
// the tap winds down. Clear state + show the cursor log::warn!("CGEventTap disabled by timeout — re-enabling");
// here, then notify the producer loop so the service unsafe {
// can tear down cleanly. CGEventTapEnable(port as *mut c_void, true);
log::error!("CGEventTap disabled, releasing capture state"); }
} 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() { if state.current_pos.is_some() {
let _ = CGDisplay::show_cursor(&CGDisplay::main()); let _ = CGDisplay::show_cursor(&CGDisplay::main());
state.current_pos = None; state.current_pos = None;
@@ -507,6 +537,13 @@ fn create_event_tap<'a>(
) )
.map_err(|_| MacosCaptureCreationError::EventTapCreation)?; .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 let tap_source: CFRunLoopSource = tap
.mach_port() .mach_port()
.create_runloop_source(0) .create_runloop_source(0)
@@ -711,6 +748,12 @@ extern "C" {
seconds: CFTimeInterval, seconds: CFTimeInterval,
); );
fn CGPreflightListenEventAccess() -> bool; 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")] #[link(name = "ApplicationServices", kind = "framework")]