claudectl_core/process.rs
1use crate::session::{ClaudeSession, SessionStatus};
2
3/// Check which PIDs are alive and fetch TTY, CPU%, MEM, command args — all via `ps`.
4/// No sysinfo dependency needed.
5pub fn fetch_and_enrich(sessions: &mut [ClaudeSession]) {
6 if sessions.is_empty() {
7 return;
8 }
9
10 let pids: Vec<String> = sessions.iter().map(|s| s.pid.to_string()).collect();
11 let pid_arg = pids.join(",");
12
13 let output = std::process::Command::new("ps")
14 .args(["-o", "pid=,tty=,%cpu=,rss=,command=", "-p", &pid_arg])
15 .env_clear()
16 .output();
17
18 let output = match output {
19 Ok(o) => o,
20 Err(e) => {
21 crate::logger::log("ERROR", &format!("ps command failed: {e}"));
22 // `ps` being unavailable means liveness is *unknown*, not that
23 // everything died. This used to mark every session `Finished`,
24 // which was survivable only because `infer_status` immediately
25 // overwrote it; now that the verdict is sticky (#473) it would
26 // report a whole machine's sessions as dead.
27 //
28 // It is not hypothetical: busybox `ps` accepts neither `-o` nor
29 // `-p`, so on Alpine and other minimal images — a normal place to
30 // run an agent — this branch is the only one ever taken. Verified
31 // in a container: `ps -o pid=,tty=,%cpu=,rss=,command= -p <pid>`
32 // exits non-zero there while procps answers fine.
33 //
34 // `kill(pid, 0)` needs no external binary and answers the one
35 // question that matters. CPU and memory stay unknown, which is
36 // what they are.
37 for s in sessions.iter_mut() {
38 s.cpu_percent = 0.0;
39 if pid_alive(s.pid) {
40 if s.status == SessionStatus::Finished {
41 s.status = SessionStatus::Unknown;
42 }
43 } else {
44 s.status = SessionStatus::Finished;
45 }
46 }
47 return;
48 }
49 };
50
51 // busybox `ps` takes neither `-o` nor `-p`. It still *runs*, so this is an
52 // `Ok(output)` with a non-zero status and empty stdout — indistinguishable
53 // from "every one of those pids is gone" unless we ask separately. On
54 // Alpine and other minimal images that is the only outcome there is, so
55 // without this check every session on the machine reads as dead.
56 if !output.status.success() && !ps_supports_query() {
57 crate::logger::log(
58 "WARN",
59 "ps cannot answer -o/-p (busybox?); falling back to kill(pid, 0) for liveness",
60 );
61 for s in sessions.iter_mut() {
62 s.cpu_percent = 0.0;
63 if pid_alive(s.pid) {
64 if s.status == SessionStatus::Finished {
65 s.status = SessionStatus::Unknown;
66 }
67 } else {
68 s.status = SessionStatus::Finished;
69 }
70 }
71 return;
72 }
73
74 let stdout = String::from_utf8_lossy(&output.stdout);
75
76 // Collect alive PIDs from ps output
77 let mut alive_pids = std::collections::HashSet::new();
78
79 for line in stdout.lines() {
80 let trimmed = line.trim();
81 let fields: Vec<&str> = trimmed.split_whitespace().collect();
82 if fields.len() < 5 {
83 continue;
84 }
85 let Ok(pid) = fields[0].parse::<u32>() else {
86 continue;
87 };
88 let tty = fields[1].to_string();
89 let cpu = fields[2].parse::<f32>().unwrap_or(0.0);
90 let rss_kb = fields[3].parse::<f64>().unwrap_or(0.0);
91 let mem_mb = rss_kb / 1024.0;
92 let command = fields[4..].join(" ");
93
94 // Only count this PID as alive if it's actually a claude process.
95 // PIDs get reused on macOS — a dead claude session's PID may belong
96 // to an unrelated process now.
97 if !command.contains("claude") {
98 continue;
99 }
100
101 alive_pids.insert(pid);
102
103 for session in sessions.iter_mut() {
104 if session.pid == pid {
105 session.tty = tty.clone();
106 session.mem_mb = mem_mb;
107
108 // CPU smoothing: track last 3 readings, use average
109 session.cpu_history.push(cpu);
110 if session.cpu_history.len() > 3 {
111 session.cpu_history.remove(0);
112 }
113 session.cpu_percent =
114 session.cpu_history.iter().sum::<f32>() / session.cpu_history.len() as f32;
115
116 // Extract args (everything after "claude")
117 if let Some(idx) = command.find("claude") {
118 let after_claude = &command[idx + 6..];
119 session.command_args = after_claude.trim().to_string();
120 }
121
122 // Extract session name from --name or --resume
123 let cmd_parts: Vec<&str> = command.split_whitespace().collect();
124 extract_session_meta(&cmd_parts, session);
125
126 break;
127 }
128 }
129 }
130
131 // Mark dead PIDs as Finished instead of removing them immediately.
132 // They'll be displayed briefly so the user can see what exited.
133 //
134 // Liveness is re-decided from `ps` on every tick, in both directions. The
135 // clearing half matters because `Finished` is now sticky — `infer_status`
136 // will not overwrite it — so a session marked dead by a transient `ps`
137 // failure above would otherwise stay dead for the life of the process.
138 for session in sessions.iter_mut() {
139 if alive_pids.contains(&session.pid) {
140 if session.status == crate::session::SessionStatus::Finished {
141 session.status = crate::session::SessionStatus::Unknown;
142 }
143 } else {
144 session.status = crate::session::SessionStatus::Finished;
145 session.cpu_percent = 0.0;
146 }
147 }
148}
149
150/// Can `ps` answer the query `fetch_and_enrich` needs?
151///
152/// A non-zero exit from `ps -p <pids>` is ambiguous: it means "no matching
153/// process" on procps, and "I do not understand `-o` or `-p`" on busybox. Both
154/// arrive as `Ok(output)` with empty stdout, so the exit status alone cannot
155/// tell a dead session from a crippled `ps`.
156///
157/// pid 1 exists on every running Unix, so asking about it separates the two.
158fn ps_supports_query() -> bool {
159 std::process::Command::new("ps")
160 .args(["-o", "pid=", "-p", "1"])
161 .env_clear()
162 .output()
163 .map(|o| o.status.success() && !o.stdout.is_empty())
164 .unwrap_or(false)
165}
166
167/// Does this pid exist?
168///
169/// Public because it is the liveness primitive the rest of the tree needs
170/// when `ps` is unavailable, and because it is worth testing directly.
171///
172/// `kill(pid, 0)` sends no signal and only checks reachability. `EPERM` means
173/// the process exists but belongs to another user — alive, not dead — so the
174/// errno is distinguished rather than testing `== 0`.
175pub fn pid_alive(pid: u32) -> bool {
176 if pid == 0 {
177 return false;
178 }
179 let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
180 if rc == 0 {
181 return true;
182 }
183 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
184}
185
186fn extract_session_meta(cmd: &[&str], session: &mut ClaudeSession) {
187 let mut i = 0;
188 while i < cmd.len() {
189 match cmd[i] {
190 "--name" | "-n" if i + 1 < cmd.len() => {
191 session.session_name = cmd[i + 1].to_string();
192 i += 2;
193 continue;
194 }
195 "--resume" | "-r" if i + 1 < cmd.len() => {
196 let val = cmd[i + 1];
197 if !looks_like_uuid(val) {
198 session.session_name = val.to_string();
199 }
200 i += 2;
201 continue;
202 }
203 _ => {}
204 }
205 i += 1;
206 }
207}
208
209fn looks_like_uuid(s: &str) -> bool {
210 s.len() == 36
211 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
212 && s.matches('-').count() == 4
213}