From e35dad529b316bb74bb56cb4f1cfd763d0f81328 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 24 Jul 2026 10:24:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(vdisplay/gamescope):=20DM-flavor-aware=20se?= =?UTF-8?q?ssion=20takeover=20=E2=80=94=20stop=20masking=20Nobara's=20plas?= =?UTF-8?q?malogin=20to=20death?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report (Nobara 43 HTPC): switching the host to Steam Game Mode mid-stream permanently black-screened the box. Live-proven root cause on a Nobara repro VM: our takeover masks the box's gamescope-session-plus unit, plasmalogin's Relogin=true then fails its session Exec repeatedly and trips systemd's start limit within ~1 s — the display manager dies, and our restore verb (unmask + user start) cannot bring a seatless gamescope back. Only 'reset-failed + restart' of the DM recovers. The takeover is now display-manager-flavor-aware: - SDDM (Bazzite/SteamOS) keeps the proven mask+SIGKILL path unchanged. - plasmalogin/unknown DMs never mask: with privilege (root or an operator polkit rule scoped to the DM unit — documented) the host stops the DM for the stream and restores it with reset-failed + restart (PUNKTFUNK_RECOVER_SESSION_CMD as fallback), recorded in the persisted takeover state so a host crash still restores; without privilege the managed takeover degrades to ATTACH and mirrors the box's live Game Mode instead of destabilizing the seat. Both legs of the privileged cycle live-verified on the repro VM (headless managed session works with zero login sessions; render nodes are 0666). A loaded-but-inactive leftover instance never triggers the DM stop. Companion fixes from the same triage: - ensure_box_gamescope_mode gains the attach-only rebuild-probe guard both managed paths already had (stale post-capture-loss detection restarted the box's unit), and no longer re-modes a box that drives a physical display — attach mirrors on-glass; re-mode is the headless-box model. - Capture-loss rebuilds targeting gamescope get a 100 s budget: the 40 s budget expired inside the first 45 s Steam-cold-start launch attempt, a guaranteed single-shot failure. - A PUNKTFUNK_COMPOSITOR pin now WARNs once per capture loss when the live session no longer matches it (the pin disables session-following — the reporter's original stream-death trigger). - A managed session that took nothing over (client gamescope pin beside a live desktop) is stopped on disconnect instead of being orphaned forever. Co-Authored-By: Claude Fable 5 --- .../src/vdisplay/linux/gamescope.rs | 325 ++++++++++++++++-- crates/punktfunk-host/src/native/stream.rs | 35 +- docs-site/content/docs/bazzite.md | 5 +- docs-site/content/docs/configuration.md | 2 +- docs-site/content/docs/gamescope.md | 36 +- docs-site/content/docs/troubleshooting.md | 15 + 6 files changed, 373 insertions(+), 45 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 2a75b953..077a4efc 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -61,6 +61,13 @@ static MANAGED_SESSION: std::sync::Mutex> = std::sync::Mute /// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects. static STOPPED_AUTOLOGIN: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); +/// The display-manager unit we stopped for the takeover on a mask-fragile DM flavor (Nobara's +/// `plasmalogin` — see [`dm_survives_masked_unit`]), so the restore brings the box back via +/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which +/// cannot work there: without a DM login session there is no seat, so gamescope never gets DRM +/// master — live-proven on the Nobara repro VM 2026-07-24). +static STOPPED_DM: std::sync::Mutex> = std::sync::Mutex::new(None); + /// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after /// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm /// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is @@ -115,6 +122,10 @@ struct TakeoverState { stopped_autologin: Vec, /// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart). steamos: bool, + /// The display-manager unit we stopped on a mask-fragile DM flavor (restore = `reset-failed` + + /// `restart` of the DM). `default` so takeover files from older hosts still parse. + #[serde(default)] + stopped_dm: Option, } /// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared @@ -133,8 +144,9 @@ fn persist_takeover() { .unwrap_or_else(|e| e.into_inner()) .clone(), steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()), + stopped_dm: STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone(), }; - if state.stopped_autologin.is_empty() && !state.steamos { + if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() { clear_takeover(); return; } @@ -162,17 +174,19 @@ pub fn restore_takeover_on_startup() { clear_takeover(); return; }; - if state.stopped_autologin.is_empty() && !state.steamos { + if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() { clear_takeover(); return; } tracing::warn!( units = ?state.stopped_autologin, steamos = state.steamos, + stopped_dm = ?state.stopped_dm, "gamescope: found a stranded takeover from a previous host instance — scheduling TV restore" ); *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin; *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos; + *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_dm; // A generous grace so a client reconnecting right after the restart cancels it (create_managed_session // clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode. *PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = @@ -263,7 +277,10 @@ impl VirtualDisplay for GamescopeDisplay { // A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via // schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this. if self.cmd.as_deref().is_some_and(is_steam_launch) { - stop_autologin_sessions(); + // A dedicated launch NEEDS Steam's single instance — no attach degrade exists here, so + // a mask-fragile-DM box without takeover privilege fails with the actionable error. + stop_autologin_sessions() + .context("dedicated Steam launch needs the box's gaming session freed")?; // B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just // the same, and the autologin stop above can't see it — free it too, or fail loudly. free_desktop_steam()?; @@ -353,7 +370,28 @@ fn create_managed_session(client: &str, mode: Mode) -> Result { // Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and // renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by // stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce. - stop_autologin_sessions(); + // On a mask-fragile-DM box without the privilege to stop the DM, the takeover would destabilize + // the seat — degrade to ATTACH instead: mirror the box's own live game-mode session (capture + + // inject, no lifecycle ownership), which needs no takeover at all. + if let Err(e) = stop_autologin_sessions() { + tracing::warn!( + error = %format!("{e:#}"), + "gamescope: managed takeover unavailable — degrading to ATTACH (mirroring the box's \ + own game-mode session)" + ); + let node_id = ensure_box_gamescope_mode(mode)?; + point_injector_at_eis(); + return Ok(VirtualOutput { + node_id, + remote_fd: None, + preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), + keepalive: Box::new(()), + ownership: DisplayOwnership::External, + reused_gen: None, + pool_gen: None, + expect_exact_dims: false, + }); + } // B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and // would make the managed session's own Steam exit at birth. The managed session's Steam itself // is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected. @@ -379,7 +417,18 @@ fn create_managed_session(client: &str, mode: Mode) -> Result { } // (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is // exactly one gamescope `Video/Source` node for discovery. - let node_id = launch_session(client, SESSION_UNIT, mode)?; + let node_id = match launch_session(client, SESSION_UNIT, mode) { + Ok(id) => id, + Err(e) => { + // The takeover already happened (autologin units stopped, possibly the DM down) — arm + // the restore now, or a failed launch strands the box sessionless until a host + // restart. Policy-timed; a quick client retry cancels it and relaunches warm. + // MANAGED_SESSION must be released first: the scheduler reads it (orphan detection). + drop(guard); + schedule_restore_tv_session(); + return Err(e); + } + }; point_injector_at_eis(); *guard = Some(SessionState { width: mode.width, @@ -807,6 +856,38 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result { return Ok(node); } } + // Attach-only rebuild probe (parity with both managed paths — this gap was the attach-path + // stale-detection hazard): right after a capture loss the caller's session detection can be + // stale, and a set-environment + unit restart here would fight the session the user just + // switched to. Mirror whatever live node exists at its own mode; refuse otherwise. + if crate::rebuild_probe_active() { + if let Some(node) = find_gamescope_node() { + tracing::info!( + node, + "gamescope: attach-only rebuild probe — mirroring the live node at its own mode" + ); + return Ok(node); + } + return Err(anyhow!( + "no live gamescope node — attach-only rebuild probe refuses to restart the box's \ + session (re-detection follows the live session)" + )); + } + // A box driving a PHYSICAL display is mirrored at its own mode, never re-moded: the re-mode + // restart is the headless-box model (no panel ⇒ the game-mode resolution is ours to set); + // on-glass it would flip the user's own screen to the client's resolution — and on a + // DM-session-driven box (Nobara) the unit restart bounces the login session with it. + if physical_display_connected() { + if let Some(node) = find_gamescope_node() { + tracing::info!( + node, + client_w = mode.width, + client_h = mode.height, + "gamescope: box drives a physical display — attaching at its own mode (no re-mode)" + ); + return Ok(node); + } + } let Some(unit) = running_autologin_gamescope_unit() else { // No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to // whatever node exists, accepting its resolution. @@ -972,17 +1053,64 @@ fn unmask_unit(unit: &str) { .status(); } +/// The unit name of the display manager driving this box's graphical logins, from the +/// `display-manager.service` alias symlink (the Fedora/Arch/openSUSE convention every +/// gamescope-session distro follows). `None` when no DM is installed (a box that boots straight +/// into a user session — getty autologin / an enabled user unit). +fn display_manager_unit() -> Option { + display_manager_unit_under(std::path::Path::new("/etc/systemd/system")) +} + +/// [`display_manager_unit`] against an arbitrary root (the unit-testable core). +fn display_manager_unit_under(base: &std::path::Path) -> Option { + let target = std::fs::read_link(base.join("display-manager.service")).ok()?; + target.file_name().map(|n| n.to_string_lossy().into_owned()) +} + +/// Does this display manager's autologin loop SURVIVE the gamescope unit being masked? Only SDDM is +/// proven to (Bazzite/SteamOS `Relogin=true` — the mask is what stops its relogin from restarting +/// the unit mid-stream, diagnosed live on .181 2026-07-07; a failing autologin leaves sddm itself +/// running). Nobara's `plasmalogin` (KDE's SDDM successor) is proven FATAL: against a masked unit +/// its session Exec fails instantly, `Relogin=true` retries, and `plasmalogin.service` trips +/// systemd's start limit within ~1 s — the DM dies and the box is a permanent black screen that +/// only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM +/// 2026-07-24). Unknown DMs are treated as fragile: the fragile path degrades gracefully, a wrong +/// "safe" kills the seat. +fn dm_survives_masked_unit(dm: &str) -> bool { + dm == "sddm.service" +} + +/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on +/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see +/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the +/// caller degrades to attach. +fn try_stop_display_manager(dm: &str) -> bool { + Command::new("systemctl") + .args(["stop", dm]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + /// 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). 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() { +/// is autologged in (e.g. a box that boots headless). +/// +/// The teardown is DM-flavor-aware ([`dm_survives_masked_unit`]): +/// * **SDDM / no DM**: 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. +/// * **Mask-fragile DM** (Nobara's `plasmalogin`, unknown DMs): masking start-limit-kills the DM +/// itself (permanent black screen), so instead **stop the DM** — no supervisor left to relogin — +/// then SIGKILL the units unmasked. Needs privilege (root / an operator polkit rule); without it +/// nothing is touched and the error tells the caller to degrade to ATTACH (mirror the box's own +/// session) rather than destabilize the seat. +fn stop_autologin_sessions() -> Result<()> { let Ok(out) = Command::new("systemctl") .args([ "--user", @@ -995,26 +1123,65 @@ fn stop_autologin_sessions() { ]) .output() else { - return; + return Ok(()); }; - let mut stopped = Vec::new(); - 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: masked + SIGKILL-stopped the autologin gaming session for this stream" - ); - stopped.push(unit.to_string()); - } + // `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION. + let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| { + let mut cols = l.split_whitespace(); + let unit = cols.next()?; + let active = cols.nth(1).unwrap_or(""); + (unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service")) + .then(|| (unit.to_string(), active.to_string())) + }) + .collect(); + if listed.is_empty() { + return Ok(()); // nothing autologged in — Steam is already free + } + let dm = display_manager_unit(); + let mask_safe = dm.as_deref().is_none_or(dm_survives_masked_unit); + if !mask_safe { + // Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive + // leftover (the box switched back to the desktop earlier) must not stop the DM — that + // would kill the user's live desktop to free nothing. + if !listed + .iter() + .any(|(_, active)| matches!(active.as_str(), "active" | "activating")) + { + return Ok(()); } + let dm = dm.expect("!is_none_or ⇒ Some"); + if !try_stop_display_manager(&dm) { + bail!( + "the box's gaming session is driven by {dm}, which does not survive a masked \ + session unit, and stopping it needs privilege — install the punktfunk \ + display-manager polkit rule (see docs) to enable the managed takeover" + ); + } + tracing::info!( + %dm, + "freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)" + ); + *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm); } - if !stopped.is_empty() { - *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped; - persist_takeover(); // A3: survive a host crash mid-stream + let units: Vec = listed.into_iter().map(|(u, _)| u).collect(); + let mut stopped = Vec::new(); + for unit in units { + if mask_safe { + 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, + masked = mask_safe, + "freed Steam: stopped the autologin gaming session for this stream" + ); + stopped.push(unit); } + *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped; + persist_takeover(); // A3: survive a host crash mid-stream + Ok(()) } /// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears @@ -1159,7 +1326,16 @@ pub fn schedule_restore_tv_session() { .lock() .unwrap_or_else(|e| e.into_inner()) .is_empty() - && !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()); + && !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) + && STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none() + // A managed session that took nothing over (started beside a live desktop — e.g. a client + // gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it + // was ORPHANED forever after disconnect ("closing the app does not end the session", + // field report 2026-07-24) — the restore stops it even with no autologin to bring back. + && MANAGED_SESSION + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_none(); if nothing_to_restore { return; // nothing was taken over → nothing to restore (also the non-managed path) } @@ -1254,8 +1430,24 @@ fn do_restore_tv_session() { } } let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner())); - if units.is_empty() { - return; // nothing was stolen → nothing to restore (also the non-Bazzite path) + let dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner())); + if units.is_empty() && dm.is_none() { + // Nothing was stolen — but a managed session that started BESIDE a live desktop (client + // gamescope pin on a KDE box) still owns the transient unit; stop it so it doesn't run + // orphaned forever after the disconnect. No-op when the unit isn't running. + if MANAGED_SESSION + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .is_some() + { + stop_session(SESSION_UNIT); + tracing::info!( + "gamescope: stopped the idle managed session (nothing was taken over — no box \ + session to restore)" + ); + } + return; } 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 @@ -1266,18 +1458,54 @@ fn do_restore_tv_session() { } *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 - // to gaming — leave the desktop alone. (We still stopped our idle managed session above.) + // user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank + // them back to gaming — leave the desktop alone. (We still stopped our idle managed session + // above.) use super::ActiveKind; if matches!( super::detect_active_session().kind, - ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots + ActiveKind::DesktopKde + | ActiveKind::DesktopGnome + | ActiveKind::DesktopWlroots + | ActiveKind::DesktopHyprland ) { tracing::info!( "gamescope: a desktop session is active — not restoring the TV gaming session" ); return; } + // Mask-fragile-DM takeover: the gamescope unit CANNOT be `--user start`ed back — without a DM + // login session there is no seat, so gamescope never gets DRM master (unit goes `failed`, + // screen stays black — live-proven on the Nobara repro VM). Restore the DM instead + // (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin + // session Exec starts the gamescope unit itself. + if let Some(dm) = dm { + let _ = Command::new("systemctl") + .args(["reset-failed", &dm]) + .status(); + let restart = Command::new("systemctl") + .args(["restart", &dm]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if restart { + tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)"); + } else if crate::try_recover_session() { + tracing::warn!( + %dm, + "display-manager restart lost its privilege — fired PUNKTFUNK_RECOVER_SESSION_CMD \ + to bring the session back" + ); + } else { + tracing::error!( + %dm, + "could not restart the display manager and no PUNKTFUNK_RECOVER_SESSION_CMD is \ + configured — the box has no graphical session until someone runs \ + `systemctl reset-failed {dm} && systemctl restart {dm}` as root" + ); + } + return; + } for unit in units { let _ = Command::new("systemctl") .args(["--user", "start", &unit]) @@ -1641,10 +1869,35 @@ impl Drop for GamescopeProc { #[cfg(test)] mod tests { use super::{ - cgroup_is_punktfunk_owned, connected_connector_under, is_steam_launch, - shape_dedicated_command, + cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under, + dm_survives_masked_unit, is_steam_launch, shape_dedicated_command, }; + #[test] + fn display_manager_flavor_detection() { + let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + // No alias symlink (no DM installed — getty autologin boxes) → None. + assert_eq!(display_manager_unit_under(&base), None); + // The Fedora-style alias symlink resolves to its target's basename (read_link, not + // canonicalize — the target needn't exist on the build box). + std::os::unix::fs::symlink( + "/usr/lib/systemd/system/plasmalogin.service", + base.join("display-manager.service"), + ) + .unwrap(); + assert_eq!( + display_manager_unit_under(&base).as_deref(), + Some("plasmalogin.service") + ); + // Only SDDM is proven to survive a masked session unit; plasmalogin start-limit-kills + // itself (live-proven), and unknown DMs default to fragile. + assert!(dm_survives_masked_unit("sddm.service")); + assert!(!dm_survives_masked_unit("plasmalogin.service")); + assert!(!dm_survives_masked_unit("gdm.service")); + std::fs::remove_dir_all(&base).unwrap(); + } + #[test] fn connector_status_scan() { let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id())); diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index da97c86a..4e66dd29 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1940,6 +1940,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option break p, Err(e2) => { + let budget = if compositor == crate::vdisplay::Compositor::Gamescope { + GAMESCOPE_REBUILD_BUDGET + } else { + REBUILD_BUDGET + }; if stop.load(Ordering::SeqCst) - || std::time::Instant::now() >= rebuild_deadline + || std::time::Instant::now() >= loss_at + budget { return Err(e2) .context("capture lost — no compositor came up within the rebuild budget"); diff --git a/docs-site/content/docs/bazzite.md b/docs-site/content/docs/bazzite.md index 7932f898..82d0e0d6 100644 --- a/docs-site/content/docs/bazzite.md +++ b/docs-site/content/docs/bazzite.md @@ -100,8 +100,9 @@ For Gaming Mode there are two models (pick one; the shipped default is **attach* - **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its gamescope session on its own display, and the host attaches to whatever's live without ever - tearing it down (a box-owned autologin session is restarted at the client's resolution on a - mismatch). Switching Desktop ↔ Game is rock-solid. + tearing it down (on a headless box, a box-owned autologin session is restarted at the client's + resolution on a mismatch; with a display connected it streams at the box's own mode). Switching + Desktop ↔ Game is rock-solid. - **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the virtual screen — restoring the box on idle. diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 73b1ea84..e8ccaf3a 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -53,7 +53,7 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics). | Setting | Values | Meaning | |---|---|---| -| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. A box-owned autologin session is restarted at the client's resolution on a mismatch; a foreign/bare gamescope streams at its own mode. | +| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. | | `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. | | `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). | | `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. | diff --git a/docs-site/content/docs/gamescope.md b/docs-site/content/docs/gamescope.md index 5ffbd560..477b0987 100644 --- a/docs-site/content/docs/gamescope.md +++ b/docs-site/content/docs/gamescope.md @@ -24,14 +24,42 @@ that has gamescope session infrastructure (Bazzite, SteamOS, Nobara) gets **mana - **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display; the host attaches to whatever's live and never tears it down, so switching Desktop ↔ Game is - rock-solid and disconnecting leaves the box where it was. When the session is the box's own - autologin unit, the host restarts it at the **client's** resolution on a mismatch; a foreign or - bare gamescope is streamed at its own mode. + rock-solid and disconnecting leaves the box where it was. When the box is **headless** (no + display connected) and the session is its own autologin unit, the host restarts it at the + **client's** resolution on a mismatch; a box driving a physical display — and any foreign or + bare gamescope — is streamed at its own mode. - **Managed** (the infra-detected default; force with `PUNKTFUNK_GAMESCOPE_MANAGED=1`) — the host takes the box's gamescope session over and relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode runs on the virtual screen, physical displays drop out of it — restoring the box on idle after disconnect. +### Nobara and other autologin display managers + +The managed takeover has to stop the box's Gaming Mode session to free Steam. How it does that +depends on the display manager driving the autologin: + +- **SDDM** (Bazzite, SteamOS): handled automatically — no setup. +- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager + itself for the length of the stream and restart it afterwards, which needs privilege. Allow it + with a polkit rule (adjust the unit and user names to your box): + + ```js + // /etc/polkit-1/rules.d/49-punktfunk-dm.rules + polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "plasmalogin.service" && + subject.user == "YOUR_USER") { + return polkit.Result.YES; + } + }); + ``` + + Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session + instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the + display manager. If the display-manager restart ever loses its privilege mid-restore, + `PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the + fallback. + ## Session following `PUNKTFUNK_SESSION_WATCH` follows a Gaming ↔ Desktop switch **mid-stream** — the host rebuilds the @@ -58,7 +86,7 @@ a model. See the full [Configuration reference](/docs/configuration) for every o | Setting | Values | Meaning | |---|---|---| -| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session (on its own display); the host captures whatever's live and never tears it down. A box-owned autologin session is restarted at the client's resolution on a mismatch; a foreign/bare gamescope streams at its own mode. | +| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session (on its own display); the host captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. | | `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it headless at the client's exact mode, restoring on idle. | | `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode — a headless appliance with no physical session running. | | `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. | diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 5e89201f..15ffae51 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -107,6 +107,21 @@ See [GNOME](/docs/gnome) for the GL/EGL userspace details. auto-detects the live compositor, and the pin points it at one backend even when a different session is live (it also disables Gaming ↔ Desktop following). +## The screen stays black after switching to Game Mode (Nobara) + +On distros whose Game Mode is display-manager autologin under **plasmalogin** (Nobara), a managed +takeover from a host **0.19.1 or older** could kill the display manager: it trips systemd's start +limit and the box stays black until someone restarts it. Recover from a VT (Ctrl+Alt+F3) or SSH: + +```sh +systemctl --user unmask --runtime 'gamescope-session-plus@*.service' +sudo systemctl reset-failed plasmalogin && sudo systemctl restart plasmalogin +``` + +Current hosts detect the display-manager flavor and never mask the session unit there — see +[gamescope → autologin display managers](/docs/gamescope) for the polkit rule that enables the full +managed takeover on these boxes (without it the host mirrors Game Mode instead). + ## Session fails right after editing host.env - Keys are **case-sensitive**: `punktfunk_gamescope_attach=1` sets nothing — use the exact