Skip to main content

runtimo_core/
processes.rs

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