fix(host/linux): free a desktop-session Steam before a dedicated gamescope launch

B1b: a Steam running in a plain GNOME/KDE desktop session holds Steam's single
instance, so a dedicated gamescope launch's own Steam exits at birth — the
game-library launch goes to a black screen. Release the desktop instance
(free_desktop_steam) on Steam launches before creating the managed session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 16:32:29 +02:00
parent d58524c899
commit 840e5d590e
@@ -252,6 +252,9 @@ impl VirtualDisplay for GamescopeDisplay {
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this. // 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) { if self.cmd.as_deref().is_some_and(is_steam_launch) {
stop_autologin_sessions(); stop_autologin_sessions();
// 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()?;
} }
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting // A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a // bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
@@ -316,6 +319,10 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by // 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. // stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
stop_autologin_sessions(); stop_autologin_sessions();
// 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.
free_desktop_steam()?;
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()); let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let same_mode = guard.as_ref().is_some_and(|s| { let same_mode = guard.as_ref().is_some_and(|s| {
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
@@ -894,6 +901,96 @@ fn stop_autologin_sessions() {
} }
} }
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
/// down a running game (Proton/wineserver included) on the way out, so this is generous.
const STEAM_SHUTDOWN_WAIT: Duration = Duration::from_secs(20);
/// B1b: free Steam held by a plain **desktop** session (GNOME/KDE — e.g. a Steam the user opened
/// while streaming the desktop). [`stop_autologin_sessions`] only frees `gamescope-session-plus@*`
/// autologin units, so a desktop Steam still holds the single instance — a dedicated launch's
/// nested `steam` would just forward its URI to it and exit, gamescope would follow its child
/// down, and the client would see a black screen while the game launches invisibly on the desktop
/// (observed 2026-07-14 on a GNOME host: session-recovery restarted GDM for a desktop stream, the
/// user opened Steam there, and the next game-library launch black-screened through all 8 pipeline
/// retries). Asks that Steam to quit via `steam -shutdown` (the single-instance IPC, graceful) and
/// waits for it to exit; on timeout the spawn fails with an operator-actionable error instead of
/// the misleading no-frames retry loop. Steam instances punktfunk owns are exempt — URI forwarding
/// into a reused/kept session is the designed path, and another session's live Steam must never be
/// torn down from here.
fn free_desktop_steam() -> Result<()> {
let Some(pid) = desktop_steam_pid() else {
return Ok(());
};
tracing::info!(
pid,
"freeing Steam: a desktop-session Steam holds the single instance — sending `steam -shutdown`"
);
let _ = Command::new("steam")
.arg("-shutdown")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
let deadline = Instant::now() + STEAM_SHUTDOWN_WAIT;
while Instant::now() < deadline {
if !pid_running(pid) {
tracing::info!(pid, "desktop Steam exited — single instance free");
return Ok(());
}
std::thread::sleep(Duration::from_millis(250));
}
bail!(
"Steam is already running in the host's desktop session (pid {pid}) and did not exit \
within {}s of `steam -shutdown` — close Steam on the host, then launch again",
STEAM_SHUTDOWN_WAIT.as_secs()
)
}
/// Pid of a live Steam instance running OUTSIDE anything punktfunk owns (i.e. a desktop-session
/// Steam), found via `~/.steam/steam.pid` — Steam's own single-instance marker, kept current by
/// every fresh instance. `None` when Steam isn't running, the pidfile is stale (pid dead, zombie,
/// or recycled by a non-Steam process), or the instance is punktfunk's own: a descendant of this
/// host process (a dedicated spawn's nested Steam) or inside the managed [`SESSION_UNIT`] cgroup.
fn desktop_steam_pid() -> Option<u32> {
let home = std::env::var("HOME").ok()?;
let pid = std::fs::read_to_string(format!("{home}/.steam/steam.pid"))
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())?;
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
// Steam's own processes report comm `steam` (the ubuntu12_32 binary) or `steam.sh`; anything
// else means the pid was recycled since Steam last ran.
if !matches!(comm.trim(), "steam" | "steam.sh") || !pid_running(pid) {
return None;
}
if descends_from(pid, std::process::id()) {
return None; // our own dedicated spawn's Steam
}
let cgroup = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).unwrap_or_default();
if cgroup_is_punktfunk_owned(&cgroup) {
return None; // the host service's tree or the managed session unit
}
Some(pid)
}
/// Does this `/proc/<pid>/cgroup` content place the process in a punktfunk-owned unit — the host
/// service itself or the host-managed gamescope session? Desktop Steams live in desktop app scopes
/// (e.g. `app-gnome-steam-<pid>.scope`) instead. Pure + unit-tested.
fn cgroup_is_punktfunk_owned(cgroup: &str) -> bool {
cgroup.contains("punktfunk-host.service") || cgroup.contains(&format!("{SESSION_UNIT}.service"))
}
/// Is `pid` alive and not a zombie? (A zombie keeps its `/proc` entry but has already released the
/// Steam instance, so waiting on it would spin the full shutdown deadline for nothing.)
fn pid_running(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false;
};
// Field 3 (state) follows the parenthesized comm — split after the LAST ')' since comm can
// itself contain parentheses.
stat.rsplit_once(')')
.and_then(|(_, rest)| rest.split_whitespace().next())
.is_some_and(|state| state != "Z")
}
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the /// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect /// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the /// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
@@ -1587,7 +1684,10 @@ impl Drop for GamescopeProc {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE}; use super::{
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
MIN_GAMESCOPE,
};
#[test] #[test]
fn steam_launch_detection() { fn steam_launch_detection() {
@@ -1623,6 +1723,27 @@ mod tests {
); );
} }
#[test]
fn desktop_steam_cgroup_ownership() {
// A desktop-launched Steam (the B1b conflict case, as observed on a GNOME host).
assert!(!cgroup_is_punktfunk_owned(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-gnome-steam-48605.scope"
));
// KDE spawns app scopes too; still foreign.
assert!(!cgroup_is_punktfunk_owned(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-steam@0f3a.service"
));
// Our own dedicated spawn tree (Steam nested under the host service).
assert!(cgroup_is_punktfunk_owned(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service"
));
// The host-managed gamescope session unit (SESSION_UNIT).
assert!(cgroup_is_punktfunk_owned(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-gamescope.service"
));
assert!(!cgroup_is_punktfunk_owned(""));
}
#[test] #[test]
fn parses_version_banner() { fn parses_version_banner() {
assert_eq!( assert_eq!(