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:
@@ -1007,6 +1007,77 @@ mod tests {
|
|||||||
assert_eq!(readopt(Some("fp-151"), Some(b)), 1);
|
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]
|
#[test]
|
||||||
fn state_strings_are_stable() {
|
fn state_strings_are_stable() {
|
||||||
// These reach the API and the console; renaming one is a wire change.
|
// These reach the API and the console; renaming one is a wire change.
|
||||||
|
|||||||
@@ -517,6 +517,58 @@ mod tests {
|
|||||||
assert!(s.alive(&[gone]).is_empty());
|
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]
|
#[test]
|
||||||
fn parses_uptime_and_hostile_comm() {
|
fn parses_uptime_and_hostile_comm() {
|
||||||
let td = fake_proc_root(4321.5, &[FakeProc::new(80, 1234)]);
|
let td = fake_proc_root(4321.5, &[FakeProc::new(80, 1234)]);
|
||||||
|
|||||||
Reference in New Issue
Block a user