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

* 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>

* fix(client): add deferred release and dedup for debounced Wait

When a Wait is debounced (within 300ms of grab acquisition), schedule
a deferred release thread that re-checks after the debounce window.
If no new Run refreshed the grab, the deferred thread releases it,
ensuring a genuine leave within the debounce window is not lost.

Add a deferred_pending flag to GrabOwnerState to prevent spawning
redundant threads during the X11 focus feedback loop.

Signed-off-by: Sergiusz Michalik <github@latens.me>

* fix(client): use window-scoped ID and fix deferred-release re-arming

Address PR review feedback:
- Use per-window UUID instead of connection-scoped lc.session_id so two
  windows viewing the same peer get distinct grab owners
- Reset deferred_pending on both idempotent Run refresh and owner
  handoff, so a subsequent Wait can always spawn a fresh timer
- Replace manual Default impl with derive

* fix(client): recover from poisoned mutex instead of panicking

* docs: clarify cross-platform rationale for GrabOwnerState

* fix(client): only clear deferred_pending when timer snapshot matches

* fix(client): use full u128 window ID, downgrade grab logs to debug

- Widen GrabOwnerState.owner to u128 to avoid theoretical collision
  from truncating a 128-bit UUID to 64 bits
- Downgrade all grab transition log::info! to log::debug! to reduce
  log noise during routine window switches
- Clear deferred_pending on post-debounce release path to maintain
  the "deferred_pending => timer in flight" invariant

* fix(client): gate GRAB_DEBOUNCE_MS with cfg(target_os = "linux")

* fix(grab): release grabbed keys without clobbering new owner state

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(keyboard): Simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: Sergiusz Michalik <github@latens.me>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
Sergiusz Michalik
2026-04-26 16:46:41 +02:00
committed by GitHub
parent c8ba99d1a1
commit 7308c448f1
3 changed files with 229 additions and 21 deletions

View File

@@ -605,21 +605,30 @@ pub fn session_handle_flutter_raw_key_event(
}
}
// SyncReturn<()> is used to make sure enter() and leave() are executed in the sequence this function is called.
//
// If the cursor jumps between remote page of two connections, leave view and enter view will be called.
// session_enter_or_leave() will be called then.
// As rust is multi-thread, it is possible that enter() is called before leave().
// This will cause the keyboard input to take no effect.
// As Rust is multi-threaded, enter() can be called before leave().
// The Rust-side grab ownership state filters stale transitions.
pub fn session_enter_or_leave(_session_id: SessionID, _enter: bool) -> SyncReturn<()> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(session) = sessions::get_session_by_session_id(&_session_id) {
let keyboard_mode = session.get_keyboard_mode();
// Use the full per-window UUID (not lc.session_id which is per-connection)
// so that two windows viewing the same peer get distinct grab owners.
let window_id = _session_id.as_u128();
if _enter {
set_cur_session_id_(_session_id, &keyboard_mode);
session.enter(keyboard_mode);
crate::keyboard::client::change_grab_status(
crate::common::GrabState::Run,
&keyboard_mode,
window_id,
);
} else {
session.leave(keyboard_mode);
crate::keyboard::client::change_grab_status(
crate::common::GrabState::Wait,
&keyboard_mode,
window_id,
);
}
}
SyncReturn(())

View File

@@ -82,8 +82,67 @@ lazy_static::lazy_static! {
pub mod client {
use super::*;
/// Tracks grab ownership and serializes transitions across threads.
///
/// Multiple Flutter isolates (one per session window) call
/// `change_grab_status(Run/Wait)` concurrently. Without serialization a
/// stale `Wait` from session A can clobber session B's freshly acquired
/// grab on any desktop OS.
///
/// Windows and macOS are less susceptible in practice because the Flutter
/// side triggers `enterView` only after a mouse click inside the window,
/// but we cannot rely on that. On Linux/X11, `XGrabKeyboard` can also
/// cause a focus-change feedback loop (~10 Hz), so `last_grab` debounces
/// spurious `Wait` events that arrive shortly after a `Run`.
#[derive(Default)]
struct GrabOwnerState {
owner: Option<u128>,
last_grab: Option<std::time::Instant>,
/// True while a deferred-release thread is in flight. Prevents
/// spawning redundant threads during the X11 feedback loop.
deferred_pending: bool,
}
/// 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).
#[cfg(target_os = "linux")]
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()));
}
#[cfg(target_os = "linux")]
lazy_static::lazy_static! {
static ref GRAB_OP_LOCK: Mutex<()> = Mutex::new(());
}
#[cfg(target_os = "linux")]
fn apply_run_grab_if_owner(session_id: u128, disable_first: bool) {
let _lock = GRAB_OP_LOCK.lock().unwrap();
let gs = GRAB_STATE.lock().unwrap();
if gs.owner != Some(session_id) {
return;
}
drop(gs);
if disable_first {
log::debug!("[grab] handoff: disable_grab before re-grab");
rdev::disable_grab();
}
rdev::enable_grab();
}
#[cfg(target_os = "linux")]
fn disable_grab_if_released() {
let _lock = GRAB_OP_LOCK.lock().unwrap();
let should_disable = {
let gs = GRAB_STATE.lock().unwrap();
gs.owner.is_none() && gs.last_grab.is_none()
};
if should_disable {
rdev::disable_grab();
}
}
pub fn start_grab_loop() {
@@ -96,36 +155,167 @@ 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: u128) {
#[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 release_after_unlock = None;
#[cfg(target_os = "linux")]
let mut run_grab_after_unlock = None;
#[cfg(target_os = "linux")]
let mut disable_after_unlock = false;
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());
// Reset so the next Wait can spawn a fresh deferred-release
// timer with an up-to-date snapshot of last_grab.
gs.deferred_pending = false;
log::debug!(
"[grab] Run(0x{:x}): already owner, refresh debounce",
session_id
);
return;
}
log::debug!(
"[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();
let had_owner = gs.owner.is_some();
gs.owner = Some(session_id);
gs.last_grab = Some(std::time::Instant::now());
// Invalidate any in-flight deferred release from the previous
// owner so it cannot suppress a fresh timer for the new owner.
gs.deferred_pending = false;
#[cfg(target_os = "linux")]
{
run_grab_after_unlock = Some(had_owner);
}
}
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.
// A deferred release is scheduled so that a genuine leave within
// the debounce window is not permanently lost.
#[cfg(target_os = "linux")]
if let Some(t) = gs.last_grab {
let elapsed = t.elapsed().as_millis();
if elapsed < GRAB_DEBOUNCE_MS {
if !gs.deferred_pending {
log::debug!(
"[grab] Wait(0x{:x}): debounced ({}ms < {}ms), scheduling deferred release",
session_id, elapsed, GRAB_DEBOUNCE_MS,
);
gs.deferred_pending = true;
let remaining = (GRAB_DEBOUNCE_MS - elapsed) as u64 + 50;
let snapshot = gs.last_grab;
let mode = keyboard_mode.to_string();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(remaining));
let release_keys = {
let mut gs = GRAB_STATE.lock().unwrap();
// Release only if no new Run has refreshed the grab since.
if gs.owner == Some(session_id) && gs.last_grab == snapshot {
let to_release = take_remote_keys();
gs.deferred_pending = false;
log::debug!(
"[grab] Wait(0x{:x}): deferred release",
session_id
);
KEYBOARD_HOOKED.store(false, Ordering::SeqCst);
gs.owner = None;
gs.last_grab = None;
Some(to_release)
} else {
log::debug!(
"[grab] Wait(0x{:x}): deferred release cancelled (grab refreshed)",
session_id,
);
None
}
};
if let Some(to_release) = release_keys {
disable_grab_if_released();
release_remote_keys_for_events(&mode, to_release);
}
});
} else {
log::debug!(
"[grab] Wait(0x{:x}): debounced, deferred release already pending",
session_id,
);
}
return;
}
}
log::debug!("[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);
gs.owner = None;
gs.last_grab = None;
gs.deferred_pending = false;
release_after_unlock = Some(take_remote_keys());
#[cfg(target_os = "linux")]
rdev::disable_grab();
{
disable_after_unlock = true;
}
}
GrabState::Exit => {}
}
drop(gs);
#[cfg(target_os = "linux")]
{
if disable_after_unlock {
disable_grab_if_released();
}
if let Some(disable_first) = run_grab_after_unlock {
apply_run_grab_if_owner(session_id, disable_first);
}
}
if let Some(to_release) = release_after_unlock {
release_remote_keys_for_events(keyboard_mode, to_release);
}
}
pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option<i32>) {
@@ -341,7 +531,6 @@ fn notify_exit_relative_mouse_mode() {
flutter::push_session_event(&session_id, "exit_relative_mouse_mode", vec![]);
}
/// Handle relative mouse mode shortcuts in the rdev grab loop.
/// Returns true if the event should be blocked from being sent to the peer.
#[cfg(feature = "flutter")]
@@ -540,10 +729,12 @@ pub fn is_long_press(event: &Event) -> bool {
return false;
}
pub fn release_remote_keys(keyboard_mode: &str) {
// todo!: client quit suddenly, how to release keys?
let to_release = TO_RELEASE.lock().unwrap().clone();
TO_RELEASE.lock().unwrap().clear();
fn take_remote_keys() -> HashMap<Key, Event> {
let mut to_release = TO_RELEASE.lock().unwrap();
std::mem::take(&mut *to_release)
}
fn release_remote_keys_for_events(keyboard_mode: &str, to_release: HashMap<Key, Event>) {
for (key, mut event) in to_release.into_iter() {
event.event_type = EventType::KeyRelease(key);
client::process_event(keyboard_mode, &event, None);
@@ -558,6 +749,12 @@ pub fn release_remote_keys(keyboard_mode: &str) {
}
}
#[allow(dead_code)]
pub fn release_remote_keys(keyboard_mode: &str) {
// todo!: client quit suddenly, how to release keys?
release_remote_keys_for_events(keyboard_mode, take_remote_keys());
}
pub fn get_keyboard_mode_enum(keyboard_mode: &str) -> KeyboardMode {
match keyboard_mode {
"map" => KeyboardMode::Map,
@@ -748,7 +945,6 @@ pub fn event_to_key_events(
) -> Vec<KeyEvent> {
peer.retain(|c| !c.is_whitespace());
let mut key_event = KeyEvent::new();
update_modifiers_state(event);
match event.event_type {
@@ -761,6 +957,7 @@ pub fn event_to_key_events(
_ => {}
}
let mut key_event = KeyEvent::new();
key_event.mode = keyboard_mode.into();
let mut key_events = match keyboard_mode {

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 as u128;
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 as u128;
keyboard::client::change_grab_status(GrabState::Wait, &keyboard_mode, session_id);
}
// flutter only TODO new input