fix(client): serialize X11 keyboard grab and debounce focus feedback

When two RustDesk sessions run fullscreen on separate monitors on
Linux/X11, keyboard input gets stuck on the wrong session or stops
working entirely. This happens because each Flutter isolate calls
change_grab_status concurrently, racing on KEYBOARD_HOOKED and the
rdev grab channel.

Additionally, XGrabKeyboard causes a focus-change feedback loop:
grab shifts focus away from the Flutter window, triggering PointerExit,
which releases the grab, restoring focus, triggering PointerEnter,
which re-grabs -- cycling at ~10 Hz and blocking keyboard input.

Fix by:
- Serializing grab transitions with a mutex and tracking the owning
  session (by lc.session_id), so a stale Wait from session A cannot
  clobber session B's freshly acquired grab.
- Debouncing Wait events (300 ms) from the same session that just
  acquired the grab, breaking the X11 focus feedback loop.
- Refreshing the debounce timer on idempotent Run calls (enterView
  while already owner), keeping the grab stable during normal use.

Signed-off-by: Sergiusz Michalik <github@latens.me>
This commit is contained in:
Sergiusz Michalik 2026-04-17 15:45:52 +02:00
parent 642c281ad0
commit c00496c2a0
2 changed files with 96 additions and 6 deletions

View file

@ -82,8 +82,31 @@ lazy_static::lazy_static! {
pub mod client {
use super::*;
/// Tracks grab ownership and serializes transitions across threads.
///
/// On Linux/X11, `XGrabKeyboard` can cause a focus-change feedback loop:
/// grab -> focus shifts -> PointerExit -> ungrab -> focus returns ->
/// PointerEnter -> grab -> ... at ~10 Hz. `last_grab` lets us debounce
/// spurious `Wait` events that arrive shortly after a `Run` for the same
/// session - these are X11 focus feedback, not real user actions.
struct GrabOwnerState {
owner: Option<u64>,
last_grab: Option<std::time::Instant>,
}
impl Default for GrabOwnerState {
fn default() -> Self {
Self { owner: None, last_grab: None }
}
}
/// How long after a grab acquisition we suppress Wait from the same session.
/// Must exceed one full X11 feedback cycle (~100 ms: 50 ms enable + 50 ms disable).
const GRAB_DEBOUNCE_MS: u128 = 300;
lazy_static::lazy_static! {
static ref IS_GRAB_STARTED: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
static ref GRAB_STATE: Arc<Mutex<GrabOwnerState>> = Arc::new(Mutex::new(GrabOwnerState::default()));
}
pub fn start_grab_loop() {
@ -96,33 +119,98 @@ pub mod client {
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn change_grab_status(state: GrabState, keyboard_mode: &str) {
pub fn change_grab_status(state: GrabState, keyboard_mode: &str, session_id: u64) {
#[cfg(feature = "flutter")]
if !IS_RDEV_ENABLED.load(Ordering::SeqCst) {
return;
}
// Serialize transitions so a stale `Wait` from a previous owner cannot
// clobber a fresh `Run` from a different session window.
let mut gs = GRAB_STATE.lock().unwrap();
match state {
GrabState::Ready => {}
GrabState::Run => {
#[cfg(windows)]
update_grab_get_key_name(keyboard_mode);
// Idempotent: if this session already owns the grab, just
// refresh the debounce timer (proves the session is still
// actively focused) and skip the actual grab call.
if gs.owner == Some(session_id) {
gs.last_grab = Some(std::time::Instant::now());
log::debug!("[grab] Run(0x{:x}): already owner, refresh debounce", session_id);
return;
}
log::info!(
"[grab] Run(0x{:x}): prev_owner={}, mode={}",
session_id,
gs.owner.map_or("none".to_string(), |id| format!("0x{:x}", id)),
keyboard_mode,
);
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
KEYBOARD_HOOKED.swap(true, Ordering::SeqCst);
KEYBOARD_HOOKED.store(true, Ordering::SeqCst);
#[cfg(target_os = "linux")]
rdev::enable_grab();
{
// On handoff, explicitly release any prior owner's X11 grab
// before taking our own. This keeps the rdev control thread
// and the X server in a consistent state even if the prior
// owner never sent `Wait` (e.g. disconnected or raced).
if gs.owner.is_some() {
log::info!("[grab] handoff: disable_grab before re-grab");
rdev::disable_grab();
}
rdev::enable_grab();
}
gs.owner = Some(session_id);
gs.last_grab = Some(std::time::Instant::now());
}
GrabState::Wait => {
// Drop stale `Wait` events that do not correspond to the
// current grab owner. This prevents a late PointerExit from
// session A from releasing session B's freshly acquired grab.
if gs.owner != Some(session_id) {
log::debug!(
"[grab] Wait(0x{:x}): ignored, owner={}",
session_id,
gs.owner.map_or("none".to_string(), |id| format!("0x{:x}", id)),
);
return;
}
// Debounce: on Linux/X11, XGrabKeyboard causes a focus-change
// feedback loop (grab -> PointerExit -> ungrab -> PointerEnter ->
// grab -> ...). Suppress Wait if the grab was acquired recently
// by this same session -- it is X11 feedback, not a real leave.
#[cfg(target_os = "linux")]
if let Some(t) = gs.last_grab {
let elapsed = t.elapsed().as_millis();
if elapsed < GRAB_DEBOUNCE_MS {
log::debug!(
"[grab] Wait(0x{:x}): debounced ({}ms < {}ms)",
session_id, elapsed, GRAB_DEBOUNCE_MS,
);
return;
}
}
log::info!("[grab] Wait(0x{:x}): releasing grab", session_id);
#[cfg(windows)]
rdev::set_get_key_unicode(false);
release_remote_keys(keyboard_mode);
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
KEYBOARD_HOOKED.swap(false, Ordering::SeqCst);
KEYBOARD_HOOKED.store(false, Ordering::SeqCst);
#[cfg(target_os = "linux")]
rdev::disable_grab();
gs.owner = None;
gs.last_grab = None;
}
GrabState::Exit => {}
}

View file

@ -870,12 +870,14 @@ impl<T: InvokeUiSession> Session<T> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn enter(&self, keyboard_mode: String) {
keyboard::client::change_grab_status(GrabState::Run, &keyboard_mode);
let session_id = self.lc.read().unwrap().session_id;
keyboard::client::change_grab_status(GrabState::Run, &keyboard_mode, session_id);
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn leave(&self, keyboard_mode: String) {
keyboard::client::change_grab_status(GrabState::Wait, &keyboard_mode);
let session_id = self.lc.read().unwrap().session_id;
keyboard::client::change_grab_status(GrabState::Wait, &keyboard_mode, session_id);
}
// flutter only TODO new input