Skip to main content

agentmux_remote/
procs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The host's processes for Tower (`fsproto::Request::Procs`,
5//! SPEC_TOWER_TASK_MANAGER_PANE_2026_10_08.md §8): sampled here, on the host,
6//! with the same readers srv uses locally (`agentmux_procstats`), so only a
7//! small frame crosses the link.
8//!
9//! - **Small:** the `top` busiest by CPU and the `top` largest by memory
10//!   (the union, at most `2 × top` rows), plus totals for all of them. A
11//!   filter is applied here first, so a search finds a process that isn't in
12//!   either top list.
13//! - **No command lines.** Names only: a command line can hold a secret, and
14//!   never leaves the host through this.
15//! - **Rates on the host:** CPU is the change since the previous `Procs` on
16//!   this connection, so nothing but the frame is sent and srv keeps no
17//!   per-host state.
18//!
19//! `PaneProcs` (a terminal's stats badge,
20//! SPEC_TERMINAL_STATS_BADGE_REMOTE_PANES_2026_10_12.md) sums one AgentMux
21//! pane's processes from the same measurement: the tree under a durable
22//! session's shell, or, for a WSL or plain SSH pane, every process whose
23//! environment holds `AGENTMUX_BLOCKID=<pane>` and what those started. Each
24//! process's environment is read once (Linux: `/proc/<pid>/environ`, the
25//! user's own processes only) and remembered while it runs.
26
27use std::collections::{HashMap, HashSet, VecDeque};
28use std::time::Instant;
29
30use agentmux_procstats::{ProcInfo, ProcKey, RateMeter};
31
32use crate::fsproto::{PaneFound, PaneFrame, ProcFrame, ProcRow};
33
34/// Most rows per list a request may ask for.
35pub const MAX_TOP: u32 = 500;
36
37/// A request this soon after the last measurement reuses it (with its own
38/// filter): two Tower panes on one host, whatever their filters, must not
39/// shrink each other's CPU window to milliseconds.
40pub const REUSE_WITHIN: std::time::Duration = std::time::Duration::from_millis(900);
41
42/// Most processes one pane's tree counts (as srv's badge does for a local
43/// pane): a fork bomb shouldn't stall a sample.
44pub const MAX_PANE_PROCS: usize = 64;
45
46/// The variable that names the AgentMux pane a process belongs to.
47const PANE_MARKER: &[u8] = b"AGENTMUX_BLOCKID=";
48
49/// One connection's sampler: the previous round's CPU times, and the last
50/// measurement for requests that arrive within [`REUSE_WITHIN`] of it.
51#[derive(Default)]
52pub struct Sampler {
53    rates: RateMeter<ProcKey>,
54    last: Option<(Instant, Vec<ProcInfo>, Vec<Option<f64>>)>,
55    /// Each running process's pane (`AGENTMUX_BLOCKID`), `None` for none or
56    /// unreadable, read once.
57    markers: HashMap<ProcKey, Option<String>>,
58}
59
60impl Sampler {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// A frame of the host's processes now (module doc). A host whose
66    /// process table can't be read answers with an empty frame.
67    pub fn frame(&mut self, top: u32, filter: &str) -> ProcFrame {
68        self.measure();
69        let (_, procs, rates) = self.last.as_ref().expect("measured above");
70        select(procs, rates, top, filter, agentmux_procstats::cpu_count() as u32)
71    }
72
73    /// What one pane's processes use (module doc): the tree under
74    /// `session_pid` (a durable session's shell, from the daemon) when it is
75    /// running, else the processes marked with `block_id` and theirs.
76    pub fn pane(&mut self, block_id: &str, session_pid: Option<u32>) -> PaneFrame {
77        self.measure();
78        let Self { last, markers, .. } = self;
79        let (_, procs, rates) = last.as_ref().expect("measured above");
80        let cpu_count = agentmux_procstats::cpu_count() as u32;
81        if let Some(root) = session_pid.and_then(|pid| procs.iter().position(|p| p.pid == pid)) {
82            return pane_frame(procs, rates, &[root], PaneFound::Session, cpu_count);
83        }
84        let marked: Vec<usize> = if block_id.is_empty() {
85            Vec::new()
86        } else {
87            (0..procs.len())
88                .filter(|&i| {
89                    let p = &procs[i];
90                    markers.entry(p.key()).or_insert_with(|| marker_of(p.pid)).as_deref() == Some(block_id)
91                })
92                .collect()
93        };
94        let found = if marked.is_empty() { PaneFound::None } else { PaneFound::Marker };
95        pane_frame(procs, rates, &marked, found, cpu_count)
96    }
97
98    /// Take a new measurement unless the last is within [`REUSE_WITHIN`].
99    fn measure(&mut self) {
100        if self.last.as_ref().is_some_and(|(at, _, _)| at.elapsed() < REUSE_WITHIN) {
101            return;
102        }
103        let procs = agentmux_procstats::snapshot().unwrap_or_default();
104        let now = Instant::now();
105        let rates: Vec<Option<f64>> =
106            procs.iter().map(|p| p.cpu_ns.and_then(|ns| self.rates.sample(p.key(), ns, now))).collect();
107        self.rates.finish();
108        // Forget the markers of processes that have exited.
109        let alive: HashSet<ProcKey> = procs.iter().map(ProcInfo::key).collect();
110        self.markers.retain(|k, _| alive.contains(k));
111        self.last = Some((now, procs, rates));
112    }
113}
114
115/// The pane a process belongs to: its `AGENTMUX_BLOCKID`. `None` where it has
116/// none, can't be read (another user's process), or the OS keeps no
117/// environment where this can read it (anything but Linux).
118fn marker_of(pid: u32) -> Option<String> {
119    #[cfg(target_os = "linux")]
120    {
121        let env = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
122        marker_in(&env)
123    }
124    #[cfg(not(target_os = "linux"))]
125    {
126        let _ = pid;
127        None
128    }
129}
130
131/// `AGENTMUX_BLOCKID`'s value in a NUL-separated environment.
132fn marker_in(env: &[u8]) -> Option<String> {
133    env.split(|&b| b == 0)
134        .find_map(|kv| kv.strip_prefix(PANE_MARKER))
135        .filter(|v| !v.is_empty())
136        .map(|v| String::from_utf8_lossy(v).into_owned())
137}
138
139/// A pane's frame: `roots` and every process under them (at most
140/// [`MAX_PANE_PROCS`]), with the host's totals. The earliest-started root is
141/// the pane's shell: its name comes first and its start is the pane's.
142pub fn pane_frame(procs: &[ProcInfo], rates: &[Option<f64>], roots: &[usize], found: PaneFound, cpu_count: u32) -> PaneFrame {
143    let by_pid: HashMap<u32, usize> = procs.iter().enumerate().map(|(i, p)| (p.pid, i)).collect();
144    let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
145    for (i, p) in procs.iter().enumerate() {
146        if let Some(&parent) = p.ppid.and_then(|pp| by_pid.get(&pp)) {
147            if p.is_child_of(&procs[parent]) {
148                children.entry(parent).or_default().push(i);
149            }
150        }
151    }
152    let mut roots = roots.to_vec();
153    roots.sort_by_key(|&i| (procs[i].started_at_ms.unwrap_or(u64::MAX), procs[i].pid));
154    let mut tree: Vec<usize> = Vec::new();
155    let mut seen: HashSet<usize> = HashSet::new();
156    let mut queue: VecDeque<usize> = VecDeque::new();
157    for &r in &roots {
158        if tree.len() < MAX_PANE_PROCS && seen.insert(r) {
159            tree.push(r);
160            queue.push_back(r);
161        }
162    }
163    while let Some(i) = queue.pop_front() {
164        for &c in children.get(&i).into_iter().flatten() {
165            if tree.len() >= MAX_PANE_PROCS {
166                break;
167            }
168            if seen.insert(c) {
169                tree.push(c);
170                queue.push_back(c);
171            }
172        }
173    }
174
175    let mut names: Vec<(String, u32)> = Vec::new();
176    for &i in &tree {
177        match names.iter_mut().find(|(n, _)| *n == procs[i].name) {
178            Some((_, count)) => *count += 1,
179            None => names.push((procs[i].name.clone(), 1)),
180        }
181    }
182    // The shell stays first; the rest, most numerous first.
183    if names.len() > 1 {
184        names[1..].sort_by(|a, b| b.1.cmp(&a.1));
185    }
186    let sum = |f: &dyn Fn(usize) -> Option<u64>| tree.iter().filter_map(|&i| f(i)).reduce(|a, b| a + b);
187    PaneFrame {
188        found,
189        processes: tree.len() as u32,
190        names,
191        started_at_ms: tree.first().and_then(|&i| procs[i].started_at_ms),
192        cpu_milli: sum(&|i| rates[i].map(|r| milli(r) as u64)),
193        mem: sum(&|i| procs[i].mem_private),
194        mem_resident: sum(&|i| procs[i].mem_resident),
195        unmeasured: tree.iter().filter(|&&i| !procs[i].measured()).count() as u32,
196        memory_metric: agentmux_procstats::MEMORY_METRIC.to_string(),
197        cpu_count,
198        host_cpu_milli: rates.iter().flatten().fold(None, |acc: Option<u64>, &r| Some(acc.unwrap_or(0) + milli(r) as u64)),
199        host_mem: procs.iter().filter_map(|p| p.mem_private).sum(),
200        host_processes: procs.len() as u32,
201        host_unmeasured: procs.iter().filter(|p| !p.measured()).count() as u32,
202    }
203}
204
205fn milli(rate: f64) -> u32 {
206    (rate * 1000.0).round().clamp(0.0, (u32::MAX - 1) as f64) as u32
207}
208
209/// Whether every word of `filter` is in the name or the PID.
210fn matches(p: &ProcInfo, words: &[String]) -> bool {
211    let hay = format!("{} {}", p.name.to_lowercase(), p.pid);
212    words.iter().all(|w| hay.contains(w.as_str()))
213}
214
215/// The frame for `procs` (with each one's CPU rate, fraction of one core):
216/// totals over all of them, rows for the busiest and largest that match.
217pub fn select(procs: &[ProcInfo], rates: &[Option<f64>], top: u32, filter: &str, cpu_count: u32) -> ProcFrame {
218    let top = top.min(MAX_TOP) as usize;
219    let words: Vec<String> = filter.to_lowercase().split_whitespace().map(str::to_string).collect();
220    let matching: Vec<usize> = (0..procs.len()).filter(|&i| matches(&procs[i], &words)).collect();
221
222    let mut by_cpu = matching.clone();
223    by_cpu.sort_by(|&a, &b| rates[b].unwrap_or(-1.0).total_cmp(&rates[a].unwrap_or(-1.0)));
224    let mut by_mem = matching.clone();
225    by_mem.sort_by_key(|&i| std::cmp::Reverse(procs[i].mem_private.map_or(-1, |m| m as i128)));
226    let mut seen = HashSet::new();
227    let chosen: Vec<usize> = by_cpu.into_iter().take(top).chain(by_mem.into_iter().take(top)).filter(|i| seen.insert(*i)).collect();
228
229    ProcFrame {
230        cpu_count,
231        memory_metric: agentmux_procstats::MEMORY_METRIC.to_string(),
232        total: procs.len() as u32,
233        matched: matching.len() as u32,
234        unmeasured: procs.iter().filter(|p| !p.measured()).count() as u32,
235        cpu_milli: rates.iter().flatten().fold(None, |acc: Option<u64>, &r| Some(acc.unwrap_or(0) + milli(r) as u64)),
236        mem: procs.iter().filter_map(|p| p.mem_private).sum(),
237        rows: chosen
238            .into_iter()
239            .map(|i| {
240                let p = &procs[i];
241                ProcRow {
242                    pid: p.pid,
243                    ppid: p.ppid,
244                    start_key: p.start_key,
245                    started_at_ms: p.started_at_ms,
246                    name: p.name.clone(),
247                    cpu_milli: rates[i].map(milli),
248                    mem: p.mem_private,
249                    mem_resident: p.mem_resident,
250                }
251            })
252            .collect(),
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn p(pid: u32, name: &str, mem: Option<u64>) -> ProcInfo {
261        ProcInfo {
262            pid,
263            ppid: Some(1),
264            start_key: pid as u64,
265            started_at_ms: Some(1),
266            name: name.into(),
267            cpu_ns: mem.map(|_| 0),
268            mem_private: mem,
269            mem_resident: mem,
270            mem_commit: None,
271        }
272    }
273
274    fn pids(f: &ProcFrame) -> Vec<u32> {
275        let mut v: Vec<u32> = f.rows.iter().map(|r| r.pid).collect();
276        v.sort();
277        v
278    }
279
280    #[test]
281    fn the_busiest_and_the_largest_are_sent_with_totals_for_all() {
282        let procs = vec![p(1, "init", Some(10)), p(2, "big", Some(9_000)), p(3, "busy", Some(20)), p(4, "idle", Some(30))];
283        let rates = vec![Some(0.0), Some(0.01), Some(1.5), None];
284        let f = select(&procs, &rates, 1, "", 8);
285        assert_eq!(pids(&f), vec![2, 3], "top 1 by CPU (busy) and top 1 by memory (big)");
286        assert_eq!((f.total, f.matched, f.cpu_count), (4, 4, 8));
287        assert_eq!(f.cpu_milli, Some(1510));
288        assert_eq!(f.mem, 9_060);
289        let busy = f.rows.iter().find(|r| r.pid == 3).unwrap();
290        assert_eq!(busy.cpu_milli, Some(1500));
291    }
292
293    #[test]
294    fn a_filter_finds_what_neither_top_list_holds() {
295        let mut procs: Vec<ProcInfo> = (10..60).map(|i| p(i, "worker", Some(i as u64 * 100))).collect();
296        procs.push(p(4242, "sshd", Some(1)));
297        let rates = vec![Some(0.1); procs.len()];
298        let f = select(&procs, &rates, 5, "SSHD", 2);
299        assert_eq!(pids(&f), vec![4242]);
300        assert_eq!((f.total, f.matched), (51, 1), "totals still count every process");
301        let f = select(&procs, &rates, 5, "4242", 2);
302        assert_eq!(pids(&f), vec![4242], "by PID");
303    }
304
305    #[test]
306    fn an_unmeasured_process_is_counted_and_sent_without_numbers() {
307        let procs = vec![p(1, "launchd", None), p(2, "node", Some(5))];
308        let f = select(&procs, &[None, Some(0.2)], 10, "", 4);
309        assert_eq!(f.unmeasured, 1);
310        assert_eq!(select(&procs, &[None, None], 10, "", 4).cpu_milli, None, "no rate yet: unknown, not 0");
311        let launchd = f.rows.iter().find(|r| r.pid == 1).unwrap();
312        assert_eq!((launchd.cpu_milli, launchd.mem), (None, None));
313    }
314
315    fn child(pid: u32, ppid: u32, started: u64, name: &str, mem: u64) -> ProcInfo {
316        ProcInfo {
317            pid,
318            ppid: Some(ppid),
319            start_key: started,
320            started_at_ms: Some(started),
321            name: name.into(),
322            cpu_ns: Some(0),
323            mem_private: Some(mem),
324            mem_resident: Some(mem * 2),
325            mem_commit: None,
326        }
327    }
328
329    /// sshd (1) -> bash (10, the pane's shell) -> node (11) -> node (12);
330    /// bash -> git (13); a second marked shell of the same pane (20, a
331    /// later `wsl.exe` relaunch); an unrelated process (30).
332    fn pane_table() -> Vec<ProcInfo> {
333        vec![
334            child(1, 0, 100, "sshd", 5),
335            child(10, 1, 1_000, "bash", 4),
336            child(11, 10, 2_000, "node", 50),
337            child(12, 11, 3_000, "node", 30),
338            child(13, 10, 2_500, "git", 2),
339            child(20, 1, 5_000, "bash", 3),
340            child(30, 1, 1_500, "cron", 1),
341        ]
342    }
343
344    #[test]
345    fn a_pane_is_its_roots_and_everything_under_them() {
346        let procs = pane_table();
347        let rates = vec![Some(0.0), Some(0.01), Some(0.5), Some(0.25), None, Some(0.0), Some(0.0)];
348        let f = pane_frame(&procs, &rates, &[5, 1], PaneFound::Marker, 4);
349        assert_eq!(f.found, PaneFound::Marker);
350        assert_eq!(f.processes, 5, "both shells, two nodes and git; not sshd or cron");
351        assert_eq!(f.names, vec![("bash".to_string(), 2), ("node".to_string(), 2), ("git".to_string(), 1)]);
352        assert_eq!(f.started_at_ms, Some(1_000), "the earliest root is the shell");
353        assert_eq!(f.cpu_milli, Some(10 + 500 + 250));
354        assert_eq!(f.mem, Some(4 + 50 + 30 + 2 + 3));
355        assert_eq!(f.mem_resident, Some(2 * (4 + 50 + 30 + 2 + 3)));
356        assert_eq!(f.unmeasured, 0);
357        assert_eq!((f.cpu_count, f.host_processes, f.host_mem, f.host_unmeasured), (4, 7, 95, 0));
358        // A child run as another user (sudo) on macOS: counted, not measured.
359        let mut procs = pane_table();
360        procs[3].cpu_ns = None;
361        procs[3].mem_private = None;
362        procs[3].mem_resident = None;
363        let f = pane_frame(&procs, &rates, &[5, 1], PaneFound::Marker, 4);
364        assert_eq!((f.processes, f.unmeasured, f.mem), (5, 1, Some(4 + 50 + 2 + 3)));
365        assert_eq!(f.host_unmeasured, 1);
366    }
367
368    #[test]
369    fn no_roots_is_the_hosts_totals_only() {
370        let procs = pane_table();
371        let rates = vec![None; procs.len()];
372        let f = pane_frame(&procs, &rates, &[], PaneFound::None, 2);
373        assert_eq!((f.processes, f.names.len(), f.mem, f.cpu_milli), (0, 0, None, None));
374        assert_eq!((f.host_processes, f.host_cpu_milli), (7, None), "no rate yet: unknown, not 0");
375        // Another user's process on macOS: listed, but not in the totals.
376        let mut procs = pane_table();
377        procs[6].cpu_ns = None;
378        procs[6].mem_private = None;
379        let f = pane_frame(&procs, &rates, &[], PaneFound::None, 2);
380        assert_eq!((f.host_processes, f.host_unmeasured, f.host_mem), (7, 1, 94));
381    }
382
383    #[test]
384    fn a_pane_is_capped() {
385        let mut procs = vec![child(1, 0, 1, "bash", 1)];
386        procs.extend((2..500).map(|i| child(i, 1, 2, "x", 1)));
387        let rates = vec![None; procs.len()];
388        assert_eq!(pane_frame(&procs, &rates, &[0], PaneFound::Session, 1).processes, MAX_PANE_PROCS as u32);
389    }
390
391    #[test]
392    fn the_marker_is_read_from_an_environment() {
393        assert_eq!(marker_in(b"HOME=/root\0AGENTMUX_BLOCKID=b-1\0TERM=x\0"), Some("b-1".to_string()));
394        assert_eq!(marker_in(b"HOME=/root\0XAGENTMUX_BLOCKID=b-1\0"), None, "a longer name isn't it");
395        assert_eq!(marker_in(b"AGENTMUX_BLOCKID=\0"), None);
396        assert_eq!(marker_in(b""), None);
397    }
398
399    /// The real sampler on Linux: a child started with the marker is the
400    /// pane, found by its environment, with what it started; this test
401    /// process (unmarked) is not.
402    #[cfg(target_os = "linux")]
403    #[test]
404    fn a_marked_process_and_its_children_are_the_pane() {
405        let mut shell = std::process::Command::new("sh")
406            .args(["-c", "sleep 30 & sleep 30; true"])
407            .env("AGENTMUX_BLOCKID", "test-pane-procs")
408            .spawn()
409            .expect("spawn sh");
410        std::thread::sleep(std::time::Duration::from_millis(300));
411        let mut s = Sampler::new();
412        let f = s.pane("test-pane-procs", None);
413        let _ = shell.kill();
414        let _ = shell.wait();
415        assert_eq!(f.found, PaneFound::Marker, "{f:?}");
416        assert!(f.processes >= 2, "sh and its sleeps: {f:?}");
417        assert_eq!(f.names.first().map(|n| n.0.as_str()), Some("sh"), "{f:?}");
418        let none = s.pane("no-such-pane", None);
419        assert_eq!((none.found, none.processes), (PaneFound::None, 0));
420        assert!(none.host_processes > 1);
421    }
422
423    #[test]
424    fn a_session_shell_is_found_by_its_pid() {
425        let mut s = Sampler::new();
426        let f = s.pane("", Some(std::process::id()));
427        assert_eq!(f.found, PaneFound::Session, "{f:?}");
428        assert!(f.processes >= 1);
429        let gone = s.pane("", Some(u32::MAX - 3));
430        assert_eq!(gone.found, PaneFound::None, "a shell that isn't running");
431    }
432
433    #[test]
434    fn top_is_capped() {
435        let procs: Vec<ProcInfo> = (1..=2000).map(|i| p(i, "x", Some(i as u64))).collect();
436        let rates = vec![Some(0.0); procs.len()];
437        assert!(select(&procs, &rates, u32::MAX, "", 1).rows.len() <= 2 * MAX_TOP as usize);
438    }
439
440    /// The real sampler: this test process is in its own host's frame; a
441    /// request within the reuse window, whatever its filter, reuses the
442    /// measurement (no new round); a later one has rates.
443    #[test]
444    fn a_real_frame_and_its_rates() {
445        let mut s = Sampler::new();
446        let me = std::process::id();
447        let first = s.frame(MAX_TOP, &me.to_string());
448        assert!(first.rows.iter().any(|r| r.pid == me), "{first:?}");
449        let other_filter = s.frame(MAX_TOP, "");
450        assert_eq!(other_filter.total, first.total, "the same measurement");
451        assert!(other_filter.rows.iter().all(|r| r.cpu_milli.is_none()), "still no previous round");
452        std::thread::sleep(REUSE_WITHIN + std::time::Duration::from_millis(50));
453        let second = s.frame(MAX_TOP, &me.to_string());
454        assert!(second.rows.iter().find(|r| r.pid == me).unwrap().cpu_milli.is_some());
455        assert!(second.total > 1);
456    }
457}