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, LivenessInfo};
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/// Get liveness information for a process in a single file read.
343///
344/// Returns start_time, ppid, and whether the process has a TTY, all from one
345/// read of `/proc/[pid]/stat`. This is more efficient than calling `get_start_time`,
346/// `get_ppid`, and `get_tty` separately (1 file read vs 3).
347///
348/// ~10µs per call.
349///
350/// # Use Case
351///
352/// This is designed for detecting "orphaned" processes - those that lost their
353/// terminal due to tmux/terminal bugs but are still running. An orphaned process
354/// typically has:
355/// - ppid == 1 (re-parented to init after parent died)
356/// - no controlling TTY (tty_nr == 0)
357///
358/// Returns `None` if the process doesn't exist or /proc/[pid]/stat can't be read.
359pub fn get_liveness_info(pid: i32) -> Option<LivenessInfo> {
360    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
361
362    // Find the closing paren of comm (process name can contain spaces/parens)
363    let comm_end = stat.rfind(')')?;
364    let fields: Vec<&str> = stat[comm_end + 2..].split_whitespace().collect();
365
366    // After (comm), fields are:
367    // state(0) ppid(1) pgrp(2) session(3) tty_nr(4) ... starttime(19)
368    // We need ppid(1), tty_nr(4), and starttime(19)
369    if fields.len() < 20 {
370        return None;
371    }
372
373    let ppid: i32 = fields[1].parse().ok()?;
374    let tty_nr: i32 = fields[4].parse().ok()?;
375    let starttime_ticks: u64 = fields[19].parse().ok()?;
376
377    // Convert starttime to seconds since epoch
378    let ticks_per_sec = clock_ticks_per_sec();
379    let boot_time = get_boot_time()?;
380    let start_time = boot_time + (starttime_ticks / ticks_per_sec) as i64;
381
382    // tty_nr == 0 means no controlling terminal
383    let has_tty = tty_nr != 0;
384
385    Some(LivenessInfo {
386        start_time,
387        ppid,
388        has_tty,
389    })
390}
391
392/// List all PIDs on the system.
393///
394/// Reads /proc directory for numeric entries.
395/// ~100-200µs for ~500-1000 processes.
396pub fn list_all_pids() -> Vec<i32> {
397    let Ok(entries) = fs::read_dir("/proc") else {
398        return Vec::new();
399    };
400
401    entries
402        .filter_map(|e| e.ok())
403        .filter_map(|e| e.file_name().to_str()?.parse::<i32>().ok())
404        .filter(|&pid| pid > 0)
405        .collect()
406}
407
408/// Info about a single process from a full system scan.
409#[derive(Debug, Clone)]
410pub struct ProcessInfo {
411    pub pid: i32,
412    pub ppid: i32,
413    pub cpu_time: super::CpuTime,
414    pub memory_bytes: u64,
415}
416
417/// Scan all processes on the system and return their info.
418///
419/// This is the "full scan" approach - get all processes in one pass,
420/// then filter to the ones you need. Faster than targeted discovery
421/// when you have many sessions (crossover at ~20-30 sessions).
422///
423/// ~300-500µs for ~500-1000 processes (vs ~50µs for 5 targeted sessions).
424pub fn scan_all_processes() -> Vec<ProcessInfo> {
425    let pids = list_all_pids();
426    let mut result = Vec::with_capacity(pids.len());
427
428    for pid in pids {
429        // Get ppid
430        let ppid = match get_ppid(pid) {
431            Some(p) => p,
432            None => continue, // Process died
433        };
434
435        // Get CPU time
436        let cpu_time = match get_cpu_time(pid) {
437            Some(c) => c,
438            None => continue, // Process died
439        };
440
441        // Get memory
442        let memory_bytes = get_memory(pid).unwrap_or(0);
443
444        result.push(ProcessInfo {
445            pid,
446            ppid,
447            cpu_time,
448            memory_bytes,
449        });
450    }
451
452    result
453}
454
455/// Build a HashMap of pid -> parent pid for ALL processes on the system.
456///
457/// This is significantly faster than spawning `ps -eo pid=,ppid=`:
458/// - Direct /proc reads: ~5-10ms for 500 processes
459/// - ps subprocess: ~100ms
460///
461/// Iterates `/proc` and reads `/proc/[pid]/stat` for each PID.
462/// Returns HashMap<pid, ppid> for all running processes.
463pub fn build_parent_map() -> std::collections::HashMap<i32, i32> {
464    let pids = list_all_pids();
465    let mut map = std::collections::HashMap::with_capacity(pids.len());
466
467    for pid in pids {
468        if let Some(ppid) = get_ppid(pid) {
469            map.insert(pid, ppid);
470        }
471    }
472
473    map
474}
475
476/// Get environment variables for a process.
477///
478/// Reads `/proc/[pid]/environ` which contains null-separated KEY=VALUE pairs.
479/// Returns None if process doesn't exist or environ can't be read.
480/// ~10-50µs depending on environment size.
481pub fn get_process_environ(pid: i32) -> Option<std::collections::HashMap<String, String>> {
482    let environ = fs::read(format!("/proc/{pid}/environ")).ok()?;
483
484    let mut map = std::collections::HashMap::new();
485
486    // Environment is null-separated KEY=VALUE pairs
487    for entry in environ.split(|&b| b == 0) {
488        if entry.is_empty() {
489            continue;
490        }
491        if let Ok(s) = std::str::from_utf8(entry)
492            && let Some((key, value)) = s.split_once('=')
493        {
494            map.insert(key.to_string(), value.to_string());
495        }
496    }
497
498    Some(map)
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn test_build_parent_map() {
507        let map = build_parent_map();
508        // Should have many processes
509        assert!(map.len() > 10);
510        // Our process should be in it
511        let our_pid = std::process::id() as i32;
512        assert!(map.contains_key(&our_pid));
513        // Our parent should exist
514        let ppid = map.get(&our_pid).unwrap();
515        assert!(map.contains_key(ppid) || *ppid == 1);
516    }
517
518    #[test]
519    fn test_get_cpu_time_self() {
520        let pid = std::process::id() as i32;
521        let cpu = get_cpu_time(pid);
522        assert!(cpu.is_some());
523    }
524
525    #[test]
526    fn test_get_memory_self() {
527        let pid = std::process::id() as i32;
528        let mem = get_memory(pid);
529        assert!(mem.is_some());
530        assert!(mem.unwrap() > 0);
531    }
532
533    #[test]
534    fn test_get_cpu_time_invalid() {
535        let cpu = get_cpu_time(999_999_999);
536        assert!(cpu.is_none());
537    }
538
539    #[test]
540    fn test_process_exists() {
541        let pid = std::process::id() as i32;
542        assert!(process_exists(pid));
543        assert!(!process_exists(999_999_999));
544    }
545
546    #[test]
547    fn test_get_disk_io_self() {
548        let pid = std::process::id() as i32;
549        let io = get_disk_io(pid);
550        assert!(io.is_some());
551        // Disk I/O may be 0 if binary is cached, just verify we got a result
552        let _io = io.unwrap();
553    }
554
555    #[test]
556    fn test_get_all_stats_self() {
557        let pid = std::process::id() as i32;
558        let stats = get_all_stats(pid);
559        assert!(stats.is_some());
560        let stats = stats.unwrap();
561
562        // Memory should be non-zero
563        assert!(stats.memory_rss > 0, "Memory should be non-zero");
564
565        // Disk I/O may be 0 if binary is cached - just verify struct is populated
566    }
567
568    #[test]
569    fn test_get_all_stats_invalid_pid() {
570        let stats = get_all_stats(999_999_999);
571        assert!(stats.is_none());
572    }
573}