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