Skip to main content

lean_host_mcp/cli/
processes.rs

1//! Process-registry diagnostics for `lean-host-mcp` servers.
2//!
3//! The registry is intentionally narrow: only a running server writes its own
4//! record, and the doctor command lists or removes those records. It never
5//! scans process names and never kills a PID by substring.
6
7use std::ffi::OsStr;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use anyhow::{Context, Result};
14use clap::Args;
15use serde::{Deserialize, Serialize};
16
17const REGISTRY_ENV: &str = "LEAN_HOST_MCP_PROCESS_REGISTRY_DIR";
18
19#[derive(Debug, Args)]
20pub struct DoctorProcessesArgs {
21    /// Remove registry records whose PID is no longer alive.
22    #[arg(long)]
23    pub cleanup_stale_records: bool,
24}
25
26#[derive(Debug)]
27pub struct ServerProcessRecord {
28    path: PathBuf,
29}
30
31impl ServerProcessRecord {
32    /// Register this running server in the per-user process registry.
33    ///
34    /// The record is removed on normal process shutdown. If a launcher kills
35    /// the process before `Drop` runs, `doctor processes --cleanup-stale-records`
36    /// removes the stale record later.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the registry directory, current executable, current
41    /// directory, record serialization, or record write cannot be resolved.
42    pub fn register(transport: &str, bind: Option<String>, http_path: Option<&str>) -> Result<Self> {
43        let dir = registry_dir()?;
44        fs::create_dir_all(&dir).with_context(|| format!("create process registry {}", dir.display()))?;
45        let pid = std::process::id();
46        let path = dir.join(format!("{pid}.json"));
47        let record = ProcessRecord {
48            pid,
49            executable: std::env::current_exe().context("resolve current executable")?,
50            cwd: std::env::current_dir().context("resolve current working directory")?,
51            started_unix_millis: unix_millis(),
52            parent_pid_at_start: current_parent_pid(),
53            process_group_id: current_process_group_id(),
54            transport: transport.to_owned(),
55            bind,
56            http_path: http_path.map(ToOwned::to_owned),
57        };
58        let bytes = serde_json::to_vec_pretty(&record).context("serialize process record")?;
59        fs::write(&path, bytes).with_context(|| format!("write process record {}", path.display()))?;
60        Ok(Self { path })
61    }
62}
63
64impl Drop for ServerProcessRecord {
65    fn drop(&mut self) {
66        match fs::remove_file(&self.path) {
67            Ok(()) => {}
68            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
69            Err(err) => tracing::warn!(path = %self.path.display(), error = %err, "remove process record failed"),
70        }
71    }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75struct ProcessRecord {
76    pid: u32,
77    executable: PathBuf,
78    cwd: PathBuf,
79    started_unix_millis: u128,
80    #[serde(default)]
81    parent_pid_at_start: Option<u32>,
82    #[serde(default)]
83    process_group_id: Option<u32>,
84    transport: String,
85    bind: Option<String>,
86    http_path: Option<String>,
87}
88
89#[derive(Debug)]
90struct ListedRecord {
91    path: PathBuf,
92    record: ProcessRecord,
93    alive: bool,
94    executable_match: Option<bool>,
95    current_parent_pid: Option<u32>,
96    current_process_group_id: Option<u32>,
97    parent_alive_at_start: Option<bool>,
98    child_pids: Vec<u32>,
99}
100
101/// List registered server records and optionally remove stale records.
102///
103/// # Errors
104///
105/// Returns an error if the registry cannot be read, a record cannot be parsed,
106/// or a requested stale-record cleanup cannot remove its file.
107pub fn run(args: &DoctorProcessesArgs) -> Result<()> {
108    let records = list_records()?;
109    for listed in &records {
110        println!(
111            "pid={} alive={} executable_match={} transport={} bind={} http_path={} cwd={} started_unix_millis={} parent_pid_at_start={} current_parent_pid={} parent_alive_at_start={} process_group_id={} current_process_group_id={} stale_client={} record={}",
112            listed.record.pid,
113            listed.alive,
114            display_match(listed.executable_match),
115            listed.record.transport,
116            listed.record.bind.as_deref().unwrap_or("-"),
117            listed.record.http_path.as_deref().unwrap_or("-"),
118            listed.record.cwd.display(),
119            listed.record.started_unix_millis,
120            display_u32(listed.record.parent_pid_at_start),
121            display_u32(listed.current_parent_pid),
122            display_bool(listed.parent_alive_at_start),
123            display_u32(listed.record.process_group_id),
124            display_u32(listed.current_process_group_id),
125            stale_stdio_client(listed),
126            listed.path.display(),
127        );
128        if !listed.child_pids.is_empty() {
129            println!("  child_pids={}", join_u32(&listed.child_pids));
130        }
131        if !listed.alive {
132            println!("  stale_record_cleanup=lean-host-mcp doctor processes --cleanup-stale-records");
133        } else if stale_stdio_client(listed) {
134            println!("  exact_terminate=kill -TERM {}", listed.record.pid);
135        }
136    }
137    if args.cleanup_stale_records {
138        for listed in records {
139            if listed.alive {
140                continue;
141            }
142            fs::remove_file(&listed.path).with_context(|| format!("remove stale record {}", listed.path.display()))?;
143            eprintln!("removed stale process record {}", listed.path.display());
144        }
145    }
146    Ok(())
147}
148
149fn list_records() -> Result<Vec<ListedRecord>> {
150    let dir = registry_dir()?;
151    if !dir.exists() {
152        return Ok(Vec::new());
153    }
154    let mut out = Vec::new();
155    for entry in fs::read_dir(&dir).with_context(|| format!("read process registry {}", dir.display()))? {
156        let entry = entry?;
157        let path = entry.path();
158        if path.extension() != Some(OsStr::new("json")) {
159            continue;
160        }
161        let bytes = fs::read(&path).with_context(|| format!("read process record {}", path.display()))?;
162        let record: ProcessRecord =
163            serde_json::from_slice(&bytes).with_context(|| format!("parse process record {}", path.display()))?;
164        let alive = process_alive(record.pid);
165        let executable_match = alive
166            .then(|| executable_matches(record.pid, &record.executable))
167            .flatten();
168        let current_parent_pid = alive.then(|| parent_pid(record.pid)).flatten();
169        let current_process_group_id = alive.then(|| process_group_id(record.pid)).flatten();
170        let parent_alive_at_start = record.parent_pid_at_start.map(process_alive);
171        let child_pids = if alive { child_pids(record.pid) } else { Vec::new() };
172        out.push(ListedRecord {
173            path,
174            record,
175            alive,
176            executable_match,
177            current_parent_pid,
178            current_process_group_id,
179            parent_alive_at_start,
180            child_pids,
181        });
182    }
183    out.sort_by_key(|listed| listed.record.pid);
184    Ok(out)
185}
186
187fn registry_dir() -> Result<PathBuf> {
188    if let Some(path) = std::env::var_os(REGISTRY_ENV) {
189        return Ok(PathBuf::from(path));
190    }
191    let cache = dirs::cache_dir().context("could not resolve user cache directory")?;
192    Ok(cache.join("lean-host-mcp").join("processes"))
193}
194
195fn unix_millis() -> u128 {
196    SystemTime::now()
197        .duration_since(UNIX_EPOCH)
198        .map_or(0, |duration| duration.as_millis())
199}
200
201fn display_match(value: Option<bool>) -> &'static str {
202    match value {
203        Some(true) => "yes",
204        Some(false) => "no",
205        None => "unknown",
206    }
207}
208
209fn display_bool(value: Option<bool>) -> &'static str {
210    match value {
211        Some(true) => "yes",
212        Some(false) => "no",
213        None => "unknown",
214    }
215}
216
217fn display_u32(value: Option<u32>) -> String {
218    value.map_or_else(|| "-".to_owned(), |pid| pid.to_string())
219}
220
221fn stale_stdio_client(listed: &ListedRecord) -> bool {
222    listed.alive
223        && listed.record.transport == "stdio"
224        && listed
225            .record
226            .parent_pid_at_start
227            .is_some_and(|recorded| recorded > 1 && Some(recorded) != listed.current_parent_pid)
228}
229
230fn join_u32(values: &[u32]) -> String {
231    values.iter().map(u32::to_string).collect::<Vec<_>>().join(",")
232}
233
234#[cfg(unix)]
235pub fn process_alive(pid: u32) -> bool {
236    std::process::Command::new("kill")
237        .arg("-0")
238        .arg(pid.to_string())
239        .stderr(std::process::Stdio::null())
240        .status()
241        .is_ok_and(|status| status.success())
242}
243
244#[cfg(not(unix))]
245pub fn process_alive(_pid: u32) -> bool {
246    false
247}
248
249#[cfg(unix)]
250pub fn current_parent_pid() -> Option<u32> {
251    parent_pid(std::process::id())
252}
253
254#[cfg(not(unix))]
255pub fn current_parent_pid() -> Option<u32> {
256    None
257}
258
259#[cfg(unix)]
260fn current_process_group_id() -> Option<u32> {
261    process_group_id(std::process::id())
262}
263
264#[cfg(not(unix))]
265fn current_process_group_id() -> Option<u32> {
266    None
267}
268
269#[cfg(target_os = "linux")]
270fn executable_matches(pid: u32, expected: &Path) -> Option<bool> {
271    fs::read_link(format!("/proc/{pid}/exe"))
272        .ok()
273        .map(|actual| actual == expected)
274}
275
276#[cfg(all(unix, not(target_os = "linux")))]
277fn executable_matches(pid: u32, expected: &Path) -> Option<bool> {
278    let output = std::process::Command::new("ps")
279        .args(["-p", &pid.to_string(), "-o", "command="])
280        .output()
281        .ok()?;
282    if !output.status.success() {
283        return None;
284    }
285    let command = String::from_utf8_lossy(&output.stdout);
286    let actual = command.split_whitespace().next()?;
287    Some(Path::new(actual) == expected)
288}
289
290#[cfg(not(unix))]
291fn executable_matches(_pid: u32, _expected: &Path) -> Option<bool> {
292    None
293}
294
295#[cfg(unix)]
296fn parent_pid(pid: u32) -> Option<u32> {
297    ps_single_u32(pid, "ppid=")
298}
299
300#[cfg(not(unix))]
301fn parent_pid(_pid: u32) -> Option<u32> {
302    None
303}
304
305#[cfg(unix)]
306fn process_group_id(pid: u32) -> Option<u32> {
307    ps_single_u32(pid, "pgid=")
308}
309
310#[cfg(not(unix))]
311fn process_group_id(_pid: u32) -> Option<u32> {
312    None
313}
314
315#[cfg(unix)]
316fn ps_single_u32(pid: u32, field: &str) -> Option<u32> {
317    let output = std::process::Command::new("ps")
318        .args(["-o", field, "-p", &pid.to_string()])
319        .output()
320        .ok()?;
321    if !output.status.success() {
322        return None;
323    }
324    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
325}
326
327#[cfg(unix)]
328fn child_pids(parent: u32) -> Vec<u32> {
329    let output = std::process::Command::new("ps").args(["-axo", "pid=,ppid="]).output();
330    let Ok(output) = output else {
331        return Vec::new();
332    };
333    if !output.status.success() {
334        return Vec::new();
335    }
336    String::from_utf8_lossy(&output.stdout)
337        .lines()
338        .filter_map(|line| {
339            let mut fields = line.split_whitespace();
340            let pid = fields.next()?.parse::<u32>().ok()?;
341            let ppid = fields.next()?.parse::<u32>().ok()?;
342            (ppid == parent).then_some(pid)
343        })
344        .collect()
345}
346
347#[cfg(not(unix))]
348fn child_pids(_parent: u32) -> Vec<u32> {
349    Vec::new()
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn display_match_names_unknown_and_mismatch() {
358        assert_eq!(display_match(None), "unknown");
359        assert_eq!(display_match(Some(false)), "no");
360        assert_eq!(display_match(Some(true)), "yes");
361    }
362
363    #[test]
364    fn doctor_stale_stdio_client_requires_exact_parent_change() {
365        let listed = ListedRecord {
366            path: PathBuf::from("record.json"),
367            record: ProcessRecord {
368                pid: 100,
369                executable: PathBuf::from("/bin/lean-host-mcp"),
370                cwd: PathBuf::from("/tmp/project"),
371                started_unix_millis: 1,
372                parent_pid_at_start: Some(50),
373                process_group_id: Some(25),
374                transport: "stdio".to_owned(),
375                bind: None,
376                http_path: None,
377            },
378            alive: true,
379            executable_match: Some(true),
380            current_parent_pid: Some(1),
381            current_process_group_id: Some(25),
382            parent_alive_at_start: Some(false),
383            child_pids: vec![101],
384        };
385        assert!(stale_stdio_client(&listed));
386
387        let mut current = listed;
388        current.current_parent_pid = Some(50);
389        assert!(!stale_stdio_client(&current));
390
391        current.record.transport = "http".to_owned();
392        current.current_parent_pid = Some(1);
393        assert!(!stale_stdio_client(&current));
394    }
395}