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    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
148/// Ask jemalloc to purge every arena's dirty/muzzy pages back to the OS —
149/// the manual `malloc_trim` equivalent. No-op on builds without jemalloc.
150pub fn purge_arenas() {
151    #[cfg(all(unix, not(target_env = "musl")))]
152    {
153        // MALLCTL_ARENAS_ALL = 4096; `arena.<i>.purge` is a void mallctl
154        // (jemalloc rejects a non-null newp), so issue it as a zero-length read.
155        let _ = unsafe { tikv_jemalloc_ctl::raw::read::<()>(b"arena.4096.purge\0") };
156    }
157}
158
159/// Outcome of one mallctl write, for the ladder line (never fatal).
160pub 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
167/// `background_thread` on/off. Turning it off joins every `jemalloc_bg_thd`
168/// (PLAN-phase4 §4.1) — call before spawning threads so none are created for
169/// the arenas they touch. No-op `Ok` without jemalloc.
170pub 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
183/// Current `background_thread` setting (`None` without jemalloc).
184pub 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
195/// Decay times for **existing** arenas (`arena.<ALL>.*_decay_ms`) and the
196/// default for future ones (`arenas.*_decay_ms`). `0` = purge a freed run
197/// on the next decay tick; `-1` = never. §4.2.
198pub 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        // ssize_t on every supported target.
203        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            // `arena.<i>.*_decay_ms` rejects MALLCTL_ARENAS_ALL (EFAULT);
211            // walk the initialised arenas instead. Uninitialised ones take
212            // the `arenas.*` default above when created.
213            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
232/// `prctl(PR_GET_THP_DISABLE)` — is THP already off for this process
233/// (inherited across `execve`, so a re-exec'd client sees `Some(true)`)?
234pub fn thp_disabled() -> Option<bool> {
235    #[cfg(target_os = "linux")]
236    {
237        // SAFETY: plain prctl with integer arguments.
238        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
247/// Kernel THP mode: `Some(true)` when
248/// `/sys/kernel/mm/transparent_hugepage/enabled` is `[always]`, `Some(false)`
249/// for `[madvise]`/`[never]`, `None` when unreadable (non-Linux, no sysfs).
250pub 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
267/// `prctl(PR_SET_THP_DISABLE)` — no transparent huge pages for this process
268/// from now on. With `THP=always` every touched thread stack, the `.bss` and
269/// each jemalloc chunk costs a 2 MiB page; the thin client wants 4 KiB
270/// granularity. Inherited by threads created afterwards. Linux only.
271pub fn disable_thp() -> MallctlResult {
272    #[cfg(target_os = "linux")]
273    {
274        // SAFETY: plain prctl with integer arguments.
275        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
288/// `thread.tcache.enabled` for the **calling** thread (§4.3 fallback).
289pub 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
302/// `SYNAPS_MEM_TRACE=1` turns on the per-turn memory trace and the broker
303/// install log line. Read once; one atomic load per call afterwards.
304pub 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
309/// One `agent_core::memstat` info line ("turn memory") — called at
310/// `SessionEvent::Done` so `synaps.log` carries a greppable per-turn memory
311/// trace. No-op (one atomic load) unless `SYNAPS_MEM_TRACE=1`.
312pub 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
328/// Process-start anchor for [`ladder`]'s `t_ms`. Pinned by the first
329/// [`ladder`] call (the `main` stage in the attach client), so call it first.
330static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
331
332/// The boot-ladder sink: `SYNAPS_MEM_TRACE_FILE` (default
333/// `${XDG_RUNTIME_DIR:-/tmp}/synaps-memtrace-<pid>.log`), opened once, 0600.
334/// `None` when the file could not be opened (the ladder then drops lines).
335fn 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
359/// Boot-ladder stage (PLAN-phase4 §7.1): appends one line to the trace file
360/// — **not** `tracing` (the attach client has no subscriber) and **not**
361/// stderr (it is the TUI). No-op (one atomic load) unless `SYNAPS_MEM_TRACE=1`.
362///
363/// Line format: `t_ms=<since first call> stage=<stage> rss_anon_kb=…
364/// jemalloc_allocated_kb=… active_kb=… resident_kb=… retained_kb=…
365/// metadata_kb=… threads=… <extra>`.
366pub 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
396/// Scrub credentials out of a space-joined argv before it leaves the
397/// process. Redacted: the value of any `--flag value` / `--flag=value` whose
398/// flag name mentions key/token/secret/password/bearer/auth/credential, any
399/// `KEY=value` env-style arg with such a key, and any bare arg that looks
400/// like a key (`sk-…`, `ghp_…`, `xox…`, `Bearer …`, or ≥ 32 chars of
401/// base64/hex with no path separator).
402pub 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
473/// Classify a process from its cmdline (argv joined by spaces) and, when
474/// known, the engine binary's own name.
475pub 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    // Extension sidecars are launched from a plugin directory:
491    // `.../plugins/<name>/<entry>` or `.../extensions/<name>/...`.
492    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    /// `AnonHugePages:` line of a `smaps_rollup` (0 when unreadable).
563    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    /// `ppid -> [pid]` for every process visible under `proc_root`.
634    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            // `pid (comm) state ppid ...` — comm may contain spaces/parens.
647            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        // `mem_trace_enabled()` is cached per process and the test binary
715        // does not set SYNAPS_MEM_TRACE: `ladder` must be a no-op and must
716        // not create the default trace file.
717        if mem_trace_enabled() {
718            return; // the harness itself is tracing; nothing to assert
719        }
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        // Bare long opaque tokens go too; ordinary args survive.
768        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        // Classification still sees the raw argv; the row carries the scrubbed one.
772        assert_eq!(classify(raw), ProcRole::ExtensionSidecar { name: "x".into() });
773    }
774
775    /// Fake `/proc/<pid>` with an MCP child carrying `--api-key sk-abc`:
776    /// the serialized row must not contain the key.
777    #[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        // Non-root rows are descendants: their ppid is in the set.
833        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        // The value must not panic; jemalloc fields are zero on non-jemalloc builds.
847        purge_arenas();
848    }
849}