Files
punktfunk/crates/punktfunk-host/src/procscan/linux.rs
T
enricobuehler d5857abd91
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m12s
ci / bench (push) Successful in 7m19s
apple / swift (push) Successful in 6m33s
ci / rust-arm64 (push) Successful in 10m0s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 9m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
arch / build-publish (push) Successful in 13m0s
deb / build-publish-host (push) Successful in 13m49s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m44s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 17m7s
deb / build-publish-client-arm64 (push) Successful in 10m24s
windows-host / package (push) Successful in 19m52s
windows-host / winget-source (push) Skipped
docker / build-push-arm64cross (push) Successful in 4m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m7s
ci / rust (push) Successful in 28m24s
apple / screenshots (push) Successful in 25m43s
fix(session/gamelease): a launcher handing off is not the game exiting
On glass (.41), launching any Steam title over the compat plane ended the
session ~7 s later, before the game had started:

  launched app  command=steam steam://rungameid/2379780  pid=12699
  watching the launched game  kind="child"
  the launched game is running  kind="child"  procs=0      <- 3 ms in, zero game procs
  the launched game exited  ->  session ended

Linux launches are `Child` leases: the host spawns the launcher itself. The
watcher counts "my child is alive" as the game running, so the lease left the
waiting phase on its FIRST poll — and the shim reclassification lives in that
phase, so it never got to run. When `steam` handed off and exited, that read as
the game exiting.

The design always called for the shim window ("never fires B from a shim exit")
and the code implements it; the lease had simply moved past where it applies. So
when the store gave us signals to recognize the real game by, a bare live child
no longer counts as the game until that window has passed. With no signals the
child is all we have and still counts immediately — custom commands are
unchanged.

Windows could not reproduce this: it holds no child, so its leases are always
`Matched`, and the whole .173 pass was structurally blind to it. Both planes on
Linux were affected — which is most of the audience for this feature.

Also fixes the live-process fixture, which was CI-red for an unrelated reason:
it copied `/bin/sleep` to a file named `game`, and modern coreutils (uutils on
Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary dispatching on argv[0]
— under any other name it exits instantly, leaving nothing in /proc and looking
exactly like a broken matcher. Verified by A/B: the same test fails identically
on a tree predating these changes.

.25 (Ubuntu 26.04): 305 passed, 0 failed.
2026-07-26 21:24:12 +02:00

601 lines
25 KiB
Rust

//! 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<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 — 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<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: 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))
.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;
}
}
// A bare executable name, the operator-supplied fallback. Case-insensitive because it is typed
// by hand and the cost of a case mismatch (the game is never recognized) far outweighs the
// cost of a case collision (two differently-cased binaries, both started since this launch).
if let Some(want) = spec.process_name.as_deref() {
let named = image
.as_deref()
.and_then(|i| i.file_name())
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case(want));
if named {
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<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: u64,
exe: Option<PathBuf>,
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<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.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]
);
}
/// The operator-supplied fallback ([`DetectSpec::process_name`]): the image's own file name and
/// nothing else — never a directory that happens to be called the same, and never a process whose
/// path merely contains the name.
#[test]
fn matches_a_bare_process_name_against_the_image_name_only() {
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(20, 50_000).exe("/opt/retroarch/bin/RetroArch"),
FakeProc::new(21, 50_000).exe("/usr/bin/retroarch-assets-helper"),
FakeProc::new(22, 50_000).exe("/home/p/retroarch/launcher.sh"),
],
);
let s = scanner(td.path());
let spec = DetectSpec {
process_name: Some("retroarch".into()),
..Default::default()
};
// Case-insensitive (the name is typed by hand), but a whole-name match: neither the
// longer-named helper nor the script living in a `retroarch/` directory qualifies.
assert_eq!(pids(s.find(&spec, None)), vec![20]);
}
#[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/<pid>/stat` field ordering,
/// the uid check, or the uptime clock being wrong on a real box.
#[test]
fn finds_a_real_process_it_just_started() {
// A real game's *binary* lives under its install dir, which is what makes the install-dir
// recipe work. So the stand-in must too: copy a long-running binary into the directory and run
// it from there. (A wrapper script that `exec`s something outside the directory would leave no
// trace of the directory in either the image path or the command line — worth knowing, and the
// reason a copied binary is the honest fixture here.)
//
// It has to keep the name `sleep`. Modern coreutils (uutils on Ubuntu 25.10+, busybox
// elsewhere) is a MULTI-CALL binary that dispatches on `argv[0]`: copied to any other name it
// prints "unknown program" and exits instantly, leaving nothing in `/proc` to find — which
// looks exactly like the matcher being broken. That cost a CI red, green on a distro with a
// standalone `sleep` and failing on one without.
let td = tempfile::tempdir().expect("tempdir");
let game = td.path().join("sleep");
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));
}
}