cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix(input): reconnect Gamescope pointer feedback

Dami committed 1901365
Rebuild bootstrap and active Xwayland state after compositor replacement, rediscover the native pointer surface, fail unavailable during recovery, and retry with bounded backoff.
commit
190136519a33c2cd68e0318a70b89dcc5dcf8b69
parent
dc5e2187012b2e649adbe3d4cc773d0a84b09819
author
Dami <dami.etoile@gmail.com>
1 file changed, +217 -44
Mcrates/sigil-host/src/cursor.rs261+++++++++++++++++++++++++-----
M crates/sigil-host/src/cursor.rs +217 -44
@@ -12,6 +12,10 @@ use sigil_protocol::{PointerPosition, PointerSurfaceDimensions};
1212 #[cfg(target_os = "linux")]1313 const POINTER_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_micros(16_667);1414 #[cfg(any(target_os = "linux", test))]15+const BOOTSTRAP_RECONNECT_INITIAL: Duration = Duration::from_millis(100);16+#[cfg(any(target_os = "linux", test))]17+const BOOTSTRAP_RECONNECT_MAXIMUM: Duration = Duration::from_secs(2);18+#[cfg(any(target_os = "linux", test))]1519 const POINTER_ACTIVITY_VISIBLE_TIMEOUT: Duration = Duration::from_secs(3);1620 #[cfg(target_os = "linux")]1721 const FOCUS_DISPLAY_PROPERTY: &[u8] = b"GAMESCOPE_MOUSE_FOCUS_DISPLAY";
@@ -84,6 +88,41 @@ impl PointerActivityVisibility {
8488 }8589 }8690 91+#[cfg(any(target_os = "linux", test))]92+#[derive(Debug)]93+struct ReconnectBackoff {94+ delay: Duration,95+ retry_not_before: Instant,96+}97+98+#[cfg(any(target_os = "linux", test))]99+impl ReconnectBackoff {100+ fn new(now: Instant) -> Self {101+ Self {102+ delay: BOOTSTRAP_RECONNECT_INITIAL,103+ retry_not_before: now,104+ }105+ }106+107+ fn ready(&self, now: Instant) -> bool {108+ now >= self.retry_not_before109+ }110+111+ fn failed(&mut self, now: Instant) {112+ self.retry_not_before = now.checked_add(self.delay).unwrap_or(now);113+ self.delay = self114+ .delay115+ .checked_mul(2)116+ .unwrap_or(BOOTSTRAP_RECONNECT_MAXIMUM)117+ .min(BOOTSTRAP_RECONNECT_MAXIMUM);118+ }119+120+ fn recovered(&mut self, now: Instant) {121+ self.delay = BOOTSTRAP_RECONNECT_INITIAL;122+ self.retry_not_before = now;123+ }124+}125+87126 #[derive(Clone)]88127 pub struct PointerPositionTracker {89128 inner: Arc<TrackerInner>,
@@ -117,29 +156,32 @@ impl PointerPositionTracker {
117156 configured_display: Option<&str>,118157 pointer_surface_dimensions: PointerSurfaceDimensions,119158 ) -> Result<Self> {120- use x11rb::protocol::xproto::ConnectionExt;121-122159 let bootstrap_display = match configured_display {123160 Some(display) => display.to_owned(),124161 None => std::env::var("DISPLAY")125162 .context("neither gamescope_pipewire.xwayland_display nor DISPLAY is configured")?,126163 };127164 validate_display_name(&bootstrap_display)?;128- let (bootstrap_connection, bootstrap_root) = connect_display(&bootstrap_display)129- .context("connecting to bootstrap Gamescope Xwayland")?;130- let focus_atom = bootstrap_connection131- .intern_atom(false, FOCUS_DISPLAY_PROPERTY)132- .context("interning Gamescope mouse-focus property")?133- .reply()134- .context("receiving Gamescope mouse-focus atom")?135- .atom;136- let focus_display = query_focus_display(&bootstrap_connection, bootstrap_root, focus_atom)137- .context("querying initial Gamescope mouse-focus display")?;165+ let bootstrap = connect_bootstrap_display(&bootstrap_display)?;166+ anyhow::ensure!(167+ bootstrap.pointer_surface_dimensions == pointer_surface_dimensions,168+ "bootstrap Gamescope pointer surface is {}x{}, but capture preflight resolved {}x{}",169+ bootstrap.pointer_surface_dimensions.width,170+ bootstrap.pointer_surface_dimensions.height,171+ pointer_surface_dimensions.width,172+ pointer_surface_dimensions.height173+ );174+ let focus_display =175+ query_focus_display(&bootstrap.connection, bootstrap.root, bootstrap.focus_atom)176+ .context("querying initial Gamescope mouse-focus display")?;138177 let active = connect_active_display(&focus_display)139178 .context("connecting to active Gamescope Xwayland")?;140- let initial_position =141- query_pointer_position(&active.connection, active.root, pointer_surface_dimensions)142- .context("querying initial Gamescope Xwayland pointer position")?;179+ let initial_position = query_pointer_position(180+ &active.connection,181+ active.root,182+ bootstrap.pointer_surface_dimensions,183+ )184+ .context("querying initial Gamescope Xwayland pointer position")?;143185 let initial = available_pointer_state(initial_position, query_cursor_visible(&active).ok());144186 let (latest, _) = tokio::sync::watch::channel(initial);145187 let stop = Arc::new(AtomicBool::new(false));
@@ -148,27 +190,102 @@ impl PointerPositionTracker {
148190 std::thread::Builder::new()149191 .name("sigil-xwayland-pointer".into())150192 .spawn(move || {193+ let mut bootstrap = Some(bootstrap);151194 let mut active_display = focus_display;152195 let mut active_connection = Some(active);153196 let mut activity_visibility = PointerActivityVisibility::new(initial_position);197+ let mut reconnect_backoff = ReconnectBackoff::new(Instant::now());154198 while !thread_stop.load(Ordering::Acquire) {155199 std::thread::sleep(POINTER_POLL_INTERVAL);200+ if bootstrap.is_none() {201+ let now = Instant::now();202+ if !reconnect_backoff.ready(now) {203+ continue;204+ }205+ match connect_bootstrap_display(&bootstrap_display) {206+ Ok(reconnected) => {207+ let focus_display = query_focus_display(208+ &reconnected.connection,209+ reconnected.root,210+ reconnected.focus_atom,211+ );212+ let recovered = focus_display.and_then(|focus_display| {213+ let active = connect_active_display(&focus_display)?;214+ let position = query_pointer_position(215+ &active.connection,216+ active.root,217+ reconnected.pointer_surface_dimensions,218+ )?;219+ Ok((focus_display, active, position))220+ });221+ match recovered {222+ Ok((focus_display, active, position)) => {223+ let visible = query_cursor_visible(&active).ok();224+ let pointer_surface_dimensions =225+ reconnected.pointer_surface_dimensions;226+ bootstrap = Some(reconnected);227+ active_display = focus_display;228+ active_connection = Some(active);229+ activity_visibility =230+ PointerActivityVisibility::new(position);231+ reconnect_backoff.recovered(now);232+ let state = available_pointer_state(position, visible);233+ thread_latest.send_if_modified(|latest| {234+ if *latest == state {235+ false236+ } else {237+ *latest = state;238+ true239+ }240+ });241+ tracing::info!(242+ display = %bootstrap_display,243+ pointer_surface_width = pointer_surface_dimensions.width,244+ pointer_surface_height = pointer_surface_dimensions.height,245+ "Gamescope Xwayland pointer feedback reconnected"246+ );247+ }248+ Err(error) => {249+ reconnect_backoff.failed(now);250+ tracing::warn!(251+ display = %bootstrap_display,252+ error = %error,253+ "Gamescope Xwayland pointer feedback reconnect incomplete"254+ );255+ }256+ }257+ }258+ Err(error) => {259+ reconnect_backoff.failed(now);260+ tracing::warn!(261+ display = %bootstrap_display,262+ error = %error,263+ "Gamescope Xwayland pointer feedback reconnect failed"264+ );265+ }266+ }267+ continue;268+ }269+ let bootstrap_connection = bootstrap270+ .as_ref()271+ .expect("bootstrap connection was checked above");156272 let focus_display = match query_focus_display(157- &bootstrap_connection,158- bootstrap_root,159- focus_atom,273+ &bootstrap_connection.connection,274+ bootstrap_connection.root,275+ bootstrap_connection.focus_atom,160276 ) {161277 Ok(display) => display,162- Err(_) => {278+ Err(error) => {279+ tracing::warn!(280+ display = %bootstrap_display,281+ error = %error,282+ "Gamescope bootstrap Xwayland connection was lost"283+ );284+ bootstrap = None;285+ active_connection = None;163286 activity_visibility.reset();164- thread_latest.send_if_modified(|latest| {165- if *latest == PointerState::UNAVAILABLE {166- false167- } else {168- *latest = PointerState::UNAVAILABLE;169- true170- }171- });287+ reconnect_backoff.failed(Instant::now());288+ publish_unavailable(&thread_latest);172289 continue;173290 }174291 };
@@ -178,20 +295,13 @@ impl PointerPositionTracker {
178295 activity_visibility.reset();179296 }180297 let Some(active) = active_connection.as_ref() else {181- thread_latest.send_if_modified(|latest| {182- if *latest == PointerState::UNAVAILABLE {183- false184- } else {185- *latest = PointerState::UNAVAILABLE;186- true187- }188- });298+ publish_unavailable(&thread_latest);189299 continue;190300 };191301 match query_pointer_position(192302 &active.connection,193303 active.root,194- pointer_surface_dimensions,304+ bootstrap_connection.pointer_surface_dimensions,195305 ) {196306 Ok(position) => {197307 let visible = activity_visibility.resolve(
@@ -212,14 +322,7 @@ impl PointerPositionTracker {
212322 Err(_) => {213323 active_connection = None;214324 activity_visibility.reset();215- thread_latest.send_if_modified(|latest| {216- if *latest == PointerState::UNAVAILABLE {217- false218- } else {219- *latest = PointerState::UNAVAILABLE;220- true221- }222- });325+ publish_unavailable(&thread_latest);223326 }224327 }225328 }
@@ -262,6 +365,53 @@ fn connect_display(display: &str) -> Result<(x11rb::rust_connection::RustConnect
262365 Ok((connection, root))263366 }264367 368+#[cfg(target_os = "linux")]369+struct BootstrapXwayland {370+ connection: x11rb::rust_connection::RustConnection,371+ root: u32,372+ focus_atom: u32,373+ pointer_surface_dimensions: PointerSurfaceDimensions,374+}375+376+#[cfg(target_os = "linux")]377+fn connect_bootstrap_display(display: &str) -> Result<BootstrapXwayland> {378+ use x11rb::protocol::xproto::ConnectionExt;379+380+ let (connection, root) =381+ connect_display(display).context("connecting to bootstrap Gamescope Xwayland")?;382+ let focus_atom = connection383+ .intern_atom(false, FOCUS_DISPLAY_PROPERTY)384+ .context("interning Gamescope mouse-focus property")?385+ .reply()386+ .context("receiving Gamescope mouse-focus atom")?387+ .atom;388+ let geometry = connection389+ .get_geometry(root)390+ .context("sending bootstrap X11 GetGeometry")?391+ .reply()392+ .context("receiving bootstrap X11 GetGeometry reply")?;393+ let pointer_surface_dimensions = PointerSurfaceDimensions::new(geometry.width, geometry.height)394+ .context("validating bootstrap Gamescope pointer surface dimensions")?;395+ Ok(BootstrapXwayland {396+ connection,397+ root,398+ focus_atom,399+ pointer_surface_dimensions,400+ })401+}402+403+#[cfg(target_os = "linux")]404+fn publish_unavailable(latest: &tokio::sync::watch::Sender<PointerState>) {405+ latest.send_if_modified(|latest| {406+ if *latest == PointerState::UNAVAILABLE {407+ false408+ } else {409+ *latest = PointerState::UNAVAILABLE;410+ true411+ }412+ });413+}414+265415 #[cfg(target_os = "linux")]266416 struct ActiveXwayland {267417 connection: x11rb::rust_connection::RustConnection,
@@ -608,4 +758,27 @@ mod tests {
608758 visibility.reset();609759 assert!(!visibility.resolve(moved, None, sampled_at));610760 }761+762+ #[test]763+ fn bootstrap_reconnect_backoff_is_bounded_and_resets_after_recovery() {764+ let started_at = Instant::now();765+ let mut backoff = ReconnectBackoff::new(started_at);766+767+ assert!(backoff.ready(started_at));768+ backoff.failed(started_at);769+ assert!(!backoff.ready(started_at));770+ assert!(backoff.ready(started_at + BOOTSTRAP_RECONNECT_INITIAL));771+772+ let mut now = started_at + BOOTSTRAP_RECONNECT_INITIAL;773+ for _ in 0..16 {774+ backoff.failed(now);775+ assert!(backoff.delay <= BOOTSTRAP_RECONNECT_MAXIMUM);776+ now += BOOTSTRAP_RECONNECT_MAXIMUM;777+ }778+ assert_eq!(backoff.delay, BOOTSTRAP_RECONNECT_MAXIMUM);779+780+ backoff.recovered(now);781+ assert!(backoff.ready(now));782+ assert_eq!(backoff.delay, BOOTSTRAP_RECONNECT_INITIAL);783+ }611784 }