feat(host/gamelease): a launcher tile's session stops depending on invisible state
ci / web (pull_request) Failing after 2s
ci / rust-arm64 (pull_request) Failing after 3s
ci / bun-nix (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 4m56s
ci / rust (pull_request) Successful in 9m1s

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.
This commit is contained in:
2026-08-06 18:35:09 +02:00
parent 453b9850fa
commit e9da37aaf6
5 changed files with 97 additions and 2 deletions
+82 -2
View File
@@ -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,
},
@@ -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<GsApp> {
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<GsApp> {
.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,
@@ -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<LaunchTarget> {
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<LaunchTarget> {
// the existing warning fires there.
Some(LaunchTarget {
game,
launcher: entry.role == GameRole::Launcher,
detect: entry.detect,
command: None,
})
@@ -1715,6 +1715,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
plane: crate::events::Plane::Native,
spec: target.detect.clone(),
nested,
launcher: target.launcher,
child,
launch_stamp,
},
@@ -381,6 +381,7 @@ mod tests {
// No signals: an inert lease, so no watcher thread races this test's assertions.
spec: crate::library::DetectSpec::default(),
nested: false,
launcher: false,
child: None,
launch_stamp: None,
},