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