1use 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
30pub 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 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 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
88pub 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
122pub fn remote_unavailable(reason: String) -> BlockStatsRemote {
124 BlockStatsRemote { found: "unavailable".to_string(), figures: None, host: None, peak: None, reason: Some(reason) }
125}
126
127fn 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
153fn 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#[derive(Debug, Default)]
166pub struct Peaks {
167 by_block: HashMap<String, (Shell, u64)>,
168}
169
170pub type Shell = (u32, u64);
173
174impl Peaks {
175 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 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 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}