Skip to main content

runtimo_core/
processes.rs

1//! Process Execution Awareness — What's running and consuming resources.
2//!
3//! Tracks processes, resource consumption, and execution context.
4//! Captures a snapshot via `ps` with explicit format, computes summaries
5//! (total CPU%, memory%, zombie count), and identifies top consumers.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use runtimo_core::ProcessSnapshot;
11//!
12//! let snap = ProcessSnapshot::capture();
13//! println!("Processes: {}", snap.summary.total_processes);
14//! println!("Zombies: {}", snap.summary.zombie_count);
15//!
16//! for proc in snap.top_by_cpu(5) {
17//!     println!("{}: {:.1}% CPU", proc.command, proc.cpu_percent);
18//! }
19//! ```
20
21use crate::cmd::run_cmd;
22use serde::{Deserialize, Serialize};
23use std::sync::Mutex;
24
25static PROCESS_CACHE: Mutex<Option<(ProcessSnapshot, std::time::Instant)>> = Mutex::new(None);
26const CACHE_TTL_SECS: u64 = 30;
27
28/// Process list snapshot at a point in time.
29///
30/// Contains the raw process list, a computed summary, and a timestamp.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[allow(clippy::exhaustive_structs)]
33pub struct ProcessSnapshot {
34    /// Unix timestamp (seconds) when the snapshot was taken.
35    pub timestamp: u64,
36    /// Individual process records parsed from `ps -eo`.
37    pub processes: Vec<ProcessInfo>,
38    /// Aggregated summary statistics.
39    pub summary: ProcessSummary,
40}
41
42/// Information about a single running process.
43///
44/// Parsed from one line of `ps -eo` output.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[allow(clippy::exhaustive_structs)]
47pub struct ProcessInfo {
48    /// Process ID.
49    pub pid: u32,
50    /// Parent Process ID (PPID) for lineage tracking.
51    pub ppid: u32,
52    /// Owning user name.
53    pub user: String,
54    /// CPU usage percentage.
55    pub cpu_percent: f32,
56    /// Memory usage percentage.
57    pub mem_percent: f32,
58    /// Virtual memory size in kilobytes (KB).
59    pub vsz: u64,
60    /// Resident set size in kilobytes (KB).
61    pub rss: u64,
62    /// Process state string (e.g. `"S"`, `"R"`, `"Z"`).
63    pub stat: String,
64    /// Start time of the process.
65    pub start_time: String,
66    /// Elapsed running time.
67    pub elapsed: String,
68    /// Full command line.
69    pub command: String,
70}
71
72/// Aggregated summary of a process snapshot.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[allow(clippy::exhaustive_structs)]
75pub struct ProcessSummary {
76    /// Total number of processes.
77    pub total_processes: usize,
78    /// Sum of all process CPU percentages.
79    pub total_cpu_percent: f32,
80    /// Sum of all process memory percentages.
81    pub total_mem_percent: f32,
82    /// Command name of the top CPU consumer.
83    pub top_cpu_consumer: Option<String>,
84    /// Command name of the top memory consumer.
85    pub top_mem_consumer: Option<String>,
86    /// Number of zombie (`Z` state) processes.
87    pub zombie_count: usize,
88}
89
90impl ProcessSnapshot {
91    /// Captures a full process snapshot via `ps` with explicit format.
92    ///
93    /// Results are cached for 30 seconds to avoid re-parsing on
94    /// repeated calls within the same execution window.
95    pub fn capture() -> Self {
96        let now = std::time::Instant::now();
97        {
98            // Handle poison error by recovering from the poisoned state
99            let cache = PROCESS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
100            if let Some((cached, instant)) = cache.as_ref() {
101                if now.duration_since(*instant).as_secs() < CACHE_TTL_SECS {
102                    return cached.clone();
103                }
104            }
105        }
106
107        let timestamp = std::time::SystemTime::now()
108            .duration_since(std::time::UNIX_EPOCH)
109            .map_or(0, |d| d.as_secs());
110
111        let mut processes = Vec::new();
112        // Use ps with explicit format to get PPID: PID,PPID,USER,CPU,MEM,VSZ,RSS,STAT,START,TIME,COMMAND
113        // This gives us parent process ID for lineage tracking
114        let ps_output =
115            run_cmd("ps -eo pid,ppid,user,%cpu,%mem,vsz,rss,stat,start,time,comm --no-headers");
116
117        for line in ps_output.lines() {
118            if let Some(proc) = parse_ps_line(line) {
119                processes.push(proc);
120            }
121        }
122
123        let summary = ProcessSummary::compute(&processes);
124
125        let snapshot = Self {
126            timestamp,
127            processes,
128            summary,
129        };
130
131        // Handle poison error by recovering from the poisoned state
132        let mut cache = PROCESS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
133        *cache = Some((snapshot.clone(), now));
134        snapshot
135    }
136
137    /// Returns all zombie processes with their PID, command, and PPID.
138    ///
139    /// Zombies are defunct child processes whose parent has not yet called
140    /// `waitpid(2)`. They consume no resources but each occupies a PID slot.
141    #[must_use]
142    pub fn zombies(&self) -> Vec<&ProcessInfo> {
143        self.processes
144            .iter()
145            .filter(|p| p.stat.starts_with('Z'))
146            .collect()
147    }
148
149    /// Clears the process snapshot cache.
150    ///
151    /// Use before capturing an after-kill snapshot to ensure fresh data.
152    pub fn clear_cache() {
153        let mut cache = PROCESS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
154        *cache = None;
155    }
156
157    /// Returns the top `n` processes by CPU usage.
158    #[must_use]
159    pub fn top_by_cpu(&self, n: usize) -> Vec<&ProcessInfo> {
160        let mut procs: Vec<_> = self.processes.iter().collect();
161        procs.sort_by(|a, b| {
162            b.cpu_percent
163                .partial_cmp(&a.cpu_percent)
164                .unwrap_or(std::cmp::Ordering::Equal)
165        });
166        procs.into_iter().take(n).collect()
167    }
168
169    /// Returns the top `n` processes by memory usage.
170    #[must_use]
171    pub fn top_by_mem(&self, n: usize) -> Vec<&ProcessInfo> {
172        let mut procs: Vec<_> = self.processes.iter().collect();
173        procs.sort_by(|a, b| {
174            b.mem_percent
175                .partial_cmp(&a.mem_percent)
176                .unwrap_or(std::cmp::Ordering::Equal)
177        });
178        procs.into_iter().take(n).collect()
179    }
180
181    /// Prints a human-readable process report to stdout.
182    #[allow(clippy::arithmetic_side_effects)] // i+1 for human-readable display numbering
183    pub fn print_report(&self) {
184        println!("\n{}", "=".repeat(80));
185        println!(" PROCESS SNAPSHOT [{}]", self.timestamp);
186        println!("{}", "=".repeat(80));
187
188        println!("\n--- SUMMARY ---");
189        println!(" Total Processes: {}", self.summary.total_processes);
190        println!(" Total CPU: {:.1}%", self.summary.total_cpu_percent);
191        println!(" Total Memory: {:.1}%", self.summary.total_mem_percent);
192        println!(" Zombies: {}", self.summary.zombie_count);
193
194        if let Some(ref top_cpu) = self.summary.top_cpu_consumer {
195            println!(
196                " Top CPU: {} ({:.1}%)",
197                top_cpu,
198                self.processes
199                    .iter()
200                    .find(|p| p.command == *top_cpu)
201                    .map_or(0.0, |p| p.cpu_percent)
202            );
203        }
204
205        if let Some(ref top_mem) = self.summary.top_mem_consumer {
206            println!(
207                " Top Memory: {} ({:.1}%)",
208                top_mem,
209                self.processes
210                    .iter()
211                    .find(|p| p.command == *top_mem)
212                    .map_or(0.0, |p| p.mem_percent)
213            );
214        }
215
216        println!("\n--- TOP 10 BY CPU ---");
217        for (i, proc) in self.top_by_cpu(10).iter().enumerate() {
218            println!(
219                "{:2}. {:6} {:6} {:5.1} {:5.1} {:8} {:8} {:?} {}",
220                i + 1,
221                proc.pid,
222                proc.user,
223                proc.cpu_percent,
224                proc.mem_percent,
225                format_size(proc.vsz),
226                format_size(proc.rss),
227                proc.stat,
228                truncate(&proc.command, 50)
229            );
230        }
231
232        println!("\n--- TOP 10 BY MEMORY ---");
233        for (i, proc) in self.top_by_mem(10).iter().enumerate() {
234            println!(
235                "{:2}. {:6} {:6} {:5.1} {:5.1} {:8} {:8} {:?} {}",
236                i + 1,
237                proc.pid,
238                proc.user,
239                proc.cpu_percent,
240                proc.mem_percent,
241                format_size(proc.vsz),
242                format_size(proc.rss),
243                proc.stat,
244                truncate(&proc.command, 50)
245            );
246        }
247
248        println!("\n{}", "=".repeat(80));
249    }
250}
251
252/// Parses a single line of process output into a [`ProcessInfo`].
253///
254/// Expected format: PID PPID USER %CPU %MEM VSZ RSS STAT START TIME COMMAND
255/// Returns `None` if the line has fewer than 10 whitespace-separated fields.
256#[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
257fn parse_ps_line(line: &str) -> Option<ProcessInfo> {
258    let parts: Vec<&str> = line.split_whitespace().collect();
259    if parts.len() < 10 {
260        return None;
261    }
262
263    let pid = parts[0].parse().ok()?;
264    let ppid = parts[1].parse().ok()?;
265    let user = parts[2].to_string();
266    let cpu_percent = parts[3].parse().unwrap_or(0.0);
267    let mem_percent = parts[4].parse().unwrap_or(0.0);
268    let vsz: u64 = parts[5].parse().unwrap_or(0);
269    let rss: u64 = parts[6].parse().unwrap_or(0);
270    let stat = parts[7].to_string();
271    let start_time = parts[8].to_string();
272    let elapsed = parts[9].to_string();
273    let command = parts.get(10..).map(|s| s.join(" ")).unwrap_or_default();
274
275    Some(ProcessInfo {
276        pid,
277        ppid,
278        user,
279        cpu_percent,
280        mem_percent,
281        vsz,
282        rss,
283        stat,
284        start_time,
285        elapsed,
286        command,
287    })
288}
289
290impl ProcessSummary {
291    fn compute(processes: &[ProcessInfo]) -> Self {
292        let total_processes = processes.len();
293        let total_cpu_percent: f32 = processes.iter().map(|p| p.cpu_percent).sum();
294        let total_mem_percent: f32 = processes.iter().map(|p| p.mem_percent).sum();
295
296        let top_cpu_consumer = processes
297            .iter()
298            .max_by(|a, b| {
299                a.cpu_percent
300                    .partial_cmp(&b.cpu_percent)
301                    .unwrap_or(std::cmp::Ordering::Equal)
302            })
303            .map(|p| p.command.clone());
304
305        let top_mem_consumer = processes
306            .iter()
307            .max_by(|a, b| {
308                a.mem_percent
309                    .partial_cmp(&b.mem_percent)
310                    .unwrap_or(std::cmp::Ordering::Equal)
311            })
312            .map(|p| p.command.clone());
313
314        let zombie_count = processes.iter().filter(|p| p.stat.starts_with('Z')).count();
315
316        Self {
317            total_processes,
318            total_cpu_percent,
319            total_mem_percent,
320            top_cpu_consumer,
321            top_mem_consumer,
322            zombie_count,
323        }
324    }
325}
326
327/// Formats a size in kilobytes as a human-readable string (K/M/G).
328#[allow(clippy::cast_precision_loss)]
329fn format_size(kb: u64) -> String {
330    if kb >= 1024 * 1024 {
331        format!("{:.1}G", kb as f64 / (1024.0 * 1024.0))
332    } else if kb >= 1024 {
333        format!("{:.1}M", kb as f64 / 1024.0)
334    } else {
335        format!("{}K", kb)
336    }
337}
338
339/// Truncates a string to `max_len` characters, appending `"..."` if truncated.
340///
341/// Uses `char_indices()` for safe UTF-8 boundary slicing — never panics on
342/// multi-byte characters.
343fn truncate(s: &str, max_len: usize) -> String {
344    if s.chars().count() > max_len {
345        let end = max_len.saturating_sub(3);
346        let byte_end = s.char_indices().nth(end).map_or(s.len(), |(i, _)| i);
347        format!("{}...", &s[..byte_end])
348    } else {
349        s.to_string()
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    #[test]
357    fn test_process_snapshot() {
358        let snapshot = ProcessSnapshot::capture();
359        assert!(!snapshot.processes.is_empty());
360        assert!(snapshot.summary.total_processes > 0);
361    }
362
363    #[test]
364    fn test_truncate_ascii() {
365        assert_eq!(truncate("hello world", 8), "hello...");
366        assert_eq!(truncate("short", 10), "short");
367    }
368
369    #[test]
370    fn test_truncate_multibyte_utf8() {
371        // This must NOT panic — the old code panicked on multi-byte boundaries
372        let cjk = "你好世界这是一个很长的命令行参数"; // 15 CJK chars
373        let result = truncate(cjk, 8);
374        assert!(result.ends_with("..."));
375        // Should not panic and should be valid UTF-8
376        assert!(result.is_char_boundary(result.len()));
377    }
378
379    #[test]
380    fn test_format_size() {
381        // format_size expects KB input
382        assert_eq!(format_size(0), "0K");
383        assert_eq!(format_size(512), "512K");
384        assert_eq!(format_size(1024), "1.0M");
385        assert_eq!(format_size(1024 * 1024), "1.0G");
386        assert_eq!(format_size(1024 * 512), "512.0M");
387        assert_eq!(format_size(1024 * 1024 * 2), "2.0G");
388    }
389
390    #[test]
391    fn test_process_vsz_rss_in_kb() {
392        let snap = ProcessSnapshot::capture();
393        // Every process should have vsz/rss as reasonable KB values
394        // (not multiplied by 1024 — that was the old bug)
395        for p in &snap.processes {
396            // vsz can be very large on 64-bit systems (virtual memory is cheap)
397            // but should not exceed 1PB (1024*1024*1024 KB)
398            assert!(
399                p.vsz < 1_000_000_000,
400                "vsz={}KB is unreasonably large for {}",
401                p.vsz,
402                p.command
403            );
404            // rss is physical memory — should be under 100GB for any single process
405            assert!(
406                p.rss < 100_000_000,
407                "rss={}KB is unreasonably large for {}",
408                p.rss,
409                p.command
410            );
411        }
412    }
413}