diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index eca76c29..bb58a01f 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -1007,6 +1007,77 @@ mod tests { assert_eq!(readopt(Some("fp-151"), Some(b)), 1); } + /// 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. + /// + /// Ignored by default because it must outlive [`SHIM_WINDOW`] to prove the game was not mistaken + /// for a launcher shim, and then wait out [`EXIT_CONFIRM`] — about 12 s. Run it on a Linux box + /// with `cargo test -p punktfunk-host -- --ignored gamelease`. + #[cfg(target_os = "linux")] + #[test] + #[ignore = "drives a real process for ~12s (shim window + exit confirmation)"] + fn a_real_child_is_tracked_from_running_to_exited() { + use std::os::unix::process::CommandExt; + use std::sync::atomic::AtomicUsize; + + let td = tempfile::tempdir().expect("tempdir"); + let script = td.path().join("game.sh"); + std::fs::write(&script, "#!/bin/sh\nexec sleep 30\n").unwrap(); + let launch_uptime = launch_clock(); + let child = std::process::Command::new("/bin/sh") + .arg(&script) + .process_group(0) + .spawn() + .expect("spawn the fake game"); + + static EXITS: AtomicUsize = AtomicUsize::new(0); + EXITS.store(0, Ordering::SeqCst); + let lease = open( + LeaseRequest { + game: GameRef { + id: Some("custom:live".into()), + store: Some("custom".into()), + title: "Live Child".into(), + }, + client: "test".into(), + plane: crate::events::Plane::Native, + spec: DetectSpec::dir(td.path()), + nested: false, + child: Some((child, true)), + launch_uptime, + }, + Box::new(|| { + EXITS.fetch_add(1, Ordering::SeqCst); + }), + ); + let shared = lease.shared(); + assert!(matches!(shared.kind(), LeaseKind::Child)); + + // Seen running on the first poll, because the host holds the child. + std::thread::sleep(Duration::from_millis(1_500)); + assert_eq!(shared.state(), GameState::Running, "should be running"); + assert_eq!(EXITS.load(Ordering::SeqCst), 0, "nothing has exited yet"); + + // Past the shim window, so its exit counts as the game's rather than a launcher handing off. + std::thread::sleep(SHIM_WINDOW); + terminate(shared.clone(), "test asked"); + + // The ladder asks politely first; `sleep` dies on SIGTERM, so this resolves well inside the + // termination grace, and the watcher then confirms it gone. + let deadline = Instant::now() + TERM_GRACE + EXIT_CONFIRM + Duration::from_secs(4); + while Instant::now() < deadline && shared.state() != GameState::Exited { + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(shared.state(), GameState::Exited, "the game should be gone"); + // The host asked for this exit, so it must NOT also end the session — that is the difference + // between "the player quit" and "we closed it". + assert_eq!( + EXITS.load(Ordering::SeqCst), + 0, + "a host-requested end must not fire the session-ending action" + ); + } + #[test] fn state_strings_are_stable() { // These reach the API and the console; renaming one is a wire change. diff --git a/crates/punktfunk-host/src/procscan.rs b/crates/punktfunk-host/src/procscan.rs index 62d07812..e00ffea5 100644 --- a/crates/punktfunk-host/src/procscan.rs +++ b/crates/punktfunk-host/src/procscan.rs @@ -517,6 +517,58 @@ mod tests { assert!(s.alive(&[gone]).is_empty()); } + /// Everything above runs against a fixture tree, which proves the parsing but not that the + /// parsing describes this kernel. This one scans the **real** `/proc` for a process it just + /// started — the only version of this test that would notice `/proc//stat` field ordering, + /// the uid check, or the uptime clock being wrong on a real box. + #[test] + fn finds_a_real_process_it_just_started() { + // A real game's *binary* lives under its install dir, which is what makes the install-dir + // recipe work. So the stand-in must too: copy a long-running binary into the directory and run + // 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.) + let td = tempfile::tempdir().expect("tempdir"); + let game = td.path().join("game"); + std::fs::copy("/bin/sleep", &game).expect("copy a stand-in game binary"); + let s = Scanner::system(); + let before = s.uptime().expect("real /proc/uptime is readable"); + + let mut child = std::process::Command::new(&game) + .arg("20") + .spawn() + .expect("spawn the fake game"); + // Give it a moment to be visible in /proc. + std::thread::sleep(std::time::Duration::from_millis(300)); + + let found = s.find(&DetectSpec::dir(td.path()), Some(before)); + assert!( + found.iter().any(|p| p.pid == child.id()), + "scanning the real /proc did not find pid {} under {}; found {found:?}", + child.id(), + td.path().display() + ); + // The same process is still alive when re-verified by (pid, start time). + assert!(!s.alive(&found).is_empty()); + // The exact-exe recipe finds it too, on the same live process. + assert!(s + .find(&DetectSpec::exe(&game), Some(before)) + .iter() + .any(|p| p.pid == child.id())); + + // A launch reference AFTER this process started must exclude it — the rule that keeps a + // pre-existing copy of a game from being adopted, checked against a real start time. + let after = s.uptime().unwrap() + 60.0; + assert!(s.find(&DetectSpec::dir(td.path()), Some(after)).is_empty()); + + let _ = child.kill(); + let _ = child.wait(); + // Once it is gone and reaped, neither the scan nor the liveness re-check sees it. + std::thread::sleep(std::time::Duration::from_millis(300)); + assert!(s.find(&DetectSpec::dir(td.path()), Some(before)).is_empty()); + assert!(s.alive(&found).is_empty()); + } + #[test] fn parses_uptime_and_hostile_comm() { let td = fake_proc_root(4321.5, &[FakeProc::new(80, 1234)]);