Skip to main content

agentmux_srv/backend/
blockstats_detail.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! What a terminal's stats badge tooltip shows beyond the badge's own CPU and
5//! memory (`blockstats.detail`), and the badge's peak memory.
6//!
7//! The badge (`sysinfo::run_sysinfo_loop`, through `sampler::panes`) counts
8//! the pane's processes as Tower groups them: its Job Object or cgroup, else
9//! its shell's tree. The tooltip describes the shell and every process under it:
10//! their names, when the shell started, and two more memory figures from one
11//! `agentmux_procstats` snapshot, private and committed. A process that left
12//! the shell's tree is in the badge's figures but not in the tooltip's. A snapshot costs
13//! tens of milliseconds, so it is taken only while a tooltip is open, never
14//! on the badge's every-second tick.
15//!
16//! A remote pane (SSH, WSL) is measured on the machine it is connected to,
17//! by AgentMux's helper there (`PaneProcs`); [`remote`] turns its answer
18//! into the badge's figures, with a peak kept here.
19//! SPEC_TERMINAL_STATS_BADGE_REMOTE_PANES_2026_10_12.md.
20
21use std::collections::{HashMap, VecDeque};
22use std::sync::{Mutex, OnceLock};
23use std::time::{Duration, Instant};
24
25use agentmux_procstats::ProcInfo;
26use agentmux_remote::fsproto::{PaneFound, PaneFrame};
27
28use crate::backend::rpc_types::{BlockStatsFigures, BlockStatsHost, BlockStatsName, BlockStatsRemote};
29
30/// The tooltip's numbers for the process tree under `root_pid`, at most
31/// `max` processes (the badge's own cap). `None` when the shell isn't in the
32/// snapshot (it exited).
33pub fn detail(snap: &[ProcInfo], root_pid: u32, max: usize) -> Option<BlockStatsFigures> {
34    let root = snap.iter().position(|p| p.pid == root_pid)?;
35    let by_pid: HashMap<u32, usize> = snap.iter().enumerate().map(|(i, p)| (p.pid, i)).collect();
36    // Parent → children, trusting a parent only if it really is one (a
37    // Windows parent PID can name a newer process that reused it).
38    let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
39    for (i, p) in snap.iter().enumerate() {
40        if let Some(&parent) = p.ppid.and_then(|pp| by_pid.get(&pp)) {
41            if p.is_child_of(&snap[parent]) {
42                children.entry(parent).or_default().push(i);
43            }
44        }
45    }
46    let mut tree = vec![root];
47    let mut queue = VecDeque::from([root]);
48    'walk: while let Some(i) = queue.pop_front() {
49        for &c in children.get(&i).into_iter().flatten() {
50            if tree.len() >= max {
51                break 'walk;
52            }
53            if !tree.contains(&c) {
54                tree.push(c);
55                queue.push_back(c);
56            }
57        }
58    }
59
60    let sum = |f: fn(&ProcInfo) -> Option<u64>| -> Option<u64> {
61        tree.iter().filter_map(|&i| f(&snap[i])).reduce(|a, b| a + b)
62    };
63    let mut names: Vec<BlockStatsName> = Vec::new();
64    for &i in &tree {
65        let name = display_name(&snap[i].name);
66        match names.iter_mut().find(|n| n.name == name) {
67            Some(n) => n.count += 1,
68            None => names.push(BlockStatsName { name: name.to_string(), count: 1 }),
69        }
70    }
71    // The shell stays first; the rest, most numerous first (stable: ties keep
72    // the order the walk met them in).
73    names[1..].sort_by(|a, b| b.count.cmp(&a.count));
74
75    Some(BlockStatsFigures {
76        processes: tree.len() as u32,
77        names,
78        started_at_ms: snap[root].started_at_ms,
79        cpu: None,
80        mem_resident: None,
81        mem_private: sum(|p| p.mem_private),
82        mem_commit: sum(|p| p.mem_commit),
83        unmeasured: None,
84        memory_metric: agentmux_procstats::MEMORY_METRIC.to_string(),
85    })
86}
87
88/// A remote pane's helper answer as the badge's figures, with its peak.
89pub fn remote(block_id: &str, f: PaneFrame) -> BlockStatsRemote {
90    let percent = |milli: u64| milli as f64 / 10.0;
91    let host = BlockStatsHost {
92        cpu_count: f.cpu_count,
93        cpu: f.host_cpu_milli.map(percent),
94        mem: f.host_mem,
95        processes: f.host_processes,
96        unmeasured: f.host_unmeasured,
97        memory_metric: f.memory_metric.clone(),
98    };
99    let found = match f.found {
100        PaneFound::Session => "session",
101        PaneFound::Marker => "marker",
102        PaneFound::None => "none",
103    };
104    let figures = (f.found != PaneFound::None).then(|| BlockStatsFigures {
105        processes: f.processes,
106        names: f.names.into_iter().map(|(name, count)| BlockStatsName { name, count }).collect(),
107        started_at_ms: f.started_at_ms,
108        cpu: f.cpu_milli.map(percent),
109        mem_resident: f.mem_resident,
110        mem_private: f.mem,
111        mem_commit: None,
112        unmeasured: Some(f.unmeasured),
113        memory_metric: f.memory_metric,
114    });
115    let peak = figures.as_ref().and_then(|fig| {
116        let mem = fig.mem_resident?;
117        Some(remote_peaks().lock().unwrap().observe(block_id, (0, fig.started_at_ms.unwrap_or(0)), mem))
118    });
119    BlockStatsRemote { found: found.to_string(), figures, host: Some(host), peak, reason: None }
120}
121
122/// A remote pane whose machine couldn't be asked, and why.
123pub fn remote_unavailable(reason: String) -> BlockStatsRemote {
124    BlockStatsRemote { found: "unavailable".to_string(), figures: None, host: None, peak: None, reason: Some(reason) }
125}
126
127/// Remote panes' peaks, by pane and shell (its start: the PID is the other
128/// machine's). A pane not asked about for [`REMOTE_PEAK_IDLE`] is forgotten.
129fn remote_peaks() -> &'static Mutex<RemotePeaks> {
130    static PEAKS: OnceLock<Mutex<RemotePeaks>> = OnceLock::new();
131    PEAKS.get_or_init(Default::default)
132}
133
134const REMOTE_PEAK_IDLE: Duration = Duration::from_secs(600);
135
136#[derive(Default)]
137struct RemotePeaks {
138    peaks: Peaks,
139    seen: HashMap<String, Instant>,
140}
141
142impl RemotePeaks {
143    fn observe(&mut self, block_id: &str, shell: Shell, mem: u64) -> u64 {
144        let now = Instant::now();
145        self.seen.insert(block_id.to_string(), now);
146        self.seen.retain(|_, at| now.duration_since(*at) < REMOTE_PEAK_IDLE);
147        let seen = &self.seen;
148        self.peaks.retain(|id| seen.contains_key(id));
149        self.peaks.observe(block_id, shell, mem)
150    }
151}
152
153/// An executable's name as a person calls it: `pwsh`, not `pwsh.exe`.
154fn display_name(name: &str) -> &str {
155    let n = name.len();
156    if n > 4 && name.is_char_boundary(n - 4) && name[n - 4..].eq_ignore_ascii_case(".exe") {
157        &name[..n - 4]
158    } else {
159        name
160    }
161}
162
163/// The highest memory each pane's badge has shown since AgentMux started
164/// watching its shell. A new shell in the same pane (a restart) starts over.
165#[derive(Debug, Default)]
166pub struct Peaks {
167    by_block: HashMap<String, (Shell, u64)>,
168}
169
170/// Which shell a peak is for: its PID and start time, so a restarted shell
171/// that got the old one's PID back is still a new shell.
172pub type Shell = (u32, u64);
173
174impl Peaks {
175    /// Record the badge's `mem` for `block_id`, whose shell is `shell`; its
176    /// peak so far.
177    pub fn observe(&mut self, block_id: &str, shell: Shell, mem: u64) -> u64 {
178        let entry = self.by_block.entry(block_id.to_string()).or_insert((shell, mem));
179        if entry.0 != shell {
180            *entry = (shell, mem);
181        }
182        entry.1 = entry.1.max(mem);
183        entry.1
184    }
185
186    /// Forget every pane `keep` says no longer has stats.
187    pub fn retain(&mut self, keep: impl Fn(&str) -> bool) {
188        self.by_block.retain(|id, _| keep(id));
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn proc(pid: u32, ppid: Option<u32>, started: u64, name: &str, private: u64, commit: Option<u64>) -> ProcInfo {
197        ProcInfo {
198            pid,
199            ppid,
200            start_key: started,
201            started_at_ms: Some(started),
202            name: name.to_string(),
203            cpu_ns: Some(0),
204            mem_private: Some(private),
205            mem_resident: Some(private * 2),
206            mem_commit: commit,
207        }
208    }
209
210    fn table() -> Vec<ProcInfo> {
211        vec![
212            proc(1, None, 100, "agentmux-srv.exe", 500, Some(900)),
213            proc(10, Some(1), 1_000, "pwsh.exe", 10, Some(90)),
214            proc(11, Some(10), 2_000, "node.exe", 20, Some(40)),
215            proc(12, Some(11), 3_000, "node.exe", 5, Some(8)),
216            proc(13, Some(10), 2_500, "git.exe", 1, Some(2)),
217            // Claims pwsh as its parent but started before it: a reused PID.
218            proc(14, Some(10), 500, "stranger.exe", 999, Some(999)),
219            proc(20, Some(1), 1_500, "bash", 7, None),
220        ]
221    }
222
223    #[test]
224    fn sums_the_shell_and_everything_under_it() {
225        let d = detail(&table(), 10, 64).unwrap();
226        assert_eq!(d.processes, 4);
227        assert_eq!(d.started_at_ms, Some(1_000));
228        assert_eq!(d.mem_private, Some(10 + 20 + 5 + 1));
229        assert_eq!(d.mem_commit, Some(90 + 40 + 8 + 2));
230        assert_eq!(d.memory_metric, agentmux_procstats::MEMORY_METRIC);
231    }
232
233    #[test]
234    fn names_the_shell_first_then_the_most_numerous() {
235        let d = detail(&table(), 10, 64).unwrap();
236        let names: Vec<(&str, u32)> = d.names.iter().map(|n| (n.name.as_str(), n.count)).collect();
237        assert_eq!(names, [("pwsh", 1), ("node", 2), ("git", 1)]);
238    }
239
240    #[test]
241    fn a_figure_no_process_reports_is_absent_not_zero() {
242        let d = detail(&table(), 20, 64).unwrap();
243        assert_eq!(d.processes, 1);
244        assert_eq!(d.mem_private, Some(7));
245        assert_eq!(d.mem_commit, None, "committed memory is Windows-only");
246    }
247
248    #[test]
249    fn stops_at_the_badges_cap() {
250        let d = detail(&table(), 10, 2).unwrap();
251        assert_eq!(d.processes, 2);
252    }
253
254    #[test]
255    fn an_exited_shell_has_no_detail() {
256        assert_eq!(detail(&table(), 99, 64), None);
257    }
258
259    #[test]
260    fn display_name_drops_only_a_trailing_exe() {
261        assert_eq!(display_name("pwsh.exe"), "pwsh");
262        assert_eq!(display_name("Code.EXE"), "Code");
263        assert_eq!(display_name("bash"), "bash");
264        assert_eq!(display_name(".exe"), ".exe");
265        assert_eq!(display_name("exe"), "exe");
266    }
267
268    fn frame(found: PaneFound, started: u64, resident: u64) -> PaneFrame {
269        PaneFrame {
270            found,
271            processes: 3,
272            names: vec![("bash".into(), 1), ("node".into(), 2)],
273            started_at_ms: Some(started),
274            cpu_milli: Some(1_250),
275            mem: Some(40 << 20),
276            mem_resident: Some(resident),
277            unmeasured: 0,
278            memory_metric: "anonymous resident + swap".into(),
279            cpu_count: 8,
280            host_cpu_milli: Some(3_000),
281            host_mem: 6 << 30,
282            host_processes: 400,
283            host_unmeasured: 0,
284        }
285    }
286
287    #[test]
288    fn a_remote_answer_becomes_the_badges_figures_and_peak() {
289        let r = remote("rb-1", frame(PaneFound::Session, 1_000, 90));
290        assert_eq!(r.found, "session");
291        let fig = r.figures.unwrap();
292        assert_eq!((fig.cpu, fig.mem_resident, fig.processes), (Some(125.0), Some(90), 3));
293        assert_eq!(fig.names[0].name, "bash");
294        let host = r.host.unwrap();
295        assert_eq!((host.cpu, host.cpu_count, host.processes), (Some(300.0), 8, 400));
296        assert_eq!(r.peak, Some(90));
297        assert_eq!(remote("rb-1", frame(PaneFound::Session, 1_000, 60)).peak, Some(90), "the peak holds");
298        assert_eq!(remote("rb-1", frame(PaneFound::Marker, 2_000, 50)).peak, Some(50), "a new shell starts over");
299    }
300
301    #[test]
302    fn a_remote_pane_found_by_neither_way_has_only_the_hosts_figures() {
303        let r = remote("rb-2", frame(PaneFound::None, 0, 0));
304        assert_eq!((r.found.as_str(), r.figures, r.peak), ("none", None, None));
305        assert!(r.host.is_some());
306        let u = remote_unavailable("no helper".into());
307        assert_eq!((u.found.as_str(), u.reason.as_deref(), u.host), ("unavailable", Some("no helper"), None));
308    }
309
310    #[test]
311    fn a_peak_holds_until_the_shell_changes() {
312        let mut peaks = Peaks::default();
313        assert_eq!(peaks.observe("b", (10, 1_000), 100), 100);
314        assert_eq!(peaks.observe("b", (10, 1_000), 180), 180);
315        assert_eq!(peaks.observe("b", (10, 1_000), 90), 180, "a trim doesn't lower the peak");
316        assert_eq!(peaks.observe("b", (11, 2_000), 50), 50, "a restarted shell starts over");
317        assert_eq!(peaks.observe("b", (11, 3_000), 40), 40, "even with the old shell's PID");
318        peaks.retain(|id| id != "b");
319        assert_eq!(peaks.observe("b", (11, 3_000), 30), 30, "a forgotten pane starts over");
320    }
321}