Skip to main content

opencode_ctl/
liveness.rs

1//! Process identity probing, the pid sentinels and the per-side liveness rules.
2
3use 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
19/// `sysctl -n kern.boottime` -> epoch seconds (the `sec = N` field).
20pub 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
42/// `ps -o lstart=` output (`Sun Oct 18 12:34:56 2026`, no zone) parsed as UTC
43/// to epoch seconds. Callers run `ps` with `TZ=UTC`.
44pub 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
65/// Field 22 of a `/proc/<pid>/stat` line: the 20th whitespace-separated token
66/// after the last `)` (the comm field may itself contain `)` and spaces).
67pub 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        // Only a good value is cached: a failed or empty PowerShell call is retried next time.
184        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
231/// Delete `<home>/stdout/<pid>-<starttime>` directories whose process is dead or whose start time
232/// differs from the live process's (a reused pid). Names of any other shape are left alone.
233/// Returns the directories removed.
234pub 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        // The transport names an unreadable start time "0".
252        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
261/// The PowerShell boot expression's stdout as a boot value: digits only, else `None`.
262pub 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// ------------------------------------------------------------------ sentinels
268
269/// `<pid>:<boot_id>:<starttime>`.
270#[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    /// A leading U+FEFF and surrounding ASCII whitespace (CRLF included) are dropped first.
279    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
305/// Windows boot values are epoch seconds truncated to the minute; clock adjustments drift them,
306/// a reboot moves them by far more: equal within 120 s.
307pub 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
314/// Exact on POSIX; within 120 s on Windows.
315pub 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
323/// The pid exists, its boot value matches and its start time is exactly the sentinel's.
324pub 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
358/// A side with no sentinel yet is `starting` for this long after its clock origin.
359const 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
371/// `starting` while the clock origin is younger than 30 s, else `unknown` (also without an origin).
372fn 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
379/// The highest generation that has a `tui-pid` file.
380fn 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
404/// One side's liveness, re-derived from the files on every call (spec Pairing, liveness values).
405pub 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            // The fallback keeps a live older tab visible past a failed `attach`; a dead older tab must
441            // not hide the tab `attach` has just re-opened, whose fresh wrapper reads `starting`. That
442            // tab can exit before it writes its `tui-pid` (its EXIT trap runs first): then it is dead.
443            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            // An attach record carries no controller. Once `resume` (or a re-binding MCP server) has put
455            // controller files beside it, the side is real; a genuine attach record has none of them.
456            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
493/// An MCP server that starts under `OPENCODE_CTL_PAIR`: unless `claude-pid` names an alive
494/// process, drop `claude-exited` and `dead-seen` and write `claude-pid` from the parent process.
495pub 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    // A failed boot read is no value: a sentinel written with it could never verify, and a live
505    // controller could not be told from a dead one.
506    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}