Files
punktfunk/crates/punktfunk-host/src/windows/game_term.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

170 lines
7.7 KiB
Rust

//! 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<Self> {
// 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<u32>,
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
}