1use anyhow::Result;
2
3pub fn run_with_timeout(
15 mut cmd: std::process::Command,
16 timeout: std::time::Duration,
17) -> Option<std::process::Output> {
18 use std::process::Stdio;
19 use std::time::Instant;
20
21 let mut child = cmd
22 .stdin(Stdio::null())
23 .stdout(Stdio::piped())
24 .stderr(Stdio::piped())
25 .spawn()
26 .ok()?;
27
28 let start = Instant::now();
29 loop {
30 match child.try_wait() {
31 Ok(Some(_)) => return child.wait_with_output().ok(),
33 Ok(None) => {
34 if start.elapsed() >= timeout {
35 let _ = child.kill();
36 let _ = child.wait();
37 return None;
38 }
39 std::thread::sleep(std::time::Duration::from_millis(50));
40 }
41 Err(_) => return None,
42 }
43 }
44}
45
46pub fn spawn_detached(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
64 #[cfg(windows)]
65 {
66 use std::os::windows::process::CommandExt;
67
68 const DETACHED_PROCESS: u32 = 0x0000_0008;
69 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
70 const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
71
72 let detached = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
73 match cmd
74 .creation_flags(detached | CREATE_BREAKAWAY_FROM_JOB)
75 .spawn()
76 {
77 Ok(child) => Ok(child),
78 Err(_) => cmd.creation_flags(detached).spawn(),
79 }
80 }
81 #[cfg(not(windows))]
82 {
83 cmd.spawn()
84 }
85}
86
87pub fn is_alive(pid: u32) -> bool {
89 #[cfg(unix)]
90 {
91 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
95 }
96 #[cfg(windows)]
97 {
98 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE, WAIT_TIMEOUT};
99 use windows_sys::Win32::System::Threading::{
100 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
101 };
102
103 unsafe {
107 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
108 if handle.is_null() {
109 return false;
110 }
111 let wait = WaitForSingleObject(handle, 0);
112 if wait == WAIT_TIMEOUT {
113 CloseHandle(handle);
114 return true;
115 }
116 let mut exit_code: u32 = 0;
117 GetExitCodeProcess(handle, &mut exit_code);
118 CloseHandle(handle);
119 exit_code == STILL_ACTIVE as u32
120 }
121 }
122}
123
124pub fn terminate_gracefully(pid: u32) -> Result<()> {
127 #[cfg(unix)]
128 {
129 let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
132 if ret != 0 {
133 anyhow::bail!(
134 "Failed to send SIGTERM to PID {pid}: {}",
135 std::io::Error::last_os_error()
136 );
137 }
138 Ok(())
139 }
140 #[cfg(windows)]
141 {
142 force_kill(pid)
143 }
144}
145
146pub fn force_kill(pid: u32) -> Result<()> {
148 #[cfg(unix)]
149 {
150 let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
153 if ret != 0 {
154 anyhow::bail!(
155 "Failed to send SIGKILL to PID {pid}: {}",
156 std::io::Error::last_os_error()
157 );
158 }
159 Ok(())
160 }
161 #[cfg(windows)]
162 {
163 use windows_sys::Win32::Foundation::CloseHandle;
164 use windows_sys::Win32::System::Threading::{
165 OpenProcess, PROCESS_TERMINATE, TerminateProcess,
166 };
167
168 unsafe {
171 let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
172 if handle.is_null() {
173 anyhow::bail!(
174 "Failed to open PID {pid} for termination: {}",
175 std::io::Error::last_os_error()
176 );
177 }
178 let ok = TerminateProcess(handle, 1);
179 CloseHandle(handle);
180 if ok == 0 {
181 anyhow::bail!(
182 "Failed to terminate PID {pid}: {}",
183 std::io::Error::last_os_error()
184 );
185 }
186 Ok(())
187 }
188 }
189}
190
191fn protected_self_pids() -> std::collections::HashSet<u32> {
207 let mut protected = std::collections::HashSet::new();
208 protected.insert(std::process::id());
209 #[cfg(unix)]
210 {
211 let mut pid = std::process::id();
212 for _ in 0..16 {
213 let Ok(output) = std::process::Command::new("ps")
214 .args(["-o", "ppid=", "-p", &pid.to_string()])
215 .output()
216 else {
217 break;
218 };
219 let Ok(ppid) = String::from_utf8_lossy(&output.stdout)
220 .trim()
221 .parse::<u32>()
222 else {
223 break;
224 };
225 if ppid <= 1 || !protected.insert(ppid) {
226 break;
227 }
228 pid = ppid;
229 }
230
231 let own_pgid = unsafe { libc::getpgrp() };
233 if own_pgid > 0 {
234 protected.extend(process_group_pids(own_pgid));
235 }
236 }
237 protected
238}
239
240#[cfg(unix)]
241fn process_group_pids(pgid: libc::pid_t) -> Vec<u32> {
242 std::process::Command::new("pgrep")
243 .args(["-g", &pgid.to_string()])
244 .output()
245 .ok()
246 .map(|output| {
247 String::from_utf8_lossy(&output.stdout)
248 .lines()
249 .filter_map(|line| line.trim().parse::<u32>().ok())
250 .collect()
251 })
252 .unwrap_or_default()
253}
254
255pub fn find_pids_by_name(name: &str) -> Vec<u32> {
258 let protected = protected_self_pids();
259 let mut pids = Vec::new();
260
261 #[cfg(unix)]
262 {
263 if let Ok(output) = std::process::Command::new("pgrep")
265 .arg("-x")
266 .arg(name)
267 .output()
268 {
269 collect_pids(&output.stdout, &protected, &mut pids);
270 }
271
272 if let Ok(output) = std::process::Command::new("pgrep")
275 .arg("-f")
276 .arg(format!("/{name}(\\s|$)"))
277 .output()
278 {
279 collect_pids(&output.stdout, &protected, &mut pids);
280 }
281
282 pids.sort_unstable();
283 pids.dedup();
284 }
285
286 #[cfg(windows)]
287 {
288 if let Ok(output) = std::process::Command::new("tasklist")
289 .args([
290 "/FI",
291 &format!("IMAGENAME eq {name}.exe"),
292 "/FO",
293 "CSV",
294 "/NH",
295 ])
296 .output()
297 {
298 let stdout = String::from_utf8_lossy(&output.stdout);
299 for line in stdout.lines() {
300 let parts: Vec<&str> = line.split(',').collect();
301 if parts.len() >= 2 {
302 let pid_str = parts[1].trim().trim_matches('"');
303 if let Ok(pid) = pid_str.parse::<u32>() {
304 if !protected.contains(&pid) {
305 pids.push(pid);
306 }
307 }
308 }
309 }
310 }
311 }
312
313 pids
314}
315
316#[cfg(unix)]
317fn collect_pids(stdout: &[u8], protected: &std::collections::HashSet<u32>, out: &mut Vec<u32>) {
318 let text = String::from_utf8_lossy(stdout);
319 for line in text.lines() {
320 if let Ok(pid) = line.trim().parse::<u32>()
321 && !protected.contains(&pid)
322 {
323 out.push(pid);
324 }
325 }
326}
327
328pub fn find_killable_pids(name: &str) -> Vec<u32> {
332 killable_excluding_mcp(find_pids_by_name(name), &find_mcp_server_pids(name))
333}
334
335fn killable_excluding_mcp(all: Vec<u32>, mcp: &[u32]) -> Vec<u32> {
340 all.into_iter().filter(|p| !mcp.contains(p)).collect()
341}
342
343#[cfg(unix)]
344fn find_mcp_server_pids(name: &str) -> Vec<u32> {
345 find_pids_by_name(name)
346 .into_iter()
347 .filter(|&pid| is_mcp_stdio_process(pid))
348 .collect()
349}
350
351#[cfg(not(unix))]
352fn find_mcp_server_pids(_name: &str) -> Vec<u32> {
353 Vec::new()
354}
355
356#[cfg(unix)]
357fn is_mcp_stdio_process(pid: u32) -> bool {
358 if let Ok(output) = std::process::Command::new("ps")
359 .args(["-o", "ppid=,command=", "-p", &pid.to_string()])
360 .output()
361 {
362 let text = String::from_utf8_lossy(&output.stdout);
363 let t = text.trim();
364 if t.contains("Cursor") || t.contains("cursor") || t.contains("code") {
365 return true;
366 }
367 let parts: Vec<&str> = t.split_whitespace().collect();
368 if let Some(ppid_str) = parts.first()
369 && let Ok(ppid) = ppid_str.parse::<u32>()
370 && let Ok(pp_out) = std::process::Command::new("ps")
371 .args(["-o", "command=", "-p", &ppid.to_string()])
372 .output()
373 {
374 let pp_cmd = String::from_utf8_lossy(&pp_out.stdout);
375 if pp_cmd.contains("Cursor") || pp_cmd.contains("cursor") || pp_cmd.contains("code") {
376 return true;
377 }
378 }
379 let cmd_part = parts.get(1..).map(|p| p.join(" ")).unwrap_or_default();
380 if (cmd_part.ends_with("/lean-ctx") || cmd_part == "lean-ctx")
382 && !cmd_part.contains("proxy")
383 && !cmd_part.contains("dashboard")
384 && !cmd_part.contains("daemon")
385 && !cmd_part.contains("stop")
386 && !cmd_part.contains("hook")
387 {
388 return true;
389 }
390 if cmd_part.contains("hook observe")
392 || cmd_part.contains("hook rewrite")
393 || cmd_part.contains("hook redirect")
394 {
395 return true;
396 }
397 }
398 false
399}
400
401pub fn kill_all_by_name(name: &str) -> usize {
404 let pids = find_killable_pids(name);
405 if pids.is_empty() {
406 return 0;
407 }
408
409 for &pid in &pids {
410 let _ = terminate_gracefully(pid);
411 }
412
413 std::thread::sleep(std::time::Duration::from_millis(500));
414
415 let mut killed = 0;
416 for &pid in &pids {
417 if is_alive(pid) {
418 let _ = force_kill(pid);
419 }
420 killed += 1;
421 }
422
423 std::thread::sleep(std::time::Duration::from_millis(200));
424
425 killed
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn current_process_is_alive() {
434 assert!(is_alive(std::process::id()));
435 }
436
437 #[test]
438 fn bogus_pid_is_not_alive() {
439 assert!(!is_alive(u32::MAX - 42));
440 }
441
442 #[cfg(unix)]
443 #[test]
444 fn run_with_timeout_returns_output_for_fast_command() {
445 let mut cmd = std::process::Command::new("echo");
446 cmd.arg("hello");
447 let out = run_with_timeout(cmd, std::time::Duration::from_secs(5))
448 .expect("fast command should complete");
449 assert!(out.status.success());
450 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
451 }
452
453 #[cfg(unix)]
454 #[test]
455 fn run_with_timeout_kills_slow_command() {
456 let mut cmd = std::process::Command::new("sleep");
457 cmd.arg("30");
458 let start = std::time::Instant::now();
459 let result = run_with_timeout(cmd, std::time::Duration::from_millis(300));
460 assert!(result.is_none(), "slow command must time out");
461 assert!(
462 start.elapsed() < std::time::Duration::from_secs(5),
463 "timeout must not wait for the full command"
464 );
465 }
466
467 #[test]
468 fn killable_excludes_mcp_pids() {
469 let killable = killable_excluding_mcp(vec![1, 2, 3, 4], &[2, 4]);
473 assert_eq!(killable, vec![1, 3]);
474 assert!(!killable.contains(&2));
475 assert!(!killable.contains(&4));
476 }
477
478 #[test]
479 fn killable_with_no_mcp_returns_all() {
480 let all = vec![10, 20, 30];
481 assert_eq!(killable_excluding_mcp(all.clone(), &[]), all);
482 }
483
484 #[cfg(unix)]
488 #[test]
489 fn protected_pids_cover_own_process_group() {
490 let pgid = unsafe { libc::getpgrp() };
492 let members_before = process_group_pids(pgid);
500 let protected = protected_self_pids();
501 let members_after = process_group_pids(pgid);
502 for pid in members_before {
503 if !members_after.contains(&pid) {
504 continue; }
506 assert!(
507 protected.contains(&pid),
508 "stable group member {pid} missing from protected set"
509 );
510 }
511 }
512
513 #[test]
518 fn protected_pids_cover_self_and_ancestors() {
519 let protected = protected_self_pids();
520 assert!(protected.contains(&std::process::id()));
521 #[cfg(unix)]
522 {
523 let out = std::process::Command::new("ps")
525 .args(["-o", "ppid=", "-p", &std::process::id().to_string()])
526 .output()
527 .expect("ps runs");
528 if let Ok(ppid) = String::from_utf8_lossy(&out.stdout).trim().parse::<u32>()
529 && ppid > 1
530 {
531 assert!(
532 protected.contains(&ppid),
533 "parent {ppid} missing from {protected:?}"
534 );
535 }
536 }
537 }
538
539 #[cfg(unix)]
540 #[test]
541 fn find_pids_never_reports_own_process_tree() {
542 let protected = protected_self_pids();
545 for pid in find_pids_by_name("lean-ctx") {
546 assert!(!protected.contains(&pid), "own tree pid {pid} reported");
547 }
548 }
549}