diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index a0938453..3a189b49 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -128,6 +128,11 @@ static STOPPED_DM: std::sync::Mutex> = std::sync::Mutex::new(None /// [`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. +/// +/// ⚠ The takeover itself no longer masks anything — it idles the autologin session instead +/// ([`install_idle_dropin`]), because a masked unit FAILS and a failing unit is what the display +/// manager relogin-loops against. So this is now only ever true for a takeover adopted from a +/// host old enough to have laid one, and the lift paths stay for exactly that box. 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 @@ -158,6 +163,10 @@ static SWITCH_HONORED_AT: std::sync::Mutex> = std::sync::Mutex:: /// giving the DM's desktop session time to come up so re-detection follows it instead. const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120); +/// Whether [`install_idle_dropin`] has one outstanding. Process memory only — the sweep in +/// [`restore_takeover_on_startup`] is what covers a host that died holding one. +static IDLE_DROPIN_ARMED: std::sync::Mutex = std::sync::Mutex::new(false); + /// 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 @@ -373,6 +382,15 @@ pub fn restore_takeover_on_startup() { ); systemctl_user(&["daemon-reload"]); } + // Same shape, same reason: a host that died mid-stream leaves the box's Game Mode replaced by + // a session that does nothing at all, which looks exactly like broken hardware. Runtime-dir + // state, so a reboot clears it too — this covers the restart that does not. + if remove_idle_dropin() { + tracing::warn!( + "gamescope: removed a leftover idle drop-in from a previous host instance — the box's \ + own Game Mode session would have started and then done nothing" + ); + } let Ok(bytes) = std::fs::read(takeover_state_path()) else { return; // no takeover file — clean start }; @@ -1107,8 +1125,9 @@ fn discover_session_display_env() -> Option<(Option, Option, Opt /// ⚠ Only for callers whose timeout answer is the SAFE one. Both current callers time out into /// "assume active"/"keep looping", so a miss costs a poll tick. A caller whose timeout would invert /// the answer into the refusing direction must NOT use this bound — `loginctl show-user -p Linger` -/// was given it and became a hard connect failure on a correctly-configured box (see -/// [`linger_enabled`], now on [`UNIT_QUERY_BUDGET`]): 300 ms is an in-memory-read budget, and +/// was given it and became a hard connect failure on a correctly-configured box (the linger probe +/// that produced it is gone with the DM-stop path, but the lesson is not): 300 ms is an +/// in-memory-read budget, and /// anything that spawns a process and makes a D-Bus round trip is not that. const UNIT_STATE_BUDGET: Duration = Duration::from_millis(300); @@ -1287,6 +1306,74 @@ fn legacy_session_plus_dropin_path() -> std::path::PathBuf { .join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf") } +/// Where the takeover's IDLE drop-in lives. Same runtime-dir argument as +/// [`session_plus_dropin_path`], and here it is the safety property the mechanism rests on rather +/// than a tidiness one: this drop-in replaces the box's game-mode `ExecStart`, so a copy that +/// outlived the host would leave the box unable to enter Game Mode at all. Under +/// `$XDG_RUNTIME_DIR` it dies with the login session, and a reboot restores game mode by itself — +/// on top of the unconditional sweep [`restore_takeover_on_startup`] does. +fn idle_dropin_path() -> std::path::PathBuf { + let base = crate::session::runtime_dir(); + std::path::Path::new(&base) + .join("systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-idle.conf") +} + +/// `sleep`'s path on this box. The idle `ExecStart` must not be a command that can fail to +/// EXECUTE: a unit that dies on start is precisely the relogin storm this drop-in exists to avoid +/// ([`mask_unit`] has that chain), so resolve it instead of hardcoding one distro's layout. +fn sleep_binary() -> &'static str { + ["/usr/bin/sleep", "/bin/sleep"] + .into_iter() + .find(|p| std::path::Path::new(p).exists()) + .unwrap_or("/usr/bin/sleep") +} + +/// Idle the box's autologin game session for the stream's duration: a drop-in over the +/// `gamescope-session-plus@` TEMPLATE (so it reaches whichever instance this box autologs into) +/// that replaces `ExecStart` with a process which merely sleeps. +/// +/// This is what the takeover uses INSTEAD of stopping the display manager, and it satisfies all +/// three things that path has to get right at once. Steam is freed (the session runs nothing). +/// The DM does not storm: its autologin still SUCCEEDS, so there is no failed session to relogin +/// against — unlike a masked unit, which fails in milliseconds and is the storm's engine. And the +/// box keeps a live display manager, so a session switch the user asks for can still be serviced; +/// that is the one a stopped DM could not, and it stranded `.41` on Steam's "Switch to Desktop" +/// modal until a reboot. +/// +/// Measured on that box: with this installed, `steam` is down, `sddm` stays active, the unit sits +/// `active (running)` with `NRestarts=0`, and a subsequent `switch-to-desktop-mode` brings Plasma +/// up in ~10 s. +fn install_idle_dropin() -> Result<()> { + let path = idle_dropin_path(); + let dir = path + .parent() + .context("the idle drop-in path has no parent directory")?; + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + std::fs::write( + &path, + format!( + "[Service]\nExecStart=\nExecStart={} infinity\n", + sleep_binary() + ), + ) + .with_context(|| format!("write {}", path.display()))?; + systemctl_user(&["daemon-reload"]); + *IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true; + Ok(()) +} + +/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was +/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and +/// the drop-in outliving a host that died is exactly the case that has to be swept. +fn remove_idle_dropin() -> bool { + let removed = std::fs::remove_file(idle_dropin_path()).is_ok(); + *IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = false; + if removed { + systemctl_user(&["daemon-reload"]); + } + removed +} + /// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and /// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS` /// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60). @@ -2128,6 +2215,12 @@ fn kill_unit(unit: &str) { /// 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"). +/// ⚠ Nothing in the takeover lays a mask any more — it idles the autologin session instead +/// ([`install_idle_dropin`]), precisely because a masked unit FAILS and a failing unit is what the +/// display manager relogin-loops against. This is kept for the test that builds the state +/// [`lift_autologin_mask`] exists to clean up: a takeover adopted from a host old enough to have +/// masked. That lift is still live code, so the state has to stay constructible. +#[cfg(test)] fn mask_unit(unit: &str) { let _ = crate::proc::status_within( Command::new("systemctl").args(["--user", "mask", "--runtime", unit]), @@ -2230,16 +2323,24 @@ struct DmPlan { /// no Steam, masking them while a DM is up is the relogin storm ([`mask_unit`]), and stopping /// the DM would kill the user's live desktop for it. skip: bool, - /// Stop the DM for the stream's duration (only a live instance justifies it). Masking is not - /// a plan input: it is laid only once this stop has LANDED, so it can never substitute for it. - stop_dm: bool, + /// A display manager drives this LIVE gaming session, so its autologin brings the session + /// straight back the moment we free Steam. That is what the idle drop-in answers + /// ([`install_idle_dropin`]) — not, any longer, stopping the DM. + /// + /// Stopping it satisfied the same requirement and broke a different one: a box with no DM has + /// nothing that can start a desktop session, so the user's own "Switch to Desktop" hung on + /// Steam's modal until a reboot (field report 2026-08-18). It hung UNDETECTABLY, which is why + /// no amount of watching fixes it: on a `steamos-manager` box the switch is a D-Bus call whose + /// every trace — the sddm state file, the session units, the login mode — is written by the + /// display manager we had just stopped. Leave the DM up and there is nothing to detect. + dm_relogins: bool, } /// See [`DmPlan`]. fn dm_plan(dm: Option<&str>, any_live: bool) -> DmPlan { DmPlan { skip: !any_live, - stop_dm: dm.is_some() && any_live, + dm_relogins: dm.is_some() && any_live, } } @@ -2608,122 +2709,11 @@ fn systemctl_system(args: &[&str]) -> bool { out.status.success() } -/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user` -/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login -/// session. logind then stops `user@.service` once `UserStopDelaySec` (10 s by default) -/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart -/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0** -/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0 -/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was -/// enabled there for the sessionless tests. -/// -/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is -/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure -/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then -/// degrades to attach, which mirrors the box's own session and never stops the DM. -/// -/// `Err` carries **why** it could not be ensured, because the helper path is reached here first: -/// on a sessionless host the `linger` verb goes through the same [`dm_helper`] gate the `stop` -/// verb does, so a user outside the `punktfunk` group fails at THIS step and never reaches the -/// DM-stop one. Dropping the reason here would just move the misdiagnosis one message earlier. -fn ensure_host_survives_dm_stop() -> std::result::Result<(), String> { - if !host_is_under_user_manager() { - return Ok(()); // root / a system unit — the DM stop cannot reach us - } - if linger_enabled() { - return Ok(()); - } - // `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the - // user's session can do this itself; a sessionless one (the packaged unit) goes through the - // helper, whose grant is scoped to the calling uid. - let uid = uid_string(); - let _ = crate::proc::status_within( - Command::new("loginctl").args(["--no-ask-password", "enable-linger", &uid]), - UNIT_QUERY_BUDGET, - ); - let helper = if linger_enabled() { - Ok(()) // the plain verb was enough — the helper was never needed - } else { - dm_helper("linger").map_err(|e| e.to_string()) - }; - match helper { - Ok(()) if linger_enabled() => { - tracing::info!( - uid, - "enabled lingering for this user — the managed takeover stops the display manager, \ - which ends this login session, and without lingering logind would stop the host \ - along with it (`loginctl disable-linger` reverts it)" - ); - Ok(()) - } - // The verb reported success and `loginctl` still says no: not a privilege problem, so say - // that instead of blaming the grant the operator would then go and re-check. - Ok(()) => Err(format!( - "`loginctl enable-linger {uid}` reported success but lingering is still off" - )), - Err(why) => Err(why), - } -} - -/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager -/// stop take us down)? Read from our own cgroup path. -fn host_is_under_user_manager() -> bool { - std::fs::read_to_string("/proc/self/cgroup") - .as_deref() - .map(cgroup_under_user_manager) - .unwrap_or(false) -} - -/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a -/// `user@.service` manager? Pure + unit-tested. A system unit -/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from -/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only -/// reaches units the user manager owns. -fn cgroup_under_user_manager(cgroup: &str) -> bool { - cgroup.contains("user@") -} - /// Our uid as a string — what `loginctl` wants for a user argument. fn uid_string() -> String { crate::proc::current_uid().to_string() } -/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)? An -/// unanswered one reads as "not lingering", which refuses the takeover rather than risking the DM -/// stop taking the host down with it. -/// -/// [`UNIT_QUERY_BUDGET`], not [`UNIT_STATE_BUDGET`], and the failure DIRECTION is why. The 300 ms -/// bound is documented as "anything near it means the manager is wedged — the case each caller's -/// failure path already covers", and that holds for the other two callers, whose timeout answers -/// `true`/keep-looping (benign). Here a timeout INVERTS the answer to `false`, and `false` is the -/// refusing direction: `ensure_host_survives_dm_stop` then reports "`enable-linger` reported success -/// but lingering is still off" and the bare-spawn Steam path fails a connect that would have worked, -/// blaming a lingering configuration that is in fact correct. And this is not an in-memory read the -/// way `systemctl is-active` is: it is a process spawn plus libsystemd's dynamic link plus a logind -/// D-Bus round trip, sampled at the busiest moment on the box (a takeover, with Steam and a -/// compositor being torn down). Only a genuinely wedged logind exceeds 5 s. -fn linger_enabled() -> bool { - crate::proc::output_within( - Command::new("loginctl").args(["show-user", &uid_string(), "-p", "Linger", "--value"]), - UNIT_QUERY_BUDGET, - ) - .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes") - .unwrap_or(false) -} - -/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on -/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit -/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the -/// packaged pkexec helper. The `Err` is the HELPER's reason (the plain verb's failure is expected -/// and carries no information: an unprivileged host is meant to fail it), and the caller puts it -/// in front of the operator instead of guessing. -fn try_stop_display_manager(dm: &str) -> std::result::Result<(), DmHelperError> { - if systemctl_system(&["stop", dm]) { - return Ok(()); - } - dm_helper("stop") -} - /// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start /// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin /// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an @@ -2974,88 +2964,37 @@ fn stop_autologin_sessions() -> Result<()> { if plan.skip { return Ok(()); } - if plan.stop_dm { - let dm = dm.expect("stop_dm ⇒ Some"); - // The DM stop ends this user's last login session. If our own lifetime hangs off the user - // manager and lingering can't be turned on, that stop kills the host ~10s later — with the - // box's display manager down and nobody left to bring it back. - // - // BOTH arms below now BAIL, on every DM flavor. They did not always: SDDM used to degrade - // to mask-only here, on the reasoning that the mask still protects Steam and the cost is - // just relogin churn. It is not just churn — the mask is IN sddm's relogin path, so a - // mask without the stop is a 4–5 logins/s fork storm that drops the pad from 250 Hz to - // 1.4 Hz ([`mask_unit`]). Degrading to attach costs the client's mode; degrading to - // mask-only costs the user their input plane. Attach wins. - // - // Both bails quote the REASON they were handed rather than describing one. - // 0.26.0/0.27.0 described one — "the packaged pf-dm-helper polkit action is missing or was - // denied (reinstall the punktfunk package, or install the display-manager polkit rule from - // the docs)" — and on the box that produced it the action was installed, permissive, - // correctly annotated, and pkexec had already RUN the helper; the helper's refusal ("user - // 'x' is not in the 'punktfunk' group") was thrown away with its stderr. Both suggested - // remedies were dead ends: neither a reinstall nor a polkit rule adds anyone to a group. - if let Err(why) = ensure_host_survives_dm_stop() { - // The reason goes LAST in both bails: the helper's own refusal ends in a command - // to paste, and burying that mid-sentence is how it stops being read. - bail!( - "stopping {dm} ends this user's last login session, and without lingering \ - logind would stop the user manager — and this host with it — about 10s \ - later, leaving the box with no display manager and nothing to restore it; \ - lingering could not be enabled, so the managed takeover is unavailable. \ - Either run `sudo loginctl enable-linger $USER` once, as the setup docs ask, \ - and reconnect — or fix the privileged path: {why}" - ); - } - if let Err(why) = try_stop_display_manager(&dm) { - // ERROR, not WARN, and it names the SHAPE: this is the branch whose silence cost an - // evening on .41 — the takeover degraded, nothing failed loudly, and the storm that - // followed read as a pad bug. The `bail!` below reaches the caller's own warn line; - // this one exists so the shape survives into the journal even if the caller's does - // not, because the four shapes need four different fixes. - tracing::error!( - %dm, - shape = why.shape(), - reason = %why, - "the managed takeover planned to stop the display manager and could not — \ - degrading to ATTACH rather than fighting its autologin: a killed session under \ - a running DM relogin-loops at 4-5/s and starves the box's input plane" - ); - bail!( - "the box's gaming session is driven by {dm}, and stopping it for the stream needs \ - privilege this host does not have; taking over without stopping it would leave \ - its autologin relogin-looping against us for the whole stream, so the managed \ - takeover is unavailable — {why}" - ); - } - tracing::info!( - %dm, - "freed Steam: stopped the display manager for this stream (its autologin \ - Relogin loop would otherwise churn against the takeover)" - ); - // Baseline the switch sentinel HERE, not just at a successful launch: setting - // STOPPED_DM is what arms the honor gate, so from this instant an unbaselined - // sentinel would read as an in-stream "Switch to Desktop" — including the write from - // the switch that just brought the box INTO game mode. A successful launch - // re-baselines (tighter still). - record_session_select_baseline(); - *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm); + // Already idled by an earlier connect in this stream's life? Then the session listed as "live" + // above is our own idled one — it holds no Steam and there is nothing left to free. Without + // this, every reconnect and every in-place rebuild would kill and restart the box's session + // again to accomplish exactly nothing. + if *IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) { + return Ok(()); + } + // The display manager STAYS UP. Freeing Steam means ending the session its autologin owns, and + // the two ways to stop that autologin fighting us are not equivalent: stopping the DM works + // until the user asks for a desktop session, at which point nothing on the box can give them + // one ([`DmPlan::dm_relogins`]). Idling the session instead keeps the autologin succeeding — + // no failed unit to relogin against, no storm — while leaving the DM able to service that + // switch. + if plan.dm_relogins { + install_idle_dropin().context("idling the box's autologin game session for the stream")?; } - // Reaching here means no display manager can relogin against us: either there is none - // (`!plan.stop_dm` with `dm == None`), or the stop above LANDED — both failure arms bail. That - // is the precondition the mask needs, and the only one under which it is a defense rather than - // the storm's accelerator ([`mask_unit`]). let units: Vec = listed.into_iter().map(|(u, _)| u).collect(); let mut stopped = Vec::new(); - // 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 { - mask_unit(&unit); // belt-and-braces: no DM is up to relogin through it kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak + if plan.dm_relogins { + // Bring it back ourselves rather than waiting for the DM to notice: deterministic, and + // it closes the window in which the DM sees a dead session and starts churning. The + // drop-in above is already loaded, so what comes back runs nothing. + systemctl_user(&["restart", &unit]); + } tracing::info!( %unit, - dm_stopped = plan.stop_dm, - "freed Steam: masked and stopped the autologin gaming session for this stream" + idled = plan.dm_relogins, + "freed Steam: the box's autologin gaming session is idled for this stream (its \ + display manager stays up, so the box can still switch sessions)" ); stopped.push(unit); } @@ -3591,6 +3530,17 @@ fn do_restore_tv_session() { // rests on. It used to sit after the desktop-active and DM returns, so those two paths leaked // it. disarm_session_plus_dropin(); + // The idle drop-in belongs to the same rule and leaks the same way — worse, in fact: the bind + // one leaves the box's Game Mode running OUR gamescope, this one leaves it running NOTHING. + // The desktop-active return below is the live case (the user switched away, so we never + // restart the units), and a drop-in left there is a box whose Game Mode silently does nothing + // for the rest of the login. + if remove_idle_dropin() { + tracing::info!( + "gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \ + real again" + ); + } unset_forced_session_screen_env(); // 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 @@ -3648,12 +3598,16 @@ fn do_restore_tv_session() { clear_takeover(); return; } + // (The idle drop-in is already gone — removed above every early return, so the restarts + // below bring the box's real session back rather than another idle one.) for unit in units { // Checked, not discarded: this call and the SteamOS `restart` above were the two places // that logged an unconditional success over a thrown-away exit status. A `--user start` // fails for reasons an operator can act on (the unit is masked, its start limit tripped), // and the DM branch thirty lines up already shows the shape — say what happened. - match issue_restore_verb(&["start", &unit]) { + // `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an + // active unit is a no-op that would report success over a session still running nothing. + match issue_restore_verb(&["restart", &unit]) { RestoreVerb::Done => tracing::info!( unit, "restored the TV's autologin gaming session (debounce elapsed, no client)" @@ -5278,15 +5232,14 @@ impl Drop for GamescopeProc { mod tests { use super::{ any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned, - cgroup_under_user_manager, classify_output_size, connected_connector_under, - display_manager_unit_under, dm_plan, game_hz, gamescope_output_size, hdr_args, - is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, - our_wsi_layer_dir, plan_bind, release_autologin_mask, script_hardcodes_gamescope, - sentinel_advanced, shape_dedicated_command, switch_ends_mask_window, - takeover_state_is_live, unmask_unit, xwayland_refusal_marker, BindOff, BindPlan, - BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan, AUTOLOGIN_MASKED, - DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV, - X11_SOCKET_DIR, + classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan, + game_hz, gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags, + mode_mismatch, nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask, + script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command, + switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker, + BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan, + AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT, + STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR, }; use std::time::{Duration, Instant}; @@ -5461,26 +5414,6 @@ mod tests { ); } - #[test] - fn user_manager_lifetime_detection() { - // The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the - // login session the DM stop ends — this is the case that needs lingering. - assert!(cgroup_under_user_manager( - "0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n" - )); - assert!(cgroup_under_user_manager( - "0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n" - )); - // A system unit outlives every session — the DM stop cannot reach it. - assert!(!cgroup_under_user_manager( - "0::/system.slice/punktfunk-host.service\n" - )); - // Started from a login shell: owned by the session scope, not the user manager. - assert!(!cgroup_under_user_manager( - "0::/user.slice/user-1000.slice/session-2.scope\n" - )); - } - #[test] fn session_select_sentinel_needs_a_baseline() { let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000); @@ -5583,27 +5516,27 @@ mod tests { } #[test] - fn dm_plan_stops_any_dm_that_drove_a_live_session() { - // A live gaming session behind a DM: stop the DM, whatever the flavor. The mask alone does - // NOT stop the relogin loop — on .41 it is what makes the loop fast, because the session - // script's last act is `systemctl --user --wait start gamescope-session-plus@…` and a - // masked unit fails that in milliseconds (4-5 logins/s, pad at 1.4 Hz, 2026-08-18). + fn dm_plan_idles_any_dm_that_drove_a_live_session() { + // A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two + // things that do NOT work is flavor-dependent — a mask fails the unit in milliseconds and + // makes the relogin loop fast (4-5 logins/s, pad at 1.4 Hz, 2026-08-18), and stopping the + // DM leaves nothing able to start a desktop session when the user asks for one. let p = dm_plan(Some("sddm.service"), true); - assert!(!p.skip && p.stop_dm); + assert!(!p.skip && p.dm_relogins); // Flavor is no longer an input: plasmalogin gets the same plan as sddm. It used to differ // only to pick a DEGRADED mode (mask-only for sddm), and that degrade is now gone — // `stop_autologin_sessions` bails to ATTACH instead. let q = dm_plan(Some("plasmalogin.service"), true); - assert!(q.skip == p.skip && q.stop_dm == p.stop_dm); + assert!(q.skip == p.skip && q.dm_relogins == p.dm_relogins); // Nothing live, DM present: hands off entirely, on EVERY flavor. Killing loaded-but- // inactive leftovers frees no Steam; masking them while the DM is up is the storm; and // stopping the DM would kill the user's live desktop for it. assert!(dm_plan(Some("sddm.service"), false).skip); assert!(dm_plan(Some("plasmalogin.service"), false).skip); - // No DM at all (getty autologin), live: mask+kill, nothing to stop — masking is sound - // here precisely because no relogin loop exists to run into it. + // No DM at all (getty autologin), live: kill and leave it stopped. Nothing relogins, so + // there is no autologin to idle — and no reason to leave a drop-in on the box. let p = dm_plan(None, true); - assert!(!p.skip && !p.stop_dm); + assert!(!p.skip && !p.dm_relogins); assert!(dm_plan(None, false).skip); } diff --git a/docs-site/content/docs/bazzite.md b/docs-site/content/docs/bazzite.md index cc4a365a..cabc93b7 100644 --- a/docs-site/content/docs/bazzite.md +++ b/docs-site/content/docs/bazzite.md @@ -135,13 +135,12 @@ sysext creates it on merge: sudo usermod -aG punktfunk "$USER" # then log out and back in ``` -This box **is** a Gaming Mode box, so that group is not optional in practice: it authorizes the -helper the host uses to stop the display manager when it takes the Gaming Mode session over at your -client's resolution, and it gates the usbip `attach` file the **virtual Steam Deck controller** -(paddles, trackpads, gyro) attaches through. It is a separate group on purpose — writing that file +This box **is** a Gaming Mode box, so that group is worth having: it gates the usbip `attach` file +the **virtual Steam Deck controller** (paddles, trackpads, gyro) attaches through. (The Gaming Mode +takeover itself no longer needs it — it idles the box's session with a user-level drop-in rather +than stopping the display manager.) It is a separate group on purpose — writing that file can materialise arbitrary emulated USB hardware, so it is not folded into the group everyone is -told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller, and the -takeover degrades to mirroring the box's own screen — see +told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller — see [gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers). ## Configure @@ -172,8 +171,9 @@ and on Bazzite (which ships `gamescope-session-plus`) that is **managed**: relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the virtual screen — restoring the box on idle. This is the model that gives the client a display of its **own**, and the only one under which a game launched from a client's library gets a - dedicated session. It needs the [`punktfunk` group](#allow-controller-input): the takeover stops - the display manager for the length of the stream, and without that grant it cannot. + dedicated session. The takeover idles the box's own session for the length of the + stream (a user-level drop-in — no privilege needed, and the display manager stays up, so Steam's + "Switch to Desktop" still works mid-stream). - **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session on its own display, and the host attaches to whatever's live without ever tearing it down (on a headless box, a box-owned autologin session is restarted at the client's resolution on a mismatch; with a diff --git a/docs-site/content/docs/gamescope.md b/docs-site/content/docs/gamescope.md index c0f55318..9cb733b2 100644 --- a/docs-site/content/docs/gamescope.md +++ b/docs-site/content/docs/gamescope.md @@ -40,12 +40,23 @@ set** — which is what every shipped template does — a box that has gamescope ### Nobara and other autologin display managers -The managed takeover has to stop the box's Gaming Mode session to free Steam — and when that -session is a display-manager autologin, it has to stop the **display manager** too, for the length -of the stream. That is a privileged operation, and the privilege is granted to one group. +The managed takeover has to stop the box's Gaming Mode session to free Steam — and when a display +manager autologs into that session, stopping it alone accomplishes nothing: the autologin puts it +straight back. So the host **idles** that session for the length of the stream instead, with a +systemd drop-in that replaces its `ExecStart` with a process that just sleeps. The autologin still +succeeds (nothing relogin-loops), the session it logs into does nothing (Steam is free), and the +**display manager keeps running** — which is what lets you still switch the box to Desktop Mode +from Steam while a stream is up. -> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover's root helper -> runs for members of that group and for nobody else, so this one command is what authorizes it: +That needs no privilege at all: the drop-in is a user-level unit override, written under +`$XDG_RUNTIME_DIR` so it cannot outlive the login session, and a reboot clears it regardless. +Versions before this one stopped the display manager for the stream's duration — which needed a +root helper, the `punktfunk` group, and lingering, and left the box with nothing able to start a +desktop session, so Steam's own "Switch to Desktop" hung until a reboot. + +> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover itself no +> longer needs it — the group now gates the usbip nodes the virtual Steam Deck pad attaches +> through, so without it the pad arrives as an ordinary Xbox 360 controller: > > ```sh > sudo usermod -aG punktfunk "$USER" # then log out and back in @@ -60,14 +71,12 @@ of the stream. That is a privileged operation, and the privilege is granted to o > symptom side is [Game Mode: black screen on > connect](/docs/troubleshooting#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution). -How the takeover gets that privilege depends on the display manager driving the autologin: +The display-manager flavor is no longer an input — SDDM, plasmalogin and the rest all get the +idled session above, and none of them is stopped. The root helper described below is therefore no +longer part of a normal takeover; it is kept for the restore path, and for a box where an older +host left a display manager stopped: -- **SDDM** (Bazzite, SteamOS): SDDM survives having the session unit masked, so a box without the - grant still streams — at the cost of SDDM relogin-looping against the takeover for the whole - stream, which churns logind sessions and can starve the game. -- **plasmalogin** (Nobara) and other display managers: masking is fatal there (the autologin - start-limit-kills the display manager), so the host stops the display manager itself and - restarts it afterwards. The packages ship that privilege: a root helper +- The packages ship it: a root helper (`/usr/libexec/punktfunk/pf-dm-helper`, or `/usr/lib/punktfunk/pf-dm-helper` from the Arch package) behind its own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically when the plain `systemctl` verbs are denied. The helper only stops/restores the unit the diff --git a/packaging/bazzite/host.env b/packaging/bazzite/host.env index 75b1371d..d4880465 100644 --- a/packaging/bazzite/host.env +++ b/packaging/bazzite/host.env @@ -27,11 +27,16 @@ PUNKTFUNK_VIDEO_SOURCE=virtual # instead of a copy of the TV, and it is also what lets a game launched from a client's library get # a dedicated session to itself. # -# ⚠ The managed takeover has to stop the display manager for the length of the stream, and that is -# privileged: it works for members of the `punktfunk` group and nobody else. Join it once — -# `sudo usermod -aG punktfunk "$USER"`, then log out and back in. Skip it and the takeover cannot -# stop SDDM, which relogin-loops against it for the whole stream and can starve the game. The host -# checks at startup and says so in its log. +# The takeover needs no privilege and leaves the display manager RUNNING: it idles the box's own +# gaming session for the stream's duration (a user-level systemd drop-in that replaces the +# session's ExecStart), so the autologin still succeeds — nothing relogin-loops — while Steam is +# free for the stream. Because SDDM stays up, Steam's own "Switch to Desktop" still works while you +# are streaming. Earlier versions stopped the display manager instead, which needed the `punktfunk` +# group and left that switch hanging until a reboot. +# +# The `punktfunk` group is still worth joining for the virtual Steam Deck pad (it gates the usbip +# nodes; without it the pad arrives as an ordinary Xbox 360 controller): +# sudo usermod -aG punktfunk "$USER" # then log out and back in # # Opt IN to the ATTACH model if you would rather the BOX keep ownership: the host captures whatever # gamescope is live and never tears it down, so Desktop<->Game switching is rock-solid and the box