diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index bb58a01f..2a12fd43 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -156,7 +156,7 @@ pub struct LeaseShared { spec: DetectSpec, /// Seconds-since-boot at launch: the floor for adopting a process (`None` = the uptime clock was /// unreadable, so no start-time filtering is possible and only signals are used). - launch_uptime: Option, + launch_stamp: Option, /// The child the host spawned for this launch, when it spawned one ([`LeaseKind::Child`]). /// Cleared once that child is reaped. child: Mutex>, @@ -254,15 +254,15 @@ pub struct LeaseRequest { /// Seconds-since-boot from **before** the launch ([`launch_clock`]): the floor for adopting a /// process, which is what keeps a copy of the game the player already had open from being /// mistaken for this session's. `None` disables the filter (no readable uptime clock). - pub launch_uptime: Option, + pub launch_stamp: Option, } /// The reference instant for adopting this launch's processes, in seconds since boot. Call it -/// **before** anything spawns; see [`LeaseRequest::launch_uptime`]. +/// **before** anything spawns; see [`LeaseRequest::launch_stamp`]. pub fn launch_clock() -> Option { #[cfg(target_os = "linux")] { - crate::procscan::Scanner::system().uptime() + crate::procscan::launch_stamp() } #[cfg(not(target_os = "linux"))] { @@ -284,7 +284,7 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease { spec, nested, child, - launch_uptime, + launch_stamp, } = req; let kind = if nested { @@ -310,7 +310,7 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease { state: AtomicU8::new(GameState::Launching as u8), cancel: Arc::new(AtomicBool::new(false)), spec, - launch_uptime, + launch_stamp, child: Mutex::new(owned), terminating: AtomicBool::new(false), created_ms: now_ms(), @@ -366,14 +366,14 @@ fn spawn_watcher( if matches!(shared.kind, LeaseKind::Nested) && shared.spec.is_empty() { return None; } - #[cfg(not(target_os = "linux"))] + // Platforms with no matcher at all (macOS has no launch path either) keep a lease for the status + // surface, but nothing polls it. + #[cfg(not(any(target_os = "linux", windows)))] { - // Windows lands in phase 2 (Job Objects + Toolhelp matching); until then a lease exists for - // the status surface but nothing polls. let _ = (child, on_exit); return None; } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", windows))] { std::thread::Builder::new() .name("pf1-gamelease".into()) @@ -383,13 +383,22 @@ fn spawn_watcher( } /// The watch loop: wait for the game to appear, then for it to go away. -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", windows))] fn watch(shared: Arc, mut child: Option, on_exit: OnExit) { let scanner = crate::procscan::Scanner::system(); let cancelled = || shared.cancel.load(Ordering::Relaxed); let spawned_at = Instant::now(); let mut kind = shared.kind.clone(); + // The game's processes as last seen. Phase 2 re-verifies these rather than re-scanning the whole + // process table each second: `alive` costs one query per known process, while `find` costs one per + // process on the box (and on Windows that means an `OpenProcess` each). A full re-scan still + // happens the moment they all vanish, which is what catches a game that re-execs into a new pid. + // + // Deliberately uninitialized: the only way out of phase 1 and into phase 2 is the branch that + // assigns it, so an initial value would be dead. + let mut known: Vec; + // ---- Phase 1: wait for the game to show up. ---- let start_deadline = spawned_at + START_GRACE; loop { @@ -458,8 +467,9 @@ fn watch(shared: Arc, mut child: Option, 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(); - let live = scanner.find(&shared.spec, shared.launch_uptime); + let live = scanner.find(&shared.spec, shared.launch_stamp); if !live.is_empty() || child_alive { + known = live.clone(); shared.was_running.store(true, Ordering::Relaxed); shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); shared.set_state(GameState::Running); @@ -487,6 +497,7 @@ fn watch(shared: Arc, mut child: Option, on_ex // ---- Phase 2: wait for it to go away, confirmed across a window. ---- let mut gone_since: Option = None; + let mut vetoed = false; loop { if cancelled() { return; @@ -502,22 +513,47 @@ fn watch(shared: Arc, mut child: Option, on_ex } } let child_alive = matches!(kind, LeaseKind::Child) && child.is_some(); - // Re-scan rather than only re-checking known pids: a game that re-execs (a launcher stub - // becoming the real binary, an engine relaunching itself) keeps the same identity. - let live = scanner.find(&shared.spec, shared.launch_uptime); + // Cheap first: are the processes we already know about still there? Only when none of them is + // do we pay for a full scan — which is also what notices a game that re-exec'd into a new pid + // (a launcher stub becoming the real binary, an engine relaunching itself). + let live = { + let still = scanner.alive(&known); + if still.is_empty() { + scanner.find(&shared.spec, shared.launch_stamp) + } else { + still + } + }; if !live.is_empty() || child_alive { + known = live; gone_since = None; + vetoed = false; shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); } else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM { - finish(&shared, &on_exit, "the game exited"); - return; + // Last check before ending a session: does anything outside the process scan still think + // the game is up? Only a veto, never a reason to call it running — see + // `procscan::running_hint`. The failure mode of honoring it is a stream that stays up. + if crate::procscan::running_hint(&shared.spec) == Some(true) { + if !vetoed { + vetoed = true; + tracing::info!( + title = %shared.game.title, + "no game processes found, but its launcher still reports it running — not \ + ending the session" + ); + } + gone_since = None; + } else { + finish(&shared, &on_exit, "the game exited"); + return; + } } std::thread::sleep(POLL); } } /// Record the exit and, unless the host itself ended the game, run the session-ending action. -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", windows))] fn finish(shared: &Arc, on_exit: &OnExit, why: &str) { shared.set_state(GameState::Exited); let terminated = shared.is_terminating(); @@ -621,6 +657,8 @@ fn terminate_blocking(shared: &LeaseShared) { LeaseKind::Child | LeaseKind::Matched => { #[cfg(target_os = "linux")] unix_term_ladder(shared); + #[cfg(windows)] + windows_term_ladder(shared); } LeaseKind::Untracked => {} } @@ -653,7 +691,7 @@ fn unix_term_ladder(shared: &LeaseShared) { // Re-scan and re-verify immediately before signalling, so a pid recycled since the last // sweep is never hit. scanner - .alive(&scanner.find(&shared.spec, shared.launch_uptime)) + .alive(&scanner.find(&shared.spec, shared.launch_stamp)) .into_iter() // SAFETY: as above, for a single pid just re-verified to be the process we adopted. .filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 }) @@ -673,7 +711,7 @@ fn unix_term_ladder(shared: &LeaseShared) { while Instant::now() < deadline { std::thread::sleep(POLL); let still = scanner - .alive(&scanner.find(&shared.spec, shared.launch_uptime)) + .alive(&scanner.find(&shared.spec, shared.launch_stamp)) .len(); // Signal 0 only probes for existence — the child (or its group) is gone once it fails. let child_gone = !signal_child(0); @@ -691,6 +729,52 @@ fn unix_term_ladder(shared: &LeaseShared) { ); } +/// Windows: ask the game's windows to close, wait, then terminate what's left. +/// +/// Same shape as the Unix ladder, different primitives — and one structural difference: the host never +/// holds a child here. Every Windows launch goes through a launcher or the shell +/// (`steam://`, `com.epicgames.launcher://`, `shell:AppsFolder\…`), so the game is always recognized +/// rather than owned, and the pid set comes entirely from the matcher. +#[cfg(windows)] +fn windows_term_ladder(shared: &LeaseShared) { + let scanner = crate::procscan::Scanner::system(); + let live = || scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp)); + + let pids: Vec = live().into_iter().map(|p| p.pid).collect(); + if pids.is_empty() { + tracing::info!(title = %shared.game.title, "the game is already gone — nothing to end"); + return; + } + // Polite first: a `WM_CLOSE` is what clicking the window's X does, so the game runs its own + // shutdown and can save. + let asked = crate::game_term::request_close(&pids); + tracing::debug!( + title = %shared.game.title, + windows_asked = asked, + procs = pids.len(), + grace_s = TERM_GRACE.as_secs(), + "asked the game to close" + ); + let deadline = Instant::now() + TERM_GRACE; + while Instant::now() < deadline { + std::thread::sleep(POLL); + if live().is_empty() { + tracing::info!(title = %shared.game.title, "the game closed when asked"); + return; + } + } + // Re-verify (pid, creation time) one last time, then insist. Windows recycles pids briskly, so the + // list handed to `kill` must be freshly checked rather than the one gathered above. + let remaining: Vec = live().into_iter().map(|p| p.pid).collect(); + let killed = crate::game_term::kill(&remaining); + tracing::warn!( + title = %shared.game.title, + killed, + grace_s = TERM_GRACE.as_secs(), + "the game did not close when asked — killed it" + ); +} + // --------------------------------------------------------------------------------------------- // The grace registry: leases whose session is gone but whose game is on probation // --------------------------------------------------------------------------------------------- @@ -886,7 +970,7 @@ mod tests { nested, child: None, // No start-time floor: these leases are never matched against real processes. - launch_uptime: None, + launch_stamp: None, } } @@ -1023,7 +1107,7 @@ mod tests { 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 launch_stamp = launch_clock(); let child = std::process::Command::new("/bin/sh") .arg(&script) .process_group(0) @@ -1044,7 +1128,7 @@ mod tests { spec: DetectSpec::dir(td.path()), nested: false, child: Some((child, true)), - launch_uptime, + launch_stamp, }, Box::new(|| { EXITS.fetch_add(1, Ordering::SeqCst); diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 15860fad..bd59611c 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -44,6 +44,10 @@ mod events; // The lifetime of a launched game: whether it is running, when it exits (which can end the session), // and how to end it (which a session ending can ask for) — design/session-game-lifetime.md. mod gamelease; +// The Win32 half of ending a game: WM_CLOSE onto the interactive desktop, then TerminateProcess. +#[cfg(target_os = "windows")] +#[path = "windows/game_term.rs"] +mod game_term; mod gamestream; #[cfg(target_os = "linux")] #[path = "linux/gpuclocks.rs"] @@ -70,8 +74,8 @@ mod native_pairing; mod pipeline; mod plugins; // Finding a launched game's processes from its store's detect signals — the read side of the -// session⇄game lifetime binding (design/session-game-lifetime.md §4). -#[cfg(target_os = "linux")] +// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a +// platform with neither (macOS, which has no launch path either) the module is an empty shell. mod procscan; mod send_pacing; #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 98e7bf96..01434d53 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1115,7 +1115,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option/stat` field 22 — start time in clock ticks since boot. Unique per (pid, boot). - pub start_ticks: u64, + pub start: u64, } -/// Largest `cmdline`/`environ` blob we will read from a single process. Both are bounded by `ARG_MAX` -/// in practice; the cap exists so a pathological process can't make the host allocate without bound -/// during a once-a-second scan. -const MAX_PROC_BLOB: u64 = 512 * 1024; +/// Tolerance on the "started after the launch" test, in seconds. +/// +/// The launch stamp is taken just before the spawn, but start times are quantized (~10 ms on Linux) +/// and a launcher can race ahead of the host's own bookkeeping, so an exact comparison would +/// occasionally reject the real game. Two seconds is far below the time any launcher takes to bring a +/// game up, so it cannot let a *pre-existing* instance through. +pub const START_SLACK_SECS: f64 = 2.0; -/// Tolerance on the "started after the launch" test, in seconds. The launch timestamp is taken just -/// before the spawn, but clock-tick granularity (`starttime` is quantized to ~10 ms) and a launcher -/// that raced ahead of our own bookkeeping mean an exact comparison would occasionally reject the -/// real game. Two seconds is far below the time any launcher takes to bring a game up, so it cannot -/// let a *pre-existing* instance through. -const START_SLACK_SECS: f64 = 2.0; - -/// A `/proc` reader. Parameterized on its root, uid filter, and clock rate so the matching logic can -/// be unit-tested against a fixture tree instead of the live system. -pub struct Scanner { - root: PathBuf, - /// Only consider processes owned by this uid. `None` (tests only) considers all. - uid: Option, - ticks_per_sec: f64, -} - -impl Scanner { - /// The live system: `/proc`, this host process's own uid, the kernel's clock rate. - pub fn system() -> Self { - // SAFETY: a parameterless POSIX call that always succeeds and touches no memory. - let uid = unsafe { libc::getuid() }; - // SAFETY: `sysconf` reads a static system limit by name; no memory of ours is involved, and a - // non-positive answer (which the caller below handles) is its documented failure signal. - let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; - Self { - root: PathBuf::from("/proc"), - uid: Some(uid), - // A non-positive sysconf answer would poison every start-time comparison; fall back to - // the universal Linux value rather than divide by nonsense. - ticks_per_sec: if ticks > 0 { ticks as f64 } else { 100.0 }, - } +/// The reference instant for adopting a launch's processes, in **seconds on the platform's +/// process-start timeline** — seconds since boot on Linux, seconds since the Windows epoch on +/// Windows. Only ever compared against a process's own start time on the same platform, never +/// interpreted as a wall clock or persisted. +/// +/// Call it **before** anything spawns; see [`crate::gamelease::LeaseRequest::launch_stamp`]. `None` +/// when the platform has no matcher (macOS) or the clock could not be read, which disables the +/// start-time filter rather than rejecting everything. +pub fn launch_stamp() -> Option { + #[cfg(any(target_os = "linux", windows))] + { + Scanner::system().now_stamp() } - - /// Seconds since boot, on the same timeline as a process's start time. `None` when `/proc/uptime` - /// can't be read (the caller then skips start-time filtering rather than rejecting everything). - pub fn uptime(&self) -> Option { - let text = std::fs::read_to_string(self.root.join("uptime")).ok()?; - text.split_whitespace().next()?.parse().ok() - } - - /// Every process matching any of `spec`'s signals, restricted to those that started at or after - /// `min_start` (seconds since boot; `None` disables the filter). - /// - /// The signals are a union: a Steam appid, an env marker, an exact executable, or an install - /// directory each independently qualify a process. That is deliberate — the store with the - /// sharpest signal wins, but a store that only knows its install directory is still covered. - pub fn find(&self, spec: &DetectSpec, min_start: Option) -> Vec { - if spec.is_empty() { - return Vec::new(); - } - // Resolve symlinks in the install dir once per scan: a game reached through a symlinked - // library folder would otherwise never prefix-match its own canonical image path. - let dir = spec - .install_dir - .as_deref() - .map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf())); - let exe = spec - .exe - .as_deref() - .map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf())); - let steam_tok = spec.steam_appid.map(|id| format!("AppId={id}")); - - let Ok(entries) = std::fs::read_dir(&self.root) else { - return Vec::new(); - }; - let mut out = Vec::new(); - for e in entries.flatten() { - let name = e.file_name(); - let Some(pid) = name.to_str().and_then(|s| s.parse::().ok()) else { - continue; // not a pid dir - }; - let dir_path = e.path(); - if let Some(uid) = self.uid { - let owned = std::fs::metadata(&dir_path) - .map(|m| { - use std::os::unix::fs::MetadataExt; - m.uid() == uid - }) - .unwrap_or(false); - if !owned { - continue; - } - } - let Some(start_ticks) = self.start_ticks(&dir_path) else { - continue; // vanished mid-scan, or an unparseable stat - }; - if let Some(min) = min_start { - let started = start_ticks as f64 / self.ticks_per_sec; - if started + START_SLACK_SECS < min { - continue; // predates this launch — never ours (rule 1) - } - } - if self.matches( - &dir_path, - spec, - steam_tok.as_deref(), - exe.as_deref(), - dir.as_deref(), - ) { - out.push(ProcRef { pid, start_ticks }); - } - } - out - } - - /// Which of `procs` are still the same live processes — pid present **and** start time unchanged, - /// so a recycled pid is never reported alive (rule 2). - pub fn alive(&self, procs: &[ProcRef]) -> Vec { - procs - .iter() - .copied() - .filter(|p| self.start_ticks(&self.root.join(p.pid.to_string())) == Some(p.start_ticks)) - .collect() - } - - /// Does this process match any of the spec's signals? - fn matches( - &self, - dir_path: &Path, - spec: &DetectSpec, - steam_tok: Option<&str>, - exe: Option<&Path>, - install_dir: Option<&Path>, - ) -> bool { - // The process's own image, resolved through /proc//exe. Absent for a kernel thread or a - // process whose binary was replaced. - let image = std::fs::read_link(dir_path.join("exe")).ok(); - if let Some(want) = exe { - if image.as_deref() == Some(want) { - return true; - } - } - if let Some(dir) = install_dir { - if image.as_deref().is_some_and(|i| i.starts_with(dir)) { - return true; - } - } - - // The command line covers what the image can't: a Proton/Wine title's image is the *runtime* - // (`…/proton`, `wine64-preloader`), and the game only appears as an argument; Steam's launch - // reaper is likewise identified by its argv, not its binary. - let cmdline = read_capped(&dir_path.join("cmdline")); - if let Some(cmdline) = cmdline.as_deref() { - if let Some(tok) = steam_tok { - // Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and - // Steam's own (non-reaper) helper steps aren't mistaken for the game. - let mut launch = false; - let mut appid = false; - for arg in cmdline.split(|&b| b == 0) { - if arg == b"SteamLaunch" { - launch = true; - } else if arg == tok.as_bytes() { - appid = true; - } - } - if launch && appid { - return true; - } - } - if let Some(dir) = install_dir { - // Require a path separator after the directory, so an install dir of `/games/x` is - // not satisfied by an unrelated `/games/xyz/…` argument. (The image-path check above - // needs no such care — `Path::starts_with` already compares whole components.) - let needle = dir.as_os_str().as_encoded_bytes(); - let under_dir = |arg: &[u8]| { - arg.strip_prefix(needle) - .is_some_and(|rest| rest.first() == Some(&b'/')) - }; - if cmdline.split(|&b| b == 0).any(under_dir) { - return true; - } - } - if let Some(want) = exe { - let needle = want.as_os_str().as_encoded_bytes(); - if cmdline.split(|&b| b == 0).any(|arg| arg == needle) { - return true; - } - } - } - - // Env markers last: reading another process's environment is the most invasive of these - // reads, so it only happens for a spec that actually asked for one. The contents are matched - // and discarded — never logged. - if let Some(marker) = &spec.env_marker { - if let Some(env) = read_capped(&dir_path.join("environ")) { - let want: Vec = match &marker.value { - Some(v) => format!("{}={v}", marker.key).into_bytes(), - None => format!("{}=", marker.key).into_bytes(), - }; - let hit = env.split(|&b| b == 0).any(|kv| match marker.value { - // An exact `KEY=VALUE` entry. - Some(_) => kv == want.as_slice(), - // Presence of the key with any value. - None => kv.starts_with(want.as_slice()), - }); - if hit { - return true; - } - } - } - false - } - - /// `/proc//stat` field 22 (`starttime`). The `comm` field is parenthesized and may itself - /// contain spaces and parentheses, so fields are counted from the **last** `)`: the token right - /// after it is field 3 (`state`), which puts `starttime` at index 19 of the remainder. - fn start_ticks(&self, dir_path: &Path) -> Option { - let stat = std::fs::read_to_string(dir_path.join("stat")).ok()?; - let tail = &stat[stat.rfind(')')? + 1..]; - tail.split_whitespace().nth(19)?.parse().ok() + #[cfg(not(any(target_os = "linux", windows)))] + { + None } } -/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished -/// or the file is unreadable — both routine during a scan. -fn read_capped(path: &Path) -> Option> { - use std::io::Read; - let file = std::fs::File::open(path).ok()?; - let mut buf = Vec::new(); - file.take(MAX_PROC_BLOB).read_to_end(&mut buf).ok()?; - Some(buf) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::library::DetectSpec; - - /// Build a fake `/proc` tree. Each process gets a `stat` (with a deliberately nasty `comm`), and - /// optionally a `cmdline`, an `environ`, and an `exe` symlink. - struct FakeProc { - pid: u32, - start_ticks: u64, - exe: Option, - cmdline: Vec<&'static str>, - environ: Vec<&'static str>, +/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan. +/// +/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the +/// primary signal. `Some(true)` = something else believes it is up, so hold off; `Some(false)` = that +/// something agrees it is gone; `None` = no opinion available, which is the common case. +/// +/// Linux has none by design: Steam's launch reaper is both the sharpest signal and already a *process*, +/// so it is covered by the scan itself. Windows has no reaper, which is exactly where a second opinion +/// earns its keep. +pub fn running_hint(spec: &crate::library::DetectSpec) -> Option { + #[cfg(windows)] + { + spec.steam_appid.and_then(windows::steam_running_hint) } - - impl FakeProc { - fn new(pid: u32, start_ticks: u64) -> Self { - Self { - pid, - start_ticks, - exe: None, - cmdline: Vec::new(), - environ: Vec::new(), - } - } - fn exe(mut self, p: impl Into) -> Self { - self.exe = Some(p.into()); - self - } - fn cmdline(mut self, args: &[&'static str]) -> Self { - self.cmdline = args.to_vec(); - self - } - fn environ(mut self, kvs: &[&'static str]) -> Self { - self.environ = kvs.to_vec(); - self - } - } - - fn fake_proc_root(uptime: f64, procs: &[FakeProc]) -> tempfile::TempDir { - let td = tempfile::tempdir().expect("tempdir"); - std::fs::write(td.path().join("uptime"), format!("{uptime} 1000.0\n")).unwrap(); - // Non-pid entries must be skipped, not parsed. - std::fs::create_dir_all(td.path().join("self")).unwrap(); - std::fs::write(td.path().join("cmdline"), b"").unwrap(); - for p in procs { - let dir = td.path().join(p.pid.to_string()); - std::fs::create_dir_all(&dir).unwrap(); - // `stat` is `pid (comm) state ppid … starttime …`, with starttime as field 22. Counting - // from the last ')', index 0 is `state` (field 3), so starttime lands at index 19. The - // comm is hostile on purpose — a space and a close-paren inside the parens, which is - // exactly what naive field-splitting gets wrong. - let mut tail = vec!["0".to_string(); 20]; - tail[0] = "S".to_string(); // field 3 - tail[19] = p.start_ticks.to_string(); // field 22 - std::fs::write( - dir.join("stat"), - format!("{} (evil ) name) {}\n", p.pid, tail.join(" ")), - ) - .unwrap(); - if !p.cmdline.is_empty() { - let mut blob = Vec::new(); - for a in &p.cmdline { - blob.extend_from_slice(a.as_bytes()); - blob.push(0); - } - std::fs::write(dir.join("cmdline"), blob).unwrap(); - } - if !p.environ.is_empty() { - let mut blob = Vec::new(); - for a in &p.environ { - blob.extend_from_slice(a.as_bytes()); - blob.push(0); - } - std::fs::write(dir.join("environ"), blob).unwrap(); - } - if let Some(exe) = &p.exe { - std::os::unix::fs::symlink(exe, dir.join("exe")).unwrap(); - } - } - td - } - - fn scanner(root: &Path) -> Scanner { - Scanner { - root: root.to_path_buf(), - uid: None, // the fixture's owner is the test user; don't couple the test to it - ticks_per_sec: 100.0, - } - } - - fn pids(mut v: Vec) -> Vec { - v.sort_by_key(|p| p.pid); - v.into_iter().map(|p| p.pid).collect() - } - - #[test] - fn empty_spec_matches_nothing() { - let td = fake_proc_root(100.0, &[FakeProc::new(1, 100).exe("/games/x/run")]); - let s = scanner(td.path()); - assert!(s.find(&DetectSpec::default(), None).is_empty()); - } - - #[test] - fn matches_exact_exe_and_install_dir() { - let td = fake_proc_root( - 1000.0, - &[ - FakeProc::new(10, 50_000).exe("/games/hades/Hades"), - FakeProc::new(11, 50_000).exe("/games/hades/tools/crash.bin"), - FakeProc::new(12, 50_000).exe("/usr/bin/firefox"), - ], - ); - let s = scanner(td.path()); - // Exact exe → only that process. - assert_eq!( - pids(s.find(&DetectSpec::exe("/games/hades/Hades"), None)), - vec![10] - ); - // Install dir → every process running out of the game's tree, but nothing outside it. - assert_eq!( - pids(s.find(&DetectSpec::dir("/games/hades"), None)), - vec![10, 11] - ); - } - - #[test] - fn install_dir_does_not_match_a_sibling_with_the_same_prefix() { - // `/games/x` must not adopt a process running out of `/games/xyz` — a real hazard when one - // library folder's name is a prefix of another's. - let td = fake_proc_root( - 1000.0, - &[ - FakeProc::new(90, 50_000).exe("/games/xyz/other"), - FakeProc::new(91, 50_000).cmdline(&["wrapper", "/games/xyz/other"]), - ], - ); - let s = scanner(td.path()); - assert!(s.find(&DetectSpec::dir("/games/x"), None).is_empty()); - // The genuine directory still matches, by image path and by argument. - assert_eq!( - pids(s.find(&DetectSpec::dir("/games/xyz"), None)), - vec![90, 91] - ); - } - - #[test] - fn matches_proton_style_game_only_in_the_cmdline() { - // The image is the Proton runtime; the game appears only as an argument. - let td = fake_proc_root( - 1000.0, - &[FakeProc::new(20, 50_000) - .exe("/steam/runtime/proton") - .cmdline(&["proton", "waitforexitandrun", "/games/elden/eldenring.exe"])], - ); - let s = scanner(td.path()); - assert_eq!( - pids(s.find(&DetectSpec::dir("/games/elden"), None)), - vec![20] - ); - } - - #[test] - fn matches_steam_reaper_exactly() { - let td = fake_proc_root( - 1000.0, - &[ - FakeProc::new(30, 50_000).cmdline(&[ - "reaper", - "SteamLaunch", - "AppId=570", - "--", - "dota", - ]), - // A different appid that shares a prefix must NOT match (AppId=57 vs 570). - FakeProc::new(31, 50_000).cmdline(&[ - "reaper", - "SteamLaunch", - "AppId=57", - "--", - "other", - ]), - // `SteamLaunch` without the appid, and the appid without `SteamLaunch`, are both no. - FakeProc::new(32, 50_000).cmdline(&["reaper", "SteamLaunch", "--", "shader"]), - FakeProc::new(33, 50_000).cmdline(&["something", "AppId=570"]), - ], - ); - let s = scanner(td.path()); - assert_eq!(pids(s.find(&DetectSpec::steam(570), None)), vec![30]); - assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]); - } - - #[test] - fn matches_env_marker_by_exact_value_or_presence() { - let td = fake_proc_root( - 1000.0, - &[ - FakeProc::new(40, 50_000).environ(&["HOME=/home/u", "HEROIC_APP_NAME=Quail"]), - FakeProc::new(41, 50_000).environ(&["HEROIC_APP_NAME=OtherGame"]), - FakeProc::new(42, 50_000).environ(&["HOME=/home/u"]), - ], - ); - let s = scanner(td.path()); - let exact = DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".to_string())); - assert_eq!(pids(s.find(&exact, None)), vec![40]); - // Presence-only matches any value, but still nothing that lacks the key. - let any = DetectSpec::default().with_env("HEROIC_APP_NAME", None); - assert_eq!(pids(s.find(&any, None)), vec![40, 41]); - } - - #[test] - fn never_adopts_a_process_that_predates_the_launch() { - // Uptime 1000 s; the launch happened at t=900. pid 50 started at t=500 (a copy the player - // already had open), pid 51 at t=950 (ours). - let td = fake_proc_root( - 1000.0, - &[ - FakeProc::new(50, 50_000).exe("/games/hades/Hades"), - FakeProc::new(51, 95_000).exe("/games/hades/Hades"), - ], - ); - let s = scanner(td.path()); - let spec = DetectSpec::dir("/games/hades"); - assert_eq!(pids(s.find(&spec, Some(900.0))), vec![51]); - // Without the filter both are visible — proving the filter is what excluded it. - assert_eq!(pids(s.find(&spec, None)), vec![50, 51]); - } - - #[test] - fn start_slack_tolerates_tick_granularity() { - // Started a hair (0.5 s) BEFORE the recorded launch instant: still ours. - let td = fake_proc_root(1000.0, &[FakeProc::new(60, 89_950).exe("/games/x/run")]); - let s = scanner(td.path()); - assert_eq!( - pids(s.find(&DetectSpec::dir("/games/x"), Some(900.0))), - vec![60] - ); - // A full minute early is not. - assert!(s.find(&DetectSpec::dir("/games/x"), Some(960.0)).is_empty()); - } - - #[test] - fn alive_rejects_a_recycled_pid() { - let td = fake_proc_root(1000.0, &[FakeProc::new(70, 50_000).exe("/games/x/run")]); - let s = scanner(td.path()); - let same = ProcRef { - pid: 70, - start_ticks: 50_000, - }; - let recycled = ProcRef { - pid: 70, - start_ticks: 99_999, - }; - let gone = ProcRef { - pid: 71, - start_ticks: 50_000, - }; - assert_eq!(s.alive(&[same]), vec![same]); - assert!(s.alive(&[recycled]).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//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)]); - let s = scanner(td.path()); - assert_eq!(s.uptime(), Some(4321.5)); - // The comm field contains a space and a ')' — start time must still parse. - assert_eq!(s.start_ticks(&td.path().join("80")), Some(1234)); + #[cfg(not(windows))] + { + let _ = spec; + None } } diff --git a/crates/punktfunk-host/src/procscan/linux.rs b/crates/punktfunk-host/src/procscan/linux.rs new file mode 100644 index 00000000..18b0d337 --- /dev/null +++ b/crates/punktfunk-host/src/procscan/linux.rs @@ -0,0 +1,558 @@ +//! The Linux matcher: `/proc`. +//! +//! Contract, rules, and the shared vocabulary live in [`super`]; this module is only the reading of +//! `/proc` — and the parsing that gets wrong more often than it looks (`stat`'s `comm` field can +//! contain spaces *and* parentheses, so fields must be counted from the last `)`). + +use super::{ProcRef, START_SLACK_SECS}; +use crate::library::DetectSpec; +use std::path::{Path, PathBuf}; + +/// Largest `cmdline`/`environ` blob we will read from a single process. Both are bounded by `ARG_MAX` +/// in practice; the cap exists so a pathological process can't make the host allocate without bound +/// during a once-a-second scan. +const MAX_PROC_BLOB: u64 = 512 * 1024; + +/// A `/proc` reader. Parameterized on its root, uid filter, and clock rate so the matching logic can +/// be unit-tested against a fixture tree instead of the live system. +pub struct Scanner { + root: PathBuf, + /// Only consider processes owned by this uid. `None` (tests only) considers all. + uid: Option, + ticks_per_sec: f64, +} + +impl Scanner { + /// The live system: `/proc`, this host process's own uid, the kernel's clock rate. + pub fn system() -> Self { + // SAFETY: a parameterless POSIX call that always succeeds and touches no memory. + let uid = unsafe { libc::getuid() }; + // SAFETY: `sysconf` reads a static system limit by name; no memory of ours is involved, and a + // non-positive answer (which the caller below handles) is its documented failure signal. + let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + Self { + root: PathBuf::from("/proc"), + uid: Some(uid), + // A non-positive sysconf answer would poison every start-time comparison; fall back to + // the universal Linux value rather than divide by nonsense. + ticks_per_sec: if ticks > 0 { ticks as f64 } else { 100.0 }, + } + } + + /// Seconds since boot — the Linux process-start timeline (see [`super::launch_stamp`]). `None` + /// when `/proc/uptime` can't be read, which disables start-time filtering rather than rejecting + /// every candidate. + pub fn now_stamp(&self) -> Option { + let text = std::fs::read_to_string(self.root.join("uptime")).ok()?; + text.split_whitespace().next()?.parse().ok() + } + + /// Every process matching any of `spec`'s signals, restricted to those that started at or after + /// `min_start` (seconds since boot; `None` disables the filter). + /// + /// The signals are a union: a Steam appid, an env marker, an exact executable, or an install + /// directory each independently qualify a process. That is deliberate — the store with the + /// sharpest signal wins, but a store that only knows its install directory is still covered. + pub fn find(&self, spec: &DetectSpec, min_start: Option) -> Vec { + if spec.is_empty() { + return Vec::new(); + } + // Resolve symlinks in the install dir once per scan: a game reached through a symlinked + // library folder would otherwise never prefix-match its own canonical image path. + let dir = spec + .install_dir + .as_deref() + .map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf())); + let exe = spec + .exe + .as_deref() + .map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf())); + let steam_tok = spec.steam_appid.map(|id| format!("AppId={id}")); + + let Ok(entries) = std::fs::read_dir(&self.root) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for e in entries.flatten() { + let name = e.file_name(); + let Some(pid) = name.to_str().and_then(|s| s.parse::().ok()) else { + continue; // not a pid dir + }; + let dir_path = e.path(); + if let Some(uid) = self.uid { + let owned = std::fs::metadata(&dir_path) + .map(|m| { + use std::os::unix::fs::MetadataExt; + m.uid() == uid + }) + .unwrap_or(false); + if !owned { + continue; + } + } + let Some(start_ticks) = self.start_ticks(&dir_path) else { + continue; // vanished mid-scan, or an unparseable stat + }; + if let Some(min) = min_start { + let started = start_ticks as f64 / self.ticks_per_sec; + if started + START_SLACK_SECS < min { + continue; // predates this launch — never ours (rule 1) + } + } + if self.matches( + &dir_path, + spec, + steam_tok.as_deref(), + exe.as_deref(), + dir.as_deref(), + ) { + out.push(ProcRef { + pid, + start: start_ticks, + }); + } + } + out + } + + /// Which of `procs` are still the same live processes — pid present **and** start time unchanged, + /// so a recycled pid is never reported alive (rule 2). + pub fn alive(&self, procs: &[ProcRef]) -> Vec { + procs + .iter() + .copied() + .filter(|p| self.start_ticks(&self.root.join(p.pid.to_string())) == Some(p.start)) + .collect() + } + + /// Does this process match any of the spec's signals? + fn matches( + &self, + dir_path: &Path, + spec: &DetectSpec, + steam_tok: Option<&str>, + exe: Option<&Path>, + install_dir: Option<&Path>, + ) -> bool { + // The process's own image, resolved through /proc//exe. Absent for a kernel thread or a + // process whose binary was replaced. + let image = std::fs::read_link(dir_path.join("exe")).ok(); + if let Some(want) = exe { + if image.as_deref() == Some(want) { + return true; + } + } + if let Some(dir) = install_dir { + if image.as_deref().is_some_and(|i| i.starts_with(dir)) { + return true; + } + } + + // The command line covers what the image can't: a Proton/Wine title's image is the *runtime* + // (`…/proton`, `wine64-preloader`), and the game only appears as an argument; Steam's launch + // reaper is likewise identified by its argv, not its binary. + let cmdline = read_capped(&dir_path.join("cmdline")); + if let Some(cmdline) = cmdline.as_deref() { + if let Some(tok) = steam_tok { + // Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and + // Steam's own (non-reaper) helper steps aren't mistaken for the game. + let mut launch = false; + let mut appid = false; + for arg in cmdline.split(|&b| b == 0) { + if arg == b"SteamLaunch" { + launch = true; + } else if arg == tok.as_bytes() { + appid = true; + } + } + if launch && appid { + return true; + } + } + if let Some(dir) = install_dir { + // Require a path separator after the directory, so an install dir of `/games/x` is + // not satisfied by an unrelated `/games/xyz/…` argument. (The image-path check above + // needs no such care — `Path::starts_with` already compares whole components.) + let needle = dir.as_os_str().as_encoded_bytes(); + let under_dir = |arg: &[u8]| { + arg.strip_prefix(needle) + .is_some_and(|rest| rest.first() == Some(&b'/')) + }; + if cmdline.split(|&b| b == 0).any(under_dir) { + return true; + } + } + if let Some(want) = exe { + let needle = want.as_os_str().as_encoded_bytes(); + if cmdline.split(|&b| b == 0).any(|arg| arg == needle) { + return true; + } + } + } + + // Env markers last: reading another process's environment is the most invasive of these + // reads, so it only happens for a spec that actually asked for one. The contents are matched + // and discarded — never logged. + if let Some(marker) = &spec.env_marker { + if let Some(env) = read_capped(&dir_path.join("environ")) { + let want: Vec = match &marker.value { + Some(v) => format!("{}={v}", marker.key).into_bytes(), + None => format!("{}=", marker.key).into_bytes(), + }; + let hit = env.split(|&b| b == 0).any(|kv| match marker.value { + // An exact `KEY=VALUE` entry. + Some(_) => kv == want.as_slice(), + // Presence of the key with any value. + None => kv.starts_with(want.as_slice()), + }); + if hit { + return true; + } + } + } + false + } + + /// `/proc//stat` field 22 (`starttime`). The `comm` field is parenthesized and may itself + /// contain spaces and parentheses, so fields are counted from the **last** `)`: the token right + /// after it is field 3 (`state`), which puts `starttime` at index 19 of the remainder. + fn start_ticks(&self, dir_path: &Path) -> Option { + let stat = std::fs::read_to_string(dir_path.join("stat")).ok()?; + let tail = &stat[stat.rfind(')')? + 1..]; + tail.split_whitespace().nth(19)?.parse().ok() + } +} + +/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished +/// or the file is unreadable — both routine during a scan. +fn read_capped(path: &Path) -> Option> { + use std::io::Read; + let file = std::fs::File::open(path).ok()?; + let mut buf = Vec::new(); + file.take(MAX_PROC_BLOB).read_to_end(&mut buf).ok()?; + Some(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::library::DetectSpec; + + /// Build a fake `/proc` tree. Each process gets a `stat` (with a deliberately nasty `comm`), and + /// optionally a `cmdline`, an `environ`, and an `exe` symlink. + struct FakeProc { + pid: u32, + start: u64, + exe: Option, + cmdline: Vec<&'static str>, + environ: Vec<&'static str>, + } + + impl FakeProc { + fn new(pid: u32, start: u64) -> Self { + Self { + pid, + start, + exe: None, + cmdline: Vec::new(), + environ: Vec::new(), + } + } + fn exe(mut self, p: impl Into) -> Self { + self.exe = Some(p.into()); + self + } + fn cmdline(mut self, args: &[&'static str]) -> Self { + self.cmdline = args.to_vec(); + self + } + fn environ(mut self, kvs: &[&'static str]) -> Self { + self.environ = kvs.to_vec(); + self + } + } + + fn fake_proc_root(uptime: f64, procs: &[FakeProc]) -> tempfile::TempDir { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("uptime"), format!("{uptime} 1000.0\n")).unwrap(); + // Non-pid entries must be skipped, not parsed. + std::fs::create_dir_all(td.path().join("self")).unwrap(); + std::fs::write(td.path().join("cmdline"), b"").unwrap(); + for p in procs { + let dir = td.path().join(p.pid.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + // `stat` is `pid (comm) state ppid … starttime …`, with starttime as field 22. Counting + // from the last ')', index 0 is `state` (field 3), so starttime lands at index 19. The + // comm is hostile on purpose — a space and a close-paren inside the parens, which is + // exactly what naive field-splitting gets wrong. + let mut tail = vec!["0".to_string(); 20]; + tail[0] = "S".to_string(); // field 3 + tail[19] = p.start.to_string(); // field 22 + std::fs::write( + dir.join("stat"), + format!("{} (evil ) name) {}\n", p.pid, tail.join(" ")), + ) + .unwrap(); + if !p.cmdline.is_empty() { + let mut blob = Vec::new(); + for a in &p.cmdline { + blob.extend_from_slice(a.as_bytes()); + blob.push(0); + } + std::fs::write(dir.join("cmdline"), blob).unwrap(); + } + if !p.environ.is_empty() { + let mut blob = Vec::new(); + for a in &p.environ { + blob.extend_from_slice(a.as_bytes()); + blob.push(0); + } + std::fs::write(dir.join("environ"), blob).unwrap(); + } + if let Some(exe) = &p.exe { + std::os::unix::fs::symlink(exe, dir.join("exe")).unwrap(); + } + } + td + } + + fn scanner(root: &Path) -> Scanner { + Scanner { + root: root.to_path_buf(), + uid: None, // the fixture's owner is the test user; don't couple the test to it + ticks_per_sec: 100.0, + } + } + + fn pids(mut v: Vec) -> Vec { + v.sort_by_key(|p| p.pid); + v.into_iter().map(|p| p.pid).collect() + } + + #[test] + fn empty_spec_matches_nothing() { + let td = fake_proc_root(100.0, &[FakeProc::new(1, 100).exe("/games/x/run")]); + let s = scanner(td.path()); + assert!(s.find(&DetectSpec::default(), None).is_empty()); + } + + #[test] + fn matches_exact_exe_and_install_dir() { + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(10, 50_000).exe("/games/hades/Hades"), + FakeProc::new(11, 50_000).exe("/games/hades/tools/crash.bin"), + FakeProc::new(12, 50_000).exe("/usr/bin/firefox"), + ], + ); + let s = scanner(td.path()); + // Exact exe → only that process. + assert_eq!( + pids(s.find(&DetectSpec::exe("/games/hades/Hades"), None)), + vec![10] + ); + // Install dir → every process running out of the game's tree, but nothing outside it. + assert_eq!( + pids(s.find(&DetectSpec::dir("/games/hades"), None)), + vec![10, 11] + ); + } + + #[test] + fn install_dir_does_not_match_a_sibling_with_the_same_prefix() { + // `/games/x` must not adopt a process running out of `/games/xyz` — a real hazard when one + // library folder's name is a prefix of another's. + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(90, 50_000).exe("/games/xyz/other"), + FakeProc::new(91, 50_000).cmdline(&["wrapper", "/games/xyz/other"]), + ], + ); + let s = scanner(td.path()); + assert!(s.find(&DetectSpec::dir("/games/x"), None).is_empty()); + // The genuine directory still matches, by image path and by argument. + assert_eq!( + pids(s.find(&DetectSpec::dir("/games/xyz"), None)), + vec![90, 91] + ); + } + + #[test] + fn matches_proton_style_game_only_in_the_cmdline() { + // The image is the Proton runtime; the game appears only as an argument. + let td = fake_proc_root( + 1000.0, + &[FakeProc::new(20, 50_000) + .exe("/steam/runtime/proton") + .cmdline(&["proton", "waitforexitandrun", "/games/elden/eldenring.exe"])], + ); + let s = scanner(td.path()); + assert_eq!( + pids(s.find(&DetectSpec::dir("/games/elden"), None)), + vec![20] + ); + } + + #[test] + fn matches_steam_reaper_exactly() { + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(30, 50_000).cmdline(&[ + "reaper", + "SteamLaunch", + "AppId=570", + "--", + "dota", + ]), + // A different appid that shares a prefix must NOT match (AppId=57 vs 570). + FakeProc::new(31, 50_000).cmdline(&[ + "reaper", + "SteamLaunch", + "AppId=57", + "--", + "other", + ]), + // `SteamLaunch` without the appid, and the appid without `SteamLaunch`, are both no. + FakeProc::new(32, 50_000).cmdline(&["reaper", "SteamLaunch", "--", "shader"]), + FakeProc::new(33, 50_000).cmdline(&["something", "AppId=570"]), + ], + ); + let s = scanner(td.path()); + assert_eq!(pids(s.find(&DetectSpec::steam(570), None)), vec![30]); + assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]); + } + + #[test] + fn matches_env_marker_by_exact_value_or_presence() { + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(40, 50_000).environ(&["HOME=/home/u", "HEROIC_APP_NAME=Quail"]), + FakeProc::new(41, 50_000).environ(&["HEROIC_APP_NAME=OtherGame"]), + FakeProc::new(42, 50_000).environ(&["HOME=/home/u"]), + ], + ); + let s = scanner(td.path()); + let exact = DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".to_string())); + assert_eq!(pids(s.find(&exact, None)), vec![40]); + // Presence-only matches any value, but still nothing that lacks the key. + let any = DetectSpec::default().with_env("HEROIC_APP_NAME", None); + assert_eq!(pids(s.find(&any, None)), vec![40, 41]); + } + + #[test] + fn never_adopts_a_process_that_predates_the_launch() { + // Uptime 1000 s; the launch happened at t=900. pid 50 started at t=500 (a copy the player + // already had open), pid 51 at t=950 (ours). + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(50, 50_000).exe("/games/hades/Hades"), + FakeProc::new(51, 95_000).exe("/games/hades/Hades"), + ], + ); + let s = scanner(td.path()); + let spec = DetectSpec::dir("/games/hades"); + assert_eq!(pids(s.find(&spec, Some(900.0))), vec![51]); + // Without the filter both are visible — proving the filter is what excluded it. + assert_eq!(pids(s.find(&spec, None)), vec![50, 51]); + } + + #[test] + fn start_slack_tolerates_tick_granularity() { + // Started a hair (0.5 s) BEFORE the recorded launch instant: still ours. + let td = fake_proc_root(1000.0, &[FakeProc::new(60, 89_950).exe("/games/x/run")]); + let s = scanner(td.path()); + assert_eq!( + pids(s.find(&DetectSpec::dir("/games/x"), Some(900.0))), + vec![60] + ); + // A full minute early is not. + assert!(s.find(&DetectSpec::dir("/games/x"), Some(960.0)).is_empty()); + } + + #[test] + fn alive_rejects_a_recycled_pid() { + let td = fake_proc_root(1000.0, &[FakeProc::new(70, 50_000).exe("/games/x/run")]); + let s = scanner(td.path()); + let same = ProcRef { + pid: 70, + start: 50_000, + }; + let recycled = ProcRef { + pid: 70, + start: 99_999, + }; + let gone = ProcRef { + pid: 71, + start: 50_000, + }; + assert_eq!(s.alive(&[same]), vec![same]); + assert!(s.alive(&[recycled]).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//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.now_stamp().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.now_stamp().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)]); + let s = scanner(td.path()); + assert_eq!(s.now_stamp(), Some(4321.5)); + // The comm field contains a space and a ')' — start time must still parse. + assert_eq!(s.start_ticks(&td.path().join("80")), Some(1234)); + } +} diff --git a/crates/punktfunk-host/src/procscan/windows.rs b/crates/punktfunk-host/src/procscan/windows.rs new file mode 100644 index 00000000..5c9b773d --- /dev/null +++ b/crates/punktfunk-host/src/procscan/windows.rs @@ -0,0 +1,327 @@ +//! The Windows matcher: a Toolhelp snapshot plus each process's full image path. +//! +//! Contract and rules live in [`super`]. Two differences from the Linux side shape everything here: +//! +//! * **The host is SYSTEM**, so it can open essentially any process. That makes rule 1 (never adopt a +//! process that predates the launch) load-bearing rather than a nicety — without it a scan would +//! happily adopt a copy of the game the player started an hour ago, and a session ending could kill +//! it. +//! * **There is no launch reaper and no readable environment.** Steam's `SteamLaunch AppId=` argv +//! trick is Linux-only, and reading another process's environment block on Windows needs +//! `NtQueryInformationProcess` against an undocumented layout — not something to build a +//! game-killing decision on. So the recipe is the image path: exactly the game's executable, or any +//! executable under its install directory. Every Windows store the library scans reports one or +//! both ([`crate::library::DetectSpec`]). + +use super::{ProcRef, START_SLACK_SECS}; +use crate::library::DetectSpec; +use std::path::{Path, PathBuf}; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, + PROCESS_QUERY_LIMITED_INFORMATION, +}; + +/// 100-nanosecond `FILETIME` ticks per second — the unit every Win32 time API here reports in. +const FILETIME_TICKS_PER_SEC: f64 = 10_000_000.0; + +/// Image-path buffer, in UTF-16 units. Deliberately well past `MAX_PATH` (260): a game installed under +/// a deep library folder can exceed it, and a truncated path would silently fail to match. A path +/// longer than this makes `QueryFullProcessImageNameW` fail, which skips that process — the same +/// outcome as any other unreadable one. +const IMAGE_PATH_MAX: usize = 4096; + +/// A process scanner over the live system. Unit (unlike the Linux one, which is parameterized for its +/// fixture tests): there is no way to hand Win32 a fake process table, so the Windows matching logic +/// is tested through its pure helpers ([`under_dir`], [`same_path`]) plus a live-process test. +pub struct Scanner; + +impl Scanner { + pub fn system() -> Self { + Self + } + + /// Seconds on the Windows process-start timeline — the `FILETIME` epoch, which is what + /// `GetProcessTimes` reports a creation time in. `None` if the clock could not be read. + /// + /// Derived from `SystemTime` rather than a Win32 call because both are the same UTC epoch offset + /// by a constant, and the only use is comparing against a creation time read moments later; a + /// clock step between the two would need to exceed [`START_SLACK_SECS`] to matter. + pub fn now_stamp(&self) -> Option { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + /// Seconds between the `FILETIME` epoch (1601-01-01) and the Unix epoch. + const FILETIME_EPOCH_OFFSET_SECS: f64 = 11_644_473_600.0; + Some(now.as_secs_f64() + FILETIME_EPOCH_OFFSET_SECS) + } + + /// Every process matching any of `spec`'s signals, restricted to those that started at or after + /// `min_start` (seconds on the [`Self::now_stamp`] timeline; `None` disables the filter). + pub fn find(&self, spec: &DetectSpec, min_start: Option) -> Vec { + // Only the path-based signals exist on Windows: no reaper argv, no readable environment. A spec + // carrying neither must match *nothing* — falling through would scan on an empty predicate. + if spec.exe.is_none() && spec.install_dir.is_none() { + return Vec::new(); + } + let exe = spec + .exe + .as_deref() + .map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf())); + let dir = spec + .install_dir + .as_deref() + .map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf())); + + let mut out = Vec::new(); + for pid in snapshot_pids() { + // pid 0 (System Idle) and 4 (System) are neither openable nor ever a game. + if pid <= 4 { + continue; + } + let Some((start, image)) = process_start_and_image(pid) else { + continue; // exited mid-scan, or a protected process we can't query — never a game + }; + if let Some(min) = min_start { + if start as f64 / FILETIME_TICKS_PER_SEC + START_SLACK_SECS < min { + continue; // predates this launch — never ours (rule 1) + } + } + let hit = exe.as_deref().is_some_and(|w| same_path(&image, w)) + || dir.as_deref().is_some_and(|d| under_dir(&image, d)); + if hit { + out.push(ProcRef { pid, start }); + } + } + out + } + + /// Which of `procs` are still the same live processes — pid present **and** creation time + /// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so + /// this check is what makes signalling a remembered pid safe at all. + pub fn alive(&self, procs: &[ProcRef]) -> Vec { + procs + .iter() + .copied() + .filter(|p| process_start_and_image(p.pid).is_some_and(|(start, _)| start == p.start)) + .collect() + } +} + +/// An out-of-band opinion on whether the game is still running, used only to **veto** declaring it +/// gone (see [`super::running_hint`]). +/// +/// For a Steam title: Steam records a per-app `Running` flag in the logged-in user's hive. That flag is +/// not trustworthy on its own — Steam sets it around updates and DLC installs too, and leaves it stale +/// if it crashes — but as a veto it is exactly right. If the matcher can't see the game (its executable +/// lives outside the install dir the manifest named, or a nested launcher owns it) while Steam still +/// says the app is running, ending the session would be a false positive the player feels immediately. +/// Erring towards "still running" only ever leaves a stream up. +/// +/// Reads every **loaded** user hive under `HKEY_USERS` rather than resolving the interactive user's SID +/// through `WTSQueryUserToken`: only logged-in users' hives are loaded, which is the same set, and it +/// avoids a token dance for a best-effort hint. +pub fn steam_running_hint(appid: u32) -> Option { + use winreg::enums::{HKEY_USERS, KEY_READ}; + use winreg::RegKey; + + let users = RegKey::predef(HKEY_USERS); + let mut saw_key = false; + for sid in users.enum_keys().flatten() { + // The `…_Classes` companion hives hold no Steam state. + if sid.ends_with("_Classes") { + continue; + } + let path = format!("{sid}\\Software\\Valve\\Steam\\Apps\\{appid}"); + let Ok(app) = users.open_subkey_with_flags(&path, KEY_READ) else { + continue; + }; + saw_key = true; + if app.get_value::("Running").unwrap_or(0) != 0 { + return Some(true); + } + } + // A key that exists and says 0 is a real "not running"; no key at all means Steam has never run + // this app on this box, which is no opinion rather than a negative one. + saw_key.then_some(false) +} + +/// Every pid in a Toolhelp snapshot. Mirrors the walk in [`crate::detect`]'s Windows facts, which +/// wants basenames rather than pids. +fn snapshot_pids() -> Vec { + let mut out = Vec::new(); + // SAFETY: the canonical Toolhelp walk. `entry` is zeroed with `dwSize` set before the first read + // (the 260-wide `szExeFile` array has no usable `Default`), and the snapshot handle is closed on + // every exit path. + unsafe { + let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else { + return out; + }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..std::mem::zeroed() + }; + if Process32FirstW(snap, &mut entry).is_ok() { + loop { + out.push(entry.th32ProcessID); + if Process32NextW(snap, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snap); + } + out +} + +/// A process's creation time (`FILETIME` ticks) and full image path. +/// +/// Opened with `PROCESS_QUERY_LIMITED_INFORMATION` — the least privilege that answers both questions, +/// and the one that works against elevated and protected-ish processes without asking for the right to +/// read their memory. `None` for anything that can't be opened or has already exited, which is the +/// routine case during a scan and never a reason to log. +fn process_start_and_image(pid: u32) -> Option<(u64, PathBuf)> { + // SAFETY: `OpenProcess` yields an owned handle only on `Ok`; it is closed exactly once below on + // every path. `GetProcessTimes` writes four `FILETIME`s we fully own; `QueryFullProcessImageNameW` + // writes into `buf` and updates `len` in place, bounded by the `len` we pass in (buf.len()). + unsafe { + let handle: HANDLE = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; + let mut created = Default::default(); + let (mut exit, mut kernel, mut user) = + (Default::default(), Default::default(), Default::default()); + let times = + GetProcessTimes(handle, &mut created, &mut exit, &mut kernel, &mut user).is_ok(); + + let mut buf = [0u16; IMAGE_PATH_MAX]; + let mut len = buf.len() as u32; + // `PROCESS_NAME_FORMAT(0)` is `PROCESS_NAME_WIN32` — a drive-letter path, which is what the + // library's store-derived paths look like (the alternative, `PROCESS_NAME_NATIVE`, yields + // `\Device\HarddiskVolume…` and would never compare equal to one). + let named = QueryFullProcessImageNameW( + handle, + PROCESS_NAME_FORMAT(0), + windows::core::PWSTR(buf.as_mut_ptr()), + &mut len, + ) + .is_ok(); + let _ = CloseHandle(handle); + + if !times || !named { + return None; + } + let start = ((created.dwHighDateTime as u64) << 32) | created.dwLowDateTime as u64; + let image = PathBuf::from(String::from_utf16_lossy(&buf[..len as usize])); + Some((start, image)) + } +} + +/// Is `image` the same file as `want`? Windows paths are case-insensitive, and the scanner compares a +/// canonicalized store-derived path against a live image path, so the comparison must be too. +fn same_path(image: &Path, want: &Path) -> bool { + eq_ignore_case(image, want) +} + +/// Does `image` live under `dir`? Case-insensitive, and requires a separator after the directory so an +/// install dir of `…\Games\X` is not satisfied by `…\Games\XY\game.exe`. +fn under_dir(image: &Path, dir: &Path) -> bool { + let (i, d) = (wide_lower(image), wide_lower(dir)); + let Some(rest) = i.strip_prefix(d.as_str()) else { + return false; + }; + rest.starts_with('\\') || rest.starts_with('/') +} + +fn eq_ignore_case(a: &Path, b: &Path) -> bool { + wide_lower(a) == wide_lower(b) +} + +/// Lowercase a path for comparison, normalizing the `\\?\` prefix `canonicalize` prepends (a +/// store-derived path canonicalizes to the verbatim form while a live image path does not, and the two +/// must still compare equal). +fn wide_lower(p: &Path) -> String { + let s = p.to_string_lossy(); + let s = s.strip_prefix(r"\\?\").unwrap_or(&s); + s.to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_dir_match_is_case_insensitive_and_separator_aware() { + let dir = Path::new(r"C:\Games\Hades"); + assert!(under_dir(Path::new(r"c:\games\hades\Hades.exe"), dir)); + assert!(under_dir(Path::new(r"C:\Games\Hades\bin\crash.exe"), dir)); + // A sibling whose name merely starts with the same text is not under it. + assert!(!under_dir(Path::new(r"C:\Games\HadesII\game.exe"), dir)); + // The directory itself is not "under" itself (no process image is a bare directory anyway). + assert!(!under_dir(dir, dir)); + assert!(!under_dir(Path::new(r"D:\Games\Hades\Hades.exe"), dir)); + } + + #[test] + fn exe_match_is_case_insensitive_and_ignores_the_verbatim_prefix() { + // `canonicalize` yields the `\\?\` verbatim form; a live image path never has it. + assert!(same_path( + Path::new(r"C:\Games\Hades\Hades.exe"), + Path::new(r"\\?\c:\games\hades\hades.exe") + )); + assert!(!same_path( + Path::new(r"C:\Games\Hades\Hades.exe"), + Path::new(r"C:\Games\Hades\Other.exe") + )); + } + + #[test] + fn a_spec_with_no_path_signal_matches_nothing() { + // No reaper and no environment reading on Windows: an appid-only or env-only spec has nothing + // to match here, and must not fall through to matching everything. + let s = Scanner::system(); + assert!(s.find(&DetectSpec::steam(570), None).is_empty()); + assert!(s + .find( + &DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".into())), + None + ) + .is_empty()); + assert!(s.find(&DetectSpec::default(), None).is_empty()); + } + + /// The live check: scan the real process table for this test process itself, which is the only way + /// to notice a wrong `PROCESSENTRY32W` size, a failing `OpenProcess` access mask, or creation + /// times read from the wrong `FILETIME` half. + #[test] + fn finds_this_process_by_its_own_image_path() { + let me = std::env::current_exe().expect("current exe"); + let s = Scanner::system(); + let pid = std::process::id(); + let found = s.find(&DetectSpec::exe(&me), None); + assert!( + found.iter().any(|p| p.pid == pid), + "scanning the real process table did not find this test process ({pid}) as {}", + me.display() + ); + // Its own directory finds it too. + let dir = me.parent().expect("exe has a parent"); + assert!(s + .find(&DetectSpec::dir(dir), None) + .iter() + .any(|p| p.pid == pid)); + // The same process re-verifies as alive by (pid, creation time)… + let mine: Vec = found.into_iter().filter(|p| p.pid == pid).collect(); + assert_eq!(s.alive(&mine), mine); + // …and a wrong creation time for the same pid does not. + let recycled = vec![ProcRef { + pid, + start: mine[0].start ^ 0xFFFF, + }]; + assert!(s.alive(&recycled).is_empty()); + // A launch reference in the future excludes it — rule 1, against a real creation time. + let future = s.now_stamp().unwrap() + 3_600.0; + assert!(s.find(&DetectSpec::exe(&me), Some(future)).is_empty()); + } +} diff --git a/crates/punktfunk-host/src/windows/game_term.rs b/crates/punktfunk-host/src/windows/game_term.rs new file mode 100644 index 00000000..dcd95d7e --- /dev/null +++ b/crates/punktfunk-host/src/windows/game_term.rs @@ -0,0 +1,169 @@ +//! Asking a game to close on Windows, and killing it if it won't. +//! +//! The two halves of [`crate::gamelease`]'s termination ladder that need Win32: post `WM_CLOSE` to the +//! game's windows, then `TerminateProcess` whatever ignored it. Kept out of `procscan` (read-only by +//! construction) and out of `gamelease` (platform-neutral orchestration). +//! +//! ### Why the desktop dance +//! +//! The polite half is the reason this module is not three lines. `EnumWindows` enumerates the windows +//! of the **calling thread's desktop** — and the host runs as SYSTEM in session 0, whose desktop has +//! none of the interactive user's windows on it. Without first binding the thread to the input desktop +//! the enumeration comes back empty and the ladder silently degrades to killing every game outright, +//! which is exactly the unsaved-progress outcome the grace window exists to avoid. +//! +//! Same `OpenInputDesktop`/`SetThreadDesktop` pattern the input injector uses for `SendInput` +//! (`pf-inject`'s `sendinput.rs`), for the same reason: a UAC prompt, the lock screen or +//! Ctrl-Alt-Del swaps the input desktop out from under us. + +use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM, WPARAM}; +use windows::Win32::System::StationsAndDesktops::{ + CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, + HDESK, +}; +use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}; +use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindowThreadProcessId, IsWindowVisible, PostMessageW, WM_CLOSE, +}; + +/// Exit code reported for a game the host killed. Arbitrary but non-zero, so anything reading it can +/// tell it apart from a clean quit. +const KILLED_EXIT_CODE: u32 = 1; + +/// `GENERIC_ALL` for a desktop open. The `windows` crate models desktop rights as their own type, so +/// the generic right isn't reachable through it — the same local const the input backends define +/// (`pf-inject`'s `sendinput.rs` / `pointer_windows.rs`). +const DESKTOP_GENERIC_ALL: u32 = 0x1000_0000; + +/// Binds the calling thread to the desktop currently receiving input, for as long as it is held. +/// +/// The thread this runs on is dedicated and short-lived (`pf1-gameterm`), so the previous desktop is +/// not restored — only the handle is released. +struct InputDesktop(HDESK); + +impl InputDesktop { + /// `None` when the input desktop can't be opened or bound (an unprivileged host, or a secure + /// desktop we aren't allowed onto). Callers degrade rather than fail: without it the polite pass + /// finds no windows, and the kill pass still works. + fn attach() -> Option { + // SAFETY: FFI calls taking by-value args only (constant desktop flags, a bool, an access + // mask). `OpenInputDesktop` yields an owned `HDESK` only on `Ok`; it is then either installed + // on this thread and owned by the returned guard (closed exactly once in `Drop`), or closed + // here on the failure path. `SetThreadDesktop` rebinds only the calling thread — which owns + // this guard — and succeeds only when that thread has no windows or hooks, true of the fresh + // termination thread. + unsafe { + let h = OpenInputDesktop( + DESKTOP_CONTROL_FLAGS(0), + false, + DESKTOP_ACCESS_FLAGS(DESKTOP_GENERIC_ALL), + ) + .ok()?; + if SetThreadDesktop(h).is_ok() { + Some(Self(h)) + } else { + let _ = CloseDesktop(h); + None + } + } + } +} + +impl Drop for InputDesktop { + fn drop(&mut self) { + // SAFETY: `self.0` is the handle this guard owns and has not closed; `CloseDesktop` runs once + // here with no later use. + unsafe { + let _ = CloseDesktop(self.0); + } + } +} + +/// What the enumeration callback needs, passed through `LPARAM`. +/// +/// Owns its pid list rather than borrowing: the value round-trips through a raw pointer, and a +/// borrowed lifetime there is a subtlety with no upside for one small allocation per termination. +struct CloseCtx { + pids: Vec, + posted: usize, +} + +/// Ask every visible top-level window belonging to `pids` to close, the way clicking its X would — +/// so a game runs its own shutdown path and gets the chance to save. Returns how many windows were +/// asked. +/// +/// Zero is a perfectly ordinary answer: a game may be mid-load with no window yet, or the host may not +/// have reached the input desktop. The caller's job is to wait and then insist, not to treat this as +/// success. +pub fn request_close(pids: &[u32]) -> usize { + if pids.is_empty() { + return 0; + } + // Without the input desktop this enumerates session 0 and finds nothing (see the module docs), so + // failing to attach means skipping the polite pass rather than doing it uselessly. Held for the + // whole enumeration. + let Some(_desktop) = InputDesktop::attach() else { + tracing::debug!( + "could not bind to the input desktop — skipping the polite close and going straight to \ + the kill pass" + ); + return 0; + }; + let mut ctx = CloseCtx { + pids: pids.to_vec(), + posted: 0, + }; + // SAFETY: `EnumWindows` invokes `enum_close` synchronously for each top-level window on this + // thread's desktop and returns before this frame exits, so the `&mut ctx` pointer it carries in + // the `LPARAM` stays valid for the whole call and is not aliased (nothing else touches `ctx` + // while the enumeration runs). + unsafe { + let _ = EnumWindows(Some(enum_close), LPARAM(&mut ctx as *mut CloseCtx as isize)); + } + ctx.posted +} + +/// `EnumWindows` callback: post `WM_CLOSE` to each visible window owned by one of the target pids. +/// +/// Visible top-level windows only — a game's message-only and tool windows would swallow the close, and +/// posting to them achieves nothing while making the count meaningless. +unsafe extern "system" fn enum_close(hwnd: HWND, lparam: LPARAM) -> windows::core::BOOL { + // SAFETY: `lparam` is the `&mut CloseCtx` `request_close` passed in, valid for the whole + // enumeration; the callback is invoked synchronously on the same thread, so this is the only live + // reference to it. + let ctx = unsafe { &mut *(lparam.0 as *mut CloseCtx) }; + let mut pid = 0u32; + // SAFETY: `hwnd` is the window the enumeration handed us; `pid` is a live local we own. + unsafe { + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if ctx.pids.contains(&pid) && IsWindowVisible(hwnd).as_bool() { + // Posted, not sent: a `SendMessage` would block this thread on a hung game's message pump. + if PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)).is_ok() { + ctx.posted += 1; + } + } + } + true.into() // keep enumerating — a game can own several windows +} + +/// Kill `pids` outright. Returns how many were terminated. +/// +/// The caller must have re-verified each pid against its recorded start time immediately before +/// calling this ([`crate::procscan::Scanner::alive`]) — Windows recycles pids briskly, and this call is +/// unforgiving about being pointed at the wrong one. +pub fn kill(pids: &[u32]) -> usize { + let mut killed = 0; + for &pid in pids { + // SAFETY: `OpenProcess` yields an owned handle only on `Ok`, which is closed exactly once + // below; `TerminateProcess` takes it by value plus a plain exit code. + unsafe { + if let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, pid) { + if TerminateProcess(h, KILLED_EXIT_CODE).is_ok() { + killed += 1; + } + let _ = CloseHandle(h); + } + } + } + killed +}