1use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "kind", rename_all = "snake_case")]
14pub enum ProcRole {
15 Engine,
16 ExtensionSidecar { name: String },
17 McpServer { name: String },
18 Shell,
19 Other,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ProcMem {
25 pub pid: u32,
26 pub ppid: u32,
27 pub cmd: String,
31 pub role: ProcRole,
32 pub rss_kb: u64,
33 pub pss_kb: u64,
34 pub uss_kb: u64,
36 pub anon_kb: u64,
38 pub threads: u32,
39}
40
41#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
44pub struct SelfMem {
45 pub rss_kb: u64,
46 pub rss_anon_kb: u64,
47 pub threads: u32,
48 pub jemalloc_allocated_kb: u64,
49 pub jemalloc_active_kb: u64,
50 pub jemalloc_resident_kb: u64,
51 pub jemalloc_retained_kb: u64,
52 #[serde(default)]
53 pub jemalloc_metadata_kb: u64,
54 #[serde(default)]
57 pub anon_huge_kb: u64,
58}
59
60#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
62pub struct MemTotals {
63 pub procs: usize,
64 pub rss_kb: u64,
65 pub pss_kb: u64,
66 pub uss_kb: u64,
67 pub anon_kb: u64,
68 pub threads: u32,
69}
70
71impl MemTotals {
72 pub fn of(rows: &[ProcMem]) -> Self {
73 rows.iter().fold(Self::default(), |mut t, r| {
74 t.procs += 1;
75 t.rss_kb += r.rss_kb;
76 t.pss_kb += r.pss_kb;
77 t.uss_kb += r.uss_kb;
78 t.anon_kb += r.anon_kb;
79 t.threads += r.threads;
80 t
81 })
82 }
83}
84
85#[cfg_attr(target_os = "linux", allow(dead_code))]
86fn unsupported() -> std::io::Error {
87 std::io::Error::new(
88 std::io::ErrorKind::Unsupported,
89 "memstat: /proc walking is Linux-only",
90 )
91}
92
93pub fn tree(pid: u32) -> std::io::Result<Vec<ProcMem>> {
97 #[cfg(target_os = "linux")]
98 {
99 linux::tree(Path::new("/proc"), pid)
100 }
101 #[cfg(not(target_os = "linux"))]
102 {
103 let _ = pid;
104 Err(unsupported())
105 }
106}
107
108pub fn process(pid: u32) -> std::io::Result<ProcMem> {
110 #[cfg(target_os = "linux")]
111 {
112 linux::read_proc(Path::new("/proc"), pid)
113 }
114 #[cfg(not(target_os = "linux"))]
115 {
116 let _ = pid;
117 Err(unsupported())
118 }
119}
120
121pub fn self_snapshot() -> SelfMem {
124 let mut snap = SelfMem::default();
125 #[cfg(target_os = "linux")]
126 {
127 if let Ok(s) = linux::read_status(Path::new("/proc/self/status")) {
128 snap.rss_kb = s.rss_kb;
129 snap.rss_anon_kb = s.anon_kb;
130 snap.threads = s.threads;
131 }
132 snap.anon_huge_kb = linux::anon_huge_kb(Path::new("/proc/self/smaps_rollup"));
133 }
134 #[cfg(all(unix, not(target_env = "musl")))]
135 {
136 use tikv_jemalloc_ctl::{epoch, stats};
137 if epoch::advance().is_ok() {
138 snap.jemalloc_allocated_kb = stats::allocated::read().unwrap_or(0) as u64 / 1024;
139 snap.jemalloc_active_kb = stats::active::read().unwrap_or(0) as u64 / 1024;
140 snap.jemalloc_resident_kb = stats::resident::read().unwrap_or(0) as u64 / 1024;
141 snap.jemalloc_retained_kb = stats::retained::read().unwrap_or(0) as u64 / 1024;
142 snap.jemalloc_metadata_kb = stats::metadata::read().unwrap_or(0) as u64 / 1024;
143 }
144 }
145 snap
146}
147
148pub fn purge_arenas() {
151 #[cfg(all(unix, not(target_env = "musl")))]
152 {
153 let _ = unsafe { tikv_jemalloc_ctl::raw::read::<()>(b"arena.4096.purge\0") };
156 }
157}
158
159pub type MallctlResult = std::result::Result<(), String>;
161
162#[cfg(all(unix, not(target_env = "musl")))]
163fn mallctl_err(name: &str, e: impl std::fmt::Display) -> String {
164 format!("{name}: {e}")
165}
166
167pub fn set_background_threads(on: bool) -> MallctlResult {
171 #[cfg(all(unix, not(target_env = "musl")))]
172 {
173 tikv_jemalloc_ctl::background_thread::write(on)
174 .map_err(|e| mallctl_err("background_thread", e))
175 }
176 #[cfg(not(all(unix, not(target_env = "musl"))))]
177 {
178 let _ = on;
179 Ok(())
180 }
181}
182
183pub fn background_threads_enabled() -> Option<bool> {
185 #[cfg(all(unix, not(target_env = "musl")))]
186 {
187 tikv_jemalloc_ctl::background_thread::read().ok()
188 }
189 #[cfg(not(all(unix, not(target_env = "musl"))))]
190 {
191 None
192 }
193}
194
195pub fn set_decay_ms(dirty_ms: i64, muzzy_ms: i64) -> MallctlResult {
199 #[cfg(all(unix, not(target_env = "musl")))]
200 {
201 use tikv_jemalloc_ctl::raw;
202 let d = dirty_ms as libc::ssize_t;
204 let m = muzzy_ms as libc::ssize_t;
205 unsafe {
206 raw::write(b"arenas.dirty_decay_ms\0", d)
207 .map_err(|e| mallctl_err("arenas.dirty_decay_ms", e))?;
208 raw::write(b"arenas.muzzy_decay_ms\0", m)
209 .map_err(|e| mallctl_err("arenas.muzzy_decay_ms", e))?;
210 let n: u32 = raw::read(b"arenas.narenas\0")
214 .map_err(|e| mallctl_err("arenas.narenas", e))?;
215 for i in 0..n {
216 let dk = format!("arena.{i}.dirty_decay_ms\0");
217 let mk = format!("arena.{i}.muzzy_decay_ms\0");
218 if raw::write(dk.as_bytes(), d).is_ok() {
219 let _ = raw::write(mk.as_bytes(), m);
220 }
221 }
222 }
223 Ok(())
224 }
225 #[cfg(not(all(unix, not(target_env = "musl"))))]
226 {
227 let _ = (dirty_ms, muzzy_ms);
228 Ok(())
229 }
230}
231
232pub fn thp_disabled() -> Option<bool> {
235 #[cfg(target_os = "linux")]
236 {
237 let rc = unsafe { libc::prctl(libc::PR_GET_THP_DISABLE, 0u64, 0u64, 0u64, 0u64) };
239 (rc >= 0).then_some(rc == 1)
240 }
241 #[cfg(not(target_os = "linux"))]
242 {
243 None
244 }
245}
246
247pub fn thp_sysfs_always() -> Option<bool> {
251 #[cfg(target_os = "linux")]
252 {
253 let s = std::fs::read_to_string("/sys/kernel/mm/transparent_hugepage/enabled").ok()?;
254 Some(thp_mode_is_always(&s))
255 }
256 #[cfg(not(target_os = "linux"))]
257 {
258 None
259 }
260}
261
262#[allow(dead_code)]
263fn thp_mode_is_always(sysfs: &str) -> bool {
264 sysfs.split_whitespace().any(|w| w == "[always]")
265}
266
267pub fn disable_thp() -> MallctlResult {
272 #[cfg(target_os = "linux")]
273 {
274 let rc = unsafe { libc::prctl(libc::PR_SET_THP_DISABLE, 1u64, 0u64, 0u64, 0u64) };
276 if rc == 0 {
277 Ok(())
278 } else {
279 Err(format!("prctl(PR_SET_THP_DISABLE): {}", std::io::Error::last_os_error()))
280 }
281 }
282 #[cfg(not(target_os = "linux"))]
283 {
284 Ok(())
285 }
286}
287
288pub fn set_thread_tcache(on: bool) -> MallctlResult {
290 #[cfg(all(unix, not(target_env = "musl")))]
291 {
292 unsafe { tikv_jemalloc_ctl::raw::write(b"thread.tcache.enabled\0", on) }
293 .map_err(|e| mallctl_err("thread.tcache.enabled", e))
294 }
295 #[cfg(not(all(unix, not(target_env = "musl"))))]
296 {
297 let _ = on;
298 Ok(())
299 }
300}
301
302pub fn mem_trace_enabled() -> bool {
305 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
306 *ON.get_or_init(|| std::env::var("SYNAPS_MEM_TRACE").is_ok_and(|v| v == "1"))
307}
308
309pub fn log_turn_memory() {
313 if !mem_trace_enabled() {
314 return;
315 }
316 let s = self_snapshot();
317 tracing::info!(
318 target: "agent_core::memstat",
319 rss_anon_kb = s.rss_anon_kb,
320 jemalloc_allocated_kb = s.jemalloc_allocated_kb,
321 jemalloc_resident_kb = s.jemalloc_resident_kb,
322 threads = s.threads,
323 broker_installs = crate::auth::global_broker_install_count(),
324 "turn memory"
325 );
326}
327
328static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
331
332fn ladder_sink() -> Option<&'static std::sync::Mutex<std::fs::File>> {
336 static SINK: std::sync::OnceLock<Option<std::sync::Mutex<std::fs::File>>> =
337 std::sync::OnceLock::new();
338 SINK.get_or_init(|| {
339 let path = std::env::var_os("SYNAPS_MEM_TRACE_FILE")
340 .map(std::path::PathBuf::from)
341 .unwrap_or_else(|| {
342 let dir = std::env::var_os("XDG_RUNTIME_DIR")
343 .map(std::path::PathBuf::from)
344 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
345 dir.join(format!("synaps-memtrace-{}.log", std::process::id()))
346 });
347 let mut opts = std::fs::OpenOptions::new();
348 opts.create(true).append(true);
349 #[cfg(unix)]
350 {
351 use std::os::unix::fs::OpenOptionsExt;
352 opts.mode(0o600);
353 }
354 opts.open(path).ok().map(std::sync::Mutex::new)
355 })
356 .as_ref()
357}
358
359pub fn ladder(stage: &'static str, extra: &dyn std::fmt::Display) {
367 if !mem_trace_enabled() {
368 return;
369 }
370 let start = *START.get_or_init(std::time::Instant::now);
371 let line = ladder_line(start.elapsed().as_millis(), stage, &self_snapshot(), extra);
372 if let Some(sink) = ladder_sink() {
373 if let Ok(mut f) = sink.lock() {
374 use std::io::Write;
375 let _ = f.write_all(line.as_bytes());
376 }
377 }
378}
379
380fn ladder_line(t_ms: u128, stage: &str, s: &SelfMem, extra: &dyn std::fmt::Display) -> String {
381 let extra = extra.to_string();
382 let sep = if extra.is_empty() { "" } else { " " };
383 format!(
384 "t_ms={t_ms} stage={stage} rss_anon_kb={} jemalloc_allocated_kb={} active_kb={} resident_kb={} retained_kb={} metadata_kb={} threads={} anon_huge_kb={}{sep}{extra}\n",
385 s.rss_anon_kb,
386 s.jemalloc_allocated_kb,
387 s.jemalloc_active_kb,
388 s.jemalloc_resident_kb,
389 s.jemalloc_retained_kb,
390 s.jemalloc_metadata_kb,
391 s.threads,
392 s.anon_huge_kb,
393 )
394}
395
396pub fn redact_cmdline(cmd: &str) -> String {
403 const SENSITIVE: &[&str] = &[
404 "key",
405 "token",
406 "secret",
407 "password",
408 "passwd",
409 "bearer",
410 "auth",
411 "credential",
412 ];
413 fn sensitive(name: &str) -> bool {
414 let n = name.to_ascii_lowercase();
415 SENSITIVE.iter().any(|s| n.contains(s))
416 }
417 fn looks_like_key(arg: &str) -> bool {
418 let a = arg.trim_matches(|c| c == '"' || c == '\'');
419 if a.starts_with("sk-")
420 || a.starts_with("ghp_")
421 || a.starts_with("github_pat_")
422 || a.starts_with("xox")
423 || a.starts_with("AKIA")
424 || a.eq_ignore_ascii_case("bearer")
425 {
426 return true;
427 }
428 a.len() >= 32
429 && !a.contains('/')
430 && !a.contains('.')
431 && a.chars()
432 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '=')
433 }
434 let mut out = Vec::new();
435 let mut redact_next = false;
436 for arg in cmd.split(' ').filter(|s| !s.is_empty()) {
437 if redact_next {
438 out.push("***".to_string());
439 redact_next = false;
440 continue;
441 }
442 if let Some(flag) = arg.strip_prefix('-') {
443 let flag = flag.trim_start_matches('-');
444 match flag.split_once('=') {
445 Some((name, _)) if sensitive(name) => {
446 out.push(format!("{}=***", &arg[..arg.len() - flag.len() + name.len()]));
447 }
448 Some(_) => out.push(arg.to_string()),
449 None => {
450 if sensitive(flag) {
451 redact_next = true;
452 }
453 out.push(arg.to_string());
454 }
455 }
456 continue;
457 }
458 if let Some((name, _)) = arg.split_once('=') {
459 if sensitive(name) {
460 out.push(format!("{name}=***"));
461 continue;
462 }
463 }
464 if looks_like_key(arg) {
465 out.push("***".to_string());
466 continue;
467 }
468 out.push(arg.to_string());
469 }
470 out.join(" ")
471}
472
473pub fn classify(cmd: &str) -> ProcRole {
476 let argv: Vec<&str> = cmd.split(' ').filter(|s| !s.is_empty()).collect();
477 let Some(&argv0) = argv.first() else {
478 return ProcRole::Other;
479 };
480 let base = Path::new(argv0)
481 .file_name()
482 .map(|s| s.to_string_lossy().into_owned())
483 .unwrap_or_default();
484 if base.starts_with("synaps") {
485 return ProcRole::Engine;
486 }
487 if base == "bash" || base == "sh" || base == "zsh" || base == "fish" || base == "pwsh" {
488 return ProcRole::Shell;
489 }
490 for arg in &argv {
493 if let Some(name) = plugin_name_from_path(arg) {
494 return ProcRole::ExtensionSidecar { name };
495 }
496 }
497 if argv.iter().any(|a| a.contains("mcp")) {
498 let name = Path::new(argv0)
499 .file_name()
500 .map(|s| s.to_string_lossy().into_owned())
501 .unwrap_or_else(|| argv0.to_string());
502 return ProcRole::McpServer { name };
503 }
504 ProcRole::Other
505}
506
507fn plugin_name_from_path(arg: &str) -> Option<String> {
508 let p = Path::new(arg);
509 let comps: Vec<String> = p
510 .components()
511 .map(|c| c.as_os_str().to_string_lossy().into_owned())
512 .collect();
513 for marker in ["plugins", "extensions"] {
514 if let Some(i) = comps.iter().position(|c| c == marker) {
515 if let Some(name) = comps.get(i + 1) {
516 if i + 2 < comps.len() {
517 return Some(name.clone());
518 }
519 }
520 }
521 }
522 None
523}
524
525#[cfg(target_os = "linux")]
526mod linux {
527 use super::*;
528 use std::collections::HashMap;
529
530 #[derive(Default)]
531 pub(super) struct Status {
532 pub ppid: u32,
533 pub rss_kb: u64,
534 pub anon_kb: u64,
535 pub threads: u32,
536 }
537
538 fn kb(line: &str) -> u64 {
539 line.split_whitespace()
540 .nth(1)
541 .and_then(|v| v.parse().ok())
542 .unwrap_or(0)
543 }
544
545 pub(super) fn read_status(path: &Path) -> std::io::Result<Status> {
546 let text = std::fs::read_to_string(path)?;
547 let mut s = Status::default();
548 for line in text.lines() {
549 if let Some(v) = line.strip_prefix("PPid:") {
550 s.ppid = v.trim().parse().unwrap_or(0);
551 } else if line.starts_with("VmRSS:") {
552 s.rss_kb = kb(line);
553 } else if line.starts_with("RssAnon:") {
554 s.anon_kb = kb(line);
555 } else if let Some(v) = line.strip_prefix("Threads:") {
556 s.threads = v.trim().parse().unwrap_or(0);
557 }
558 }
559 Ok(s)
560 }
561
562 pub(super) fn anon_huge_kb(path: &Path) -> u64 {
564 std::fs::read_to_string(path)
565 .ok()
566 .and_then(|t| t.lines().find(|l| l.starts_with("AnonHugePages:")).map(kb))
567 .unwrap_or(0)
568 }
569
570 struct Rollup {
571 rss_kb: u64,
572 pss_kb: u64,
573 uss_kb: u64,
574 }
575
576 fn read_rollup(path: &Path) -> std::io::Result<Rollup> {
577 let text = std::fs::read_to_string(path)?;
578 let (mut rss, mut pss, mut pc, mut pd) = (0, 0, 0, 0);
579 for line in text.lines() {
580 if line.starts_with("Rss:") {
581 rss = kb(line);
582 } else if line.starts_with("Pss:") {
583 pss = kb(line);
584 } else if line.starts_with("Private_Clean:") {
585 pc = kb(line);
586 } else if line.starts_with("Private_Dirty:") {
587 pd = kb(line);
588 }
589 }
590 Ok(Rollup {
591 rss_kb: rss,
592 pss_kb: pss,
593 uss_kb: pc + pd,
594 })
595 }
596
597 fn read_cmdline(path: &Path) -> String {
598 std::fs::read(path)
599 .map(|b| {
600 b.split(|c| *c == 0)
601 .filter(|s| !s.is_empty())
602 .map(|s| String::from_utf8_lossy(s).into_owned())
603 .collect::<Vec<_>>()
604 .join(" ")
605 })
606 .unwrap_or_default()
607 }
608
609 pub(super) fn read_proc(proc_root: &Path, pid: u32) -> std::io::Result<ProcMem> {
610 let dir = proc_root.join(pid.to_string());
611 let status = read_status(&dir.join("status"))?;
612 let rollup = read_rollup(&dir.join("smaps_rollup")).unwrap_or(Rollup {
613 rss_kb: status.rss_kb,
614 pss_kb: 0,
615 uss_kb: 0,
616 });
617 let raw = read_cmdline(&dir.join("cmdline"));
618 let role = classify(&raw);
619 let cmd = redact_cmdline(&raw);
620 Ok(ProcMem {
621 pid,
622 ppid: status.ppid,
623 cmd,
624 role,
625 rss_kb: rollup.rss_kb,
626 pss_kb: rollup.pss_kb,
627 uss_kb: rollup.uss_kb,
628 anon_kb: status.anon_kb,
629 threads: status.threads,
630 })
631 }
632
633 fn children_map(proc_root: &Path) -> HashMap<u32, Vec<u32>> {
635 let mut map: HashMap<u32, Vec<u32>> = HashMap::new();
636 let Ok(entries) = std::fs::read_dir(proc_root) else {
637 return map;
638 };
639 for entry in entries.flatten() {
640 let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
641 continue;
642 };
643 let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) else {
644 continue;
645 };
646 let Some(after) = stat.rfind(')') else {
648 continue;
649 };
650 let ppid = stat[after + 1..]
651 .split_whitespace()
652 .nth(1)
653 .and_then(|v| v.parse::<u32>().ok())
654 .unwrap_or(0);
655 map.entry(ppid).or_default().push(pid);
656 }
657 for v in map.values_mut() {
658 v.sort_unstable();
659 }
660 map
661 }
662
663 pub(super) fn tree(proc_root: &Path, pid: u32) -> std::io::Result<Vec<ProcMem>> {
664 let root = read_proc(proc_root, pid)?;
665 let children = children_map(proc_root);
666 let mut out = vec![root];
667 let mut queue = std::collections::VecDeque::from([pid]);
668 while let Some(p) = queue.pop_front() {
669 if let Some(kids) = children.get(&p) {
670 for &k in kids {
671 if let Ok(pm) = read_proc(proc_root, k) {
672 out.push(pm);
673 queue.push_back(k);
674 }
675 }
676 }
677 }
678 Ok(out)
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 #[test]
687 fn thp_mode_parse() {
688 assert!(super::thp_mode_is_always("[always] madvise never\n"));
689 assert!(!super::thp_mode_is_always("always [madvise] never\n"));
690 assert!(!super::thp_mode_is_always("always madvise [never]\n"));
691 assert!(!super::thp_mode_is_always(""));
692 }
693
694 #[test]
695 fn ladder_line_format_and_noop_when_disabled() {
696 let s = SelfMem {
697 rss_anon_kb: 1,
698 jemalloc_allocated_kb: 2,
699 jemalloc_active_kb: 3,
700 jemalloc_resident_kb: 4,
701 jemalloc_retained_kb: 5,
702 jemalloc_metadata_kb: 6,
703 threads: 7,
704 ..SelfMem::default()
705 };
706 assert_eq!(
707 ladder_line(42, "http", &s, &"build_ms=3"),
708 "t_ms=42 stage=http rss_anon_kb=1 jemalloc_allocated_kb=2 active_kb=3 resident_kb=4 retained_kb=5 metadata_kb=6 threads=7 anon_huge_kb=0 build_ms=3\n"
709 );
710 assert_eq!(
711 ladder_line(0, "main", &s, &""),
712 "t_ms=0 stage=main rss_anon_kb=1 jemalloc_allocated_kb=2 active_kb=3 resident_kb=4 retained_kb=5 metadata_kb=6 threads=7 anon_huge_kb=0\n"
713 );
714 if mem_trace_enabled() {
718 return; }
720 let dir = std::env::temp_dir().join(format!("synaps-ladder-test-{}", std::process::id()));
721 std::fs::create_dir_all(&dir).unwrap();
722 let file = dir.join("trace.log");
723 std::env::set_var("SYNAPS_MEM_TRACE_FILE", &file);
724 ladder("main", &"");
725 assert!(!file.exists(), "ladder wrote while disabled");
726 std::env::remove_var("SYNAPS_MEM_TRACE_FILE");
727 let _ = std::fs::remove_dir_all(&dir);
728 }
729
730 #[test]
731 fn classify_roles_from_cmdline() {
732 assert_eq!(classify("/usr/local/bin/synaps"), ProcRole::Engine);
733 assert_eq!(classify("synaps server --port 1"), ProcRole::Engine);
734 assert_eq!(classify("bash -c ls"), ProcRole::Shell);
735 assert_eq!(
736 classify("node /home/u/.synaps-cli/plugins/munder-hive-god/bridge.cjs"),
737 ProcRole::ExtensionSidecar {
738 name: "munder-hive-god".into()
739 }
740 );
741 assert_eq!(
742 classify("python3 /home/u/.synaps-cli/plugins/synaps-chronos/main.py"),
743 ProcRole::ExtensionSidecar {
744 name: "synaps-chronos".into()
745 }
746 );
747 assert_eq!(
748 classify("npx -y mcp-server-filesystem /tmp"),
749 ProcRole::McpServer { name: "npx".into() }
750 );
751 assert_eq!(classify(""), ProcRole::Other);
752 }
753
754 #[test]
755 fn redact_cmdline_scrubs_keys_and_flag_values() {
756 let raw = "npx -y some-mcp --api-key sk-abc123 --token=tok_zzz --port 8080 \
757 OPENAI_API_KEY=sk-live-1 /home/u/.synaps-cli/plugins/x/main.py";
758 let red = redact_cmdline(raw);
759 assert!(!red.contains("sk-abc123"), "{red}");
760 assert!(!red.contains("tok_zzz"), "{red}");
761 assert!(!red.contains("sk-live-1"), "{red}");
762 assert!(red.contains("--api-key ***"), "{red}");
763 assert!(red.contains("--token=***"), "{red}");
764 assert!(red.contains("OPENAI_API_KEY=***"), "{red}");
765 assert!(red.contains("--port 8080"), "{red}");
766 assert!(red.contains("/home/u/.synaps-cli/plugins/x/main.py"), "{red}");
767 let red = redact_cmdline("node bridge.cjs ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcd");
769 assert_eq!(red, "node bridge.cjs ***");
770 assert_eq!(redact_cmdline("bash -c ls"), "bash -c ls");
771 assert_eq!(classify(raw), ProcRole::ExtensionSidecar { name: "x".into() });
773 }
774
775 #[cfg_attr(not(target_os = "linux"), ignore)]
778 #[test]
779 fn proc_row_json_never_carries_api_key() {
780 let root = std::env::temp_dir().join(format!("memstat-fakeproc-{}", std::process::id()));
781 let dir = root.join("4242");
782 let _ = std::fs::remove_dir_all(&root);
783 std::fs::create_dir_all(&dir).unwrap();
784 std::fs::write(
785 dir.join("cmdline"),
786 b"npx\0-y\0my-mcp-server\0--api-key\0sk-abc\0--verbose\0",
787 )
788 .unwrap();
789 std::fs::write(
790 dir.join("status"),
791 "Name:\tnpx\nPPid:\t1\nVmRSS:\t100 kB\nRssAnon:\t50 kB\nThreads:\t2\n",
792 )
793 .unwrap();
794 let row = linux::read_proc(&root, 4242).unwrap();
795 let json = serde_json::to_string(&row).unwrap();
796 assert!(!json.contains("sk-abc"), "{json}");
797 assert!(json.contains("--api-key ***"), "{json}");
798 assert!(json.contains("--verbose"), "{json}");
799 assert_eq!(row.role, ProcRole::McpServer { name: "npx".into() });
800 let _ = std::fs::remove_dir_all(&root);
801 }
802
803 #[test]
804 fn totals_sum_rows() {
805 let row = |pss| ProcMem {
806 pid: 1,
807 ppid: 0,
808 cmd: String::new(),
809 role: ProcRole::Other,
810 rss_kb: 10,
811 pss_kb: pss,
812 uss_kb: 1,
813 anon_kb: 2,
814 threads: 3,
815 };
816 let t = MemTotals::of(&[row(5), row(7)]);
817 assert_eq!(
818 (t.procs, t.rss_kb, t.pss_kb, t.uss_kb, t.anon_kb, t.threads),
819 (2, 20, 12, 2, 4, 6)
820 );
821 }
822
823 #[cfg_attr(not(target_os = "linux"), ignore)]
824 #[test]
825 fn tree_of_self_starts_with_self() {
826 let me = std::process::id();
827 let rows = tree(me).unwrap();
828 assert_eq!(rows[0].pid, me);
829 assert!(rows[0].rss_kb > 0, "rss must be non-zero");
830 assert!(rows[0].pss_kb > 0, "pss must be non-zero (smaps_rollup)");
831 assert!(rows[0].threads >= 1);
832 let pids: std::collections::HashSet<u32> = rows.iter().map(|r| r.pid).collect();
834 for r in &rows[1..] {
835 assert!(pids.contains(&r.ppid), "{} not a descendant of {me}", r.pid);
836 }
837 }
838
839 #[cfg_attr(not(target_os = "linux"), ignore)]
840 #[test]
841 fn self_snapshot_has_kernel_numbers() {
842 let s = self_snapshot();
843 assert!(s.rss_kb > 0);
844 assert!(s.rss_anon_kb > 0);
845 assert!(s.threads >= 1);
846 purge_arenas();
848 }
849}