Files
punktfunk/crates/punktfunk-host/src/procscan.rs
T
enricobuehlerandClaude Fable 5 64c0ff96bc feat(session): bind a session's life to its game's, in both directions (Linux)
Two of the most-asked-for behaviors, and until now the host could only manage a
sliver of one of them: end the streaming session when the launched game exits,
and — when the operator asks — end the game when the session ends.

Ending the session on game exit already worked, but only for a Steam title under
a bare-spawn gamescope. Everywhere else the host spawned a launch and forgot it:
the pid was logged and dropped, so on KWin, Mutter, wlroots or gamescope-attach a
finished game left the client staring at a desktop. Ending a game was not
possible at all — there was no primitive for it.

The missing piece was never plumbing, it was knowledge: a launch says what to
run, not what the game looks like once a launcher has handed off. So each store
now also reports how to recognize its game (DetectSpec, previous commit's
subject matter, folded in here), procscan turns that into live pids on Linux, and
gamelease turns pids into a lifetime — with four kinds covering how the game got
started:

  nested    gamescope owns it; its display teardown ends the game
  child     the host spawned it, in its own process group
  matched   a launcher owns it; recognized by its store's signals
  untracked nothing identifies it — both behaviors stay off, and the host
            says so once rather than guessing

A child that exits successfully within 5s was a launcher handing off, not the
game: the lease re-resolves to matched (or to untracked) instead of reporting an
exit. That single rule is what keeps steam://, epic:// and playnite:// launches
from ending a session the moment they start.

Ending a game is destructive, so three rules bound it. It is opt-in
(game_on_session_end defaults to keep). A drop is not a decision — `always`
waits out a reconnect window (5 min) that a returning client cancels by
reclaiming its own game, matched on fingerprint and title. And only ever this
session's game: a pid is adopted only if it started after the launch, so a copy
the player already had open is never touched, and every pid is re-verified
against its start time immediately before being signalled, so a recycled pid
never is. Termination asks first (SIGTERM to the group, 10s) and only then
insists.

Also here, because they are the same decision seen from other angles:

- A management stop is now a deliberate stop. `DELETE /session` sets quit before
  stop, so it behaves like a client pressing Stop — the display skips its
  keep-alive linger instead of lingering for a session nobody is coming back to.
- `/status` reports what is running (games[]), including a game whose session has
  gone and which is waiting out its window, so the console can show it and offer
  `POST /game/end`.
- New game.running / game.exited events, filterable like every other kind, which
  is the whole polling loop of the community plugin this replaces.
- session_status::register takes a named struct: eleven positional arguments,
  half of them same-typed Arc<Atomic…>, is a transposition waiting to happen.

Gates on Linux (.21): check, clippy --all-targets, fmt, and 291 tests green.
Windows (Job Objects, Toolhelp matching) and the GameStream plane are phases 2
and 3; both are inert here, as is macOS, which has no launch path at all.

Design + plan: punktfunk-planning/design/session-game-lifetime{,-implementation-plan}.md

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

504 lines
20 KiB
Rust

//! Finding a launched game's processes on Linux, from the signals its store gave us
//! ([`crate::library::DetectSpec`]).
//!
//! Everything here reads `/proc` and nothing else — no ptrace, no injection, no handles kept. The
//! host only ever looks at processes owned by its **own uid**, and only ever *reads* metadata it
//! already has permission to see.
//!
//! Two rules keep this honest, and both matter more than the matching itself:
//!
//! 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
//! ([`Scanner::find`]'s `min_start`).
//! 2. **Never trust a bare pid.** Pids are recycled, 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 ever counted as running — or signalled.
use crate::library::DetectSpec;
use std::path::{Path, PathBuf};
/// A process the matcher adopted: its pid plus the start time that pins that pid to *this*
/// process, so a recycled pid can never be mistaken for it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProcRef {
pub pid: u32,
/// `/proc/<pid>/stat` field 22 — start time in clock ticks since boot. Unique per (pid, boot).
pub start_ticks: 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 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<u32>,
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, 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<f64> {
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<f64>) -> Vec<ProcRef> {
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::<u32>().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<ProcRef> {
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/<pid>/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 {
let needle = dir.as_os_str().as_encoded_bytes();
for arg in cmdline.split(|&b| b == 0) {
if arg.starts_with(needle) {
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<u8> = 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/<pid>/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<u64> {
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<Vec<u8>> {
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<PathBuf>,
cmdline: Vec<&'static str>,
environ: Vec<&'static str>,
}
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<PathBuf>) -> 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<ProcRef>) -> Vec<u32> {
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 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());
}
#[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));
}
}