fix(session/gamelease): a launcher handing off is not the game exiting
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m12s
ci / bench (push) Successful in 7m19s
apple / swift (push) Successful in 6m33s
ci / rust-arm64 (push) Successful in 10m0s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 9m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
arch / build-publish (push) Successful in 13m0s
deb / build-publish-host (push) Successful in 13m49s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m44s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 17m7s
deb / build-publish-client-arm64 (push) Successful in 10m24s
windows-host / package (push) Successful in 19m52s
windows-host / winget-source (push) Skipped
docker / build-push-arm64cross (push) Successful in 4m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m7s
ci / rust (push) Successful in 28m24s
apple / screenshots (push) Successful in 25m43s

On glass (.41), launching any Steam title over the compat plane ended the
session ~7 s later, before the game had started:

  launched app  command=steam steam://rungameid/2379780  pid=12699
  watching the launched game  kind="child"
  the launched game is running  kind="child"  procs=0      <- 3 ms in, zero game procs
  the launched game exited  ->  session ended

Linux launches are `Child` leases: the host spawns the launcher itself. The
watcher counts "my child is alive" as the game running, so the lease left the
waiting phase on its FIRST poll — and the shim reclassification lives in that
phase, so it never got to run. When `steam` handed off and exited, that read as
the game exiting.

The design always called for the shim window ("never fires B from a shim exit")
and the code implements it; the lease had simply moved past where it applies. So
when the store gave us signals to recognize the real game by, a bare live child
no longer counts as the game until that window has passed. With no signals the
child is all we have and still counts immediately — custom commands are
unchanged.

Windows could not reproduce this: it holds no child, so its leases are always
`Matched`, and the whole .173 pass was structurally blind to it. Both planes on
Linux were affected — which is most of the audience for this feature.

Also fixes the live-process fixture, which was CI-red for an unrelated reason:
it copied `/bin/sleep` to a file named `game`, and modern coreutils (uutils on
Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary dispatching on argv[0]
— under any other name it exits instantly, leaving nothing in /proc and looking
exactly like a broken matcher. Verified by A/B: the same test fails identically
on a tree predating these changes.

.25 (Ubuntu 26.04): 305 passed, 0 failed.
This commit is contained in:
2026-07-26 21:24:12 +02:00
parent 0c261de636
commit d5857abd91
2 changed files with 73 additions and 2 deletions
+66 -1
View File
@@ -465,7 +465,19 @@ fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_ex
// The child being alive counts as the game running for a `Child` lease, so a title with no
// detect signals is still fully tracked.
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
//
// But a launcher that is about to hand off and exit looks *exactly* like the game for its
// first few seconds. When the store gave us signals to recognize the real game by, wait out
// the shim window before believing this child is it — otherwise the lease leaves this phase
// on its very first poll, the reclassification above never gets to run, and the hand-off
// that follows is read as the game exiting. On Linux that ended a session ~7 s after
// launching any Steam title, before the game had even started (on glass, .41).
//
// With no signals the child is all we have, so it still counts immediately: a custom command
// is tracked exactly as before.
let child_alive = matches!(kind, LeaseKind::Child)
&& child.is_some()
&& (shared.spec.is_empty() || spawned_at.elapsed() >= SHIM_WINDOW);
let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive {
known = live.clone();
@@ -1135,6 +1147,59 @@ mod tests {
assert_eq!(readopt(Some("fp-151"), Some(b)), 1);
}
/// A launcher that hands off and exits must never be mistaken for the game.
///
/// This is the `steam steam://rungameid/…` shape: the host spawns a launcher as its own child,
/// the launcher tells the already-running Steam to start the game and exits within a couple of
/// seconds, and the game itself appears later (or, here, never). Ending the session on that exit
/// is the failure this guards — it killed Linux Steam launches ~7 s in, before the game started.
///
/// The spec deliberately matches nothing: what is asserted is that a *quick, successful* child
/// exit is treated as a hand-off to wait out, not as the game being gone.
#[cfg(target_os = "linux")]
#[test]
fn a_launcher_that_hands_off_and_exits_is_not_the_game_exiting() {
use std::sync::atomic::AtomicUsize;
static EXITS: AtomicUsize = AtomicUsize::new(0);
EXITS.store(0, Ordering::SeqCst);
let child = std::process::Command::new("/bin/true")
.spawn()
.expect("spawn the fake launcher");
let lease = open(
LeaseRequest {
game: GameRef {
id: Some("steam:999001".into()),
store: Some("steam".into()),
title: "Handoff".into(),
},
client: "test".into(),
plane: crate::events::Plane::Native,
// A real signal that no process will ever match — the game never shows up.
spec: DetectSpec::steam(999_001),
nested: false,
child: Some((child, false)),
launch_stamp: None,
},
Box::new(|| {
EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
// Past the shim window plus the exit-confirmation window: comfortably longer than the buggy
// path took to declare the game gone.
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(2));
assert_eq!(
EXITS.load(Ordering::SeqCst),
0,
"a launcher handing off must not end the session"
);
assert_ne!(
lease.shared().state(),
GameState::Exited,
"the game never ran, so it cannot have exited"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
+7 -1
View File
@@ -542,8 +542,14 @@ mod tests {
// it from there. (A wrapper script that `exec`s something outside the directory would leave no
// trace of the directory in either the image path or the command line — worth knowing, and the
// reason a copied binary is the honest fixture here.)
//
// It has to keep the name `sleep`. Modern coreutils (uutils on Ubuntu 25.10+, busybox
// elsewhere) is a MULTI-CALL binary that dispatches on `argv[0]`: copied to any other name it
// prints "unknown program" and exits instantly, leaving nothing in `/proc` to find — which
// looks exactly like the matcher being broken. That cost a CI red, green on a distro with a
// standalone `sleep` and failing on one without.
let td = tempfile::tempdir().expect("tempdir");
let game = td.path().join("game");
let game = td.path().join("sleep");
std::fs::copy("/bin/sleep", &game).expect("copy a stand-in game binary");
let s = Scanner::system();
let before = s.now_stamp().expect("real /proc/uptime is readable");