Files
punktfunk/crates/punktfunk-host/src/procscan.rs
T
enricobuehlerandClaude Fable 5 ed4e3d3ab6 feat(session): end-game/end-session lifetime on Windows
Phase 2. The lease, the settings, the events and the status surface were already
platform-neutral; what Windows lacked was a way to see processes and a way to ask
one to close.

`procscan` splits per-OS behind one contract. The Windows matcher is a Toolhelp
snapshot plus each process's full image path (`QueryFullProcessImageNameW`), with
creation times from `GetProcessTimes` enforcing the two rules the module exists
for: never adopt a process that predates the launch, never trust a bare pid. Both
matter more here than on Linux — the host is SYSTEM, so it can see and signal
everything, and Windows recycles pids briskly.

Path matching is case-insensitive and separator-aware, and normalizes the `\\?\`
prefix `canonicalize` adds, without which a store-derived path would never compare
equal to a live image path.

There is no reaper argv and no readable environment on Windows, so a spec carrying
only a Steam appid or an env marker matches nothing rather than falling through to
an empty predicate. Steam's appid instead feeds a **veto**: its per-app `Running`
flag in the user's hive can't be trusted to say a game IS running (Steam sets it
around updates too, and leaves it stale after a crash), but it is exactly right for
refusing to declare one gone. If the matcher can't see a game whose exe sits
outside its manifest's install dir, ending the session would be a false positive
the player feels immediately; honoring the veto only ever leaves a stream up.

Termination asks before it insists, which on Windows needs a detail that fails
silently if missed: `EnumWindows` only sees the calling thread's desktop, and the
host's is session 0, which holds none of the user's windows. So the terminating
thread binds to the input desktop first (the pattern `pf-inject`'s `sendinput.rs`
uses), posts `WM_CLOSE` to the game's visible top-level windows, waits, and only
then calls `TerminateProcess` on freshly re-verified pids. Without the desktop
bind the polite pass finds nothing and every game dies unsaved.

Job Objects, planned as WP2.3, are deferred with the reasoning in the design doc:
every Windows launch either hands off through a launcher or the shell — where a job
would wrap a shim that exits immediately — or is a direct exe whose path we already
know, and wiring one in would touch the `CreateProcessAsUserW` token path the UAC
and secure-desktop work depends on.

Also: the watcher's steady-state poll now re-verifies known pids and only re-scans
the whole table once they all vanish (which still catches a game that re-execs).
On Windows a full scan is an `OpenProcess` per process on the box, so this is the
difference between a negligible and a noticeable poll.

Gates: Linux .21 check/clippy/fmt/293 tests green; Windows .133 check + clippy
--all-targets clean, and the 4 procscan tests pass — including the live one that
finds the test process in the real process table and rejects a wrong creation time.
On-glass Windows (Steam/Epic/GOG/Xbox titles, the unsaved-progress check) still
owed; it needs a box with games on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00

93 lines
4.2 KiB
Rust

//! Finding a launched game's processes, from the signals its store gave us
//! ([`crate::library::DetectSpec`]).
//!
//! Read-only by construction: the host enumerates processes and reads metadata it already has
//! permission to see. No ptrace, no injection, no handles held open. On Linux it looks only at
//! processes owned by its **own uid**; on Windows it runs as SYSTEM and therefore *can* see
//! everything, which makes the two rules below the load-bearing part rather than an afterthought.
//!
//! ### The two rules
//!
//! 1. **Never adopt a process that predates the launch.** A player may already have the game open
//! when a session starts; treating that instance as "this session's game" would let a session end
//! kill a process it never started. Candidates are filtered by start time against
//! [`launch_stamp`], taken before anything spawns.
//! 2. **Never trust a bare pid.** Pids are recycled — aggressively so on Windows — and a lease can
//! outlive its game by a grace window, so every remembered process carries its start time and is
//! re-verified against it ([`Scanner::alive`]) before it is counted as running, or signalled.
//!
//! ### Per-OS implementations
//!
//! The two have nothing in common beyond that contract, so they are separate modules presenting the
//! same [`Scanner`] surface: `/proc` on Linux, a Toolhelp snapshot on Windows. Everything above the
//! scanner ([`crate::gamelease`]) is platform-neutral.
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::Scanner;
#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub use windows::Scanner;
/// A process the matcher adopted: its pid plus a start stamp that pins that pid to *this* process,
/// so a recycled pid can never be mistaken for it.
///
/// `start` is opaque and only ever compared for equality against a later read of the same pid — its
/// units differ per platform (clock ticks since boot on Linux, a creation `FILETIME` on Windows).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProcRef {
pub pid: u32,
pub start: u64,
}
/// 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;
/// 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<f64> {
#[cfg(any(target_os = "linux", windows))]
{
Scanner::system().now_stamp()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
None
}
}
/// 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<bool> {
#[cfg(windows)]
{
spec.steam_appid.and_then(windows::steam_running_hint)
}
#[cfg(not(windows))]
{
let _ = spec;
None
}
}