1use std::collections::{HashMap, HashSet};
25
26use agentmux_procstats::{ProcInfo, ProcKey};
27use serde::Deserialize;
28
29#[derive(Debug, Clone, PartialEq)]
31enum Kind {
32 Cef(String),
34 Srv(&'static str),
36 Plain,
38 Unreadable(u8),
42}
43
44const READ_ATTEMPTS: u8 = 5;
47
48#[derive(Debug, Default)]
51pub struct Describer {
52 kinds: HashMap<ProcKey, Kind>,
53}
54
55impl Describer {
56 pub fn describe(
59 &mut self,
60 snap: &[ProcInfo],
61 members: &[usize],
62 own_pid: u32,
63 cmdline: &dyn Fn(ProcKey) -> Option<String>,
64 ) -> HashMap<usize, String> {
65 let present: HashSet<ProcKey> = members.iter().map(|&i| snap[i].key()).collect();
66 self.kinds.retain(|k, _| present.contains(k));
67 for &i in members {
68 let p = &snap[i];
69 if p.pid == own_pid {
70 continue;
71 }
72 let attempts = match self.kinds.get(&p.key()) {
73 None => 0,
74 Some(Kind::Unreadable(n)) if *n < READ_ATTEMPTS => *n,
75 Some(_) => continue,
76 };
77 let kind = match kind_of(p, cmdline(p.key()).as_deref()) {
78 Kind::Unreadable(_) => Kind::Unreadable(attempts + 1),
79 known => known,
80 };
81 self.kinds.insert(p.key(), kind);
82 }
83
84 let by_pid: HashMap<u32, usize> = members.iter().map(|&i| (snap[i].pid, i)).collect();
85 let parent_of = |i: usize| {
86 let p = &snap[i];
87 p.ppid.and_then(|pp| by_pid.get(&pp).copied()).filter(|&q| p.is_child_of(&snap[q]))
88 };
89 let hosts: HashSet<usize> = members
91 .iter()
92 .filter(|&&i| matches!(self.kinds.get(&snap[i].key()), Some(Kind::Cef(_))))
93 .filter_map(|&i| parent_of(i))
94 .collect();
95 let mut ancestors = HashSet::new();
97 let mut at = by_pid.get(&own_pid).copied();
98 for _ in 0..16 {
99 let Some(parent) = at.and_then(parent_of) else { break };
100 ancestors.insert(parent);
101 at = Some(parent);
102 }
103
104 let mut out = HashMap::new();
105 for &i in members {
106 let p = &snap[i];
107 let label = if p.pid == own_pid {
108 Some("Server".to_string())
109 } else {
110 match self.kinds.get(&p.key()) {
111 Some(Kind::Cef(t)) => Some(t.clone()),
112 Some(Kind::Srv(role)) => Some(role.to_string()),
113 _ if hosts.contains(&i) => Some("Main process".to_string()),
114 _ if ancestors.contains(&i) => Some("Launcher".to_string()),
115 _ => None,
116 }
117 };
118 if let Some(label) = label {
119 out.insert(i, label);
120 }
121 }
122 out
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Deserialize)]
129pub struct RendererEntry {
130 pub pid: u32,
131 pub label: String,
132 #[serde(default)]
134 pub block_id: Option<String>,
135 #[serde(default)]
137 pub pool: bool,
138 #[serde(default)]
140 pub window_id: Option<String>,
141}
142
143pub fn parse_renderer_map(answer: &serde_json::Value) -> Vec<RendererEntry> {
146 answer
147 .get("data")
148 .and_then(|d| d.get("renderers"))
149 .and_then(|r| serde_json::from_value(r.clone()).ok())
150 .unwrap_or_default()
151}
152
153pub fn renderer_serves(
158 entries: &[RendererEntry],
159 pid: u32,
160 page: &dyn Fn(&str) -> Option<String>,
161 workspace: &dyn Fn(&str) -> Option<String>,
162) -> Option<String> {
163 let mut names: Vec<String> = Vec::new();
164 for e in entries.iter().filter(|e| e.pid == pid) {
165 let name = if let Some(block_id) = &e.block_id {
166 page(block_id).map_or_else(|| "browser pane".to_string(), |p| format!("browser pane {p}"))
167 } else if e.pool {
168 "pool window (idle)".to_string()
169 } else {
170 e.window_id
171 .as_deref()
172 .and_then(workspace)
173 .map_or_else(|| "window".to_string(), |w| format!("window {w}"))
174 };
175 if !names.contains(&name) {
176 names.push(name);
177 }
178 }
179 (!names.is_empty()).then(|| names.join(", "))
180}
181
182pub fn page_host(url: &str) -> Option<String> {
185 let parsed = url::Url::parse(url).ok()?;
186 parsed.host_str().filter(|h| !h.is_empty()).map(str::to_string)
187}
188
189pub fn own_view_pid(pid: u32) -> u32 {
199 #[cfg(target_os = "linux")]
200 {
201 if let Some(inner) = std::fs::read_to_string(format!("/proc/{pid}/status"))
202 .ok()
203 .and_then(|s| innermost_ns_pid(&s))
204 {
205 return inner;
206 }
207 }
208 pid
209}
210
211#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
213fn innermost_ns_pid(status: &str) -> Option<u32> {
214 status
215 .lines()
216 .find_map(|l| l.strip_prefix("NSpid:"))
217 .and_then(|rest| rest.split_whitespace().last())
218 .and_then(|p| p.parse().ok())
219}
220
221fn kind_of(p: &ProcInfo, cmdline: Option<&str>) -> Kind {
222 let Some(cmd) = cmdline else {
223 return mac_helper_type(&p.name).map_or(Kind::Unreadable(1), Kind::Cef);
224 };
225 let name = p.name.to_ascii_lowercase();
226 if name.starts_with("agentmux") {
230 if let Some(ty) = switch(cmd, "type") {
231 return Kind::Cef(cef_label(ty, switch(cmd, "utility-sub-type")));
232 }
233 }
234 if name.starts_with("agentmux-srv") {
235 return Kind::Srv(if has_arg(cmd, "__extract") {
236 "Document parser"
237 } else if has_arg(cmd, "--crash-monitor") {
238 "Crash monitor"
239 } else {
240 "Server"
241 });
242 }
243 Kind::Plain
244}
245
246fn args(cmd: &str) -> impl Iterator<Item = &str> {
247 cmd.split_whitespace().map(|a| a.trim_matches('"'))
248}
249
250fn switch<'a>(cmd: &'a str, name: &str) -> Option<&'a str> {
253 let key = format!("--{name}=");
254 args(cmd).find_map(|a| a.strip_prefix(key.as_str()))
255}
256
257fn has_arg(cmd: &str, arg: &str) -> bool {
258 args(cmd).any(|a| a == arg)
259}
260
261fn cef_label(ty: &str, utility_sub_type: Option<&str>) -> String {
262 match ty {
263 "renderer" => "Renderer".to_string(),
264 "gpu-process" => "GPU".to_string(),
265 "utility" => utility_label(utility_sub_type),
266 "crashpad-handler" => "Crash reporter".to_string(),
267 "zygote" => "Zygote".to_string(),
268 "ppapi" | "ppapi-broker" => "Plugin".to_string(),
269 _ => "Subprocess".to_string(),
271 }
272}
273
274fn utility_label(sub_type: Option<&str>) -> String {
276 let Some(service) = sub_type.and_then(|s| s.split('.').next()).filter(|s| !s.is_empty()) else {
277 return "Utility".to_string();
278 };
279 match service {
280 "network" => "Network service".to_string(),
281 "storage" => "Storage service".to_string(),
282 "audio" => "Audio service".to_string(),
283 "video_capture" => "Video capture service".to_string(),
284 "data_decoder" => "Data decoder".to_string(),
285 _ => "Utility".to_string(),
287 }
288}
289
290fn mac_helper_type(name: &str) -> Option<String> {
293 let rest = name.strip_prefix("AgentMux Helper")?;
294 let rest = rest.trim();
295 if rest.is_empty() {
296 return Some("Utility".to_string());
297 }
298 let ty = rest.strip_prefix('(')?.strip_suffix(')')?;
299 Some(match ty {
300 "Renderer" | "Alloy" => "Renderer".to_string(),
301 other => other.to_string(),
302 })
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 fn p(pid: u32, ppid: u32, name: &str) -> ProcInfo {
310 ProcInfo {
311 pid,
312 ppid: Some(ppid).filter(|&x| x != 0),
313 start_key: pid as u64 * 10,
314 started_at_ms: Some(pid as u64 * 10),
315 name: name.to_string(),
316 cpu_ns: Some(0),
317 mem_private: Some(1000),
318 mem_resident: Some(2000),
319 mem_commit: None,
320 }
321 }
322
323 fn windows_tree() -> (Vec<ProcInfo>, HashMap<u32, &'static str>) {
326 let snap = vec![
327 p(10, 1, "agentmux.exe"),
328 p(20, 10, "agentmux-0.59.18.exe"),
329 p(21, 20, "agentmux-0.59.18.exe"),
330 p(22, 20, "agentmux-0.59.18.exe"),
331 p(23, 20, "agentmux-0.59.18.exe"),
332 p(24, 20, "agentmux-0.59.18.exe"),
333 p(30, 10, "agentmux-srv-0.59.18-windows.x64.exe"),
334 p(31, 30, "agentmux-srv-0.59.18-windows.x64.exe"),
335 p(32, 30, "agentmux-srv-0.59.18-windows.x64.exe"),
336 ];
337 let cmd = HashMap::from([
338 (10, r#""C:\AgentMux\agentmux.exe""#),
339 (20, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --some-flag"#),
340 (21, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --type=gpu-process --gpu-preferences=abc"#),
341 (22, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --type=renderer --renderer-client-id=4"#),
342 (23, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --type=utility --utility-sub-type=network.mojom.NetworkService"#),
343 (24, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --type=utility --utility-sub-type=video_capture.mojom.VideoCaptureService"#),
344 (31, r#""C:\AgentMux\runtime\agentmux-srv-0.59.18-windows.x64.exe" --crash-monitor"#),
345 (32, r#""C:\AgentMux\runtime\agentmux-srv-0.59.18-windows.x64.exe" __extract --kind pdf"#),
346 ]);
347 (snap, cmd)
348 }
349
350 fn labels(snap: &[ProcInfo], cmd: &HashMap<u32, &str>, own_pid: u32) -> HashMap<u32, String> {
351 let members: Vec<usize> = (0..snap.len()).collect();
352 let read = |k: ProcKey| cmd.get(&k.pid).map(|s| s.to_string());
353 Describer::default()
354 .describe(snap, &members, own_pid, &read)
355 .into_iter()
356 .map(|(i, l)| (snap[i].pid, l))
357 .collect()
358 }
359
360 #[test]
361 fn labels_every_process_of_a_windows_install() {
362 let (snap, cmd) = windows_tree();
363 let got = labels(&snap, &cmd, 30);
364 let want: HashMap<u32, String> = [
365 (10, "Launcher"),
366 (20, "Main process"),
367 (21, "GPU"),
368 (22, "Renderer"),
369 (23, "Network service"),
370 (24, "Video capture service"),
371 (30, "Server"),
372 (31, "Crash monitor"),
373 (32, "Document parser"),
374 ]
375 .into_iter()
376 .map(|(pid, l)| (pid, l.to_string()))
377 .collect();
378 assert_eq!(got, want);
379 }
380
381 #[test]
382 fn a_macos_helper_is_labelled_by_its_name_when_its_command_line_is_unreadable() {
383 let snap = vec![
384 p(10, 1, "agentmux-launcher"),
385 p(20, 10, "agentmux-cef"),
386 p(21, 20, "AgentMux Helper (GPU)"),
387 p(22, 20, "AgentMux Helper (Renderer)"),
388 p(23, 20, "AgentMux Helper"),
389 p(30, 10, "agentmux-srv-0.59.18-darwin.arm64"),
390 ];
391 let got = labels(&snap, &HashMap::new(), 30);
392 assert_eq!(got.get(&21).map(String::as_str), Some("GPU"));
393 assert_eq!(got.get(&22).map(String::as_str), Some("Renderer"));
394 assert_eq!(got.get(&23).map(String::as_str), Some("Utility"));
395 assert_eq!(got.get(&20).map(String::as_str), Some("Main process"));
397 assert_eq!(got.get(&10).map(String::as_str), Some("Launcher"));
398 assert_eq!(got.get(&30).map(String::as_str), Some("Server"));
399 }
400
401 #[test]
402 fn a_process_that_is_neither_host_nor_launcher_gets_no_label() {
403 let snap = vec![p(10, 1, "agentmux.exe"), p(30, 10, "agentmux-srv.exe"), p(40, 30, "agentmux-helper-thing")];
404 let cmd = HashMap::from([(10, "agentmux.exe"), (40, "agentmux-helper-thing --x")]);
405 let got = labels(&snap, &cmd, 30);
406 assert_eq!(got.get(&40), None);
407 assert_eq!(got.get(&10).map(String::as_str), Some("Launcher"));
408 }
409
410 #[test]
411 fn reads_each_command_line_once_and_forgets_exited_processes() {
412 let (snap, cmd) = windows_tree();
413 let members: Vec<usize> = (0..snap.len()).collect();
414 let reads = std::cell::Cell::new(0);
415 let read = |k: ProcKey| {
416 reads.set(reads.get() + 1);
417 cmd.get(&k.pid).map(|s| s.to_string())
418 };
419 let mut d = Describer::default();
420 d.describe(&snap, &members, 30, &read);
421 let first = reads.get();
422 assert_eq!(first, snap.len() - 1, "every process but srv itself, once");
423 d.describe(&snap, &members, 30, &read);
424 assert_eq!(reads.get(), first, "a second sample reads nothing");
425 d.describe(&snap, &members[..2], 30, &read);
426 assert_eq!(d.kinds.len(), 2, "processes no longer present are forgotten");
427 }
428
429 fn entry(pid: u32, label: &str) -> RendererEntry {
430 RendererEntry { pid, label: label.to_string(), block_id: None, pool: false, window_id: None }
431 }
432
433 #[test]
434 fn names_what_each_renderer_serves() {
435 let entries = vec![
436 RendererEntry { window_id: Some("w1".into()), ..entry(1, "main") },
437 RendererEntry { block_id: Some("b1".into()), ..entry(2, "pane-b1") },
438 RendererEntry { pool: true, ..entry(3, "window-pool-2") },
439 RendererEntry { block_id: Some("gone".into()), ..entry(4, "pane-gone") },
440 entry(5, "floating-7"),
441 ];
442 let page = |id: &str| (id == "b1").then(|| "github.com".to_string());
443 let workspace = |id: &str| (id == "w1").then(|| "Main".to_string());
444 let serves = |pid| renderer_serves(&entries, pid, &page, &workspace);
445 assert_eq!(serves(1).as_deref(), Some("window Main"));
446 assert_eq!(serves(2).as_deref(), Some("browser pane github.com"));
447 assert_eq!(serves(3).as_deref(), Some("pool window (idle)"));
448 assert_eq!(serves(4).as_deref(), Some("browser pane"));
449 assert_eq!(serves(5).as_deref(), Some("window"));
450 assert_eq!(serves(9), None);
451 }
452
453 #[test]
454 fn one_renderer_serving_two_browsers_names_both_once() {
455 let entries = vec![
456 RendererEntry { block_id: Some("b1".into()), ..entry(2, "pane-a") },
457 RendererEntry { block_id: Some("b2".into()), ..entry(2, "pane-b") },
458 RendererEntry { block_id: Some("b3".into()), ..entry(2, "pane-c") },
459 ];
460 let page = |id: &str| Some(if id == "b3" { "a.test" } else { "b.test" }.to_string());
461 let none = |_: &str| None;
462 assert_eq!(
463 renderer_serves(&entries, 2, &page, &none).as_deref(),
464 Some("browser pane b.test, browser pane a.test")
465 );
466 }
467
468 #[test]
469 fn a_page_is_named_by_its_host_alone() {
470 assert_eq!(page_host("https://github.com/agentmuxai").as_deref(), Some("github.com"));
471 assert_eq!(page_host("https://user:secret@example.com:8080/a?b=c#d").as_deref(), Some("example.com"));
472 assert_eq!(page_host("http://127.0.0.1:5173/").as_deref(), Some("127.0.0.1"));
473 assert_eq!(page_host("about:blank"), None);
474 assert_eq!(page_host("not a url"), None);
475 assert_eq!(page_host(""), None);
476 }
477
478 #[test]
479 fn a_sandboxed_renderer_is_found_by_the_pid_it_sees_for_itself() {
480 let status = "Name:\tagentmux-cef\nTgid:\t48211\nNSpid:\t48211\t3\nPPid:\t48190\n";
483 assert_eq!(innermost_ns_pid(status), Some(3));
484 assert_eq!(innermost_ns_pid("NSpid:\t912\n"), Some(912));
486 assert_eq!(innermost_ns_pid("Name:\tx\nPid:\t912\n"), None);
488 }
489
490 #[test]
491 fn parses_the_hosts_answer_and_tolerates_anything_else() {
492 let answer = serde_json::json!({"ok": true, "data": {"renderers": [
493 {"pid": 7, "label": "main", "pool": false, "window_id": "w1"},
494 {"pid": 8, "label": "pane-x", "block_id": "b1", "pool": false}
495 ]}});
496 let got = parse_renderer_map(&answer);
497 assert_eq!(got.len(), 2);
498 assert_eq!(got[1].block_id.as_deref(), Some("b1"));
499 assert!(parse_renderer_map(&serde_json::json!({"ok": false, "error": "x"})).is_empty());
500 assert!(parse_renderer_map(&serde_json::json!({"ok": true, "data": {"renderers": "nope"}})).is_empty());
501 }
502
503 #[test]
504 fn a_command_line_that_could_not_be_read_is_tried_again_a_few_times() {
505 let (snap, cmd) = windows_tree();
506 let members: Vec<usize> = (0..snap.len()).collect();
507 let readable = std::cell::Cell::new(false);
509 let reads_of_21 = std::cell::Cell::new(0);
510 let read = |k: ProcKey| {
511 if k.pid == 21 {
512 reads_of_21.set(reads_of_21.get() + 1);
513 if !readable.get() {
514 return None;
515 }
516 }
517 cmd.get(&k.pid).map(|s| s.to_string())
518 };
519 let mut d = Describer::default();
520 let first = d.describe(&snap, &members, 30, &read);
521 assert_eq!(first.get(&2), None, "unreadable at first: no label yet");
522 readable.set(true);
523 let second = d.describe(&snap, &members, 30, &read);
524 assert_eq!(second.get(&2).map(String::as_str), Some("GPU"));
525 assert_eq!(reads_of_21.get(), 2);
526 d.describe(&snap, &members, 30, &read);
527 assert_eq!(reads_of_21.get(), 2, "read once it's known");
528 }
529
530 #[test]
531 fn a_process_that_can_never_be_read_is_given_up_on() {
532 let snap = vec![p(10, 1, "agentmux.exe"), p(30, 10, "agentmux-srv.exe"), p(40, 30, "agentmux-thing")];
533 let members: Vec<usize> = (0..snap.len()).collect();
534 let reads = std::cell::Cell::new(0);
535 let read = |k: ProcKey| {
536 if k.pid == 40 {
537 reads.set(reads.get() + 1);
538 }
539 None
540 };
541 let mut d = Describer::default();
542 for _ in 0..10 {
543 d.describe(&snap, &members, 30, &read);
544 }
545 assert_eq!(reads.get(), READ_ATTEMPTS as usize);
546 }
547
548 #[test]
549 fn utility_sub_types() {
550 assert_eq!(utility_label(Some("storage.mojom.StorageService")), "Storage service");
551 assert_eq!(utility_label(Some("audio.mojom.AudioService")), "Audio service");
552 assert_eq!(utility_label(Some("proxy_resolver.mojom.ProxyResolverFactory")), "Utility");
553 assert_eq!(utility_label(None), "Utility");
554 }
555
556 #[test]
557 fn other_cef_types() {
558 assert_eq!(cef_label("crashpad-handler", None), "Crash reporter");
559 assert_eq!(cef_label("zygote", None), "Zygote");
560 assert_eq!(cef_label("broker", None), "Subprocess");
561 }
562
563 #[test]
564 fn a_type_switch_on_a_process_that_is_not_ours_means_nothing() {
565 let snap = vec![
568 p(10, 1, "agentmux.exe"),
569 p(30, 10, "agentmux-srv.exe"),
570 p(40, 30, "electron.exe"),
571 ];
572 let cmd = HashMap::from([(10, "agentmux.exe"), (40, "electron.exe --type=renderer")]);
573 let got = labels(&snap, &cmd, 30);
574 assert_eq!(got.get(&40), None);
575 assert_eq!(got.get(&30).map(String::as_str), Some("Server"));
577 }
578}