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