diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 1d3b20ff..a4b1fbab 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -88,7 +88,7 @@ pub use session::{session_epoch, try_recover_session}; pub(crate) mod routing; pub use routing::{ apply_input_env, managed_session_available, preflight_takeover_privilege, - resolve_gamescope_route, restore_managed_session, restore_takeover_now, + release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now, restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session, GamescopeRoute, }; diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 6dcba18f..6fe74c0f 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -91,6 +91,13 @@ static STOPPED_AUTOLOGIN: std::sync::Mutex> = std::sync::Mutex::new( /// 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); +/// Whether this takeover runtime-masked the [`STOPPED_AUTOLOGIN`] units ([`mask_unit`]) — i.e. +/// whether there is a mask left to lift. Process memory, like the rest of the takeover mechanics. +/// [`restore_takeover_on_startup`] sets it for a stranded takeover it adopts: unmasking a unit we +/// never masked is a no-op, while missing one that IS masked leaves the box unable to enter its +/// own Game Mode until reboot. +static AUTOLOGIN_MASKED: std::sync::Mutex = std::sync::Mutex::new(false); + /// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream /// "Switch to Desktop" detector compares against. Steam's session-select script writes /// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its @@ -252,6 +259,12 @@ pub fn restore_takeover_on_startup() { stopped_dm = ?state.stopped_dm, "gamescope: found a stranded takeover from a previous host instance — scheduling TV restore" ); + // Assume the adopted takeover carries our runtime mask whenever it stopped units: whether it did + // is not persisted (it follows from the DM flavor, which can only have stayed the same), and the + // two errors are not symmetric — unmasking a unit we never masked is a no-op, while skipping one + // that IS masked bars the box from its own game mode until reboot. + *AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) = + !state.stopped_autologin.is_empty(); *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; @@ -1547,16 +1560,23 @@ fn kill_unit(unit: &str) { /// 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). /// -/// ⚠ The mask only covers the UNIT path — it is NOT what stops the relogin loop itself. On images -/// whose SDDM session helper execs the session script directly (`/etc/sddm/wayland-session -/// gamescope-session-plus steam`, f43 bazzite-deck — live-diagnosed on the .41 VM 2026-07-31) the -/// relogin never touches the unit, so the mask blocks nothing: SDDM relogins ~3×/s, each a full -/// `bash --login` session start that fails against the managed instance — 328 forks/s, load 6+, -/// 1481 logind sessions in 8 minutes, the journal flooded past its own rotation. The stream itself +/// ⚠ The mask stops the UNIT from starting — it does NOT stop the relogin loop that keeps trying. +/// On images whose SDDM session helper execs the session script directly (`/etc/sddm/wayland-session +/// gamescope-session-plus steam`, f43 bazzite-deck — live-diagnosed on the .41 VM 2026-07-31) SDDM +/// relogins ~3×/s regardless, each a full `bash --login` session start — 328 forks/s, load 6+, 1481 +/// logind sessions in 8 minutes, the journal flooded past its own rotation. The stream itself /// survives, but the storm starves the game and the encoder ("atrocious, unplayable 240fps"). The -/// real defense is stopping the DM ([`dm_plan`]); the mask stays as belt-and-braces for the window -/// before the stop lands, for images that DO route the relogin through the unit, and as the -/// degraded takeover when the stop is impossible. +/// real defense against the storm is stopping the DM ([`dm_plan`]); the mask stays as belt-and-braces +/// for the window before the stop lands, and as the degraded takeover when the stop is impossible. +/// +/// ⚠⚠ The mask DOES bite on that image, which is easy to miss and was the 2026-08-10 field bug: the +/// session script's last act is `systemctl --user --wait start gamescope-session-plus@$1.service`, so +/// a masked unit makes every entry into game mode — including the user's own deliberate "Return to +/// Gaming Mode" — fail instantly, with Steam left sitting on its "Switch to Desktop…" modal forever. +/// The mask is therefore only sound while our managed session actually holds the box: the moment the +/// box leaves it (a mid-stream switch to a desktop session), [`lift_autologin_mask`] must lift it, or +/// the way back is barred until reboot (`--runtime` lives in tmpfs — which is exactly why "it works +/// again after a reboot"). fn mask_unit(unit: &str) { let _ = Command::new("systemctl") .args(["--user", "mask", "--runtime", unit]) @@ -1571,6 +1591,58 @@ fn unmask_unit(unit: &str) { .status(); } +/// Lift the takeover's runtime mask ([`mask_unit`]) on the box's own autologin units, so the box can +/// enter its own game mode again. Idempotent and cheap once lifted (the flag short-circuits), so +/// every path that hands the box back may call it unconditionally. +/// +/// Deliberately does NOT consume [`STOPPED_AUTOLOGIN`]: the mask's lifetime is shorter than the +/// takeover's. Lifting it mid-stream only says "the box may start its own gaming session again"; the +/// restore still owes that list a `start` if the box is still ours at disconnect +/// ([`do_restore_tv_session`]). +fn lift_autologin_mask() { + let mut masked = AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()); + if !*masked { + return; + } + *masked = false; + let units = STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()); + for unit in units.iter() { + unmask_unit(unit); + } + tracing::info!( + units = ?*units, + "gamescope: lifted the takeover's runtime mask — the box can enter its own game mode again" + ); +} + +/// Does a mid-stream session switch TO `kind` end the window in which the takeover's mask is sound? +/// Only a **desktop** session does: it means the box left our managed game session for one of its +/// own, so nothing is left for the mask to defend and the user's next move — "Return to Gaming +/// Mode" — needs the unit ([`mask_unit`]). +/// +/// [`ActiveKind::Gaming`] must not lift by itself (a takeover's own managed session reads as Gaming, +/// and lifting there would void the mask for the whole stream, in exactly the storm window it exists +/// for), and neither must [`ActiveKind::None`] — a managed session momentarily down between +/// relaunches reads as `None`, and that is mid-takeover, not the end of one. +fn switch_ends_mask_window(kind: super::ActiveKind) -> bool { + use super::ActiveKind; + matches!( + kind, + ActiveKind::DesktopKde + | ActiveKind::DesktopGnome + | ActiveKind::DesktopWlroots + | ActiveKind::DesktopHyprland + ) +} + +/// The host's mid-stream session watcher calls this on every switch it confirms; see +/// [`switch_ends_mask_window`] for which ones actually lift the mask. +pub fn release_autologin_mask(switched_to: super::ActiveKind) { + if switch_ends_mask_window(switched_to) { + lift_autologin_mask(); + } +} + /// 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 @@ -2118,7 +2190,13 @@ fn honor_session_select_switch(dm: String) { "gamescope: in-stream session-select detected — restoring the display manager and \ switching the box to the desktop session" ); - // Consume the takeover state up front: from here on the box is the DM's again. + // Consume the takeover state up front: from here on the box is the DM's again. The mask goes + // FIRST and while the unit list still exists — this path discards that list, and it is the only + // record of what carries a mask. Without this the mask outlived not just the stream but the + // boot: the disconnect restore (the only other unmask) would find an empty list and lift + // nothing. It is also what lets step 1 below work at all, since the DM's autologin heads back + // into game mode through exactly this unit. + lift_autologin_mask(); std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner())); clear_takeover(); *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; @@ -2327,6 +2405,11 @@ fn stop_autologin_sessions() -> Result<()> { } let units: Vec = listed.into_iter().map(|(u, _)| u).collect(); let mut stopped = Vec::new(); + if plan.mask { + // Record that a mask is outstanding BEFORE laying it: every hand-back path lifts it off this + // flag, and one that ran between the mask and an unrecorded flag would leave it on forever. + *AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) = true; + } for unit in units { if plan.mask { mask_unit(&unit); // belt-and-braces under a stopped DM; the whole defense otherwise @@ -2623,6 +2706,12 @@ fn do_restore_tv_session() { return; } } + // Unmask BEFORE taking the list (it reads that list) and unconditionally — before the + // desktop-active early return below, and before the restart loop: a unit left masked would break + // the user's own return to gaming mode until reboot. Usually already lifted by then (the + // mid-stream switch that ends the mask's window does it — [`release_autologin_mask`]), in which + // case this is a no-op. + lift_autologin_mask(); let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner())); let dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner())); if units.is_empty() && dm.is_none() { @@ -2645,11 +2734,6 @@ 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/Hyprland) in the meantime, don't yank @@ -3955,10 +4039,11 @@ mod tests { use super::{ cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under, display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args, - is_steam_launch, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind, - script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command, - xwayland_refusal_marker, BindOff, BindPlan, DmHelperError, SessionBind, - DISTRO_GAMESCOPE_PATH, X11_SOCKET_DIR, + is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind, + release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced, + shape_dedicated_command, switch_ends_mask_window, unmask_unit, xwayland_refusal_marker, + BindOff, BindPlan, DmHelperError, SessionBind, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, + STOPPED_AUTOLOGIN, X11_SOCKET_DIR, }; /// The HDR spawn flags are what make a nested game render HDR at all — and their absence is @@ -4127,6 +4212,80 @@ mod tests { assert!(!p.skip && p.mask && !p.stop_dm); } + #[test] + fn only_a_desktop_switch_ends_the_mask_window() { + use crate::ActiveKind; + // The user switched the box to a desktop session mid-stream: our managed game session is + // over, so the mask defends nothing — and the "Return to Gaming Mode" that follows has to + // be able to start the unit (the distro session script starts exactly it). + for kind in [ + ActiveKind::DesktopKde, + ActiveKind::DesktopGnome, + ActiveKind::DesktopWlroots, + ActiveKind::DesktopHyprland, + ] { + assert!(switch_ends_mask_window(kind), "{kind:?}"); + } + // A takeover's own managed session reads as Gaming, so lifting here would void the mask for + // the whole stream — in exactly the SDDM-relogin window it exists for. (Coming BACK to + // gaming needs no lift either: it evidently already started.) + assert!(!switch_ends_mask_window(ActiveKind::Gaming)); + // A managed session momentarily down between relaunches reads as None. That is + // mid-takeover, not the end of one. + assert!(!switch_ends_mask_window(ActiveKind::None)); + } + + /// The same rule as above, but end-to-end against REAL systemd — that the decision is actually + /// wired to the mask, that a lift leaves the restart list intact, and that `--runtime` is what + /// comes off (a plain `unmask` does NOT clear a runtime mask, which is the whole reason the + /// stranded mask survived every non-reboot remedy in the field). + /// + /// Ignored by default: it needs a live `systemd --user` manager. On a Linux box with a session: + /// `cargo test -p pf-vdisplay -- --ignored the_mask_comes_off`. Uses a unit name nothing owns — + /// `mask` works on a non-existent unit (it is just a symlink to `/dev/null`), so this never goes + /// near the box's real gaming session. + #[test] + #[ignore = "needs a live systemd --user manager (run explicitly on a Linux box with a session)"] + fn the_mask_comes_off_only_when_the_box_takes_itself_back() { + const PROBE: &str = "punktfunk-mask-probe@lifetime-test.service"; + let is_enabled = || { + let out = std::process::Command::new("systemctl") + .args(["--user", "is-enabled", PROBE]) + .output() + .expect("systemctl --user"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + unmask_unit(PROBE); // a previous failed run must not decide this one + + // Lay the takeover's mask exactly as `stop_autologin_sessions` does. + *STOPPED_AUTOLOGIN.lock().unwrap() = vec![PROBE.to_string()]; + *AUTOLOGIN_MASKED.lock().unwrap() = true; + mask_unit(PROBE); + assert_eq!(is_enabled(), "masked-runtime"); + + // Mid-stream, with the box still ours: the mask is doing its job and must stay. `Gaming` is + // what our own managed session reads as, and `None` is one momentarily down between + // relaunches — lifting on either would void the mask for the whole stream. + release_autologin_mask(crate::ActiveKind::Gaming); + release_autologin_mask(crate::ActiveKind::None); + assert_eq!(is_enabled(), "masked-runtime"); + + // The user switched the box to its own desktop mid-stream: the window is over, and the way + // back into game mode has to be clear before they ask for it. + release_autologin_mask(crate::ActiveKind::DesktopKde); + assert_ne!(is_enabled(), "masked-runtime"); + // The restart list SURVIVES the lift: the mask's lifetime is shorter than the takeover's, + // and the disconnect restore still owes these units a `start`. + assert_eq!(STOPPED_AUTOLOGIN.lock().unwrap().as_slice(), [PROBE]); + // Idempotent — the watcher calls it on every switch it confirms. + release_autologin_mask(crate::ActiveKind::DesktopGnome); + assert_ne!(is_enabled(), "masked-runtime"); + + unmask_unit(PROBE); + STOPPED_AUTOLOGIN.lock().unwrap().clear(); + *AUTOLOGIN_MASKED.lock().unwrap() = false; + } + #[test] fn connector_status_scan() { let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id())); diff --git a/crates/pf-vdisplay/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs index 2fca39be..5d3494b1 100644 --- a/crates/pf-vdisplay/src/vdisplay/routing.rs +++ b/crates/pf-vdisplay/src/vdisplay/routing.rs @@ -396,6 +396,22 @@ pub fn restore_takeover_now() { #[cfg(not(target_os = "linux"))] pub fn restore_takeover_now() {} +/// Tell the takeover that the box switched to `switched_to` mid-stream. A managed takeover +/// runtime-masks the box's own autologin gaming unit so its session supervisor cannot restart it +/// underneath us — but that mask is only sound while our managed session actually holds the box. +/// Once the user has switched the box to a desktop session mid-stream, the mask defends nothing and +/// bars the way back: the distro's session script starts that very unit, so "Return to Gaming Mode" +/// fails instantly and Steam sits on its "Switch to Desktop…" modal until a reboot clears the mask. +/// Call from the mid-stream session watcher on every switch it confirms; no-op when no takeover +/// masked anything, and when the switch does not end the mask's window. +#[cfg(target_os = "linux")] +pub fn release_autologin_mask(switched_to: crate::ActiveKind) { + gamescope::release_autologin_mask(switched_to); +} + +#[cfg(not(target_os = "linux"))] +pub fn release_autologin_mask(_switched_to: crate::ActiveKind) {} + #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 6eae5b76..31bd0c1e 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1144,6 +1144,13 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender, stop: Arc= DEBOUNCE => { + // Before anything about capture: a managed takeover runtime-masks the box's own + // autologin gaming unit, and a switch to a desktop session ends the window where + // that mask is sound. Left on, it silently bars the way back — the user's "Return + // to Gaming Mode" cannot start a masked unit, so Steam sits on its "Switch to + // Desktop…" modal until a reboot clears it. Ahead of the `compositor_for_kind` + // arm below on purpose: a switch we cannot follow still has to unbar the return. + vdisplay::release_autologin_mask(cur); match vdisplay::compositor_for_kind(cur) { Some(comp) => { tracing::info!(from = ?current, to = ?cur, compositor = comp.id(),