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:
@@ -252,6 +252,9 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// 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();
|
||||
// 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
|
||||
// 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
|
||||
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
||||
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 same_mode = guard.as_ref().is_some_and(|s| {
|
||||
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
|
||||
/// 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
|
||||
@@ -1587,7 +1684,10 @@ impl Drop for GamescopeProc {
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
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]
|
||||
fn parses_version_banner() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user