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");