Skip to main content

agent_core/core/
memstat.rs

1//! Process memory instrumentation (§3.7): the numbers `mem.sh` reads from
2//! `/proc`, available in-process and via `synaps status --memory` — no root,
3//! no `smem`.
4//!
5//! Linux only. Every entry point degrades to `Unsupported` / zeroed fields on
6//! other platforms so callers never need their own `cfg`.
7
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11/// What a process in a session tree is, classified from its cmdline.
12#[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/// One process' memory, straight from `smaps_rollup` + `status`.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ProcMem {
25    pub pid: u32,
26    pub ppid: u32,
27    /// Redacted cmdline — see [`redact_cmdline`]. Never the raw argv: MCP
28    /// servers take API keys as arguments and this struct is serialized by
29    /// `synaps status --memory --json`.
30    pub cmd: String,
31    pub role: ProcRole,
32    pub rss_kb: u64,
33    pub pss_kb: u64,
34    /// `Private_Clean + Private_Dirty` — the smem "USS".
35    pub uss_kb: u64,
36    /// `RssAnon` from `status`.
37    pub anon_kb: u64,
38    pub threads: u32,
39}
40
41/// In-process snapshot: kernel view + jemalloc's own accounting (zeros when
42/// the build does not link jemalloc-ctl).
43#[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    /// `AnonHugePages` from `smaps_rollup` — THP-backed anon (a 2 MiB huge
55    /// page per touched thread stack / bss / arena chunk when THP=always).
56    #[serde(default)]
57    pub anon_huge_kb: u64,
58}
59
60/// Totals over a set of [`ProcMem`] rows.
61#[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
93/// Walk `pid` and every descendant (via `/proc/*/stat` ppid), reading
94/// `smaps_rollup` and `status` for each. Root first, then children in
95/// discovery order. Processes that vanish mid-walk are skipped.
96pub 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
108/// Memory of exactly one process (no descendants).
109pub 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
121/// Kernel + allocator view of the calling process. Never fails; fields are
122/// zero where unavailable.
123pub fn self_snapshot() -> SelfMem {
124    #[cfg_attr(not(unix), allow(unused_mut))] // nothing to fill in off Unix
125    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
149/// Ask jemalloc to purge every arena's dirty/muzzy pages back to the OS —
150/// the manual `malloc_trim` equivalent. No-op on builds without jemalloc.
151pub fn purge_arenas() {
152    #[cfg(all(unix, not(target_env = "musl")))]
153    {
154        // MALLCTL_ARENAS_ALL = 4096; `arena.<i>.purge` is a void mallctl
155        // (jemalloc rejects a non-null newp), so issue it as a zero-length read.
156        let _ = unsafe { tikv_jemalloc_ctl::raw::read::<()>(b"arena.4096.purge\0") };
157    }
158}
159
160/// Outcome of one mallctl write, for the ladder line (never fatal).
161pub 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
168/// `background_thread` on/off. Turning it off joins every `jemalloc_bg_thd`
169/// (PLAN-phase4 §4.1) — call before spawning threads so none are created for
170/// the arenas they touch. No-op `Ok` without jemalloc.
171pub 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
184/// Current `background_thread` setting (`None` without jemalloc).
185pub 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
196/// Decay times for **existing** arenas (`arena.<ALL>.*_decay_ms`) and the
197/// default for future ones (`arenas.*_decay_ms`). `0` = purge a freed run
198/// on the next decay tick; `-1` = never. §4.2.
199pub 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        // ssize_t on every supported target.
204        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            // `arena.<i>.*_decay_ms` rejects MALLCTL_ARENAS_ALL (EFAULT);
212            // walk the initialised arenas instead. Uninitialised ones take
213            // the `arenas.*` default above when created.
214            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
233/// `prctl(PR_GET_THP_DISABLE)` — is THP already off for this process
234/// (inherited across `execve`, so a re-exec'd client sees `Some(true)`)?
235pub fn thp_disabled() -> Option<bool> {
236    #[cfg(target_os = "linux")]
237    {
238        // SAFETY: plain prctl with integer arguments.
239        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
248/// Kernel THP mode: `Some(true)` when
249/// `/sys/kernel/mm/transparent_hugepage/enabled` is `[always]`, `Some(false)`
250/// for `[madvise]`/`[never]`, `None` when unreadable (non-Linux, no sysfs).
251pub 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
268/// `prctl(PR_SET_THP_DISABLE)` — no transparent huge pages for this process
269/// from now on. With `THP=always` every touched thread stack, the `.bss` and
270/// each jemalloc chunk costs a 2 MiB page; the thin client wants 4 KiB
271/// granularity. Inherited by threads created afterwards. Linux only.
272pub fn disable_thp() -> MallctlResult {
273    #[cfg(target_os = "linux")]
274    {
275        // SAFETY: plain prctl with integer arguments.
276        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
289/// `thread.tcache.enabled` for the **calling** thread (§4.3 fallback).
290pub 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
303/// `SYNAPS_MEM_TRACE=1` turns on the per-turn memory trace and the broker
304/// install log line. Read once; one atomic load per call afterwards.
305pub 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
310/// One `agent_core::memstat` info line ("turn memory") — called at
311/// `SessionEvent::Done` so `synaps.log` carries a greppable per-turn memory
312/// trace. No-op (one atomic load) unless `SYNAPS_MEM_TRACE=1`.
313pub 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
329/// Process-start anchor for [`ladder`]'s `t_ms`. Pinned by the first
330/// [`ladder`] call (the `main` stage in the attach client), so call it first.
331static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
332
333/// The boot-ladder sink: `SYNAPS_MEM_TRACE_FILE` (default
334/// `${XDG_RUNTIME_DIR:-/tmp}/synaps-memtrace-<pid>.log`), opened once, 0600.
335/// `None` when the file could not be opened (the ladder then drops lines).
336fn 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
360/// Boot-ladder stage (PLAN-phase4 §7.1): appends one line to the trace file
361/// — **not** `tracing` (the attach client has no subscriber) and **not**
362/// stderr (it is the TUI). No-op (one atomic load) unless `SYNAPS_MEM_TRACE=1`.
363///
364/// Line format: `t_ms=<since first call> stage=<stage> rss_anon_kb=…
365/// jemalloc_allocated_kb=… active_kb=… resident_kb=… retained_kb=…
366/// metadata_kb=… threads=… <extra>`.
367pub 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
397/// Scrub credentials out of a space-joined argv before it leaves the
398/// process. Redacted: the value of any `--flag value` / `--flag=value` whose
399/// flag name mentions key/token/secret/password/bearer/auth/credential, any
400/// `KEY=value` env-style arg with such a key, and any bare arg that looks
401/// like a key (`sk-…`, `ghp_…`, `xox…`, `Bearer …`, or ≥ 32 chars of
402/// base64/hex with no path separator).
403pub 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
474/// Classify a process from its cmdline (argv joined by spaces) and, when
475/// known, the engine binary's own name.
476pub 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    // Extension sidecars are launched from a plugin directory:
492    // `.../plugins/<name>/<entry>` or `.../extensions/<name>/...`.
493    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    /// `AnonHugePages:` line of a `smaps_rollup` (0 when unreadable).
564    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    /// `ppid -> [pid]` for every process visible under `proc_root`.
635    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            // `pid (comm) state ppid ...` — comm may contain spaces/parens.
648            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        // `mem_trace_enabled()` is cached per process and the test binary
716        // does not set SYNAPS_MEM_TRACE: `ladder` must be a no-op and must
717        // not create the default trace file.
718        if mem_trace_enabled() {
719            return; // the harness itself is tracing; nothing to assert
720        }
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        // Bare long opaque tokens go too; ordinary args survive.
769        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        // Classification still sees the raw argv; the row carries the scrubbed one.
773        assert_eq!(classify(raw), ProcRole::ExtensionSidecar { name: "x".into() });
774    }
775
776    /// Fake `/proc/<pid>` with an MCP child carrying `--api-key sk-abc`:
777    /// the serialized row must not contain the key.
778    #[cfg(target_os = "linux")] // calls the procfs reader, which only exists there
779    #[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        // Non-root rows are descendants: their ppid is in the set.
834        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        // The value must not panic; jemalloc fields are zero on non-jemalloc builds.
848        purge_arenas();
849    }
850}