1use 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 #[arg(long)]
23 pub cleanup_stale_records: bool,
24}
25
26#[derive(Debug)]
27pub struct ServerProcessRecord {
28 path: PathBuf,
29}
30
31impl ServerProcessRecord {
32 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
101pub 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(¤t));
390
391 current.record.transport = "http".to_owned();
392 current.current_parent_pid = Some(1);
393 assert!(!stale_stdio_client(¤t));
394 }
395}