test(session/gamelease): prove it against real processes, not just fixtures

The fixture tests establish that the parsing is right; they cannot establish that
it describes this kernel. Two tests close that gap.

procscan now scans the real /proc for a process it just started, which is the
only version that would catch wrong `stat` field ordering, a broken uid check, or
a mis-read uptime clock. Writing it found something worth keeping: a wrapper
script that `exec`s a binary outside the install dir leaves no trace of that
directory in either the image path or the command line, so the install-dir recipe
cannot see it. A real game's binary lives under its install dir, so the fixture
now models that instead — and the note is in the test for whoever wonders.

The gamelease test drives a real child from Running through a host-requested end
to Exited, and asserts the session-ending action does NOT fire for it — the
difference between "the player quit" (end the session) and "we closed it" (do
not). It needs ~12s to outlive the shim window and the exit confirmation, so it
is #[ignore]d; run it with `-- --ignored gamelease`. Verified on .21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:28:13 +02:00
co-authored by Claude Fable 5
parent a17aa61c5a
commit 10902f57d0
2 changed files with 123 additions and 0 deletions
+71
View File
@@ -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.
+52
View File
@@ -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/<pid>/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)]);