Merge remote-tracking branch 'origin/main' into worktree-nixos-wrapped-comm-detection
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m22s
ci / rust (pull_request) Successful in 6m47s
apple / swift (pull_request) Failing after 13m41s
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Successful in 13m48s

This commit is contained in:
2026-08-10 20:26:23 +02:00
4 changed files with 202 additions and 20 deletions
+1 -1
View File
@@ -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,
};
@@ -91,6 +91,13 @@ static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(
/// never gets DRM master — live-proven on the Nobara repro VM 2026-07-24).
static STOPPED_DM: std::sync::Mutex<Option<String>> = 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<bool> = 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;
@@ -1549,16 +1562,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])
@@ -1573,6 +1593,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
@@ -2120,7 +2192,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;
@@ -2329,6 +2407,11 @@ fn stop_autologin_sessions() -> Result<()> {
}
let units: Vec<String> = 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
@@ -2625,6 +2708,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() {
@@ -2647,11 +2736,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
@@ -3957,10 +4041,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
@@ -4129,6 +4214,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()));
@@ -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::*;
@@ -1144,6 +1144,13 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
match pending {
// Stable at the new kind for the debounce window — the switch is real, signal it.
Some((k, since)) if k == cur && since.elapsed() >= 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(),