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                .unwrap_or_default();
117
118        for line in ps_output.lines() {
119            if let Some(proc) = parse_ps_line(line) {
120                processes.push(proc);
121            }
122        }
123
124        let summary = ProcessSummary::compute(&processes);
125
126        let snapshot = Self {
127            timestamp,
128            processes,
129            summary,
130        };
131
132        // Handle poison error by recovering from the poisoned state
133        let mut cache = PROCESS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
134        *cache = Some((snapshot.clone(), now));
135        snapshot
136    }
137
138    /// Returns all zombie processes with their PID, command, and PPID.
139    ///
140    /// Zombies are defunct child processes whose parent has not yet called
141    /// `waitpid(2)`. They consume no resources but each occupies a PID slot.
142    #[must_use]
143    pub fn zombies(&self) -> Vec<&ProcessInfo> {
144        self.processes
145            .iter()
146            .filter(|p| p.stat.starts_with('Z'))
147            .collect()
148    }
149
150    /// Clears the process snapshot cache.
151    ///
152    /// Use before capturing an after-kill snapshot to ensure fresh data.
153    pub fn clear_cache() {
154        let mut cache = PROCESS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
155        *cache = None;
156    }
157
158    /// Returns the top `n` processes by CPU usage.
159    #[must_use]
160    pub fn top_by_cpu(&self, n: usize) -> Vec<&ProcessInfo> {
161        let mut procs: Vec<_> = self.processes.iter().collect();
162        procs.sort_by(|a, b| {
163            b.cpu_percent
164                .partial_cmp(&a.cpu_percent)
165                .unwrap_or(std::cmp::Ordering::Equal)
166        });
167        procs.into_iter().take(n).collect()
168    }
169
170    /// Returns the top `n` processes by memory usage.
171    #[must_use]
172    pub fn top_by_mem(&self, n: usize) -> Vec<&ProcessInfo> {
173        let mut procs: Vec<_> = self.processes.iter().collect();
174        procs.sort_by(|a, b| {
175            b.mem_percent
176                .partial_cmp(&a.mem_percent)
177                .unwrap_or(std::cmp::Ordering::Equal)
178        });
179        procs.into_iter().take(n).collect()
180    }
181
182    /// Prints a human-readable process report to stdout.
183    #[allow(clippy::arithmetic_side_effects)] // i+1 for human-readable display numbering
184    pub fn print_report(&self) {
185        println!("\n{}", "=".repeat(80));
186        println!(" PROCESS SNAPSHOT [{}]", self.timestamp);
187        println!("{}", "=".repeat(80));
188
189        println!("\n--- SUMMARY ---");
190        println!(" Total Processes: {}", self.summary.total_processes);
191        println!(" Total CPU: {:.1}%", self.summary.total_cpu_percent);
192        println!(" Total Memory: {:.1}%", self.summary.total_mem_percent);
193        println!(" Zombies: {}", self.summary.zombie_count);
194
195        if let Some(ref top_cpu) = self.summary.top_cpu_consumer {
196            println!(
197                " Top CPU: {} ({:.1}%)",
198                top_cpu,
199                self.processes
200                    .iter()
201                    .find(|p| p.command == *top_cpu)
202                    .map_or(0.0, |p| p.cpu_percent)
203            );
204        }
205
206        if let Some(ref top_mem) = self.summary.top_mem_consumer {
207            println!(
208                " Top Memory: {} ({:.1}%)",
209                top_mem,
210                self.processes
211                    .iter()
212                    .find(|p| p.command == *top_mem)
213                    .map_or(0.0, |p| p.mem_percent)
214            );
215        }
216
217        println!("\n--- TOP 10 BY CPU ---");
218        for (i, proc) in self.top_by_cpu(10).iter().enumerate() {
219            println!(
220                "{:2}. {:6} {:6} {:5.1} {:5.1} {:8} {:8} {:?} {}",
221                i + 1,
222                proc.pid,
223                proc.user,
224                proc.cpu_percent,
225                proc.mem_percent,
226                format_size(proc.vsz),
227                format_size(proc.rss),
228                proc.stat,
229                truncate(&proc.command, 50)
230            );
231        }
232
233        println!("\n--- TOP 10 BY MEMORY ---");
234        for (i, proc) in self.top_by_mem(10).iter().enumerate() {
235            println!(
236                "{:2}. {:6} {:6} {:5.1} {:5.1} {:8} {:8} {:?} {}",
237                i + 1,
238                proc.pid,
239                proc.user,
240                proc.cpu_percent,
241                proc.mem_percent,
242                format_size(proc.vsz),
243                format_size(proc.rss),
244                proc.stat,
245                truncate(&proc.command, 50)
246            );
247        }
248
249        println!("\n{}", "=".repeat(80));
250    }
251}
252
253/// Parses a single line of process output into a [`ProcessInfo`].
254///
255/// Expected format: PID PPID USER %CPU %MEM VSZ RSS STAT START TIME COMMAND
256/// Returns `None` if the line has fewer than 10 whitespace-separated fields.
257#[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
258fn parse_ps_line(line: &str) -> Option<ProcessInfo> {
259    let parts: Vec<&str> = line.split_whitespace().collect();
260    if parts.len() < 10 {
261        return None;
262    }
263
264    let pid = parts[0].parse().ok()?;
265    let ppid = parts[1].parse().ok()?;
266    let user = parts[2].to_string();
267    let cpu_percent = parts[3].parse().unwrap_or(0.0);
268    let mem_percent = parts[4].parse().unwrap_or(0.0);
269    let vsz: u64 = parts[5].parse().unwrap_or(0);
270    let rss: u64 = parts[6].parse().unwrap_or(0);
271    let stat = parts[7].to_string();
272    let start_time = parts[8].to_string();
273    let elapsed = parts[9].to_string();
274    let command = parts.get(10..).map(|s| s.join(" ")).unwrap_or_default();
275
276    Some(ProcessInfo {
277        pid,
278        ppid,
279        user,
280        cpu_percent,
281        mem_percent,
282        vsz,
283        rss,
284        stat,
285        start_time,
286        elapsed,
287        command,
288    })
289}
290
291impl ProcessSummary {
292    fn compute(processes: &[ProcessInfo]) -> Self {
293        let total_processes = processes.len();
294        let total_cpu_percent: f32 = processes.iter().map(|p| p.cpu_percent).sum();
295        let total_mem_percent: f32 = processes.iter().map(|p| p.mem_percent).sum();
296
297        let top_cpu_consumer = processes
298            .iter()
299            .max_by(|a, b| {
300                a.cpu_percent
301                    .partial_cmp(&b.cpu_percent)
302                    .unwrap_or(std::cmp::Ordering::Equal)
303            })
304            .map(|p| p.command.clone());
305
306        let top_mem_consumer = processes
307            .iter()
308            .max_by(|a, b| {
309                a.mem_percent
310                    .partial_cmp(&b.mem_percent)
311                    .unwrap_or(std::cmp::Ordering::Equal)
312            })
313            .map(|p| p.command.clone());
314
315        let zombie_count = processes.iter().filter(|p| p.stat.starts_with('Z')).count();
316
317        Self {
318            total_processes,
319            total_cpu_percent,
320            total_mem_percent,
321            top_cpu_consumer,
322            top_mem_consumer,
323            zombie_count,
324        }
325    }
326}
327
328/// Formats a size in kilobytes as a human-readable string (K/M/G).
329///
330/// - Values >= 1,048,576 KB → display as GiB with one decimal (e.g. "2.0G")
331/// - Values >= 1,024 KB → display as MiB (e.g. "512.0M")
332/// - Values < 1,024 KB → display as KB (e.g. "512K")
333#[allow(clippy::cast_precision_loss)]
334fn format_size(kb: u64) -> String {
335    if kb >= 1024 * 1024 {
336        format!("{:.1}G", kb as f64 / (1024.0 * 1024.0))
337    } else if kb >= 1024 {
338        format!("{:.1}M", kb as f64 / 1024.0)
339    } else {
340        format!("{}K", kb)
341    }
342}
343
344/// Truncates a string to `max_len` characters, appending `"..."` if truncated.
345///
346/// Uses `char_indices()` for safe UTF-8 boundary slicing — never panics on
347/// multi-byte characters.
348fn truncate(s: &str, max_len: usize) -> String {
349    if s.chars().count() > max_len {
350        let end = max_len.saturating_sub(3);
351        let byte_end = s.char_indices().nth(end).map_or(s.len(), |(i, _)| i);
352        format!("{}...", &s[..byte_end])
353    } else {
354        s.to_string()
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    #[test]
362    fn test_process_snapshot() {
363        let snapshot = ProcessSnapshot::capture();
364        assert!(!snapshot.processes.is_empty());
365        assert!(snapshot.summary.total_processes > 0);
366    }
367
368    #[test]
369    fn test_truncate_ascii() {
370        assert_eq!(truncate("hello world", 8), "hello...");
371        assert_eq!(truncate("short", 10), "short");
372    }
373
374    #[test]
375    fn test_truncate_multibyte_utf8() {
376        // This must NOT panic — the old code panicked on multi-byte boundaries
377        let cjk = "你好世界这是一个很长的命令行参数"; // 15 CJK chars
378        let result = truncate(cjk, 8);
379        assert!(result.ends_with("..."));
380        // Should not panic and should be valid UTF-8
381        assert!(result.is_char_boundary(result.len()));
382    }
383
384    #[test]
385    fn test_format_size() {
386        // format_size expects KB input
387        assert_eq!(format_size(0), "0K");
388        assert_eq!(format_size(512), "512K");
389        assert_eq!(format_size(1024), "1.0M");
390        assert_eq!(format_size(1024 * 1024), "1.0G");
391        assert_eq!(format_size(1024 * 512), "512.0M");
392        assert_eq!(format_size(1024 * 1024 * 2), "2.0G");
393    }
394
395    #[test]
396    fn test_process_vsz_rss_in_kb() {
397        let snap = ProcessSnapshot::capture();
398        // Every process should have vsz/rss as reasonable KB values
399        // (not multiplied by 1024 — that was the old bug)
400        for p in &snap.processes {
401            // vsz can be very large on 64-bit systems (virtual memory is cheap)
402            // but should not exceed 1PB (1024*1024*1024 KB)
403            assert!(
404                p.vsz < 1_000_000_000,
405                "vsz={}KB is unreasonably large for {}",
406                p.vsz,
407                p.command
408            );
409            // rss is physical memory — should be under 100GB for any single process
410            assert!(
411                p.rss < 100_000_000,
412                "rss={}KB is unreasonably large for {}",
413                p.rss,
414                p.command
415            );
416        }
417    }
418}