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
191pub fn find_pids_by_name(name: &str) -> Vec<u32> {
194 let my_pid = std::process::id();
195 let mut pids = Vec::new();
196
197 #[cfg(unix)]
198 {
199 if let Ok(output) = std::process::Command::new("pgrep")
201 .arg("-x")
202 .arg(name)
203 .output()
204 {
205 collect_pids(&output.stdout, my_pid, &mut pids);
206 }
207
208 if let Ok(output) = std::process::Command::new("pgrep")
211 .arg("-f")
212 .arg(format!("/{name}(\\s|$)"))
213 .output()
214 {
215 collect_pids(&output.stdout, my_pid, &mut pids);
216 }
217
218 pids.sort_unstable();
219 pids.dedup();
220 }
221
222 #[cfg(windows)]
223 {
224 if let Ok(output) = std::process::Command::new("tasklist")
225 .args([
226 "/FI",
227 &format!("IMAGENAME eq {name}.exe"),
228 "/FO",
229 "CSV",
230 "/NH",
231 ])
232 .output()
233 {
234 let stdout = String::from_utf8_lossy(&output.stdout);
235 for line in stdout.lines() {
236 let parts: Vec<&str> = line.split(',').collect();
237 if parts.len() >= 2 {
238 let pid_str = parts[1].trim().trim_matches('"');
239 if let Ok(pid) = pid_str.parse::<u32>() {
240 if pid != my_pid {
241 pids.push(pid);
242 }
243 }
244 }
245 }
246 }
247 }
248
249 pids
250}
251
252#[cfg(unix)]
253fn collect_pids(stdout: &[u8], exclude_pid: u32, out: &mut Vec<u32>) {
254 let text = String::from_utf8_lossy(stdout);
255 for line in text.lines() {
256 if let Ok(pid) = line.trim().parse::<u32>()
257 && pid != exclude_pid
258 {
259 out.push(pid);
260 }
261 }
262}
263
264pub fn find_killable_pids(name: &str) -> Vec<u32> {
268 let all = find_pids_by_name(name);
269 let mcp_pids = find_mcp_server_pids(name);
270 all.into_iter().filter(|p| !mcp_pids.contains(p)).collect()
271}
272
273#[cfg(unix)]
274fn find_mcp_server_pids(name: &str) -> Vec<u32> {
275 find_pids_by_name(name)
276 .into_iter()
277 .filter(|&pid| is_mcp_stdio_process(pid))
278 .collect()
279}
280
281#[cfg(not(unix))]
282fn find_mcp_server_pids(_name: &str) -> Vec<u32> {
283 Vec::new()
284}
285
286#[cfg(unix)]
287fn is_mcp_stdio_process(pid: u32) -> bool {
288 if let Ok(output) = std::process::Command::new("ps")
289 .args(["-o", "ppid=,command=", "-p", &pid.to_string()])
290 .output()
291 {
292 let text = String::from_utf8_lossy(&output.stdout);
293 let t = text.trim();
294 if t.contains("Cursor") || t.contains("cursor") || t.contains("code") {
295 return true;
296 }
297 let parts: Vec<&str> = t.split_whitespace().collect();
298 if let Some(ppid_str) = parts.first()
299 && let Ok(ppid) = ppid_str.parse::<u32>()
300 && let Ok(pp_out) = std::process::Command::new("ps")
301 .args(["-o", "command=", "-p", &ppid.to_string()])
302 .output()
303 {
304 let pp_cmd = String::from_utf8_lossy(&pp_out.stdout);
305 if pp_cmd.contains("Cursor") || pp_cmd.contains("cursor") || pp_cmd.contains("code") {
306 return true;
307 }
308 }
309 let cmd_part = parts.get(1..).map(|p| p.join(" ")).unwrap_or_default();
310 if (cmd_part.ends_with("/lean-ctx") || cmd_part == "lean-ctx")
312 && !cmd_part.contains("proxy")
313 && !cmd_part.contains("dashboard")
314 && !cmd_part.contains("daemon")
315 && !cmd_part.contains("stop")
316 && !cmd_part.contains("hook")
317 {
318 return true;
319 }
320 if cmd_part.contains("hook observe")
322 || cmd_part.contains("hook rewrite")
323 || cmd_part.contains("hook redirect")
324 {
325 return true;
326 }
327 }
328 false
329}
330
331pub fn kill_all_by_name(name: &str) -> usize {
334 let pids = find_killable_pids(name);
335 if pids.is_empty() {
336 return 0;
337 }
338
339 for &pid in &pids {
340 let _ = terminate_gracefully(pid);
341 }
342
343 std::thread::sleep(std::time::Duration::from_millis(500));
344
345 let mut killed = 0;
346 for &pid in &pids {
347 if is_alive(pid) {
348 let _ = force_kill(pid);
349 }
350 killed += 1;
351 }
352
353 std::thread::sleep(std::time::Duration::from_millis(200));
354
355 killed
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn current_process_is_alive() {
364 assert!(is_alive(std::process::id()));
365 }
366
367 #[test]
368 fn bogus_pid_is_not_alive() {
369 assert!(!is_alive(u32::MAX - 42));
370 }
371
372 #[cfg(unix)]
373 #[test]
374 fn run_with_timeout_returns_output_for_fast_command() {
375 let mut cmd = std::process::Command::new("echo");
376 cmd.arg("hello");
377 let out = run_with_timeout(cmd, std::time::Duration::from_secs(5))
378 .expect("fast command should complete");
379 assert!(out.status.success());
380 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
381 }
382
383 #[cfg(unix)]
384 #[test]
385 fn run_with_timeout_kills_slow_command() {
386 let mut cmd = std::process::Command::new("sleep");
387 cmd.arg("30");
388 let start = std::time::Instant::now();
389 let result = run_with_timeout(cmd, std::time::Duration::from_millis(300));
390 assert!(result.is_none(), "slow command must time out");
391 assert!(
392 start.elapsed() < std::time::Duration::from_secs(5),
393 "timeout must not wait for the full command"
394 );
395 }
396}