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
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:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user