feat(session): bind a session's life to its game's, in both directions (Linux)

Two of the most-asked-for behaviors, and until now the host could only manage a
sliver of one of them: end the streaming session when the launched game exits,
and — when the operator asks — end the game when the session ends.

Ending the session on game exit already worked, but only for a Steam title under
a bare-spawn gamescope. Everywhere else the host spawned a launch and forgot it:
the pid was logged and dropped, so on KWin, Mutter, wlroots or gamescope-attach a
finished game left the client staring at a desktop. Ending a game was not
possible at all — there was no primitive for it.

The missing piece was never plumbing, it was knowledge: a launch says what to
run, not what the game looks like once a launcher has handed off. So each store
now also reports how to recognize its game (DetectSpec, previous commit's
subject matter, folded in here), procscan turns that into live pids on Linux, and
gamelease turns pids into a lifetime — with four kinds covering how the game got
started:

  nested    gamescope owns it; its display teardown ends the game
  child     the host spawned it, in its own process group
  matched   a launcher owns it; recognized by its store's signals
  untracked nothing identifies it — both behaviors stay off, and the host
            says so once rather than guessing

A child that exits successfully within 5s was a launcher handing off, not the
game: the lease re-resolves to matched (or to untracked) instead of reporting an
exit. That single rule is what keeps steam://, epic:// and playnite:// launches
from ending a session the moment they start.

Ending a game is destructive, so three rules bound it. It is opt-in
(game_on_session_end defaults to keep). A drop is not a decision — `always`
waits out a reconnect window (5 min) that a returning client cancels by
reclaiming its own game, matched on fingerprint and title. And only ever this
session's game: a pid is adopted only if it started after the launch, so a copy
the player already had open is never touched, and every pid is re-verified
against its start time immediately before being signalled, so a recycled pid
never is. Termination asks first (SIGTERM to the group, 10s) and only then
insists.

Also here, because they are the same decision seen from other angles:

- A management stop is now a deliberate stop. `DELETE /session` sets quit before
  stop, so it behaves like a client pressing Stop — the display skips its
  keep-alive linger instead of lingering for a session nobody is coming back to.
- `/status` reports what is running (games[]), including a game whose session has
  gone and which is waiting out its window, so the console can show it and offer
  `POST /game/end`.
- New game.running / game.exited events, filterable like every other kind, which
  is the whole polling loop of the community plugin this replaces.
- session_status::register takes a named struct: eleven positional arguments,
  half of them same-typed Arc<Atomic…>, is a transposition waiting to happen.

Gates on Linux (.21): check, clippy --all-targets, fmt, and 291 tests green.
Windows (Job Objects, Toolhelp matching) and the GameStream plane are phases 2
and 3; both are inert here, as is macOS, which has no launch path at all.

Design + plan: punktfunk-planning/design/session-game-lifetime{,-implementation-plan}.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:28:13 +02:00
co-authored by Claude Fable 5
parent 0d9d78398c
commit 64c0ff96bc
23 changed files with 3212 additions and 126 deletions
+57 -13
View File
@@ -17,25 +17,32 @@ impl LibraryProvider for SteamProvider {
}
fn list(&self) -> Vec<GameEntry> {
let mut by_appid: std::collections::BTreeMap<u32, String> = Default::default();
let mut by_appid: std::collections::BTreeMap<u32, Installed> = Default::default();
for steamapps in steam_library_dirs() {
for (appid, name) in scan_manifests(&steamapps) {
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
for app in scan_manifests(&steamapps) {
// First library wins; dedups appids present in several libraries.
by_appid.entry(app.appid).or_insert(app);
}
}
let mut games: Vec<GameEntry> = by_appid
.into_iter()
.filter(|(appid, name)| !is_steam_tool(*appid, name))
.map(|(appid, title)| GameEntry {
.into_values()
.filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry {
provider: None,
id: format!("steam:{appid}"),
id: format!("steam:{}", app.appid),
store: "steam".into(),
title,
art: steam_art(appid),
art: steam_art(app.appid),
launch: Some(LaunchSpec {
kind: "steam_appid".into(),
value: appid.to_string(),
value: app.appid.to_string(),
}),
// The appid alone is authoritative on Linux (Steam's launch reaper); the install dir
// is what the Windows matcher — which has no reaper to watch — keys off instead.
detect: match app.install_dir {
Some(dir) => DetectSpec::steam(app.appid).with_dir(dir),
None => DetectSpec::steam(app.appid),
},
title: app.name,
})
.collect();
// Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the
@@ -274,8 +281,17 @@ fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
Some(&after[..after.find('"')?])
}
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles.
fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
/// One installed Steam title, as read from its `appmanifest_<appid>.acf`.
struct Installed {
appid: u32,
name: String,
/// `<steamapps>/common/<installdir>`, when the manifest names one and it exists on disk — the
/// game's own files, used to recognize its processes ([`DetectSpec::install_dir`]).
install_dir: Option<PathBuf>,
}
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → the installed titles it describes.
fn scan_manifests(steamapps: &Path) -> Vec<Installed> {
let Ok(rd) = std::fs::read_dir(steamapps) else {
return Vec::new();
};
@@ -290,7 +306,17 @@ fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
out.push((appid, name.to_string()));
// `installdir` is a bare folder name relative to this library's `common/`.
let install_dir = text
.lines()
.find_map(|l| vdf_value(l.trim(), "installdir"))
.map(|d| steamapps.join("common").join(d))
.filter(|p| p.is_dir());
out.push(Installed {
appid,
name: name.to_string(),
install_dir,
});
}
}
}
@@ -318,6 +344,9 @@ struct Shortcut {
appid: u32,
/// Display name (`AppName`).
name: String,
/// The shortcut's target (`Exe`), as stored — Steam quotes it. This *is* the game (a shortcut
/// points straight at it, with no launcher in between), so it doubles as the detect signal.
exe: String,
/// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that.
hidden: bool,
}
@@ -361,9 +390,22 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
kind: "steam_appid".into(),
value: shortcut_gameid(sc.appid).to_string(),
}),
detect: shortcut_detect(&sc.exe),
})
}
/// Detect signals for a non-Steam shortcut: its `Exe` target is the game itself, so the executable
/// (and its folder, which catches a launcher script that execs a sibling binary) identifies it. Steam
/// stores the target quoted and may include trailing arguments; only an existing absolute path is
/// asserted — a guess would be worse than no tracking at all.
fn shortcut_detect(exe: &str) -> DetectSpec {
let mut spec = crate::library::spec_from_command(exe);
if let Some(dir) = spec.exe.as_deref().and_then(Path::parent) {
spec.install_dir = Some(dir.to_path_buf());
}
spec
}
/// Every `userdata/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
/// that has signed in on this host.
fn shortcuts_files() -> Vec<PathBuf> {
@@ -489,6 +531,7 @@ fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option<Shortcut> {
Some(Shortcut {
appid,
name,
exe,
hidden,
})
}
@@ -704,6 +747,7 @@ mod tests {
let sc = Shortcut {
appid: 2_456_789_012,
name: "My Emulator".into(),
exe: "\"/opt/emu/run.sh\"".into(),
hidden: false,
};
let entry = shortcut_entry(sc).unwrap();