Skip to main content

agentsight_capture/sources/
proc.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::io;
6use std::path::PathBuf;
7use std::sync::OnceLock;
8use std::time::Instant;
9use sysinfo::{Pid, Process, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
10
11#[cfg(target_os = "linux")]
12use std::fs;
13
14pub use agent_session::{ProcessKey, ProcessTree};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct PidSeed {
18    pub pid: u32,
19    pub ppid: u32,
20}
21
22impl PidSeed {
23    pub fn arg_value(self) -> String {
24        format!("{}:{}", self.pid, self.ppid)
25    }
26}
27
28#[derive(Debug, Clone, Default)]
29pub struct ProcInfo {
30    pub pid: u32,
31    pub ppid: u32,
32    pub session_id: u32,
33    pub comm: String,
34    pub command: String,
35    pub cwd: Option<PathBuf>,
36    pub ticks: u64,
37    pub starttime_ticks: u64,
38    pub rss_kb: u64,
39    pub rss_mb: u64,
40    pub vsz_kb: u64,
41    pub threads: u32,
42    pub read_bytes: u64,
43    pub write_bytes: u64,
44}
45
46impl ProcInfo {
47    pub fn seed(&self) -> PidSeed {
48        PidSeed {
49            pid: self.pid,
50            ppid: self.ppid,
51        }
52    }
53
54    pub fn process_key(&self) -> ProcessKey {
55        ProcessKey {
56            pid: self.pid,
57            starttime_ticks: self.starttime_ticks,
58        }
59    }
60}
61
62#[derive(Debug, Clone)]
63pub struct ProcSnapshot {
64    pub at: Instant,
65    pub uptime_s: f64,
66    pub procs: BTreeMap<u32, ProcInfo>,
67}
68
69impl Default for ProcSnapshot {
70    fn default() -> Self {
71        Self {
72            at: Instant::now(),
73            uptime_s: 0.0,
74            procs: BTreeMap::new(),
75        }
76    }
77}
78
79impl ProcSnapshot {
80    pub fn collect() -> io::Result<Self> {
81        let mut system = System::new();
82        system.refresh_processes_specifics(ProcessesToUpdate::All, true, process_refresh_kind());
83        let boot_time_s = System::boot_time();
84        let procs = system
85            .processes()
86            .values()
87            .filter(|process| process.thread_kind() != Some(sysinfo::ThreadKind::Userland))
88            .map(|process| proc_info_from_sysinfo(process, boot_time_s))
89            .map(|info| (info.pid, info))
90            .collect();
91
92        Ok(Self {
93            at: Instant::now(),
94            uptime_s: System::uptime() as f64,
95            procs,
96        })
97    }
98
99    pub fn children_by_ppid(&self) -> HashMap<u32, Vec<u32>> {
100        children_by_ppid(&self.procs)
101    }
102
103    pub fn process_family(&self, root: u32) -> Vec<u32> {
104        process_family(root, &self.children_by_ppid(), &self.procs)
105    }
106
107    pub fn seeds_for_all(&self) -> Vec<PidSeed> {
108        self.procs.values().map(ProcInfo::seed).collect()
109    }
110
111    pub fn seeds_for_pid_family(&self, root: u32) -> Vec<PidSeed> {
112        self.process_family(root)
113            .into_iter()
114            .filter_map(|pid| self.procs.get(&pid).map(ProcInfo::seed))
115            .collect()
116    }
117
118    pub fn seeds_for_session(&self, session_id: u32) -> Vec<PidSeed> {
119        self.procs
120            .values()
121            .filter(|proc_info| proc_info.session_id == session_id)
122            .map(ProcInfo::seed)
123            .collect()
124    }
125
126    pub fn pids_in_session(&self, session_id: u32) -> Vec<u32> {
127        self.procs
128            .values()
129            .filter(|proc_info| proc_info.session_id == session_id)
130            .map(|proc_info| proc_info.pid)
131            .collect()
132    }
133}
134
135pub fn collect_fd_paths(process_trees: &[ProcessTree]) -> HashMap<ProcessKey, BTreeSet<PathBuf>> {
136    let mut out = HashMap::new();
137
138    for tree in process_trees {
139        for key in &tree.members {
140            if !process_key_is_current(*key) {
141                continue;
142            }
143            let paths = scan_proc_fd_paths(key.pid);
144            if !process_key_is_current(*key) {
145                continue;
146            }
147            if !paths.is_empty() {
148                out.insert(*key, paths);
149            }
150        }
151    }
152
153    out
154}
155
156fn process_key_is_current(key: ProcessKey) -> bool {
157    process_key_is_current_impl(key)
158}
159
160#[cfg(target_os = "linux")]
161fn process_key_is_current_impl(key: ProcessKey) -> bool {
162    process_starttime_ticks(key.pid) == Some(key.starttime_ticks)
163}
164
165#[cfg(not(target_os = "linux"))]
166fn process_key_is_current_impl(_key: ProcessKey) -> bool {
167    true
168}
169
170pub fn children_by_ppid(procs: &BTreeMap<u32, ProcInfo>) -> HashMap<u32, Vec<u32>> {
171    let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
172    for proc_info in procs.values() {
173        children
174            .entry(proc_info.ppid)
175            .or_default()
176            .push(proc_info.pid);
177    }
178    children
179}
180
181pub fn process_family(
182    root: u32,
183    children: &HashMap<u32, Vec<u32>>,
184    procs: &BTreeMap<u32, ProcInfo>,
185) -> Vec<u32> {
186    process_family_excluding(root, children, procs, &HashSet::new())
187}
188
189pub fn process_family_excluding(
190    root: u32,
191    children: &HashMap<u32, Vec<u32>>,
192    procs: &BTreeMap<u32, ProcInfo>,
193    excluded_roots: &HashSet<u32>,
194) -> Vec<u32> {
195    let mut out = Vec::new();
196    let mut stack = vec![root];
197    let mut seen = HashSet::new();
198    while let Some(pid) = stack.pop() {
199        if !seen.insert(pid) || !procs.contains_key(&pid) {
200            continue;
201        }
202        out.push(pid);
203        if let Some(child_pids) = children.get(&pid) {
204            stack.extend(
205                child_pids
206                    .iter()
207                    .copied()
208                    .filter(|child_pid| !excluded_roots.contains(child_pid)),
209            );
210        }
211    }
212    out
213}
214
215pub fn process_cpu_percent(
216    proc_info: &ProcInfo,
217    previous: Option<&ProcSnapshot>,
218    sample: &ProcSnapshot,
219) -> f64 {
220    let ticks_per_second = ticks_per_second();
221    if let Some(previous) = previous
222        && let Some(prev_proc) = previous.procs.get(&proc_info.pid)
223    {
224        let delta_ticks = proc_info.ticks.saturating_sub(prev_proc.ticks);
225        let delta_wall = sample.at.duration_since(previous.at).as_secs_f64();
226        if delta_wall > 0.0 {
227            return (delta_ticks as f64 / ticks_per_second) / delta_wall * 100.0;
228        }
229    }
230
231    let process_start_s = proc_info.starttime_ticks as f64 / ticks_per_second;
232    let elapsed_s = (sample.uptime_s - process_start_s).max(0.001);
233    (proc_info.ticks as f64 / ticks_per_second) / elapsed_s * 100.0
234}
235
236pub fn process_age_s(proc_info: &ProcInfo, sample: &ProcSnapshot) -> f64 {
237    let process_start_s = proc_info.starttime_ticks as f64 / ticks_per_second();
238    (sample.uptime_s - process_start_s).max(0.0)
239}
240
241fn proc_info_from_sysinfo(process: &Process, boot_time_s: u64) -> ProcInfo {
242    let pid = process.pid().as_u32();
243    let comm = process.name().to_string_lossy().into_owned();
244    let command = process_command(process, &comm);
245    let rss_bytes = process.memory();
246    let disk = process.disk_usage();
247    ProcInfo {
248        pid,
249        ppid: process.parent().map(pid_to_u32).unwrap_or_default(),
250        session_id: process.session_id().map(pid_to_u32).unwrap_or_default(),
251        comm,
252        command,
253        cwd: process.cwd().map(PathBuf::from),
254        ticks: cpu_ms_to_ticks(process.accumulated_cpu_time()),
255        starttime_ticks: platform_starttime_ticks(pid)
256            .unwrap_or_else(|| starttime_ticks_from_epoch(process.start_time(), boot_time_s)),
257        rss_kb: bytes_to_kb(rss_bytes),
258        rss_mb: bytes_to_mb(rss_bytes),
259        vsz_kb: bytes_to_kb(process.virtual_memory()),
260        threads: process
261            .tasks()
262            .map(|tasks| (tasks.len() as u32).saturating_add(1))
263            .unwrap_or(1),
264        read_bytes: disk.total_read_bytes,
265        write_bytes: disk.total_written_bytes,
266    }
267}
268
269fn process_refresh_kind() -> ProcessRefreshKind {
270    ProcessRefreshKind::nothing()
271        .with_memory()
272        .with_cpu()
273        .with_disk_usage()
274        .with_cmd(UpdateKind::Always)
275        .with_cwd(UpdateKind::Always)
276        .with_tasks()
277}
278
279fn process_command(process: &Process, fallback: &str) -> String {
280    let command = process
281        .cmd()
282        .iter()
283        .map(|arg| arg.to_string_lossy())
284        .collect::<Vec<_>>()
285        .join(" ");
286    if command.is_empty() {
287        fallback.to_string()
288    } else {
289        command
290    }
291}
292
293pub fn process_starttime_ticks(pid: u32) -> Option<u64> {
294    platform_starttime_ticks(pid).or_else(|| {
295        let sys_pid = Pid::from_u32(pid);
296        let mut system = System::new();
297        system.refresh_processes_specifics(
298            ProcessesToUpdate::Some(&[sys_pid]),
299            true,
300            ProcessRefreshKind::nothing(),
301        );
302        system
303            .process(sys_pid)
304            .map(|process| starttime_ticks_from_epoch(process.start_time(), System::boot_time()))
305    })
306}
307
308pub fn scan_proc_fd_paths(pid: u32) -> BTreeSet<PathBuf> {
309    let mut out = BTreeSet::new();
310    scan_proc_fd_paths_into(pid, &mut out);
311    out
312}
313
314#[cfg(target_os = "linux")]
315fn scan_proc_fd_paths_into(pid: u32, out: &mut BTreeSet<PathBuf>) {
316    let Ok(entries) = fs::read_dir(format!("/proc/{pid}/fd")) else {
317        return;
318    };
319    for entry in entries.flatten() {
320        let Ok(target) = fs::read_link(entry.path()) else {
321            continue;
322        };
323        out.insert(target);
324    }
325}
326
327#[cfg(target_os = "macos")]
328fn scan_proc_fd_paths_into(pid: u32, out: &mut BTreeSet<PathBuf>) {
329    let Ok(output) = std::process::Command::new(lsof_path())
330        .args(["-nP", "-Fn", "-p", &pid.to_string()])
331        .output()
332    else {
333        return;
334    };
335    if !output.status.success() {
336        return;
337    }
338    parse_lsof_file_names(&String::from_utf8_lossy(&output.stdout), out);
339}
340
341#[cfg(target_os = "macos")]
342fn lsof_path() -> &'static str {
343    if std::path::Path::new("/usr/sbin/lsof").is_file() {
344        "/usr/sbin/lsof"
345    } else {
346        "lsof"
347    }
348}
349
350#[cfg(not(any(target_os = "linux", target_os = "macos")))]
351fn scan_proc_fd_paths_into(_pid: u32, _out: &mut BTreeSet<PathBuf>) {}
352
353#[cfg(any(test, target_os = "macos"))]
354fn parse_lsof_file_names(output: &str, out: &mut BTreeSet<PathBuf>) {
355    for line in output.lines() {
356        let Some(path) = line.strip_prefix('n') else {
357            continue;
358        };
359        let path = path.trim().trim_end_matches(" (deleted)");
360        if path.starts_with('/') {
361            out.insert(PathBuf::from(path));
362        }
363    }
364}
365
366#[cfg(target_os = "linux")]
367fn platform_starttime_ticks(pid: u32) -> Option<u64> {
368    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
369    parse_proc_starttime_ticks(&stat)
370}
371
372#[cfg(not(target_os = "linux"))]
373fn platform_starttime_ticks(_pid: u32) -> Option<u64> {
374    None
375}
376
377#[cfg(target_os = "linux")]
378fn parse_proc_starttime_ticks(stat: &str) -> Option<u64> {
379    let close = stat.rfind(')')?;
380    stat[close + 1..].split_whitespace().nth(19)?.parse().ok()
381}
382
383fn bytes_to_kb(bytes: u64) -> u64 {
384    bytes / 1024
385}
386
387fn bytes_to_mb(bytes: u64) -> u64 {
388    if bytes == 0 {
389        0
390    } else {
391        bytes.div_ceil(1_048_576)
392    }
393}
394
395fn cpu_ms_to_ticks(cpu_ms: u64) -> u64 {
396    ((cpu_ms as f64 / 1000.0) * ticks_per_second()).round() as u64
397}
398
399fn pid_to_u32(pid: Pid) -> u32 {
400    pid.as_u32()
401}
402
403fn starttime_ticks_from_epoch(start_time_s: u64, boot_time_s: u64) -> u64 {
404    let since_boot_s = if start_time_s >= boot_time_s {
405        start_time_s - boot_time_s
406    } else {
407        start_time_s
408    };
409    ((since_boot_s as f64) * ticks_per_second()).round() as u64
410}
411
412pub fn process_start_timestamp_ms(starttime_ticks: u64) -> Option<u64> {
413    let boot_ms = u64::try_from(crate::time::get_boot_time_secs().saturating_mul(1000)).ok()?;
414    let process_offset_ms = ((starttime_ticks as f64 / ticks_per_second()) * 1000.0).round() as u64;
415    Some(boot_ms.saturating_add(process_offset_ms))
416}
417
418pub fn process_cpu_ms_delta(proc_info: &ProcInfo, previous: Option<&ProcSnapshot>) -> u64 {
419    let Some(previous) = previous else {
420        return 0;
421    };
422    let Some(prev_proc) = previous.procs.get(&proc_info.pid) else {
423        return 0;
424    };
425    if prev_proc.starttime_ticks != proc_info.starttime_ticks {
426        return 0;
427    }
428    let delta_ticks = proc_info.ticks.saturating_sub(prev_proc.ticks);
429    ((delta_ticks as f64 / ticks_per_second()) * 1000.0).round() as u64
430}
431
432fn ticks_per_second() -> f64 {
433    static TICKS_PER_SECOND: OnceLock<f64> = OnceLock::new();
434    *TICKS_PER_SECOND.get_or_init(|| {
435        let value = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
436        if value > 0 { value as f64 } else { 100.0 }
437    })
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use std::process::Command;
444
445    #[test]
446    fn snapshot_collects_current_process() {
447        let snapshot = ProcSnapshot::collect().unwrap();
448        let current = snapshot.procs.get(&std::process::id()).unwrap();
449
450        assert_eq!(current.pid, std::process::id());
451        #[cfg(target_os = "linux")]
452        let tid = unsafe { libc::gettid() } as u32;
453        #[cfg(target_os = "linux")]
454        assert!(!snapshot.procs.contains_key(&tid));
455        assert!(current.starttime_ticks > 0);
456        assert!(current.threads > if cfg!(target_os = "linux") { 1 } else { 0 });
457        assert!(!current.comm.is_empty() || !current.command.is_empty());
458    }
459
460    #[cfg(unix)]
461    #[test]
462    fn snapshot_counts_single_thread_process() {
463        let mut child = Command::new("sleep").arg("5").spawn().unwrap();
464        let snapshot = ProcSnapshot::collect().unwrap();
465        let threads = snapshot.procs.get(&child.id()).map(|p| p.threads);
466        let _ = child.kill();
467        let _ = child.wait();
468
469        assert_eq!(threads, Some(1));
470    }
471
472    #[test]
473    fn starttime_ticks_accepts_epoch_or_boot_relative_seconds() {
474        let ticks = ticks_per_second().round() as u64;
475
476        assert_eq!(starttime_ticks_from_epoch(125, 100), 25 * ticks);
477        assert_eq!(starttime_ticks_from_epoch(25, 100), 25 * ticks);
478    }
479
480    #[cfg(target_os = "linux")]
481    #[test]
482    fn proc_fd_scan_finds_open_file_path() {
483        let temp = tempfile::tempdir().unwrap();
484        let path = temp.path().join("fd-evidence.txt");
485        let _file = std::fs::File::create(&path).unwrap();
486
487        let paths = scan_proc_fd_paths(std::process::id());
488        assert!(paths.contains(&path));
489    }
490
491    #[test]
492    fn lsof_parser_keeps_absolute_file_names() {
493        let mut out = BTreeSet::new();
494
495        parse_lsof_file_names(
496            "p123\nn/private/tmp/session.jsonl\nnlocalhost:1234\nn\n",
497            &mut out,
498        );
499
500        assert!(out.contains(&PathBuf::from("/private/tmp/session.jsonl")));
501        assert_eq!(out.len(), 1);
502    }
503}