From 79bd847e85221d803eb8b389f697455fed0bf50a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 06:15:12 +0000 Subject: [PATCH] fix(host): survive Bazzite's SDDM session supervisor in the gamescope takeover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosed live on the .181 Bazzite F44 box (couldn't connect at all; field reports of streams dying after 30 s-5 min): Bazzite autologs into game mode via SDDM with Relogin=true, so the moment the managed takeover stops gamescope-session-plus@, SDDM logs back in and restarts it within the same second. The resurrected autologin session then fights our transient session-plus over the Steam single instance and the GPU for the whole stream: its wrapper relaunches gamescope every ~7 s (each one missing the wrapper's hard 5 s readiness window on a slow NVIDIA init), the churn SIGSEGVs gamescopes, and eventually the streaming gamescope dies with it. Meanwhile a client that gave up left the pipeline-rebuild retry loop SIGKILLing and relaunching the box's Steam session for up to ~6 more minutes. - stop_autologin_sessions: runtime-mask each autologin unit before the SIGKILL stop, so no supervisor can restart it underneath the stream; match every loaded instance (the unit flaps through activating/failed mid-churn). Every restore path unmasks unconditionally (including the desktop-active early return), and --runtime keeps the mask in tmpfs so a reboot clears it. - launch_session: supervise the transient unit while polling for the node — the session-plus wrapper kill -9s a gamescope that missed its 5 s readiness handshake and exits 1, so relaunch it (after a short driver-settle cooldown) instead of waiting the rest of the 45 s on a corpse. - build_pipeline_with_retry: abort between attempts once the session's QUIC connection is closed — no more minutes of Steam churn for a departed client. Validated live on .181: cold-boot connect streams 2059 frames/45 s (p50 5.1 ms), zero SDDM resurrections while masked, TV session restored+unmasked on disconnect, warm same-mode reconnect reuses the session (866 frames/15 s). Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/punktfunk1.rs | 16 ++- .../src/vdisplay/linux/gamescope.rs | 124 ++++++++++++++---- 2 files changed, 114 insertions(+), 26 deletions(-) diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 96ee03cb..1bddcd44 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -3096,7 +3096,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) .then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone())); let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) = - build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?; + build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?; // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). #[cfg(target_os = "windows")] drop(_idd_setup_guard); @@ -3280,6 +3280,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { bit_depth, plan, &quit, + &stop, )?; Ok((new_vd, pipe)) })(); @@ -3486,6 +3487,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { bit_depth, plan, &quit, + &stop, ) { Ok(p) => break p, Err(e2) => { @@ -3793,6 +3795,7 @@ fn build_pipeline_with_retry( bit_depth: u8, plan: crate::session_plan::SessionPlan, quit: &Arc, + stop: &Arc, ) -> Result { // ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed // gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take @@ -3819,6 +3822,17 @@ fn build_pipeline_with_retry( const MAX_ATTEMPTS: u32 = 8; let mut backoff = std::time::Duration::from_millis(500); for attempt in 1..=MAX_ATTEMPTS { + // The client is gone (connection closed → `stop`): every further attempt only churns the + // box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and + // relaunching the box's Steam session once per attempt for minutes (the .181 storm + // 2026-07-07). One in-flight attempt can still overhang; this bounds the damage to it. + if attempt > 1 && stop.load(Ordering::SeqCst) { + anyhow::bail!( + "session ended (client disconnected) during pipeline build — aborting retries \ + after {} attempt(s)", + attempt - 1 + ); + } match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) { Ok(pipe) => { if attempt > 1 { diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 952effbb..56f31026 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -824,19 +824,46 @@ fn kill_unit(unit: &str) { .status(); } -/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its +/// Runtime-mask `unit` so the box's session supervisor cannot restart it underneath the takeover. +/// Bazzite/SteamOS autologin runs under SDDM with `Relogin=true` (`/etc/sddm.conf.d/steamos.conf`): +/// the moment the autologin session dies — including our own deliberate stop — SDDM logs back in and +/// starts the unit again within the same second. A merely-stopped unit then fights our host-managed +/// session over the Steam single instance and the GPU for the whole stream (the restarted wrapper +/// relaunches gamescope every ~7 s; the contention SIGSEGVs gamescopes and eventually kills the +/// streaming one — the "stream dies after 30 s–5 min" field reports, diagnosed live on .181 +/// 2026-07-07). `--runtime` keeps the mask in tmpfs so a reboot clears it even if the host dies +/// without restoring (the same semantics as the persisted takeover file). +fn mask_unit(unit: &str) { + let _ = Command::new("systemctl") + .args(["--user", "mask", "--runtime", unit]) + .status(); +} + +/// Undo [`mask_unit`] — every restore path must unmask before (or regardless of) restarting, or the +/// box's own return-to-gaming-mode stays broken until reboot. +fn unmask_unit(unit: &str) { + let _ = Command::new("systemctl") + .args(["--user", "unmask", "--runtime", unit]) + .status(); +} + +/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its /// single-instance Steam is free for our own host-managed session. Records the units so /// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient /// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing -/// is autologged in (e.g. a box that boots headless). Uses the **SIGKILL** teardown ([`kill_unit`]) -/// to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers. +/// is autologged in (e.g. a box that boots headless). Each unit is **masked first** ([`mask_unit`] — +/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL** +/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers. +/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit +/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the +/// fight the moment the supervisor restarts it. fn stop_autologin_sessions() { let Ok(out) = Command::new("systemctl") .args([ "--user", "list-units", "--type=service", - "--state=running", + "--all", "--no-legend", "--plain", "gamescope-session-plus@*.service", @@ -849,10 +876,11 @@ fn stop_autologin_sessions() { for line in String::from_utf8_lossy(&out.stdout).lines() { if let Some(unit) = line.split_whitespace().next() { if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") { + mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak tracing::info!( unit, - "freed Steam: SIGKILL-stopped the autologin gaming session for this stream" + "freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream" ); stopped.push(unit.to_string()); } @@ -980,6 +1008,11 @@ fn do_restore_tv_session() { } clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin + // Unmask UNCONDITIONALLY (before the desktop-active early return below): a unit left masked + // would break the user's own return to gaming mode until reboot. + for unit in &units { + unmask_unit(unit); + } *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; // Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the // user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back @@ -1095,26 +1128,32 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result { let wrapper = write_gamescope_bin_wrapper()?; stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean let hz = mode.refresh_hz.max(1); - let status = Command::new("systemd-run") - .args(["--user", "--collect", &format!("--unit={unit_name}")]) - .arg("--setenv=BACKEND=headless") - .arg(format!("--setenv=SCREEN_WIDTH={}", mode.width)) - .arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height)) - .arg(format!("--setenv=PF_HZ={hz}")) - .arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display())) - .arg("--setenv=DRM_MODE=cvt") - .arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}")) - .arg("--") - .arg(SESSION_PLUS_BIN) - .arg(client) - .status() - .context( - "launch gamescope-session-plus via `systemd-run --user` (is the user systemd manager \ - up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)", - )?; - if !status.success() { - anyhow::bail!("`systemd-run --user` failed to start the gamescope session (exit {status})"); - } + let start_unit = || -> Result<()> { + let status = Command::new("systemd-run") + .args(["--user", "--collect", &format!("--unit={unit_name}")]) + .arg("--setenv=BACKEND=headless") + .arg(format!("--setenv=SCREEN_WIDTH={}", mode.width)) + .arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height)) + .arg(format!("--setenv=PF_HZ={hz}")) + .arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display())) + .arg("--setenv=DRM_MODE=cvt") + .arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}")) + .arg("--") + .arg(SESSION_PLUS_BIN) + .arg(client) + .status() + .context( + "launch gamescope-session-plus via `systemd-run --user` (is the user systemd \ + manager up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)", + )?; + if !status.success() { + anyhow::bail!( + "`systemd-run --user` failed to start the gamescope session (exit {status})" + ); + } + Ok(()) + }; + start_unit()?; // Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s. let deadline = Instant::now() + Duration::from_secs(45); loop { @@ -1128,10 +1167,45 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result { (Steam failed to start? — `journalctl --user -u {unit_name}`)" ); } + // The session-plus wrapper hard-kills a gamescope that missed its 5 s readiness handshake + // and exits 1 (a slow NVIDIA cold start routinely needs 5-15 s — the .181 storm 2026-07-07), + // and the transient unit has no Restart= — without supervision the rest of this poll would + // wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used. + if !unit_starting_or_active(unit_name) { + tracing::info!( + unit = unit_name, + "gamescope session: transient unit died (missed the wrapper's 5 s gamescope \ + readiness window?) — relaunching" + ); + // Brief cooldown before the relaunch: the wrapper SIGKILLed a gamescope mid-Vulkan-init, + // and the NVIDIA driver reclaims that context asynchronously — an instant relaunch pays + // the reclaim serialization on top of device init and misses the 5 s window again. + std::thread::sleep(Duration::from_millis(1500)); + let _ = Command::new("systemctl") + .args(["--user", "reset-failed", unit_name]) + .status(); + start_unit()?; + } std::thread::sleep(Duration::from_millis(500)); } } +/// Is the unit currently starting or up (`activating` / `active` — also `deactivating`: let a stop +/// finish; the next poll tick sees the settled state)? Unknown/unreachable states report `true` so a +/// systemctl hiccup can't trigger a relaunch storm. +fn unit_starting_or_active(unit: &str) -> bool { + let Ok(out) = Command::new("systemctl") + .args(["--user", "is-active", unit]) + .output() + else { + return true; + }; + matches!( + String::from_utf8_lossy(&out.stdout).trim(), + "active" | "activating" | "reloading" | "deactivating" + ) +} + /// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44 /// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected. fn stop_session(unit_name: &str) {