fix(wayland): back off the polling display lookups after a failure (drm) (#15865)

* fix(wayland): back off the polling display lookups after a failure (drm)

In drm builds an enumeration that fails with no endpoint named in the
environment falls back to the socket probe, which forks a child bounded by
seconds, and the display service asks again every 300 ms -- at a greeter
with no reachable compositor that is a probe child per turn, forever. Such
a failure now stamps a shared 5 s backoff, and only the polling callers
honor it: the 300 ms displays-changed check skips its turn and the 1.5 s
live layout poll returns no answer for that turn.

Only the failure that would fork stamps. A session server is spawned with
WAYLAND_DISPLAY set, so its failed connect bails in-process before any
fork; stamping there would buy nothing and cost recovery latency, so live
sessions keep master's behavior exactly. The stamp also survives
clear_wayland_displays_cache: it describes the seat, not the cache, and
the ~1/s capturer rebuild loop clears on every teardown -- dropping the
stamp with the cache would let that loop defeat the backoff and would
turn every post-hotplug failure into a "first" one forever.

The displays-changed check weighs the backoff against what is already
published. With nothing synced yet it always populates -- an unaugmented
DRM list beats the empty broadcast the send path would otherwise emit.
With a synced layout, a suppressed turn keeps it, and a fresh first
failure keeps it too; only a failure that persists across a backoff
replaces it with the DRM stack, so a hotplug at a failing seat converges
within one backoff while a transient failure never tears down a good
layout.

One-shot callers -- session init, pipewire stream setup, capturer info --
keep probing fresh through get_displays, whose failure semantics are
unchanged: replaying a transient failure there would latch an empty answer
into session-long state. Non-drm builds compile none of this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(wayland): log DRM lookup failure once

* fix(wayland): reset lookup warning after recovery

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
RustDesk
2026-08-18 09:57:06 +08:00
committed by GitHub
parent 14a4b197ad
commit 6a27910f34
2 changed files with 143 additions and 12 deletions

View File

@@ -19,6 +19,18 @@ static MISSING_LOGICAL_SIZE_WARNED: std::sync::atomic::AtomicBool =
const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000);
// drm builds only: an unnamed-endpoint failure there forks the probe child, and the pollers
// turn every few hundred milliseconds. Every other failure is one cheap in-process error.
#[cfg(any(test, feature = "drm"))]
const FAILED_LOOKUP_BACKOFF: Duration = Duration::from_secs(5);
#[cfg(any(test, feature = "drm"))]
static LAST_FAILED_LOOKUP: Mutex<Option<Instant>> = Mutex::new(None);
#[cfg(feature = "drm")]
static LOOKUP_FAILURE_WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub struct Displays {
pub primary: usize,
pub displays: Vec<WaylandDisplayInfo>,
@@ -171,11 +183,76 @@ fn get_primary_monitor() -> Option<String> {
.or_else(try_gdbus_primary)
}
// Pure, so the backoff policy is testable without a compositor.
#[cfg(any(test, feature = "drm"))]
fn lookup_allowed(failed_at: Option<Instant>, now: Instant) -> bool {
failed_at.map_or(true, |at| {
now.saturating_duration_since(at) >= FAILED_LOOKUP_BACKOFF
})
}
#[cfg(feature = "drm")]
fn backed_off() -> bool {
let failed_at = *LAST_FAILED_LOOKUP.lock().unwrap();
!lookup_allowed(failed_at, Instant::now())
}
// Mirrors the probe module's gate, latch included: connecting consumes WAYLAND_SOCKET, so a
// once-named endpoint must stay named for the life of the process.
#[cfg(feature = "drm")]
fn endpoint_named() -> bool {
use std::sync::atomic::{AtomicBool, Ordering};
static WAS_NAMED: AtomicBool = AtomicBool::new(false);
let named = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"]
.iter()
.any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()));
if named {
WAS_NAMED.store(true, Ordering::Release);
}
WAS_NAMED.load(Ordering::Acquire)
}
// Enumerates and keeps the failure stamp current. Suppresses nothing itself: one-shot callers
// (session init, pipewire) must always get a fresh read, or a transient failure latches.
fn enumerate_displays() -> hbb_common::ResultType<Vec<WaylandDisplayInfo>> {
// Read before connecting, which consumes WAYLAND_SOCKET.
#[cfg(feature = "drm")]
let named = endpoint_named();
let probed = get_wayland_displays();
// Only the failure that would fork stamps; a named endpoint fails cheaply in-process.
#[cfg(feature = "drm")]
{
*LAST_FAILED_LOOKUP.lock().unwrap() = (probed.is_err() && !named).then(Instant::now);
if let Err(err) = &probed {
if !LOOKUP_FAILURE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
warn!("Failed to get wayland displays: {}", err);
}
} else {
LOOKUP_FAILURE_WARNED.store(false, std::sync::atomic::Ordering::Relaxed);
}
}
probed
}
// True when a lookup now could neither hit the cache nor probe. Pollers skip their turn on
// it and keep their last published state; one-shot callers must not consult it.
#[cfg(feature = "drm")]
pub fn wayland_lookup_suppressed() -> bool {
DISPLAYS.lock().unwrap().is_none() && backed_off()
}
// Whether any failure stamp exists, expired or not: pollers use it to tell a first failure
// from one that has already persisted across a backoff.
#[cfg(feature = "drm")]
pub fn wayland_failure_stamped() -> bool {
LAST_FAILED_LOOKUP.lock().unwrap().is_some()
}
pub fn get_displays() -> Arc<Displays> {
let mut lock = DISPLAYS.lock().unwrap();
match lock.as_ref() {
Some(displays) => displays.clone(),
None => match get_wayland_displays() {
None => match enumerate_displays() {
Ok(displays) => {
let mut primary_index = None;
if let Some(name) = get_primary_monitor() {
@@ -201,8 +278,9 @@ pub fn get_displays() -> Arc<Displays> {
*lock = Some(displays.clone());
displays
}
Err(err) => {
warn!("Failed to get wayland displays: {}", err);
Err(_err) => {
#[cfg(not(feature = "drm"))]
warn!("Failed to get wayland displays: {}", _err);
Arc::new(Displays {
primary: 0,
displays: Vec::new(),
@@ -215,6 +293,8 @@ pub fn get_displays() -> Arc<Displays> {
#[inline]
pub fn clear_wayland_displays_cache() {
let _ = DISPLAYS.lock().unwrap().take();
// The failure stamp survives on purpose: it describes the seat, not the cache, and the
// capturer rebuild loop clears about once a second.
}
// Return (min_x, max_x, min_y, max_y)
@@ -223,17 +303,21 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
desktop_rect_of(&wayland_displays.displays)
}
// The desktop rect and per-display logical rects, always read live from the
// compositor in a single roundtrip. Skips the displays cache and the primary-monitor
// detection (which may spawn external commands), so it is cheap enough to poll for
// layout changes. https://github.com/rustdesk/rustdesk/issues/15601
// The desktop rect and per-display logical rects, read live from the compositor in a single
// roundtrip (drm builds may skip a turn during the failure backoff). Skips the displays cache
// and the primary-monitor detection, cheap enough to poll. rustdesk/rustdesk#15601
pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec<DisplayRect>)> {
match get_wayland_displays() {
#[cfg(feature = "drm")]
if backed_off() {
return None;
}
match enumerate_displays() {
Ok(displays) => {
desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays)))
}
Err(err) => {
warn!("Failed to get wayland displays: {}", err);
Err(_err) => {
#[cfg(not(feature = "drm"))]
warn!("Failed to get wayland displays: {}", _err);
None
}
}
@@ -386,6 +470,40 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e
mod tests {
use super::*;
#[test]
fn test_lookup_backoff_boundaries() {
// Future `now`s sidestep Instant subtraction, which can panic near boot.
let failed_at = Instant::now();
assert!(lookup_allowed(None, failed_at));
assert!(!lookup_allowed(
Some(failed_at),
failed_at + FAILED_LOOKUP_BACKOFF / 2
));
assert!(lookup_allowed(
Some(failed_at),
failed_at + FAILED_LOOKUP_BACKOFF
));
}
#[test]
fn test_lookup_stamp_from_the_future_only_waits() {
// saturating_duration_since answers zero rather than underflowing.
let now = Instant::now();
assert!(!lookup_allowed(Some(now + FAILED_LOOKUP_BACKOFF), now));
}
#[test]
fn test_clear_keeps_the_failure_stamp() {
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
// and dropping the stamp with it would defeat the backoff. Sole test touching these
// statics; serialize before adding another.
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
clear_wayland_displays_cache();
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
assert!(stamp.is_some());
*LAST_FAILED_LOOKUP.lock().unwrap() = None;
}
fn display(
x: i32,
y: i32,