cat / goq
clone

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

Merge pull request #95 from FelineStateMachine/feat/gamescope-pointer-reconnect

FelineStateMachine committed 9ade966
fix(input): reconnect Gamescope pointer feedback
commit
9ade966c0d7e07b9a6aef5ce004ab67136977939
parent
dc5e2187012b2e649adbe3d4cc773d0a84b09819
parent
a505aa3aa10f58ccf6c8b09ec9f359b66e5e4f74
author
FelineStateMachine <dami.etoile@gmail.com>
4 files changed, +413 -68
Mcrates/sigil-host/src/bin/sigil-probe.rs117++++++++++++--
Mcrates/sigil-host/src/cursor.rs261+++++++++++++++++++++++++-----
Mdocs/fresh-bazzite-host.md30+++-
Mscripts/run-bazzite-hardware-uat.sh73++++++++-
M crates/sigil-host/src/bin/sigil-probe.rs +102 -15
@@ -103,6 +103,10 @@ struct Args {
103103 /// complete left-click. Intended for libinput/Gamescope-backed proof.104104 #[arg(long)]105105 pointer_smoke: bool,106+ /// Require the host to return an immediately available compositor pointer107+ /// position and visibility sample. Intended for Gamescope restart proofs.108+ #[arg(long, requires = "pointer_smoke")]109+ pointer_feedback_smoke: bool,106110 /// Attach the media-feedback stream to the active session, submit one107111 /// complete report, and print the host's bounded decision. Intended for108112 /// validating shadow-only encoder configurations.
@@ -1581,6 +1585,10 @@ async fn main() -> Result<()> {
15811585 if args.pointer_smoke {15821586 input_offers.push(Capability::RelativePointer);15831587 }1588+ if args.pointer_feedback_smoke {1589+ input_offers.push(Capability::PointerPositionFeedback);1590+ input_offers.push(Capability::PointerVisibilityFeedback);1591+ }15841592 if args.gamepad_smoke {15851593 input_offers.push(Capability::Gamepad);15861594 }
@@ -1598,6 +1606,27 @@ async fn main() -> Result<()> {
15981606 session_id == input_negotiation.session_id,15991607 "media and input session IDs differ"16001608 );1609+ if args.pointer_feedback_smoke {1610+ ensure!(1611+ input_negotiation1612+ .capabilities1613+ .contains(&Capability::PointerPositionFeedback)1614+ && input_negotiation1615+ .capabilities1616+ .contains(&Capability::PointerVisibilityFeedback),1617+ "host did not accept the required pointer feedback capabilities"1618+ );1619+ let initial_feedback =1620+ read_expected_input_ack(&mut input_recv, args.timeout_seconds, 0).await?;1621+ ensure!(1622+ initial_feedback.pointer_position.is_some(),1623+ "host pointer feedback is unavailable"1624+ );1625+ ensure!(1626+ initial_feedback.pointer_visible.is_some(),1627+ "host omitted negotiated pointer visibility feedback"1628+ );1629+ }16011630 let input_started = Instant::now();16021631 write_input_event(&mut input_send, &InputEvent::Probe)16031632 .await
@@ -2453,6 +2482,14 @@ async fn main() -> Result<()> {
24532482 "not-requested"24542483 }24552484 );2485+ println!(2486+ "pointer_feedback_smoke={}",2487+ if args.pointer_feedback_smoke {2488+ "ok"2489+ } else {2490+ "not-requested"2491+ }2492+ );24562493 println!(24572494 "gamepad_smoke={}",24582495 if args.gamepad_smoke {
@@ -2748,30 +2785,80 @@ struct Negotiated {
27482785 pointer_surface_dimensions: Option<PointerSurfaceDimensions>,27492786 }27502787 2751-async fn read_expected_input_ack(2752- recv: &mut iroh::endpoint::RecvStream,2788+async fn read_expected_input_ack<R>(2789+ recv: &mut R,27532790 timeout_seconds: u64,27542791 expected_sequence: u64,2755-) -> Result<()> {2756- let input_ack = tokio::time::timeout(2757- Duration::from_secs(timeout_seconds.min(5)),2758- read_input_ack(recv),2759- )2792+) -> Result<sigil_protocol::InputAck>2793+where2794+ R: tokio::io::AsyncRead + Unpin,2795+{2796+ let input_ack = tokio::time::timeout(Duration::from_secs(timeout_seconds.min(5)), async {2797+ loop {2798+ let input_ack = read_input_ack(recv)2799+ .await?2800+ .context("host closed before input acknowledgment")?;2801+ ensure!(2802+ input_ack.sequence <= expected_sequence,2803+ "unexpected input acknowledgment sequence {}; expected at most {expected_sequence}",2804+ input_ack.sequence2805+ );2806+ if input_ack.sequence == expected_sequence {2807+ break Ok(input_ack);2808+ }2809+ }2810+ })27602811 .await2761- .context("timed out waiting for input acknowledgment")??2762- .context("host closed before input acknowledgment")?;2763- ensure!(2764- input_ack.sequence == expected_sequence,2765- "unexpected input acknowledgment sequence {}; expected {expected_sequence}",2766- input_ack.sequence2767- );2768- Ok(())2812+ .context("timed out waiting for input acknowledgment")??;2813+ Ok(input_ack)27692814 }27702815 27712816 #[cfg(test)]27722817 mod tests {27732818 use super::*;27742819 2820+ #[tokio::test]2821+ async fn expected_input_ack_skips_older_feedback_under_one_deadline() {2822+ let (mut sender, mut receiver) = tokio::io::duplex(512);2823+ for sequence in [0, 0, 1] {2824+ sigil_protocol::write_input_ack(2825+ &mut sender,2826+ &sigil_protocol::InputAck {2827+ sequence,2828+ pointer_position: None,2829+ pointer_visible: Some(false),2830+ },2831+ )2832+ .await2833+ .unwrap();2834+ }2835+2836+ let input_ack = read_expected_input_ack(&mut receiver, 1, 1).await.unwrap();2837+2838+ assert_eq!(input_ack.sequence, 1);2839+ }2840+2841+ #[tokio::test]2842+ async fn expected_input_ack_rejects_future_feedback() {2843+ let (mut sender, mut receiver) = tokio::io::duplex(256);2844+ sigil_protocol::write_input_ack(2845+ &mut sender,2846+ &sigil_protocol::InputAck {2847+ sequence: 2,2848+ pointer_position: None,2849+ pointer_visible: Some(false),2850+ },2851+ )2852+ .await2853+ .unwrap();2854+2855+ let error = read_expected_input_ack(&mut receiver, 1, 1)2856+ .await2857+ .unwrap_err();2858+2859+ assert!(error.to_string().contains("expected at most 1"));2860+ }2861+27752862 #[test]27762863 fn feedback_report_is_complete_and_keeps_the_observed_sequence() {27772864 let report = complete_feedback_report(7, MediaFeedbackFlags::NONE, 42);
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 }
M docs/fresh-bazzite-host.md +24 -6
@@ -1180,10 +1180,15 @@ Sigil reads `GAMESCOPE_MOUSE_FOCUS_DISPLAY` from the bootstrap root, reconnects
11801180 to the active local display, and samples `QueryPointer` at no more than 60 Hz.11811181 It also reads `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` from that active root so the11821182 client overlay disappears when Gamescope hides its cursor. Missing, malformed,1183-or unreachable Xwayland state disables the separately negotiated pointer1184-feedback capability; relative uinput remains available without guessed cursor1185-coordinates. The service `DISPLAY=:0` line is retained as an explicit fallback1186-for configurations created before `xwayland_display` was added.1183+or unreachable Xwayland state during startup disables the separately1184+negotiated pointer feedback capability. After successful startup, losing1185+Gamescope publishes pointer feedback as unavailable, then reconnects the1186+bootstrap display with a bounded 100 ms to 2 second backoff. A complete1187+reconnect re-interns both Gamescope atoms, discovers the replacement native1188+surface, and validates one active-display sample before publishing coordinates1189+again. Relative uinput remains available without guessed cursor coordinates.1190+The service `DISPLAY=:0` line is retained as an explicit fallback for1191+configurations created before `xwayland_display` was added.11871192 11881193 Audio is optional and must resolve one exact PipeWire sink, never a microphone.11891194 The appliance owns a persistent 48 kHz stereo null sink so capture does not
@@ -1533,10 +1538,14 @@ sudo libinput debug-events --device "$pointer_node"
15331538 target/debug/sigil-probe \15341539 --node-id <host-node-id> \15351540 --frames 120 \1536- --pointer-smoke1541+ --pointer-smoke \1542+ --pointer-feedback-smoke15371543 ```15381544 1539-The probe must report `pointer_smoke=ok`. It uses the native pointer-surface1545+The probe must report `pointer_smoke=ok` and `pointer_feedback_smoke=ok`.1546+The feedback check requires an immediately available compositor position and1547+visibility sample; an input acknowledgment with unavailable coordinates fails1548+the proof. The pointer smoke uses the native pointer-surface15401549 dimensions negotiated in the media `HostHello`, not the potentially downscaled15411550 encoded dimensions. On a 2560x1600 Gamescope surface, before the ordinary15421551 motion, evtest must show the synchronization sequence:
@@ -1547,6 +1556,15 @@ reports relative `POINTER_MOTION` and `POINTER_BUTTON` events. The probe fails
15471556 closed if the host omits the native pointer-surface dimensions. The interactive15481557 `xmessage` activation remains the downstream Gamescope/Xwayland proof.15491558 1559+For compositor replacement recovery, keep the candidate Sigil daemon running,1560+record its PID, and restart `gamescope-session-plus@steam.service` twice. After1561+each restart, wait for the `gamescope` PipeWire node and the bootstrap root1562+property to return, then rerun the pointer and feedback smoke without replaying1563+the redeemed invitation. Both runs must pass, the Sigil PID must remain1564+unchanged, and the host log must contain one `Gamescope Xwayland pointer1565+feedback reconnected` event per restart. The packaged hardware UAT performs1566+this sequence after its fixed-mode session checks.1567+15501568 For a deterministic pre-demo gamepad proof, leave `evtest "$gamepad_node"`15511569 running on the host and run this from the client checkout:15521570
M scripts/run-bazzite-hardware-uat.sh +70 -3
@@ -78,7 +78,13 @@ done
7878 real_config="$HOME/.config/sigil-spark/host.toml"7979 real_sigil="$HOME/.local/libexec/sigil-spark/current/sigil"8080 real_identity="$(sed -n 's/^identity_path = "\(.*\)"$/\1/p' "$real_config")"81+uinput_block="$(awk '82+ /^\[uinput\]$/ { emit = 1 }83+ emit && /^\[/ && $0 != "[uinput]" { exit }84+ emit { print }85+' "$real_config")"8186 [[ -n "$real_identity" && -f "$real_identity" ]]87+[[ "$uinput_block" == '[uinput]'$'\n'* ]]8288 [[ -x "$real_sigil" ]]8389 original_ready() {8490 local invocation pid
@@ -196,6 +202,7 @@ probe_node_id="$(sed -n 's/^node_id=//p' "$private/probe-identity.log")"
196202 197203 pw_dump="$(command -v pw-dump)"198204 pw_link="$(command -v pw-link)"205+xprop="$(command -v xprop)"199206 gst_launch="$(command -v gst-launch-1.0)"200207 gst_inspect="$(command -v gst-inspect-1.0)"201208 ffmpeg="$(command -v ffmpeg)"
@@ -212,9 +219,11 @@ width = 1280
212219 height = 800213220 framerate = 60214221 codec = "h264"215-input_mode = "log"222+input_mode = "uinput"216223 ffmpeg_path = "$ffmpeg"217224 225+$uinput_block226+218227 [gamescope_pipewire]219228 node_name = "gamescope"220229 media_class = "Video/Source"
@@ -235,9 +244,11 @@ state_path = "$state"
235244 source = "gamescope-pipewire"236245 framerate = 60237246 codec = "h264"238-input_mode = "log"247+input_mode = "uinput"239248 ffmpeg_path = "$ffmpeg"240249 250+$uinput_block251+241252 [gamescope_pipewire]242253 node_name = "gamescope"243254 media_class = "Video/Source"
@@ -508,6 +519,7 @@ run_probe_cycle() {
508519 local invitation_path="${5:-}"509520 local slow_consumer_ms="${6:-}"510521 local expected_path_mode="${7:-direct}"522+ local pointer_feedback_smoke="${8:-false}"511523 local log="$raw/$mode-$transport-$request_id.log"512524 local command=(513525 "$probe"
@@ -523,8 +535,15 @@ run_probe_cycle() {
523535 [[ -n "$invitation_path" ]] && command+=(--invitation "$invitation_path")524536 [[ -n "$slow_consumer_ms" ]] && command+=(--slow-consumer-ms "$slow_consumer_ms")525537 [[ "$expected_path_mode" != relay ]] || command+=(--relay-only)538+ if [[ "$pointer_feedback_smoke" == true ]]; then539+ command+=(--pointer-smoke --pointer-feedback-smoke)540+ fi526541 timeout --signal=TERM --kill-after=5s 45s "${command[@]}" >"$log" 2>&1527542 validate_probe "$log" "$transport" "$size" "$request_id" "$slow_consumer_ms" "$expected_path_mode"543+ if [[ "$pointer_feedback_smoke" == true ]]; then544+ grep -Fxq pointer_smoke=ok "$log"545+ grep -Fxq pointer_feedback_smoke=ok "$log"546+ fi528547 }529548 530549 validate_host_recovery() {
@@ -661,6 +680,47 @@ for cycle in $(seq 1 10); do
661680 run_probe_cycle fixed grouped-v3 1280x800 "$((1100 + cycle))"662681 done663682 validate_host_recovery "$raw/fixed-host.log"683+684+fixed_candidate_pid="$(systemctl --user show -p MainPID --value \685+ "$fixed_candidate_unit.service")"686+[[ "$fixed_candidate_pid" =~ ^[0-9]+$ && "$fixed_candidate_pid" -gt 1 ]]687+pointer_reconnects_before="$(grep -Fc \688+ 'Gamescope Xwayland pointer feedback reconnected' "$raw/fixed-host.log" || true)"689+for cycle in 1 2; do690+ systemctl --user restart gamescope-session-plus@steam.service691+ gamescope_ready=false692+ for _ in $(seq 1 600); do693+ if systemctl --user is-active --quiet gamescope-session-plus@steam.service \694+ && DISPLAY=:0 "$xprop" -root GAMESCOPE_MOUSE_FOCUS_DISPLAY >/dev/null 2>&1 \695+ && "$pw_dump" | jq -e '696+ .[]697+ | select(.type == "PipeWire:Interface:Node")698+ | select(.info.props["node.name"] == "gamescope")699+ | select(.info.props["media.class"] == "Video/Source")700+ ' >/dev/null701+ then702+ gamescope_ready=true703+ break704+ fi705+ sleep 0.1706+ done707+ [[ "$gamescope_ready" == true ]]708+ [[ "$(systemctl --user show -p MainPID --value \709+ "$fixed_candidate_unit.service")" == "$fixed_candidate_pid" ]]710+ expected_reconnects="$((pointer_reconnects_before + cycle))"711+ for _ in $(seq 1 100); do712+ reconnects="$(grep -Fc 'Gamescope Xwayland pointer feedback reconnected' \713+ "$raw/fixed-host.log" || true)"714+ [[ "$reconnects" -ge "$expected_reconnects" ]] && break715+ sleep 0.1716+ done717+ [[ "$reconnects" -ge "$expected_reconnects" ]]718+ run_probe_cycle "pointer-recovery-$cycle" iroh-moq 1280x800 \719+ "$((1200 + cycle))" "" "" direct true720+ sed -E $'s/\x1B\\[[0-9;]*[[:alpha:]]//g' \721+ "$raw/pointer-recovery-$cycle-iroh-moq-$((1200 + cycle)).log" \722+ >"$evidence/pointer-recovery-$cycle.log"723+done664724 stop_candidate665725 666726 native_timer="goq-uat-native-$invocation_id"
@@ -753,6 +813,9 @@ fi
753813 echo external_cqp_minimum_fps=55754814 echo "external_cqp_observed_fps=$external_cqp_observed_fps"755815 echo cross_state_capture_contention=rejected816+ echo gamescope_pointer_restart_cycles=2817+ echo gamescope_pointer_feedback_recovery=pass818+ echo gamescope_pointer_daemon_pid_stable=pass756819 echo "native_size=$native_size"757820 echo native_performance_gate=observational758821 echo "native_observed_fps=$native_observed_fps"
@@ -791,5 +854,9 @@ sha256sum -c "$private/original-identity.sha256"
791854 echo original_identity_preserved=pass792855 echo original_release_preserved=pass793856 } >"$evidence/summary.env"794-chmod 0600 "$evidence/summary.env" "$evidence"/probes/*.log "$evidence"/*-capture.log857+chmod 0600 \858+ "$evidence/summary.env" \859+ "$evidence"/probes/*.log \860+ "$evidence"/*-capture.log \861+ "$evidence"/pointer-recovery-*.log795862 cat "$evidence/summary.env"