From d5857abd91b3543959490b0451fa9dc5949fe6af Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 26 Jul 2026 21:24:12 +0200 Subject: [PATCH] fix(session/gamelease): a launcher handing off is not the game exiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/punktfunk-host/src/gamelease.rs | 67 ++++++++++++++++++++- crates/punktfunk-host/src/procscan/linux.rs | 8 ++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index 79863062..d202627c 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -465,7 +465,19 @@ fn watch(shared: Arc, mut child: Option, 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. /// diff --git a/crates/punktfunk-host/src/procscan/linux.rs b/crates/punktfunk-host/src/procscan/linux.rs index d730a923..4832af82 100644 --- a/crates/punktfunk-host/src/procscan/linux.rs +++ b/crates/punktfunk-host/src/procscan/linux.rs @@ -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");