1use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7use crate::error::ToolError;
8use crate::pairing::{self, Record};
9
10pub trait ProcessProbe: Send + Sync {
11 fn is_alive(&self, pid: u32) -> bool;
12 fn starttime(&self, pid: u32) -> Option<String>;
13 fn boot_id(&self) -> String;
14 fn parent_pid(&self, pid: u32) -> Option<u32>;
15}
16
17pub struct SystemProbe;
18
19pub fn parse_kern_boottime(s: &str) -> Option<i64> {
21 let rest = &s[s.find("sec")? + 3..];
22 let rest = rest.trim_start().strip_prefix('=')?.trim_start();
23 let end = rest
24 .find(|c: char| !c.is_ascii_digit())
25 .unwrap_or(rest.len());
26 if end == 0 {
27 return None;
28 }
29 rest[..end].parse().ok()
30}
31
32fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
33 let y = if m <= 2 { y - 1 } else { y };
34 let era = if y >= 0 { y } else { y - 399 } / 400;
35 let yoe = y - era * 400;
36 let mp = (m + 9) % 12;
37 let doy = (153 * mp + 2) / 5 + d - 1;
38 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
39 era * 146_097 + doe - 719_468
40}
41
42pub fn parse_lstart(s: &str) -> Option<i64> {
45 let t: Vec<&str> = s.split_whitespace().collect();
46 if t.len() != 5 {
47 return None;
48 }
49 const MONTHS: [&str; 12] = [
50 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
51 ];
52 let month = MONTHS.iter().position(|m| *m == t[1])? as i64 + 1;
53 let day: i64 = t[2].parse().ok()?;
54 let year: i64 = t[4].parse().ok()?;
55 let hms: Vec<i64> = t[3]
56 .split(':')
57 .map(|p| p.parse().ok())
58 .collect::<Option<_>>()?;
59 if hms.len() != 3 || !(1..=31).contains(&day) {
60 return None;
61 }
62 Some(days_from_civil(year, month, day) * 86_400 + hms[0] * 3600 + hms[1] * 60 + hms[2])
63}
64
65pub fn parse_proc_stat_starttime(s: &str) -> Option<String> {
68 let after = &s[s.rfind(')')? + 1..];
69 after.split_whitespace().nth(19).map(str::to_string)
70}
71
72#[cfg(target_os = "linux")]
73impl ProcessProbe for SystemProbe {
74 fn is_alive(&self, pid: u32) -> bool {
75 match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
76 Ok(s) => {
77 let state = s
78 .rfind(')')
79 .and_then(|i| s[i + 1..].split_whitespace().next().map(str::to_string));
80 state.as_deref() != Some("Z")
81 }
82 Err(_) => false,
83 }
84 }
85
86 fn starttime(&self, pid: u32) -> Option<String> {
87 let s = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
88 parse_proc_stat_starttime(&s)
89 }
90
91 fn boot_id(&self) -> String {
92 std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
93 .map(|s| s.trim().to_string())
94 .unwrap_or_default()
95 }
96
97 fn parent_pid(&self, pid: u32) -> Option<u32> {
98 let s = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
99 s.lines()
100 .find_map(|l| l.strip_prefix("PPid:"))
101 .and_then(|v| v.trim().parse().ok())
102 }
103}
104
105#[cfg(target_os = "macos")]
106fn run_capture(cmd: &str, args: &[&str]) -> Option<String> {
107 let out = std::process::Command::new(cmd)
108 .args(args)
109 .env("TZ", "UTC")
110 .output()
111 .ok()?;
112 if !out.status.success() {
113 return None;
114 }
115 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
116}
117
118#[cfg(target_os = "macos")]
119impl ProcessProbe for SystemProbe {
120 fn is_alive(&self, pid: u32) -> bool {
121 self.starttime(pid).is_some()
122 }
123
124 fn starttime(&self, pid: u32) -> Option<String> {
125 let out = run_capture("ps", &["-o", "lstart=", "-p", &pid.to_string()])?;
126 parse_lstart(&out).map(|v| v.to_string())
127 }
128
129 fn boot_id(&self) -> String {
130 run_capture("sysctl", &["-n", "kern.boottime"])
131 .and_then(|s| parse_kern_boottime(&s))
132 .map(|v| v.to_string())
133 .unwrap_or_default()
134 }
135
136 fn parent_pid(&self, pid: u32) -> Option<u32> {
137 run_capture("ps", &["-o", "ppid=", "-p", &pid.to_string()])?
138 .parse()
139 .ok()
140 }
141}
142
143#[cfg(windows)]
144impl ProcessProbe for SystemProbe {
145 fn is_alive(&self, pid: u32) -> bool {
146 use windows::Win32::Foundation::CloseHandle;
147 use windows::Win32::System::Threading::{
148 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
149 };
150 const STILL_ACTIVE: u32 = 259;
151 unsafe {
152 let Ok(h) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else {
153 return false;
154 };
155 let mut code = 0u32;
156 let ok = GetExitCodeProcess(h, &mut code).is_ok();
157 let _ = CloseHandle(h);
158 ok && code == STILL_ACTIVE
159 }
160 }
161
162 fn starttime(&self, pid: u32) -> Option<String> {
163 use windows::Win32::Foundation::{CloseHandle, FILETIME};
164 use windows::Win32::System::Threading::{
165 GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
166 };
167 unsafe {
168 let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
169 let (mut c, mut e, mut k, mut u) = (
170 FILETIME::default(),
171 FILETIME::default(),
172 FILETIME::default(),
173 FILETIME::default(),
174 );
175 let r = GetProcessTimes(h, &mut c, &mut e, &mut k, &mut u);
176 let _ = CloseHandle(h);
177 r.ok()?;
178 Some((((c.dwHighDateTime as u64) << 32) | c.dwLowDateTime as u64).to_string())
179 }
180 }
181
182 fn boot_id(&self) -> String {
183 static BOOT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
185 if let Some(v) = BOOT.get() {
186 return v.clone();
187 }
188 let expr = "[math]::Floor([DateTimeOffset]::new((Get-CimInstance Win32_OperatingSystem).LastBootUpTime).ToUnixTimeSeconds() / 60) * 60";
189 let value = std::process::Command::new("powershell")
190 .args(["-NoProfile", "-NonInteractive", "-Command", expr])
191 .output()
192 .ok()
193 .filter(|o| o.status.success())
194 .and_then(|o| parse_boot_output(&String::from_utf8_lossy(&o.stdout)));
195 match value {
196 Some(v) => BOOT.get_or_init(|| v).clone(),
197 None => String::new(),
198 }
199 }
200
201 fn parent_pid(&self, pid: u32) -> Option<u32> {
202 use windows::Win32::Foundation::CloseHandle;
203 use windows::Win32::System::Diagnostics::ToolHelp::{
204 CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
205 TH32CS_SNAPPROCESS,
206 };
207 unsafe {
208 let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
209 let mut entry = PROCESSENTRY32W {
210 dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
211 ..Default::default()
212 };
213 let mut found = None;
214 if Process32FirstW(snap, &mut entry).is_ok() {
215 loop {
216 if entry.th32ProcessID == pid {
217 found = Some(entry.th32ParentProcessID);
218 break;
219 }
220 if Process32NextW(snap, &mut entry).is_err() {
221 break;
222 }
223 }
224 }
225 let _ = CloseHandle(snap);
226 found
227 }
228 }
229}
230
231pub fn sweep_stdout_dirs(
235 home: &std::path::Path,
236 probe: &dyn ProcessProbe,
237) -> Vec<std::path::PathBuf> {
238 let Ok(entries) = std::fs::read_dir(home.join("stdout")) else {
239 return Vec::new();
240 };
241 let mut removed = Vec::new();
242 for entry in entries.flatten() {
243 let path = entry.path();
244 let name = entry.file_name();
245 let Some((pid, start)) = name.to_str().and_then(|n| n.split_once('-')) else {
246 continue;
247 };
248 let Ok(pid) = pid.parse::<u32>() else {
249 continue;
250 };
251 let live =
253 probe.is_alive(pid) && probe.starttime(pid).unwrap_or_else(|| "0".to_string()) == start;
254 if !live && path.is_dir() && std::fs::remove_dir_all(&path).is_ok() {
255 removed.push(path);
256 }
257 }
258 removed
259}
260
261pub fn parse_boot_output(stdout: &str) -> Option<String> {
263 let t = stdout.trim();
264 (!t.is_empty() && t.bytes().all(|b| b.is_ascii_digit())).then(|| t.to_string())
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct Sentinel {
272 pub pid: u32,
273 pub boot_id: String,
274 pub starttime: String,
275}
276
277impl Sentinel {
278 pub fn parse(raw: &str) -> Option<Sentinel> {
280 let t = raw
281 .strip_prefix('\u{feff}')
282 .unwrap_or(raw)
283 .trim_matches(|c: char| c.is_ascii_whitespace());
284 let mut it = t.splitn(3, ':');
285 let pid = it.next()?.parse().ok()?;
286 let boot_id = it.next()?;
287 let starttime = it.next()?;
288 if boot_id.is_empty() || starttime.is_empty() {
289 return None;
290 }
291 Some(Sentinel {
292 pid,
293 boot_id: boot_id.to_string(),
294 starttime: starttime.to_string(),
295 })
296 }
297}
298
299impl std::fmt::Display for Sentinel {
300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 write!(f, "{}:{}:{}", self.pid, self.boot_id, self.starttime)
302 }
303}
304
305pub fn boot_matches_windows(a: &str, b: &str) -> bool {
308 match (a.parse::<i64>(), b.parse::<i64>()) {
309 (Ok(x), Ok(y)) => (x - y).abs() <= 120,
310 _ => false,
311 }
312}
313
314pub fn boot_matches(a: &str, b: &str) -> bool {
316 if cfg!(windows) {
317 boot_matches_windows(a, b)
318 } else {
319 a == b
320 }
321}
322
323pub fn sentinel_alive(s: &Sentinel, probe: &dyn ProcessProbe) -> bool {
325 probe.is_alive(s.pid)
326 && boot_matches(&s.boot_id, &probe.boot_id())
327 && probe.starttime(s.pid).as_deref() == Some(s.starttime.as_str())
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
331#[serde(rename_all = "lowercase")]
332pub enum Liveness {
333 Alive,
334 Dead,
335 Starting,
336 Unknown,
337 None,
338}
339
340impl Liveness {
341 pub fn as_str(self) -> &'static str {
342 match self {
343 Liveness::Alive => "alive",
344 Liveness::Dead => "dead",
345 Liveness::Starting => "starting",
346 Liveness::Unknown => "unknown",
347 Liveness::None => "none",
348 }
349 }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum Side {
354 Tui,
355 Controller,
356}
357
358const STARTING_MS: i64 = 30_000;
360
361fn mtime_ms(path: &Path) -> Option<i64> {
362 let t = std::fs::metadata(path).ok()?.modified().ok()?;
363 let d = t.duration_since(std::time::UNIX_EPOCH).ok()?;
364 Some(d.as_millis() as i64)
365}
366
367fn pair_file(home: &Path, id: &str, suffix: &str) -> PathBuf {
368 home.join(format!("pair.{id}.{suffix}"))
369}
370
371fn starting_or_unknown(origin: Option<i64>, now_ms: i64) -> Liveness {
373 match origin {
374 Some(m) if now_ms - m < STARTING_MS => Liveness::Starting,
375 _ => Liveness::Unknown,
376 }
377}
378
379fn highest_tui_generation(home: &Path, id: &str) -> Option<u32> {
381 let prefix = format!("pair.{id}.g");
382 std::fs::read_dir(home)
383 .ok()?
384 .flatten()
385 .filter_map(|e| {
386 let name = e.file_name().into_string().ok()?;
387 name.strip_prefix(&prefix)?
388 .strip_suffix(".tui-pid")?
389 .parse::<u32>()
390 .ok()
391 })
392 .max()
393}
394
395fn sentinel_state(path: &Path, probe: &dyn ProcessProbe) -> Option<Liveness> {
396 let raw = std::fs::read_to_string(path).ok()?;
397 Some(match Sentinel::parse(&raw) {
398 None => Liveness::Unknown,
399 Some(s) if sentinel_alive(&s, probe) => Liveness::Alive,
400 Some(_) => Liveness::Dead,
401 })
402}
403
404pub fn side_liveness(
406 side: Side,
407 record: &Record,
408 home: &Path,
409 probe: &dyn ProcessProbe,
410 now_ms: i64,
411) -> Liveness {
412 let id = record.pair_id.as_str();
413 match side {
414 Side::Tui => {
415 if !record.tab {
416 return Liveness::None;
417 }
418 let wanted = record.generation;
419 let generation = if pair_file(home, id, &format!("g{wanted}.tui-pid")).exists() {
420 wanted
421 } else {
422 highest_tui_generation(home, id).unwrap_or(wanted)
423 };
424 let wrapper_origin = |g: u32| {
425 ["sh", "ps1"]
426 .iter()
427 .find_map(|ext| mtime_ms(&pair_file(home, id, &format!("g{g}.wrapper.{ext}"))))
428 };
429 let state = if pair_file(home, id, &format!("g{generation}.tui-exited")).exists() {
430 Liveness::Dead
431 } else {
432 match sentinel_state(
433 &pair_file(home, id, &format!("g{generation}.tui-pid")),
434 probe,
435 ) {
436 Some(state) => state,
437 None => starting_or_unknown(wrapper_origin(generation), now_ms),
438 }
439 };
440 if generation != wanted && state != Liveness::Alive {
444 if pair_file(home, id, &format!("g{wanted}.tui-exited")).exists() {
445 return Liveness::Dead;
446 }
447 if starting_or_unknown(wrapper_origin(wanted), now_ms) == Liveness::Starting {
448 return Liveness::Starting;
449 }
450 }
451 state
452 }
453 Side::Controller => {
454 if record.controller.is_none()
457 && !["claude-pid", "claude-exited", "resume-lock"]
458 .iter()
459 .any(|f| pair_file(home, id, f).exists())
460 {
461 return Liveness::None;
462 }
463 if pair_file(home, id, "claude-exited").exists() {
464 return Liveness::Dead;
465 }
466 match sentinel_state(&pair_file(home, id, "claude-pid"), probe) {
467 Some(state) => state,
468 None => {
469 let origin = mtime_ms(&pair_file(home, id, "resume-lock"))
470 .or_else(|| mtime_ms(&pairing::record_path(home, id)));
471 starting_or_unknown(origin, now_ms)
472 }
473 }
474 }
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub enum RebindOutcome {
480 Rebound,
481 ControllerAlive,
482 NoRecord,
483}
484
485fn remove_if_present(path: &Path) -> Result<(), ToolError> {
486 match std::fs::remove_file(path) {
487 Ok(()) => Ok(()),
488 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
489 Err(e) => Err(e.into()),
490 }
491}
492
493pub fn rebind_on_spawn(
496 home: &Path,
497 pair_id: &str,
498 parent_pid: u32,
499 probe: &dyn ProcessProbe,
500) -> Result<RebindOutcome, ToolError> {
501 if pairing::read_record(home, pair_id)?.is_none() {
502 return Ok(RebindOutcome::NoRecord);
503 }
504 let boot_id = probe.boot_id();
507 if boot_id.is_empty() {
508 return Err(ToolError::new(
509 crate::error::ErrorCode::IoError,
510 "cannot read the boot id of this machine",
511 ));
512 }
513 let pid_file = pair_file(home, pair_id, "claude-pid");
514 if pairing::read_sentinel(&pid_file).is_some_and(|s| sentinel_alive(&s, probe)) {
515 tracing::info!("pair {pair_id}: controller alive, no re-bind");
516 return Ok(RebindOutcome::ControllerAlive);
517 }
518 let starttime = probe.starttime(parent_pid).ok_or_else(|| {
519 ToolError::new(
520 crate::error::ErrorCode::IoError,
521 format!("cannot read the start time of parent process {parent_pid}"),
522 )
523 })?;
524 let sentinel = Sentinel {
525 pid: parent_pid,
526 boot_id,
527 starttime,
528 };
529 remove_if_present(&pair_file(home, pair_id, "claude-exited"))?;
530 remove_if_present(&pair_file(home, pair_id, "dead-seen"))?;
531 pairing::write_sentinel(&pid_file, &sentinel)?;
532 tracing::info!("pair {pair_id}: re-bound the controller side to pid {parent_pid}");
533 Ok(RebindOutcome::Rebound)
534}