agentsight_capture/sources/
proc.rs1use 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(platform_ticks_per_second)
435}
436
437#[cfg(unix)]
438fn platform_ticks_per_second() -> f64 {
439 let value = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
440 if value > 0 { value as f64 } else { 100.0 }
441}
442
443#[cfg(not(unix))]
444fn platform_ticks_per_second() -> f64 {
445 100.0
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 #[cfg(unix)]
452 use std::process::Command;
453
454 #[test]
455 fn snapshot_collects_current_process() {
456 let snapshot = ProcSnapshot::collect().unwrap();
457 let current = snapshot.procs.get(&std::process::id()).unwrap();
458
459 assert_eq!(current.pid, std::process::id());
460 #[cfg(target_os = "linux")]
461 let tid = unsafe { libc::gettid() } as u32;
462 #[cfg(target_os = "linux")]
463 assert!(!snapshot.procs.contains_key(&tid));
464 assert!(current.starttime_ticks > 0);
465 assert!(current.threads > if cfg!(target_os = "linux") { 1 } else { 0 });
466 assert!(!current.comm.is_empty() || !current.command.is_empty());
467 }
468
469 #[cfg(unix)]
470 #[test]
471 fn snapshot_counts_single_thread_process() {
472 let mut child = Command::new("sleep").arg("5").spawn().unwrap();
473 let snapshot = ProcSnapshot::collect().unwrap();
474 let threads = snapshot.procs.get(&child.id()).map(|p| p.threads);
475 let _ = child.kill();
476 let _ = child.wait();
477
478 assert_eq!(threads, Some(1));
479 }
480
481 #[test]
482 fn starttime_ticks_accepts_epoch_or_boot_relative_seconds() {
483 let ticks = ticks_per_second().round() as u64;
484
485 assert_eq!(starttime_ticks_from_epoch(125, 100), 25 * ticks);
486 assert_eq!(starttime_ticks_from_epoch(25, 100), 25 * ticks);
487 }
488
489 #[cfg(target_os = "linux")]
490 #[test]
491 fn proc_fd_scan_finds_open_file_path() {
492 let temp = tempfile::tempdir().unwrap();
493 let path = temp.path().join("fd-evidence.txt");
494 let _file = std::fs::File::create(&path).unwrap();
495
496 let paths = scan_proc_fd_paths(std::process::id());
497 assert!(paths.contains(&path));
498 }
499
500 #[test]
501 fn lsof_parser_keeps_absolute_file_names() {
502 let mut out = BTreeSet::new();
503
504 parse_lsof_file_names(
505 "p123\nn/private/tmp/session.jsonl\nnlocalhost:1234\nn\n",
506 &mut out,
507 );
508
509 assert!(out.contains(&PathBuf::from("/private/tmp/session.jsonl")));
510 assert_eq!(out.len(), 1);
511 }
512}