prock/platform/
linux.rs

1//! Linux implementation using /proc filesystem.
2//!
3//! Reads `/proc/[pid]/stat` for CPU times (~10µs per call).
4//! Reads `/proc/[pid]/task/[pid]/children` for child discovery (~10µs per call).
5
6#![allow(unsafe_code)]
7#![allow(rustdoc::invalid_html_tags)]
8
9use super::CpuTime;
10use std::fs;
11
12/// Disk I/O statistics for a process.
13#[derive(Debug, Clone, Copy, Default)]
14pub struct DiskIo {
15    /// Total bytes read from disk
16    pub read_bytes: u64,
17    /// Total bytes written to disk
18    pub write_bytes: u64,
19}
20
21/// Combined stats from reading multiple /proc files.
22#[derive(Debug, Clone, Default)]
23pub struct AllStats {
24    pub cpu_time: CpuTime,
25    pub memory_rss: u64,
26    pub disk_io: DiskIo,
27}
28
29/// Clock ticks per second (usually 100 on Linux).
30fn clock_ticks_per_sec() -> u64 {
31    // SAFETY: sysconf is safe to call
32    let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
33    if ticks <= 0 { 100 } else { ticks as u64 }
34}
35
36/// Get CPU time for a process by reading `/proc/[pid]/stat`.
37///
38/// Returns the process's own CPU time (utime + stime).
39/// For cumulative time including exited children, use `get_cpu_time_with_children`.
40/// ~10µs per call.
41pub fn get_cpu_time(pid: i32) -> Option<CpuTime> {
42    get_cpu_time_inner(pid, false)
43}
44
45/// Get CPU time for a process INCLUDING time from waited-for (exited) children.
46///
47/// This is critical for accurate tracking of short-lived child processes.
48/// Uses cutime/cstime fields which accumulate CPU from children that have exited.
49/// ~10µs per call.
50pub fn get_cpu_time_with_children(pid: i32) -> Option<CpuTime> {
51    get_cpu_time_inner(pid, true)
52}
53
54fn get_cpu_time_inner(pid: i32, include_children: bool) -> Option<CpuTime> {
55    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
56
57    // Format: pid (comm) state ppid pgrp session tty_nr tpgid flags
58    //         minflt cminflt majflt cmajflt utime stime cutime cstime ...
59    // Fields (1-indexed): 14=utime, 15=stime, 16=cutime, 17=cstime
60
61    // Find the closing paren of comm (process name can contain spaces/parens)
62    let comm_end = stat.rfind(')')?;
63    let fields: Vec<&str> = stat[comm_end + 2..].split_whitespace().collect();
64
65    // After (comm), fields are: state(0) ppid(1) ... utime(11) stime(12) cutime(13) cstime(14)
66    if fields.len() < 15 {
67        return None;
68    }
69
70    let utime_ticks: u64 = fields[11].parse().ok()?;
71    let stime_ticks: u64 = fields[12].parse().ok()?;
72
73    let (user_ticks, system_ticks) = if include_children {
74        let cutime_ticks: i64 = fields[13].parse().ok()?; // Can be negative
75        let cstime_ticks: i64 = fields[14].parse().ok()?; // Can be negative
76        (
77            utime_ticks.saturating_add(cutime_ticks.max(0) as u64),
78            stime_ticks.saturating_add(cstime_ticks.max(0) as u64),
79        )
80    } else {
81        (utime_ticks, stime_ticks)
82    };
83
84    let ticks_per_sec = clock_ticks_per_sec();
85    let ns_per_tick = 1_000_000_000 / ticks_per_sec;
86
87    Some(CpuTime {
88        user_ns: user_ticks * ns_per_tick,
89        system_ns: system_ticks * ns_per_tick,
90    })
91}
92
93/// Get memory usage (resident set size) for a process in bytes.
94///
95/// This is the physical RAM currently used by the process.
96/// ~10µs per call.
97pub fn get_memory(pid: i32) -> Option<u64> {
98    get_statm(pid).map(|(rss, _vsz)| rss)
99}
100
101/// Get virtual memory size for a process in bytes.
102///
103/// This is the total address space mapped by the process.
104/// ~10µs per call (same file read as get_memory).
105pub fn get_memory_virtual(pid: i32) -> Option<u64> {
106    get_statm(pid).map(|(_rss, vsz)| vsz)
107}
108
109/// Internal: get both RSS and VSZ from `/proc/[pid]/statm`.
110fn get_statm(pid: i32) -> Option<(u64, u64)> {
111    let statm = fs::read_to_string(format!("/proc/{pid}/statm")).ok()?;
112    let fields: Vec<&str> = statm.split_whitespace().collect();
113
114    // Field 0 is size (VSZ) in pages, field 1 is resident (RSS) in pages
115    if fields.len() < 2 {
116        return None;
117    }
118
119    let vsz_pages: u64 = fields[0].parse().ok()?;
120    let rss_pages: u64 = fields[1].parse().ok()?;
121
122    // SAFETY: sysconf is always safe to call. Returns -1 on error.
123    let page_size_raw = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
124    // Default to 4KB if sysconf fails (very unlikely on Linux)
125    let page_size = if page_size_raw <= 0 {
126        4096
127    } else {
128        page_size_raw as u64
129    };
130
131    Some((rss_pages * page_size, vsz_pages * page_size))
132}
133
134/// Get disk I/O statistics for a process.
135///
136/// Reads `/proc/[pid]/io` for cumulative disk bytes read/written.
137/// ~10µs per call.
138pub fn get_disk_io(pid: i32) -> Option<DiskIo> {
139    let io = fs::read_to_string(format!("/proc/{pid}/io")).ok()?;
140
141    let mut read_bytes = 0u64;
142    let mut write_bytes = 0u64;
143
144    // Format is "key: value" lines
145    // We want read_bytes and write_bytes (not rchar/wchar which include page cache)
146    for line in io.lines() {
147        if let Some(value) = line.strip_prefix("read_bytes: ") {
148            read_bytes = value.parse().unwrap_or(0);
149        } else if let Some(value) = line.strip_prefix("write_bytes: ") {
150            write_bytes = value.parse().unwrap_or(0);
151        }
152    }
153
154    Some(DiskIo {
155        read_bytes,
156        write_bytes,
157    })
158}
159
160/// Get all stats (CPU, memory, disk I/O) by reading multiple /proc files.
161///
162/// On Linux this reads 3 files: `/proc/[pid]/stat`, statm, and io.
163/// ~30µs per call (vs ~30µs for 3 separate calls - no savings on Linux).
164pub fn get_all_stats(pid: i32) -> Option<AllStats> {
165    let cpu_time = get_cpu_time(pid)?;
166    let memory_rss = get_memory(pid)?;
167    let disk_io = get_disk_io(pid).unwrap_or_default();
168
169    Some(AllStats {
170        cpu_time,
171        memory_rss,
172        disk_io,
173    })
174}
175
176/// Check if a process exists.
177pub fn process_exists(pid: i32) -> bool {
178    std::path::Path::new(&format!("/proc/{pid}")).exists()
179}
180
181/// Get direct child PIDs of a process.
182///
183/// Reads `/proc/[pid]/task/[pid]/children` which contains space-separated child PIDs.
184/// ~10µs per call.
185pub fn get_children(pid: i32) -> Vec<i32> {
186    fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
187        .unwrap_or_default()
188        .split_whitespace()
189        .filter_map(|s| s.parse().ok())
190        .collect()
191}
192
193/// Get parent PID of a process.
194///
195/// Reads `/proc/[pid]/stat` and extracts ppid field.
196/// ~10µs per call.
197pub fn get_ppid(pid: i32) -> Option<i32> {
198    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
199
200    // Find the closing paren of comm (process name can contain spaces/parens)
201    let comm_end = stat.rfind(')')?;
202    let fields: Vec<&str> = stat[comm_end + 2..].split_whitespace().collect();
203
204    // After (comm), fields are: state(0) ppid(1) ...
205    if fields.len() < 2 {
206        return None;
207    }
208
209    fields[1].parse().ok()
210}
211
212/// Get process start time as seconds since Unix epoch.
213///
214/// Reads `/proc/[pid]/stat` field 22 (starttime in clock ticks since boot),
215/// then converts to absolute time using system boot time.
216/// ~10µs per call.
217pub fn get_start_time(pid: i32) -> Option<i64> {
218    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
219
220    // Find the closing paren of comm (process name can contain spaces/parens)
221    let comm_end = stat.rfind(')')?;
222    let fields: Vec<&str> = stat[comm_end + 2..].split_whitespace().collect();
223
224    // After (comm), fields are: state(0) ppid(1) ... starttime(19)
225    // Field 22 in 1-indexed stat file = field 19 in 0-indexed after comm
226    if fields.len() < 20 {
227        return None;
228    }
229
230    let starttime_ticks: u64 = fields[19].parse().ok()?;
231    let ticks_per_sec = clock_ticks_per_sec();
232
233    // Get boot time from /proc/stat
234    let boot_time = get_boot_time()?;
235
236    // Convert starttime (ticks since boot) to seconds since epoch
237    let starttime_secs = starttime_ticks / ticks_per_sec;
238    Some(boot_time + starttime_secs as i64)
239}
240
241/// Get system boot time as seconds since Unix epoch.
242fn get_boot_time() -> Option<i64> {
243    let stat = fs::read_to_string("/proc/stat").ok()?;
244    for line in stat.lines() {
245        if let Some(value) = line.strip_prefix("btime ") {
246            return value.trim().parse().ok();
247        }
248    }
249    None
250}
251
252/// Get the full executable path for a process.
253///
254/// Reads `/proc/[pid]/exe` symlink to get the absolute path.
255/// ~10µs per call.
256pub fn get_process_path(pid: i32) -> Option<String> {
257    fs::read_link(format!("/proc/{pid}/exe"))
258        .ok()
259        .and_then(|p| p.to_str().map(|s| s.to_string()))
260}
261
262/// Get the kernel's internal command name for a process.
263///
264/// Returns the executable basename from `/proc/[pid]/comm`, truncated to 16 characters
265/// (kernel's TASK_COMM_LEN - 1). Examples: "zsh", "node", "tmux", "python3.13"
266///
267/// **Important**: This is NOT equivalent to `ps -o comm=`, which may return:
268/// - Full executable path
269/// - Or modified argv[0] (e.g., "claude" for renamed node, "-bash" for login shells)
270///
271/// Use `get_process_path()` if you need the full executable path.
272///
273/// ~10µs per call (vs ~2ms for spawning ps).
274pub fn get_process_comm(pid: i32) -> Option<String> {
275    fs::read_to_string(format!("/proc/{pid}/comm"))
276        .ok()
277        .map(|s| s.trim().to_string())
278        .filter(|s| !s.is_empty())
279}
280
281/// Get the controlling TTY device name for a process (like `ps -o tty=`).
282///
283/// Returns the TTY device name (e.g., "pts/0", "tty1") or None if the
284/// process has no controlling terminal.
285/// ~10µs per call.
286///
287/// **Note**: The TTY major number mappings (136-143 for pts, 4 for tty) are
288/// based on standard Linux conventions but may vary on some distributions.
289/// The fallback to `/proc/[pid]/fd/0` has a potential race condition if the
290/// process's file descriptors change between the stat read and the fd/0 read,
291/// though this is unlikely in practice.
292pub fn get_tty(pid: i32) -> Option<String> {
293    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
294
295    // Find the closing paren of comm (process name can contain spaces/parens)
296    let comm_end = stat.rfind(')')?;
297    let fields: Vec<&str> = stat[comm_end + 2..].split_whitespace().collect();
298
299    // After (comm), fields are: state(0) ppid(1) pgrp(2) session(3) tty_nr(4) ...
300    if fields.len() < 5 {
301        return None;
302    }
303
304    let tty_nr: i32 = fields[4].parse().ok()?;
305
306    // 0 means no controlling terminal
307    if tty_nr == 0 {
308        return None;
309    }
310
311    // Convert tty_nr to device name
312    // tty_nr encodes major and minor device numbers:
313    // major = (tty_nr >> 8) & 0xff
314    // minor = (tty_nr & 0xff) | ((tty_nr >> 12) & 0xfff00)
315    let major = ((tty_nr >> 8) & 0xff) as u32;
316    let minor = ((tty_nr & 0xff) | ((tty_nr >> 12) & 0xfff00)) as u32;
317
318    // Common TTY device mappings:
319    // Major 136+ = pts (pseudo-terminal slave)
320    // Major 4 = ttyN (virtual console)
321    // Major 5 = tty, console, ptmx
322    match major {
323        136..=143 => Some(format!("pts/{minor}")),
324        4 => Some(format!("tty{minor}")),
325        _ => {
326            // Try to read the link from /proc/[pid]/fd/0 as a fallback
327            // This is less reliable but works for many cases
328            fs::read_link(format!("/proc/{pid}/fd/0"))
329                .ok()
330                .and_then(|p| {
331                    let path = p.to_string_lossy();
332                    if path.starts_with("/dev/") {
333                        Some(path.strip_prefix("/dev/")?.to_string())
334                    } else {
335                        None
336                    }
337                })
338        }
339    }
340}
341
342/// List all PIDs on the system.
343///
344/// Reads /proc directory for numeric entries.
345/// ~100-200µs for ~500-1000 processes.
346pub fn list_all_pids() -> Vec<i32> {
347    let Ok(entries) = fs::read_dir("/proc") else {
348        return Vec::new();
349    };
350
351    entries
352        .filter_map(|e| e.ok())
353        .filter_map(|e| e.file_name().to_str()?.parse::<i32>().ok())
354        .filter(|&pid| pid > 0)
355        .collect()
356}
357
358/// Info about a single process from a full system scan.
359#[derive(Debug, Clone)]
360pub struct ProcessInfo {
361    pub pid: i32,
362    pub ppid: i32,
363    pub cpu_time: super::CpuTime,
364    pub memory_bytes: u64,
365}
366
367/// Scan all processes on the system and return their info.
368///
369/// This is the "full scan" approach - get all processes in one pass,
370/// then filter to the ones you need. Faster than targeted discovery
371/// when you have many sessions (crossover at ~20-30 sessions).
372///
373/// ~300-500µs for ~500-1000 processes (vs ~50µs for 5 targeted sessions).
374pub fn scan_all_processes() -> Vec<ProcessInfo> {
375    let pids = list_all_pids();
376    let mut result = Vec::with_capacity(pids.len());
377
378    for pid in pids {
379        // Get ppid
380        let ppid = match get_ppid(pid) {
381            Some(p) => p,
382            None => continue, // Process died
383        };
384
385        // Get CPU time
386        let cpu_time = match get_cpu_time(pid) {
387            Some(c) => c,
388            None => continue, // Process died
389        };
390
391        // Get memory
392        let memory_bytes = get_memory(pid).unwrap_or(0);
393
394        result.push(ProcessInfo {
395            pid,
396            ppid,
397            cpu_time,
398            memory_bytes,
399        });
400    }
401
402    result
403}
404
405/// Build a HashMap of pid -> parent pid for ALL processes on the system.
406///
407/// This is significantly faster than spawning `ps -eo pid=,ppid=`:
408/// - Direct /proc reads: ~5-10ms for 500 processes
409/// - ps subprocess: ~100ms
410///
411/// Iterates `/proc` and reads `/proc/[pid]/stat` for each PID.
412/// Returns HashMap<pid, ppid> for all running processes.
413pub fn build_parent_map() -> std::collections::HashMap<i32, i32> {
414    let pids = list_all_pids();
415    let mut map = std::collections::HashMap::with_capacity(pids.len());
416
417    for pid in pids {
418        if let Some(ppid) = get_ppid(pid) {
419            map.insert(pid, ppid);
420        }
421    }
422
423    map
424}
425
426/// Get environment variables for a process.
427///
428/// Reads `/proc/[pid]/environ` which contains null-separated KEY=VALUE pairs.
429/// Returns None if process doesn't exist or environ can't be read.
430/// ~10-50µs depending on environment size.
431pub fn get_process_environ(pid: i32) -> Option<std::collections::HashMap<String, String>> {
432    let environ = fs::read(format!("/proc/{pid}/environ")).ok()?;
433
434    let mut map = std::collections::HashMap::new();
435
436    // Environment is null-separated KEY=VALUE pairs
437    for entry in environ.split(|&b| b == 0) {
438        if entry.is_empty() {
439            continue;
440        }
441        if let Ok(s) = std::str::from_utf8(entry)
442            && let Some((key, value)) = s.split_once('=')
443        {
444            map.insert(key.to_string(), value.to_string());
445        }
446    }
447
448    Some(map)
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_build_parent_map() {
457        let map = build_parent_map();
458        // Should have many processes
459        assert!(map.len() > 10);
460        // Our process should be in it
461        let our_pid = std::process::id() as i32;
462        assert!(map.contains_key(&our_pid));
463        // Our parent should exist
464        let ppid = map.get(&our_pid).unwrap();
465        assert!(map.contains_key(ppid) || *ppid == 1);
466    }
467
468    #[test]
469    fn test_get_cpu_time_self() {
470        let pid = std::process::id() as i32;
471        let cpu = get_cpu_time(pid);
472        assert!(cpu.is_some());
473    }
474
475    #[test]
476    fn test_get_memory_self() {
477        let pid = std::process::id() as i32;
478        let mem = get_memory(pid);
479        assert!(mem.is_some());
480        assert!(mem.unwrap() > 0);
481    }
482
483    #[test]
484    fn test_get_cpu_time_invalid() {
485        let cpu = get_cpu_time(999_999_999);
486        assert!(cpu.is_none());
487    }
488
489    #[test]
490    fn test_process_exists() {
491        let pid = std::process::id() as i32;
492        assert!(process_exists(pid));
493        assert!(!process_exists(999_999_999));
494    }
495
496    #[test]
497    fn test_get_disk_io_self() {
498        let pid = std::process::id() as i32;
499        let io = get_disk_io(pid);
500        assert!(io.is_some());
501        // Disk I/O may be 0 if binary is cached, just verify we got a result
502        let _io = io.unwrap();
503    }
504
505    #[test]
506    fn test_get_all_stats_self() {
507        let pid = std::process::id() as i32;
508        let stats = get_all_stats(pid);
509        assert!(stats.is_some());
510        let stats = stats.unwrap();
511
512        // Memory should be non-zero
513        assert!(stats.memory_rss > 0, "Memory should be non-zero");
514
515        // Disk I/O may be 0 if binary is cached - just verify struct is populated
516    }
517
518    #[test]
519    fn test_get_all_stats_invalid_pid() {
520        let stats = get_all_stats(999_999_999);
521        assert!(stats.is_none());
522    }
523}