From e9da37aaf656c1e9e6fe2ff878f326811d42a947 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 18:29:47 +0200 Subject: [PATCH] feat(host/gamelease): a launcher tile's session stops depending on invisible state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was still deciding its lifetime from whatever happened to be true at launch — and the two outcomes disagree: launcher NOT already running the spawned child stays alive -> `Child` lease -> quitting the launcher ends the session launcher ALREADY running the command forwards to the live instance and exits inside SHIM_WINDOW, with no detect signals behind it -> `Untracked` -> the session persists Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that settles which one is right: Big Picture is a *mode* of an already-running Steam client, not a process — and on a Deck or SteamOS host Steam is always running — so no process signal can express "the launcher's window closed". Heroic has the same shape for a different reason (single-instance Electron: a second invocation forwards and exits). So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of `nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail: a launcher the host just started leaves a live child behind, and tracking that child is precisely the inconsistency being removed. `Untracked` already meant the right things downstream, so nothing else had to change: no watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill the user's Steam), and no `GameExited` event, so a client does not bounce back to its library when the launcher closes. Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`. Three call sites, one of which was the actual trap: the GameStream path does not build its lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently dropped the field. An operator-typed `apps.json` command has no library entry behind it and is never a launcher tile, so it passes `false` explicitly. The test pins BOTH cases from the table above, plus the same request without the flag still being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture. Gates: punktfunk-host 439 passed / 0 failed on .21 (+1). Not covered here: nothing publishes launcher tiles until the plugins are released, so there was no live exposure to fix — this is the semantics being made deliberate before the first tile is ever clicked. --- crates/punktfunk-host/src/gamelease.rs | 84 ++++++++++++++++++- .../punktfunk-host/src/gamestream/stream.rs | 7 ++ crates/punktfunk-host/src/library/launch.rs | 6 ++ crates/punktfunk-host/src/native/stream.rs | 1 + crates/punktfunk-host/src/session_status.rs | 1 + 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index ed1885ec..9ee94157 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -266,6 +266,20 @@ pub struct LeaseRequest { pub spec: DetectSpec, /// The game's own compositor-nested-ness: `true` when a bare-spawn gamescope owns it. pub nested: bool, + /// This entry opens a LAUNCHER rather than a game (design D4), which makes the lease + /// [`LeaseKind::Untracked`] no matter what else is known about it. + /// + /// A launcher has no "the game exited" moment to detect, and trying to infer one is worse than + /// not trying. Steam is the clean counterexample: Big Picture is a *mode* of an already-running + /// Steam client, not a process — and on a Deck or SteamOS host Steam is always running — so no + /// process signal can express "the Big Picture window closed". + /// + /// Without this flag the lifetime would also be decided by something the user cannot see: + /// launching a launcher that is NOT yet running leaves the host holding a live child (tracked, + /// so quitting it ends the session), while launching one that IS running has the command + /// forward and exit inside [`SHIM_WINDOW`] (untracked, so the session persists). Same tile, two + /// behaviours. Untracked is the honest one of the two, so it is the one that always applies. + pub launcher: bool, /// The child the host spawned for this launch, when it spawned one directly, and whether it /// leads its own process group (see [`OwnedChild::group_leader`]). pub child: Option<(std::process::Child, bool)>, @@ -300,11 +314,18 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease { plane, spec, nested, + launcher, child, launch_stamp, } = req; - let kind = if nested { + // A launcher tile is untracked FIRST, before anything else is considered — see + // `LeaseRequest::launcher`. Checking it ahead of `child` is the whole point: a launcher the host + // just started leaves a live child behind, and tracking that child is exactly the inconsistency + // this removes. + let kind = if launcher { + LeaseKind::Untracked + } else if nested { LeaseKind::Nested } else if child.is_some() { LeaseKind::Child @@ -335,7 +356,14 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease { last_seen_ms: AtomicU64::new(0), }); - if matches!(kind, LeaseKind::Untracked) { + if launcher { + tracing::info!( + title = %shared.game.title, + app = shared.game.id.as_deref().unwrap_or("-"), + "this entry opens a launcher, not a game — the session stays up until the client \ + leaves, and closing the launcher does not end it" + ); + } else if matches!(kind, LeaseKind::Untracked) { tracing::info!( title = %shared.game.title, app = shared.game.id.as_deref().unwrap_or("-"), @@ -1072,12 +1100,62 @@ mod tests { plane: crate::events::Plane::Native, spec, nested, + launcher: false, child: None, // No start-time floor: these leases are never matched against real processes. launch_stamp: None, } } + /// Design D4: an entry that opens a LAUNCHER is untracked, whatever else is known about it. + /// + /// Both cases below are the same tile - "Steam Big Picture" - differing only in whether Steam + /// happened to be running already, which the user cannot see: + /// + /// * not running: the host's spawned child stays alive, which would otherwise be a `Child` + /// lease, so quitting the launcher would end the session; + /// * already running: the command forwards to the live instance and exits inside + /// `SHIM_WINDOW`, leaving nothing to track, so the session would persist. + /// + /// Untracked is the honest answer of the two. Big Picture is a *mode* of an already-running + /// Steam client rather than a process, and on a Deck or SteamOS host Steam is always running, + /// so no process signal can express "the launcher's window closed". Pinning it here keeps the + /// tile's behaviour from depending on invisible state. + #[test] + fn a_launcher_entry_is_untracked_however_it_was_started() { + // Already running: nothing held, nothing to detect. + let mut r = req("steam:big-picture", DetectSpec::default(), false); + r.launcher = true; + let lease = open(r, Box::new(|| {})); + assert!(matches!(lease.shared().kind, LeaseKind::Untracked)); + assert!(!lease.shared().is_trackable()); + + // Not running: the entry also carries detect signals, which would normally make this a + // `Matched` lease. `launcher` outranks them. + let mut r = req( + "steam:big-picture-2", + DetectSpec::exe("/usr/bin/steam"), + false, + ); + r.launcher = true; + assert!( + !r.spec.is_empty(), + "the guard is only meaningful with signals" + ); + let lease = open(r, Box::new(|| {})); + assert!(matches!(lease.shared().kind, LeaseKind::Untracked)); + assert!(!lease.shared().is_trackable()); + + // The same request WITHOUT the flag is tracked - so the assertions above are the flag's + // doing, not an artifact of the fixture. + let plain = open( + req("steam:570", DetectSpec::exe("/usr/bin/steam"), false), + Box::new(|| {}), + ); + assert!(matches!(plain.shared().kind, LeaseKind::Matched)); + assert!(plain.shared().is_trackable()); + } + /// Is a lease for `id` currently on probation? fn is_pending(id: &str) -> bool { pending_snapshot() @@ -1254,6 +1332,7 @@ mod tests { // A real signal that no process will ever match — the game never shows up. spec: DetectSpec::steam(999_001), nested: false, + launcher: false, child: Some((child, false)), launch_stamp: None, }, @@ -1312,6 +1391,7 @@ mod tests { plane: crate::events::Plane::Native, spec: DetectSpec::dir(td.path()), nested: false, + launcher: false, child: Some((child, true)), launch_stamp, }, diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 37c7476f..b777b389 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -369,6 +369,7 @@ fn run( plane: crate::events::Plane::Gamestream, spec: t.detect.clone(), nested, + launcher: t.launcher, child, launch_stamp, }, @@ -580,6 +581,9 @@ fn open_gs_mirror_source( /// run it. struct GsApp { game: crate::gamelease::GameRef, + /// This entry opens a LAUNCHER rather than a game (design D4) — carried through from + /// [`crate::library::LaunchTarget`] so the lease can stay untracked for it. + launcher: bool, detect: crate::library::DetectSpec, /// The resolved shell command. `Some` on Linux, which runs it itself; `None` for a Windows /// library title, which launches by id through the interactive-session spawner instead. @@ -601,6 +605,7 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option { Some(t) => { return Some(GsApp { game: t.game, + launcher: t.launcher, detect: t.detect, command: t.command, }) @@ -619,6 +624,8 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option { .map(str::trim) .filter(|c| !c.is_empty())?; Some(GsApp { + // An operator-typed command has no library entry behind it, so it is never a launcher tile. + launcher: false, game: crate::gamelease::GameRef { id: None, store: None, diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index 87ee45fa..c9eb98ad 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -19,6 +19,10 @@ use super::*; pub struct LaunchTarget { /// Identity for the status surface and the `game.*` events. pub game: crate::gamelease::GameRef, + /// This entry opens a LAUNCHER, not a game (design D4) — so there is no "the game exited" + /// moment to detect, and the lease stays untracked no matter what else is known about it. + /// See [`crate::gamelease::LeaseRequest::launcher`]. + pub launcher: bool, /// How to recognize the running game ([`DetectSpec`]); empty when the store offers nothing. pub detect: DetectSpec, /// The resolved shell command. `Some` on Linux (where the host runs it); `None` on Windows, @@ -51,6 +55,7 @@ pub fn resolve_launch(id: &str) -> Option { let command = entry.launch.as_ref().and_then(command_for)?; Some(LaunchTarget { game, + launcher: entry.role == GameRole::Launcher, detect: entry.detect, command: Some(command), }) @@ -62,6 +67,7 @@ pub fn resolve_launch(id: &str) -> Option { // the existing warning fires there. Some(LaunchTarget { game, + launcher: entry.role == GameRole::Launcher, detect: entry.detect, command: None, }) diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index dbcf41e7..58d0a508 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1715,6 +1715,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option