1use 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
34pub const MAX_TOP: u32 = 500;
36
37pub const REUSE_WITHIN: std::time::Duration = std::time::Duration::from_millis(900);
41
42pub const MAX_PANE_PROCS: usize = 64;
45
46const PANE_MARKER: &[u8] = b"AGENTMUX_BLOCKID=";
48
49#[derive(Default)]
52pub struct Sampler {
53 rates: RateMeter<ProcKey>,
54 last: Option<(Instant, Vec<ProcInfo>, Vec<Option<f64>>)>,
55 markers: HashMap<ProcKey, Option<String>>,
58}
59
60impl Sampler {
61 pub fn new() -> Self {
62 Self::default()
63 }
64
65 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 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 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 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
115fn 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
131fn 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
139pub 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 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
209fn 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
215pub 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 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 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 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 #[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 #[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}