feat(gamescope): end dedicated stream on Steam game exit + auto --steam
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m12s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m57s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m51s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
apple / screenshots (push) Successful in 6m13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
deb / build-publish (push) Successful in 9m37s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-host (push) Successful in 9m29s
windows-host / package (push) Successful in 16m24s
arch / build-publish (push) Successful in 17m40s
android / android (push) Successful in 18m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m52s
ci / rust (push) Successful in 26m16s

Dedicated single-game sessions now end cleanly when the launched game
quits, and Steam launches get gamescope's Steam integration without an
operator env knob.

End-on-exit: the existing APP_EXITED close only fired when gamescope's
PipeWire node vanished, which never happens for Steam — the nested
`steam` is the resident singleton client and stays up after a game
quits, so gamescope (and its node) never die and the stream sat on a
hidden Steam session forever. Add a Steam AppId reaper watcher
(pf-vdisplay .../gamescope/discovery.rs): steam_appid_from_launch()
parses steam://rungameid/<id>; wait_for_steam_game_exit() waits for the
game to start (<=300s grace) then exit (3s confirm); steam_game_running()
scans same-uid /proc for Steam's launch reaper matching both the
`SteamLaunch` and `AppId=<id>` argv tokens (exact-match; reaper lifetime
== game lifetime, so shader precompile can't false-trigger). The host
spawns a pf1-gamewatch thread for nested Steam launches that closes the
connection with APP_EXITED (launcher clients return to their library)
and sets quit/stop; cancelled via stop if the session ends first.
Non-Steam nested launches keep the node-death path (gamescope's child
IS the game).

Flags: auto-enable `--steam` whenever the launch is a Steam launch (was
only the global PUNKTFUNK_GAMESCOPE_STEAM knob, default-off) — in-game
overlay / Steam+X / gamepad-UI nav for Steam titles with no operator
config. New opt-in PUNKTFUNK_GAMESCOPE_GRAB_CURSOR adds
`--force-grab-cursor` for a real game launch (FPS mouselook); default
OFF because it forces relative mode, which breaks absolute-pointer
games/menus.

Verified: fmt clean; clippy -D warnings clean on the three crates;
pf-vdisplay 64/0 (incl. new steam-appid parse test); punktfunk-host
builds + 186/0; reaper /proc detection smoke-tested (detect, exact
non-match, gone-after-exit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 16:24:34 +02:00
parent 770994b7aa
commit b89dbfa979
6 changed files with 246 additions and 11 deletions
+15 -3
View File
@@ -76,10 +76,16 @@ pub struct HostConfig {
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
/// shipped `host.env` and as a forward seam if a second backend is ever added.
pub vdisplay: Option<String>,
/// `PUNKTFUNK_GAMESCOPE_STEAM` — opt the bare headless gamescope spawn into its Steam
/// integration mode (`--steam`). Managed gamescope-session-plus/SteamOS sessions own their
/// own flags and do not consult this.
/// `PUNKTFUNK_GAMESCOPE_STEAM` — force the bare headless gamescope spawn into its Steam
/// integration mode (`--steam`) for EVERY launch. A Steam title auto-enables `--steam` on its
/// own regardless of this knob; it exists to force it on for non-Steam launches too. Managed
/// gamescope-session-plus/SteamOS sessions own their own flags and do not consult this.
pub gamescope_steam: bool,
/// `PUNKTFUNK_GAMESCOPE_GRAB_CURSOR` — add `--force-grab-cursor` to the bare headless gamescope
/// spawn for an actual game launch, forcing relative-mouse capture so FPS mouselook works over the
/// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles
/// and menus, so it's opt-in per host until validated on-glass.
pub gamescope_grab_cursor: bool,
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
@@ -152,6 +158,12 @@ impl HostConfig {
"1" | "true" | "yes" | "on"
)
}),
gamescope_grab_cursor: val("PUNKTFUNK_GAMESCOPE_GRAB_CURSOR").is_some_and(|s| {
matches!(
s.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
+1 -1
View File
@@ -78,7 +78,7 @@ pub use routing::{
#[cfg(target_os = "linux")]
pub use routing::{
cancel_pending_tv_restore, dedicated_game_exited, launch_into_gamescope_session,
launch_is_nested,
launch_is_nested, steam_appid_from_launch, watch_steam_game_exit,
};
/// Compositors punktfunk knows how to drive (plan §6).
@@ -25,7 +25,10 @@ use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
gamescope_node_present, poll_managed_node, wait_for_node,
};
pub(crate) use discovery::{game_session_exited, is_available};
pub(crate) use discovery::{
game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit,
SteamGameWatch,
};
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
@@ -1369,7 +1372,14 @@ fn shape_dedicated_command(app: &str) -> String {
/// Add the compositor-side arguments shared by every bare gamescope spawn. `steam_mode` belongs
/// before the `--` terminator; [`PUNKTFUNK_GAMESCOPE_APP`](spawn) configures the nested command
/// after it and therefore cannot enable gamescope's Steam integration itself.
fn add_bare_gamescope_args(command: &mut Command, w: u32, h: u32, hz: u32, steam_mode: bool) {
fn add_bare_gamescope_args(
command: &mut Command,
w: u32,
h: u32,
hz: u32,
steam_mode: bool,
grab_cursor: bool,
) {
command
.args(["--backend", "headless"])
.args(["-W", &w.to_string()])
@@ -1378,6 +1388,9 @@ fn add_bare_gamescope_args(command: &mut Command, w: u32, h: u32, hz: u32, steam
if steam_mode {
command.arg("--steam");
}
if grab_cursor {
command.arg("--force-grab-cursor");
}
command.args(["--xwayland-count", "1", "--"]);
}
@@ -1398,16 +1411,27 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
// Read the env fallback under the shared env lock so it can't race a concurrent session's
// `set_var` of the same key (security-review 2026-06-28 #7).
.or_else(|| crate::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok()))
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "sleep infinity".to_string());
.filter(|s| !s.trim().is_empty());
// A real app was requested (vs. the `sleep infinity` keep-alive) — used to scope the game-only
// cursor-grab flag below.
let game_launch = app.is_some();
let app = app.unwrap_or_else(|| "sleep infinity".to_string());
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the
// gamescope focus with no Steam client window to navigate.
let app = shape_dedicated_command(&app);
let relay = ei_socket_file();
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
let steam_mode = pf_host_config::config().gamescope_steam;
// Enable gamescope's Steam integration (`--steam`: in-game overlay, Steam+X shortcuts, gamepad-UI
// navigation) whenever we're launching Steam — the operator no longer has to set the global
// PUNKTFUNK_GAMESCOPE_STEAM knob for a Steam title. The knob still forces it on for every spawn.
let steam_mode = pf_host_config::config().gamescope_steam || is_steam_launch(&app);
// Opt-in relative-mouse capture for a nested game (`PUNKTFUNK_GAMESCOPE_GRAB_CURSOR`): the client
// already sends relative motion, but gamescope only enters relative mode when the app hides the
// cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook.
// Default OFF (it forces relative mode, which would break absolute-pointer games/menus).
let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor;
let mut cmd = Command::new("gamescope");
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode);
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
cmd.args([
"sh",
"-c",
@@ -29,6 +29,122 @@ pub(crate) fn game_session_exited(node_id: u32) -> bool {
}
}
/// Outcome of watching a dedicated Steam launch's game lifetime ([`wait_for_steam_game_exit`]).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SteamGameWatch {
/// The game was seen running and has now exited — the session should end (APP_EXITED).
Exited,
/// The watch was cancelled (the session ended for another reason) or the game never started
/// within the startup grace — leave the session as-is.
Cancelled,
}
/// Parse the Steam appid a dedicated launch targets from its resolved command
/// (`steam [-silent] steam://rungameid/<appid>`). `None` unless the first token is `steam` and a
/// `steam://rungameid/<digits>` URI is present — the trailing digits are the appid, which is exactly
/// what Steam's launch reaper carries as `AppId=<appid>` (gameid == appid for a plain library title,
/// the only kind the host ever resolves to this shape).
pub(crate) fn steam_appid_from_launch(cmd: &str) -> Option<u32> {
if cmd.split_whitespace().next() != Some("steam") {
return None;
}
const MARKER: &str = "steam://rungameid/";
let tail = &cmd[cmd.find(MARKER)? + MARKER.len()..];
let digits: String = tail
.chars()
.take_while(|c: &char| c.is_ascii_digit())
.collect();
digits.parse().ok()
}
/// Block until the dedicated Steam game `appid` has started and then exited, `cancel` is set, or the
/// game never appears within the startup grace. Same-uid `/proc` scan keyed on Steam's launch reaper
/// (`SteamLaunch AppId=<appid>`), whose lifetime is exactly the game's. Returns
/// [`SteamGameWatch::Exited`] only after the game was actually seen running and then stayed gone
/// across a short confirmation window — so a cold Steam boot / shader precompile (game not up yet) or
/// a transient scan miss can't end the stream early. Runs on the host's per-session watch thread.
pub(crate) fn wait_for_steam_game_exit(
appid: u32,
cancel: &std::sync::atomic::AtomicBool,
) -> SteamGameWatch {
use std::sync::atomic::Ordering;
// Cold Steam boot + first-launch shader precompile can delay the game window by minutes; give it a
// generous window to appear. A game that never starts leaves the session up (the Steam client is
// still streamed, and the node-death path still covers the Steam client itself dying).
const START_GRACE: Duration = Duration::from_secs(300);
const POLL: Duration = Duration::from_secs(1);
// Require the reaper gone across this window (a few polls) so a brief process swap can't fire early.
const EXIT_CONFIRM: Duration = Duration::from_secs(3);
let start_deadline = Instant::now() + START_GRACE;
// Phase 1: wait for the game's reaper to appear.
while !steam_game_running(appid) {
if cancel.load(Ordering::Relaxed) || Instant::now() >= start_deadline {
return SteamGameWatch::Cancelled;
}
std::thread::sleep(POLL);
}
// Phase 2: the game is up — wait for its reaper to disappear (confirmed across the window).
let mut gone_since: Option<Instant> = None;
loop {
if cancel.load(Ordering::Relaxed) {
return SteamGameWatch::Cancelled;
}
if steam_game_running(appid) {
gone_since = None;
} else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM {
return SteamGameWatch::Exited;
}
std::thread::sleep(POLL);
}
}
/// Is Steam's launch reaper for appid `appid` alive right now (same uid as the host)? Steam wraps
/// every game launch — native or Proton — in `…/reaper SteamLaunch AppId=<appid> -- <game>`, and the
/// reaper lives for the game's whole lifetime, so its presence is a precise "the game is running"
/// signal. Matched on the `SteamLaunch` + `AppId=<appid>` argv tokens together (exact-match, so
/// `AppId=57` never matches appid 570) — specific to the game reaper, so Steam's own shader-precompile
/// step (not reaper-wrapped) can't be mistaken for the game.
fn steam_game_running(appid: u32) -> bool {
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
let uid = unsafe { libc::getuid() };
let appid_tok = format!("AppId={appid}");
let Ok(entries) = std::fs::read_dir("/proc") else {
return false;
};
for e in entries.flatten() {
let name = e.file_name();
let Some(pid_str) = name.to_str() else {
continue;
};
if !pid_str.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let Ok(md) = std::fs::metadata(e.path()) else {
continue;
};
use std::os::unix::fs::MetadataExt;
if md.uid() != uid {
continue;
}
let Ok(cmdline) = std::fs::read(e.path().join("cmdline")) else {
continue;
};
let (mut launch, mut appid_match) = (false, false);
for arg in cmdline.split(|&b| b == 0) {
if arg == b"SteamLaunch" {
launch = true;
} else if arg == appid_tok.as_bytes() {
appid_match = true;
}
}
if launch && appid_match {
return true;
}
}
false
}
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
@@ -263,7 +379,29 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
#[cfg(test)]
mod tests {
use super::{parse_version, MIN_GAMESCOPE};
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE};
#[test]
fn parses_steam_appid_from_launch() {
// The resolved dedicated-launch command (pre- or post-`-silent` shaping) → the appid.
assert_eq!(
steam_appid_from_launch("steam steam://rungameid/570"),
Some(570)
);
assert_eq!(
steam_appid_from_launch("steam -silent steam://rungameid/1091500"),
Some(1091500)
);
// Non-Steam launches / bare Steam with no rungameid URI → no appid (no game-exit watch).
assert_eq!(steam_appid_from_launch("lutris lutris:rungameid/42"), None);
assert_eq!(steam_appid_from_launch("steam -gamepadui"), None);
assert_eq!(steam_appid_from_launch("vkcube"), None);
// A steam:// URI that isn't the first `steam` token (a custom command) is not treated as one.
assert_eq!(
steam_appid_from_launch("firefox steam://rungameid/570"),
None
);
}
#[test]
fn parses_version_banner() {
@@ -176,6 +176,28 @@ pub fn dedicated_game_exited(_node_id: u32) -> bool {
false
}
/// The Steam appid a dedicated launch targets (`steam … steam://rungameid/<appid>`), for the
/// game-exit watcher. `None` for a non-Steam launch (those are covered by the node-death path
/// [`dedicated_game_exited`] — gamescope's nested child IS the game). See
/// [`gamescope::steam_appid_from_launch`].
#[cfg(target_os = "linux")]
pub fn steam_appid_from_launch(cmd: &str) -> Option<u32> {
gamescope::steam_appid_from_launch(cmd)
}
/// Block until the dedicated Steam game `appid` has started and then exited — returns `true` when the
/// session should end cleanly (APP_EXITED). Returns `false` if `cancel` is set (the session ended for
/// another reason) or the game never started within the startup grace (leave the session up). Runs on
/// the host's per-session watch thread; `cancel` is the session's stop flag. See
/// [`gamescope::wait_for_steam_game_exit`].
#[cfg(target_os = "linux")]
pub fn watch_steam_game_exit(appid: u32, cancel: &std::sync::atomic::AtomicBool) -> bool {
matches!(
gamescope::wait_for_steam_game_exit(appid, cancel),
gamescope::SteamGameWatch::Exited
)
}
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
#[cfg(target_os = "linux")]
pub fn cancel_pending_tv_restore() {
@@ -978,6 +978,45 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
let _ = &launch;
// Dedicated Steam launch: end the stream cleanly when the LAUNCHED GAME exits. The node-death
// check in the capture-loss branch below can't see this for Steam — the nested `steam` is the
// resident client and stays up after a game quits, so gamescope (and its node) never dies and the
// stream would sit on a hidden Steam session forever. Watch the game process directly (Steam's
// `SteamLaunch AppId=<id>` reaper, whose lifetime == the game's) and close with APP_EXITED when
// it's gone, so a launcher client returns to its library. Non-Steam nested launches keep the
// node-death path (gamescope's child IS the game). Cancelled via `stop` when the session ends for
// another reason first; the thread self-terminates, so we don't join it.
#[cfg(target_os = "linux")]
let _game_watch = launch
.as_deref()
.filter(|_| crate::vdisplay::launch_is_nested(compositor))
.and_then(crate::vdisplay::steam_appid_from_launch)
.map(|appid| {
let conn = conn.clone();
let stop = stop.clone();
let quit = quit.clone();
std::thread::Builder::new()
.name("pf1-gamewatch".into())
.spawn(move || {
if crate::vdisplay::watch_steam_game_exit(appid, &stop) {
tracing::info!(
appid,
"dedicated Steam game exited — ending the session cleanly (APP_EXITED)"
);
// Close FIRST so APP_EXITED is the winning close code (quinn keeps the first
// application close), then set the flags: `quit` skips the display lease's
// keep-alive linger and `stop` wakes the encode/send loops out.
conn.close(
punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(),
b"game exited",
);
quit.store(true, Ordering::SeqCst);
stop.store(true, Ordering::SeqCst);
}
})
.ok()
});
let perf = pf_host_config::config().perf;
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out
// immediately; only a bigger frame's overflow is spread. `None` = auto — max(128 KB, the