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 // A session with no process of its own must never reach the pid paths
7 // below (#524). A Codex session carries `pid: 0`, and `kill(0, 0)` asks
8 // about the *caller's own process group* — so it would answer "alive"
9 // forever, and `ps -p 0` returns nothing, which the busybox fallback
10 // reads as "this pid is gone". Both answers are fiction. Filtering here
11 // rather than at every caller keeps the rule in one place.
12 //
13 // Their status comes from their transcript instead, which is the only
14 // signal they have; see `agent::codex`.
15 let mut sessions: Vec<&mut ClaudeSession> =
16 sessions.iter_mut().filter(|s| s.is_actionable()).collect();
17 if sessions.is_empty() {
18 return;
19 }
20
21 let pids: Vec<String> = sessions.iter().map(|s| s.pid.to_string()).collect();
22 let pid_arg = pids.join(",");
23
24 let output = std::process::Command::new("ps")
25 .args(["-o", "pid=,tty=,%cpu=,rss=,command=", "-p", &pid_arg])
26 .env_clear()
27 .output();
28
29 let output = match output {
30 Ok(o) => o,
31 Err(e) => {
32 crate::logger::log("ERROR", &format!("ps command failed: {e}"));
33 // `ps` being unavailable means liveness is *unknown*, not that
34 // everything died. This used to mark every session `Finished`,
35 // which was survivable only because `infer_status` immediately
36 // overwrote it; now that the verdict is sticky (#473) it would
37 // report a whole machine's sessions as dead.
38 //
39 // It is not hypothetical: busybox `ps` accepts neither `-o` nor
40 // `-p`, so on Alpine and other minimal images — a normal place to
41 // run an agent — this branch is the only one ever taken. Verified
42 // in a container: `ps -o pid=,tty=,%cpu=,rss=,command= -p <pid>`
43 // exits non-zero there while procps answers fine.
44 //
45 // `kill(pid, 0)` needs no external binary and answers the one
46 // question that matters. CPU and memory stay unknown, which is
47 // what they are.
48 for s in sessions.iter_mut() {
49 s.cpu_percent = 0.0;
50 if pid_alive(s.pid) {
51 if s.status == SessionStatus::Finished {
52 s.status = SessionStatus::Unknown;
53 }
54 } else {
55 s.status = SessionStatus::Finished;
56 }
57 }
58 return;
59 }
60 };
61
62 // busybox `ps` takes neither `-o` nor `-p`. It still *runs*, so this is an
63 // `Ok(output)` with a non-zero status and empty stdout — indistinguishable
64 // from "every one of those pids is gone" unless we ask separately. On
65 // Alpine and other minimal images that is the only outcome there is, so
66 // without this check every session on the machine reads as dead.
67 if !output.status.success() && !ps_supports_query() {
68 crate::logger::log(
69 "WARN",
70 "ps cannot answer -o/-p (busybox?); falling back to kill(pid, 0) for liveness",
71 );
72 for s in sessions.iter_mut() {
73 s.cpu_percent = 0.0;
74 if pid_alive(s.pid) {
75 if s.status == SessionStatus::Finished {
76 s.status = SessionStatus::Unknown;
77 }
78 } else {
79 s.status = SessionStatus::Finished;
80 }
81 }
82 return;
83 }
84
85 let stdout = String::from_utf8_lossy(&output.stdout);
86
87 // Collect alive PIDs from ps output
88 let mut alive_pids = std::collections::HashSet::new();
89
90 for line in stdout.lines() {
91 let trimmed = line.trim();
92 let fields: Vec<&str> = trimmed.split_whitespace().collect();
93 if fields.len() < 5 {
94 continue;
95 }
96 let Ok(pid) = fields[0].parse::<u32>() else {
97 continue;
98 };
99 let tty = fields[1].to_string();
100 let cpu = fields[2].parse::<f32>().unwrap_or(0.0);
101 let rss_kb = fields[3].parse::<f64>().unwrap_or(0.0);
102 let mem_mb = rss_kb / 1024.0;
103 let command = fields[4..].join(" ");
104
105 // Only count this PID as alive if it's actually a claude process.
106 // PIDs get reused on macOS — a dead claude session's PID may belong
107 // to an unrelated process now.
108 if !command.contains("claude") {
109 continue;
110 }
111
112 alive_pids.insert(pid);
113
114 for session in sessions.iter_mut() {
115 if session.pid == pid {
116 session.tty = tty.clone();
117 session.mem_mb = mem_mb;
118
119 // CPU smoothing: track last 3 readings, use average
120 session.cpu_history.push(cpu);
121 if session.cpu_history.len() > 3 {
122 session.cpu_history.remove(0);
123 }
124 session.cpu_percent =
125 session.cpu_history.iter().sum::<f32>() / session.cpu_history.len() as f32;
126
127 // Extract args (everything after "claude")
128 if let Some(idx) = command.find("claude") {
129 let after_claude = &command[idx + 6..];
130 session.command_args = after_claude.trim().to_string();
131 }
132
133 // Extract session name from --name or --resume
134 let cmd_parts: Vec<&str> = command.split_whitespace().collect();
135 extract_session_meta(&cmd_parts, session);
136
137 break;
138 }
139 }
140 }
141
142 // Mark dead PIDs as Finished instead of removing them immediately.
143 // They'll be displayed briefly so the user can see what exited.
144 //
145 // Liveness is re-decided from `ps` on every tick, in both directions. The
146 // clearing half matters because `Finished` is now sticky — `infer_status`
147 // will not overwrite it — so a session marked dead by a transient `ps`
148 // failure above would otherwise stay dead for the life of the process.
149 for session in sessions.iter_mut() {
150 if alive_pids.contains(&session.pid) {
151 if session.status == crate::session::SessionStatus::Finished {
152 session.status = crate::session::SessionStatus::Unknown;
153 }
154 } else {
155 session.status = crate::session::SessionStatus::Finished;
156 session.cpu_percent = 0.0;
157 }
158 }
159}
160
161/// Can `ps` answer the query `fetch_and_enrich` needs?
162///
163/// A non-zero exit from `ps -p <pids>` is ambiguous: it means "no matching
164/// process" on procps, and "I do not understand `-o` or `-p`" on busybox. Both
165/// arrive as `Ok(output)` with empty stdout, so the exit status alone cannot
166/// tell a dead session from a crippled `ps`.
167///
168/// pid 1 exists on every running Unix, so asking about it separates the two.
169fn ps_supports_query() -> bool {
170 std::process::Command::new("ps")
171 .args(["-o", "pid=", "-p", "1"])
172 .env_clear()
173 .output()
174 .map(|o| o.status.success() && !o.stdout.is_empty())
175 .unwrap_or(false)
176}
177
178/// Does this pid exist?
179///
180/// Public because it is the liveness primitive the rest of the tree needs
181/// when `ps` is unavailable, and because it is worth testing directly.
182///
183/// `kill(pid, 0)` sends no signal and only checks reachability. `EPERM` means
184/// the process exists but belongs to another user — alive, not dead — so the
185/// errno is distinguished rather than testing `== 0`.
186pub fn pid_alive(pid: u32) -> bool {
187 if pid == 0 {
188 return false;
189 }
190 let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
191 if rc == 0 {
192 return true;
193 }
194 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
195}
196
197fn extract_session_meta(cmd: &[&str], session: &mut ClaudeSession) {
198 let mut i = 0;
199 while i < cmd.len() {
200 match cmd[i] {
201 "--name" | "-n" if i + 1 < cmd.len() => {
202 session.session_name = cmd[i + 1].to_string();
203 i += 2;
204 continue;
205 }
206 "--resume" | "-r" if i + 1 < cmd.len() => {
207 let val = cmd[i + 1];
208 if !looks_like_uuid(val) {
209 session.session_name = val.to_string();
210 }
211 i += 2;
212 continue;
213 }
214 _ => {}
215 }
216 i += 1;
217 }
218}
219
220fn looks_like_uuid(s: &str) -> bool {
221 s.len() == 36
222 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
223 && s.matches('-').count() == 4
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::session::RawSession;
230
231 fn codex_session() -> ClaudeSession {
232 ClaudeSession::from_codex_rollout(&crate::agent::codex::RolloutSummary {
233 session_id: "codex-1".into(),
234 cwd: "/tmp/project".into(),
235 lifecycle: crate::agent::codex::Lifecycle::Working,
236 ..Default::default()
237 })
238 }
239
240 /// A Codex session must not reach the pid paths at all (#524).
241 ///
242 /// `pid` is 0 there, and both branches would lie about it: `ps -p 0`
243 /// returns nothing, which the busybox fallback reads as "this pid is
244 /// gone" and would mark the session `Finished`; `kill(0, 0)` asks about
245 /// the caller's own process group and would answer "alive" forever.
246 /// Its status comes from its transcript, so `fetch_and_enrich` has to
247 /// leave it exactly as it found it.
248 #[test]
249 fn a_codex_session_status_is_not_touched() {
250 let mut sessions = vec![codex_session()];
251 let before = sessions[0].status;
252 assert_eq!(before, SessionStatus::Processing);
253
254 fetch_and_enrich(&mut sessions);
255
256 assert_eq!(
257 sessions[0].status, before,
258 "a session with no process of its own must come back unchanged"
259 );
260 }
261
262 /// The filter must not swallow the real work: a live Claude Code session
263 /// alongside a Codex one still gets enriched.
264 #[test]
265 fn a_real_session_alongside_a_codex_one_is_still_enriched() {
266 let mut sessions = vec![
267 codex_session(),
268 ClaudeSession::from_raw(RawSession {
269 // This process. It is certainly alive.
270 pid: std::process::id(),
271 session_id: "live".into(),
272 cwd: "/tmp/project".into(),
273 started_at: 0,
274 }),
275 ];
276
277 fetch_and_enrich(&mut sessions);
278
279 assert_ne!(
280 sessions[1].status,
281 SessionStatus::Finished,
282 "the test's own pid is alive, so it must not be reported dead"
283 );
284 assert_eq!(
285 sessions[0].status,
286 SessionStatus::Processing,
287 "the Codex session must still be untouched"
288 );
289 }
290}