Skip to main content

lean_ctx/ipc/
process.rs

1use anyhow::Result;
2
3/// Check whether a process with the given PID is still running.
4pub fn is_alive(pid: u32) -> bool {
5    #[cfg(unix)]
6    {
7        unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
8    }
9    #[cfg(windows)]
10    {
11        use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE, WAIT_TIMEOUT};
12        use windows_sys::Win32::System::Threading::{
13            GetExitCodeProcess, OpenProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION,
14        };
15
16        unsafe {
17            let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
18            if handle.is_null() {
19                return false;
20            }
21            let wait = WaitForSingleObject(handle, 0);
22            if wait == WAIT_TIMEOUT {
23                CloseHandle(handle);
24                return true;
25            }
26            let mut exit_code: u32 = 0;
27            GetExitCodeProcess(handle, &mut exit_code);
28            CloseHandle(handle);
29            exit_code == STILL_ACTIVE as u32
30        }
31    }
32}
33
34/// Ask a process to terminate gracefully (SIGTERM on Unix, nothing on Windows
35/// since we prefer HTTP shutdown; the caller should have already tried that).
36pub fn terminate_gracefully(pid: u32) -> Result<()> {
37    #[cfg(unix)]
38    {
39        let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
40        if ret != 0 {
41            anyhow::bail!(
42                "Failed to send SIGTERM to PID {pid}: {}",
43                std::io::Error::last_os_error()
44            );
45        }
46        Ok(())
47    }
48    #[cfg(windows)]
49    {
50        force_kill(pid)
51    }
52}
53
54/// Unconditionally kill a process.
55pub fn force_kill(pid: u32) -> Result<()> {
56    #[cfg(unix)]
57    {
58        let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
59        if ret != 0 {
60            anyhow::bail!(
61                "Failed to send SIGKILL to PID {pid}: {}",
62                std::io::Error::last_os_error()
63            );
64        }
65        Ok(())
66    }
67    #[cfg(windows)]
68    {
69        use windows_sys::Win32::Foundation::CloseHandle;
70        use windows_sys::Win32::System::Threading::{
71            OpenProcess, TerminateProcess, PROCESS_TERMINATE,
72        };
73
74        unsafe {
75            let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
76            if handle.is_null() {
77                anyhow::bail!(
78                    "Failed to open PID {pid} for termination: {}",
79                    std::io::Error::last_os_error()
80                );
81            }
82            let ok = TerminateProcess(handle, 1);
83            CloseHandle(handle);
84            if ok == 0 {
85                anyhow::bail!(
86                    "Failed to terminate PID {pid}: {}",
87                    std::io::Error::last_os_error()
88                );
89            }
90            Ok(())
91        }
92    }
93}
94
95/// Find all PIDs of processes whose executable name matches `name`.
96/// Excludes the current process.
97pub fn find_pids_by_name(name: &str) -> Vec<u32> {
98    let my_pid = std::process::id();
99    let mut pids = Vec::new();
100
101    #[cfg(unix)]
102    {
103        // Exact name match first
104        if let Ok(output) = std::process::Command::new("pgrep")
105            .arg("-x")
106            .arg(name)
107            .output()
108        {
109            collect_pids(&output.stdout, my_pid, &mut pids);
110        }
111
112        // Also find processes where the full command line contains the binary path
113        // (catches processes launched via absolute path, e.g. /Users/x/.local/bin/lean-ctx)
114        if let Ok(output) = std::process::Command::new("pgrep")
115            .arg("-f")
116            .arg(format!("/{name}(\\s|$)"))
117            .output()
118        {
119            collect_pids(&output.stdout, my_pid, &mut pids);
120        }
121
122        pids.sort_unstable();
123        pids.dedup();
124    }
125
126    #[cfg(windows)]
127    {
128        if let Ok(output) = std::process::Command::new("tasklist")
129            .args([
130                "/FI",
131                &format!("IMAGENAME eq {name}.exe"),
132                "/FO",
133                "CSV",
134                "/NH",
135            ])
136            .output()
137        {
138            let stdout = String::from_utf8_lossy(&output.stdout);
139            for line in stdout.lines() {
140                let parts: Vec<&str> = line.split(',').collect();
141                if parts.len() >= 2 {
142                    let pid_str = parts[1].trim().trim_matches('"');
143                    if let Ok(pid) = pid_str.parse::<u32>() {
144                        if pid != my_pid {
145                            pids.push(pid);
146                        }
147                    }
148                }
149            }
150        }
151    }
152
153    pids
154}
155
156#[cfg(unix)]
157fn collect_pids(stdout: &[u8], exclude_pid: u32, out: &mut Vec<u32>) {
158    let text = String::from_utf8_lossy(stdout);
159    for line in text.lines() {
160        if let Ok(pid) = line.trim().parse::<u32>() {
161            if pid != exclude_pid {
162                out.push(pid);
163            }
164        }
165    }
166}
167
168/// Returns PIDs that are NOT MCP stdio servers (safe to kill during `lean-ctx stop`).
169/// MCP servers are child processes of the IDE and must not be killed — the IDE
170/// will immediately respawn them, causing a kill loop that requires a reboot.
171pub fn find_killable_pids(name: &str) -> Vec<u32> {
172    let all = find_pids_by_name(name);
173    let mcp_pids = find_mcp_server_pids(name);
174    all.into_iter().filter(|p| !mcp_pids.contains(p)).collect()
175}
176
177#[cfg(unix)]
178fn find_mcp_server_pids(name: &str) -> Vec<u32> {
179    find_pids_by_name(name)
180        .into_iter()
181        .filter(|&pid| is_mcp_stdio_process(pid))
182        .collect()
183}
184
185#[cfg(not(unix))]
186fn find_mcp_server_pids(_name: &str) -> Vec<u32> {
187    Vec::new()
188}
189
190#[cfg(unix)]
191fn is_mcp_stdio_process(pid: u32) -> bool {
192    if let Ok(output) = std::process::Command::new("ps")
193        .args(["-o", "ppid=,command=", "-p", &pid.to_string()])
194        .output()
195    {
196        let text = String::from_utf8_lossy(&output.stdout);
197        let t = text.trim();
198        if t.contains("Cursor") || t.contains("cursor") || t.contains("code") {
199            return true;
200        }
201        let parts: Vec<&str> = t.split_whitespace().collect();
202        if let Some(ppid_str) = parts.first() {
203            if let Ok(ppid) = ppid_str.parse::<u32>() {
204                if let Ok(pp_out) = std::process::Command::new("ps")
205                    .args(["-o", "command=", "-p", &ppid.to_string()])
206                    .output()
207                {
208                    let pp_cmd = String::from_utf8_lossy(&pp_out.stdout);
209                    if pp_cmd.contains("Cursor")
210                        || pp_cmd.contains("cursor")
211                        || pp_cmd.contains("code")
212                    {
213                        return true;
214                    }
215                }
216            }
217        }
218        let cmd_part = parts.get(1..).map(|p| p.join(" ")).unwrap_or_default();
219        // MCP stdio servers: bare `lean-ctx` with no subcommand (or just `mcp`)
220        if (cmd_part.ends_with("/lean-ctx") || cmd_part == "lean-ctx")
221            && !cmd_part.contains("proxy")
222            && !cmd_part.contains("dashboard")
223            && !cmd_part.contains("daemon")
224            && !cmd_part.contains("stop")
225            && !cmd_part.contains("hook")
226        {
227            return true;
228        }
229        // Hook observer/rewriter processes spawned by IDE
230        if cmd_part.contains("hook observe")
231            || cmd_part.contains("hook rewrite")
232            || cmd_part.contains("hook redirect")
233        {
234            return true;
235        }
236    }
237    false
238}
239
240/// Kill non-MCP processes matching `name` (SIGTERM then SIGKILL).
241/// Returns count of killed processes.
242pub fn kill_all_by_name(name: &str) -> usize {
243    let pids = find_killable_pids(name);
244    if pids.is_empty() {
245        return 0;
246    }
247
248    for &pid in &pids {
249        let _ = terminate_gracefully(pid);
250    }
251
252    std::thread::sleep(std::time::Duration::from_millis(500));
253
254    let mut killed = 0;
255    for &pid in &pids {
256        if is_alive(pid) {
257            let _ = force_kill(pid);
258        }
259        killed += 1;
260    }
261
262    std::thread::sleep(std::time::Duration::from_millis(200));
263
264    killed
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn current_process_is_alive() {
273        assert!(is_alive(std::process::id()));
274    }
275
276    #[test]
277    fn bogus_pid_is_not_alive() {
278        assert!(!is_alive(u32::MAX - 42));
279    }
280}