Skip to main content

agentmux_procstats/
lib.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-process CPU and memory, read without elevation
5//! (SPEC_TOWER_TASK_MANAGER_PANE_2026_10_08.md §3, §4).
6//!
7//! One [`snapshot`] lists every process this user may see, with what the OS
8//! reports for it:
9//!
10//! | OS | Source | Other users' processes |
11//! |---|---|---|
12//! | Windows | one `NtQuerySystemInformation(SystemProcessInformation)` call | full (no per-process handle is opened) |
13//! | Linux | `/proc/<pid>/stat` + `status` | full, unless `/proc` is mounted `hidepid` |
14//! | macOS | `proc_listallpids` + `proc_pid_rusage` | name and parent only: CPU and memory are `None` |
15//!
16//! Nothing here asks for a privilege: no `SeDebugPrivilege`, no
17//! `task_for_pid`, no `CAP_SYS_PTRACE`. A value the OS won't give us is
18//! `None`, never 0.
19//!
20//! CPU is a cumulative time; [`RateMeter`] turns two samples into a rate.
21
22use std::collections::HashMap;
23use std::hash::Hash;
24use std::time::Instant;
25
26#[cfg(target_os = "linux")]
27mod linux;
28#[cfg(target_os = "linux")]
29use linux as os;
30
31#[cfg(target_os = "macos")]
32mod macos;
33#[cfg(target_os = "macos")]
34use macos as os;
35
36#[cfg(windows)]
37mod windows;
38#[cfg(windows)]
39use windows as os;
40
41#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
42mod os {
43    pub fn snapshot() -> std::io::Result<Vec<super::ProcInfo>> {
44        Err(std::io::Error::new(
45            std::io::ErrorKind::Unsupported,
46            "no process reader for this OS",
47        ))
48    }
49    pub fn command_line(_pid: u32) -> Option<String> {
50        None
51    }
52    pub fn start_key(_pid: u32) -> Option<u64> {
53        None
54    }
55    pub fn cpu_count() -> usize {
56        std::thread::available_parallelism()
57            .map(|n| n.get())
58            .unwrap_or(1)
59    }
60}
61
62/// What [`ProcInfo::mem_private`] measures on this OS — the number the OS's
63/// own task manager shows where it has one (spec §4.1).
64#[cfg(windows)]
65pub const MEMORY_METRIC: &str = "private working set";
66#[cfg(target_os = "macos")]
67pub const MEMORY_METRIC: &str = "memory footprint";
68#[cfg(not(any(windows, target_os = "macos")))]
69pub const MEMORY_METRIC: &str = "anonymous resident + swap";
70
71/// One process, as the OS reported it at the moment of the snapshot.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ProcInfo {
74    pub pid: u32,
75    /// The parent the OS recorded. On Windows it is never updated, so it can
76    /// name a process that has exited or a newer one that reused its PID:
77    /// check [`ProcInfo::is_child_of`] before trusting it.
78    pub ppid: Option<u32>,
79    /// With `pid`, identifies this process across snapshots: a new process
80    /// that reuses the PID has a different key. Opaque (Windows: creation
81    /// time; Linux: start time in clock ticks since boot; macOS: the kernel's
82    /// unique id). 0 when unknown.
83    pub start_key: u64,
84    /// When it started, unix ms.
85    pub started_at_ms: Option<u64>,
86    /// The executable's name, without directory.
87    pub name: String,
88    /// User + kernel CPU time used so far, in nanoseconds.
89    pub cpu_ns: Option<u64>,
90    /// Private memory in bytes, as [`MEMORY_METRIC`] describes.
91    pub mem_private: Option<u64>,
92    /// Working set / resident set in bytes, shared pages included.
93    pub mem_resident: Option<u64>,
94    /// Committed private bytes (Windows only).
95    pub mem_commit: Option<u64>,
96}
97
98impl ProcInfo {
99    /// The key that tells this process apart from a later one with its PID.
100    pub fn key(&self) -> ProcKey {
101        ProcKey {
102            pid: self.pid,
103            start_key: self.start_key,
104        }
105    }
106
107    /// The OS couldn't tell us its CPU or memory (another user's process on
108    /// macOS).
109    pub fn measured(&self) -> bool {
110        self.cpu_ns.is_some() || self.mem_private.is_some()
111    }
112
113    /// Whether `parent` is really this process's parent: the PID matches and,
114    /// where both start times are known, the parent started first (a parent
115    /// that started later reused the PID of the real one).
116    pub fn is_child_of(&self, parent: &ProcInfo) -> bool {
117        if self.ppid != Some(parent.pid) || parent.pid == self.pid {
118            return false;
119        }
120        match (parent.started_at_ms, self.started_at_ms) {
121            (Some(p), Some(c)) => p <= c,
122            _ => true,
123        }
124    }
125}
126
127/// A process's identity across snapshots.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
129pub struct ProcKey {
130    pub pid: u32,
131    pub start_key: u64,
132}
133
134/// Every process this user may see. Processes that exit mid-read are left out.
135pub fn snapshot() -> std::io::Result<Vec<ProcInfo>> {
136    os::snapshot()
137}
138
139/// A process's full command line, read on demand (it may hold secrets, so it
140/// is never part of a [`snapshot`]). `None` when it has exited or the OS
141/// won't say.
142pub fn command_line(pid: u32) -> Option<String> {
143    os::command_line(pid)
144}
145
146/// Whether the process `key` names is still the one running under its PID
147/// (it hasn't exited, and no newer process has reused the PID). Check it
148/// before and after reading anything about a process by PID.
149pub fn is_running(key: ProcKey) -> bool {
150    os::start_key(key.pid) == Some(key.start_key)
151}
152
153/// The command line of exactly the process `key` names: `None` if it has
154/// exited or a newer process has its PID, checked both before and after the
155/// read, so a PID reused in between never hands back the successor's line.
156pub fn command_line_of(key: ProcKey) -> Option<String> {
157    if os::start_key(key.pid)? != key.start_key {
158        return None;
159    }
160    let line = os::command_line(key.pid)?;
161    (os::start_key(key.pid)? == key.start_key).then_some(line)
162}
163
164/// Logical CPUs on the whole machine — what "percent of the machine" divides
165/// by (every processor group on Windows).
166pub fn cpu_count() -> usize {
167    os::cpu_count().max(1)
168}
169
170/// Turns cumulative CPU times into rates: the fraction of one core used
171/// since the previous round (1.0 = one core fully busy).
172///
173/// Each round, call [`RateMeter::sample`] for every key still alive, then
174/// [`RateMeter::finish`]; keys not sampled in a round are forgotten, so a PID
175/// reused by a new process (a new key) starts fresh.
176#[derive(Debug)]
177pub struct RateMeter<K> {
178    prev: HashMap<K, (u64, Instant)>,
179    next: HashMap<K, (u64, Instant)>,
180}
181
182impl<K> Default for RateMeter<K> {
183    fn default() -> Self {
184        Self {
185            prev: HashMap::new(),
186            next: HashMap::new(),
187        }
188    }
189}
190
191impl<K: Eq + Hash + Clone> RateMeter<K> {
192    pub fn new() -> Self {
193        Self::default()
194    }
195
196    /// Record `total_ns` for `key` at `now`; the rate since the previous
197    /// round, or `None` on a key's first round (or a counter that went
198    /// backwards).
199    pub fn sample(&mut self, key: K, total_ns: u64, now: Instant) -> Option<f64> {
200        let rate = self.prev.get(&key).and_then(|&(was, at)| {
201            let wall = now.checked_duration_since(at)?.as_nanos();
202            (wall > 0 && total_ns >= was).then(|| (total_ns - was) as f64 / wall as f64)
203        });
204        self.next.insert(key, (total_ns, now));
205        rate
206    }
207
208    /// End a round.
209    pub fn finish(&mut self) {
210        self.prev = std::mem::take(&mut self.next);
211    }
212
213    /// Keys remembered from the last finished round.
214    pub fn len(&self) -> usize {
215        self.prev.len()
216    }
217
218    pub fn is_empty(&self) -> bool {
219        self.prev.is_empty()
220    }
221}
222
223/// The executable name in a path, without directory (either separator).
224pub fn base_name(path: &str) -> &str {
225    path.rsplit(['/', '\\']).next().unwrap_or(path)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::time::Duration;
232
233    fn proc(pid: u32, ppid: Option<u32>, started: Option<u64>) -> ProcInfo {
234        ProcInfo {
235            pid,
236            ppid,
237            start_key: started.unwrap_or(0),
238            started_at_ms: started,
239            name: String::new(),
240            cpu_ns: None,
241            mem_private: None,
242            mem_resident: None,
243            mem_commit: None,
244        }
245    }
246
247    #[test]
248    fn rate_needs_two_rounds_and_is_a_fraction_of_one_core() {
249        let mut m = RateMeter::new();
250        let t0 = Instant::now();
251        assert_eq!(
252            m.sample("a", 1_000_000_000, t0),
253            None,
254            "first round has no rate"
255        );
256        m.finish();
257        // 1.5 s of CPU over 1 s of wall: one and a half cores.
258        let r = m
259            .sample("a", 2_500_000_000, t0 + Duration::from_secs(1))
260            .unwrap();
261        assert!((r - 1.5).abs() < 1e-9, "{r}");
262    }
263
264    #[test]
265    fn a_key_missing_from_a_round_starts_over() {
266        let mut m = RateMeter::new();
267        let t0 = Instant::now();
268        m.sample(1, 100, t0);
269        m.finish();
270        m.finish(); // key 1 not sampled: forgotten
271        assert_eq!(m.sample(1, 200, t0 + Duration::from_secs(2)), None);
272        assert!(m.is_empty(), "nothing finished yet this round");
273    }
274
275    #[test]
276    fn a_counter_that_goes_backwards_has_no_rate() {
277        let mut m = RateMeter::new();
278        let t0 = Instant::now();
279        m.sample(1, 500, t0);
280        m.finish();
281        assert_eq!(m.sample(1, 100, t0 + Duration::from_secs(1)), None);
282    }
283
284    #[test]
285    fn a_parent_that_started_after_its_child_is_a_reused_pid() {
286        let parent = proc(10, None, Some(2_000));
287        let child = proc(11, Some(10), Some(1_000));
288        assert!(!child.is_child_of(&parent));
289        let real_parent = proc(10, None, Some(500));
290        assert!(child.is_child_of(&real_parent));
291        assert!(!child.is_child_of(&proc(12, None, Some(0))), "wrong pid");
292        assert!(
293            proc(11, Some(10), None).is_child_of(&parent),
294            "unknown start times trust the pid"
295        );
296    }
297
298    #[test]
299    fn base_name_strips_either_separator() {
300        assert_eq!(base_name(r"C:\Program Files\nodejs\node.exe"), "node.exe");
301        assert_eq!(base_name("/usr/bin/node"), "node");
302        assert_eq!(base_name("node"), "node");
303    }
304
305    /// The real reader on this machine: it sees this test process, measured,
306    /// with a start time and a parent.
307    #[test]
308    fn snapshot_sees_this_process_with_cpu_and_memory() {
309        let me = std::process::id();
310        let all = snapshot().expect("snapshot");
311        assert!(all.len() > 1, "only {} processes", all.len());
312        let p = all
313            .iter()
314            .find(|p| p.pid == me)
315            .expect("this process is listed");
316        assert!(p.cpu_ns.is_some(), "{p:?}");
317        assert!(p.mem_private.unwrap_or(0) > 0, "{p:?}");
318        assert!(
319            p.mem_resident.unwrap_or(0) >= p.mem_private.unwrap_or(0) / 4,
320            "{p:?}"
321        );
322        assert!(p.ppid.is_some(), "{p:?}");
323        assert!(!p.name.is_empty(), "{p:?}");
324        let now_ms = std::time::SystemTime::now()
325            .duration_since(std::time::UNIX_EPOCH)
326            .unwrap()
327            .as_millis() as u64;
328        let started = p.started_at_ms.expect("start time");
329        assert!(
330            started <= now_ms + 5_000 && started > now_ms - 24 * 3600 * 1000,
331            "started {started}, now {now_ms}"
332        );
333        // Two snapshots agree on identity.
334        let again = snapshot().unwrap();
335        assert_eq!(again.iter().find(|q| q.pid == me).unwrap().key(), p.key());
336    }
337
338    #[test]
339    fn cpu_time_grows_while_this_process_spins() {
340        let me = std::process::id();
341        let cpu = || {
342            snapshot()
343                .unwrap()
344                .into_iter()
345                .find(|p| p.pid == me)
346                .and_then(|p| p.cpu_ns)
347                .unwrap()
348        };
349        let before = cpu();
350        let spin_until = Instant::now() + Duration::from_millis(300);
351        let mut x = 0u64;
352        while Instant::now() < spin_until {
353            x = x.wrapping_mul(6364136223846793005).wrapping_add(1);
354        }
355        std::hint::black_box(x);
356        let after = cpu();
357        // At least a third of the spin shows up (coarse clocks: 10-16 ms ticks).
358        assert!(
359            after >= before + 100_000_000,
360            "before {before} after {after}"
361        );
362        // And not absurdly more (a units bug, e.g. Mach ticks read as ns).
363        assert!(
364            after < before + 30_000_000_000,
365            "before {before} after {after}"
366        );
367    }
368
369    #[test]
370    fn command_line_of_this_process_names_its_executable() {
371        let line = command_line(std::process::id()).expect("own command line");
372        let exe = std::env::current_exe().unwrap();
373        let stem = exe.file_stem().unwrap().to_string_lossy().to_string();
374        assert!(line.contains(&stem), "{line:?} lacks {stem:?}");
375        assert_eq!(command_line(u32::MAX - 7), None);
376    }
377
378    #[test]
379    fn a_command_line_only_for_the_exact_process() {
380        let me = snapshot()
381            .unwrap()
382            .into_iter()
383            .find(|p| p.pid == std::process::id())
384            .unwrap();
385        assert!(command_line_of(me.key()).is_some(), "{me:?}");
386        let newer = ProcKey {
387            pid: me.pid,
388            start_key: me.start_key.wrapping_add(1),
389        };
390        assert_eq!(command_line_of(newer), None, "same PID, another process");
391    }
392
393    #[test]
394    fn cpu_count_is_at_least_what_this_process_may_use() {
395        let usable = std::thread::available_parallelism()
396            .map(|n| n.get())
397            .unwrap_or(1);
398        assert!(cpu_count() >= usable, "{} < {usable}", cpu_count());
399    }
400}