Skip to main content

lean_ctx/ipc/
process.rs

1use anyhow::Result;
2
3/// Run a command with a hard timeout, capturing its output.
4///
5/// Returns `Some(output)` if the child exits within `timeout`, or `None` if it
6/// had to be killed (timed out) or could not be spawned. This is the safe way
7/// to invoke external control tools (`launchctl`, `systemctl`, a freshly
8/// installed binary's `--version`, …) that must never be able to hang a
9/// `lean-ctx` command — a wedged `launchctl` previously forced users to reboot.
10///
11/// Note: intended for commands with small output. The child's stdout/stderr are
12/// piped; a process that writes more than the pipe buffer (~64 KiB) without
13/// exiting could block. All current callers emit at most a few lines.
14pub 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            // Process exited: pipes are at EOF, so reading output won't block.
32            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
46/// Spawn a long-lived background process (proxy, daemon) detached from the
47/// current process so it survives the parent's exit.
48///
49/// On Unix a child simply outlives its parent, so this is a plain spawn. On
50/// Windows the child inherits the parent's console and — crucially — its Job
51/// object. AI clients (OpenCode, Codex, Claude Code) run MCP servers inside
52/// kill-on-close Jobs; without detachment the auto-started proxy dies the
53/// moment the client recycles its MCP process, which users observe as
54/// "Cannot connect to API: The socket connection was closed unexpectedly"
55/// plus repeated proxy cold-starts (GL #545).
56///
57/// Strategy on Windows:
58///  1. `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB`
59///     — fully detached, escapes the parent's Job.
60///  2. If the Job denies breakaway, `CreateProcess` fails with
61///     `ERROR_ACCESS_DENIED`; retry without the breakaway flag (still
62///     console-detached, which covers non-Job parents).
63pub 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
87/// Check whether a process with the given PID is still running.
88pub fn is_alive(pid: u32) -> bool {
89    #[cfg(unix)]
90    {
91        // SAFETY: `kill` takes the PID and signal (0 = existence probe) by
92        // value; it dereferences no pointers and reports failure via its return
93        // value, so it cannot cause undefined behaviour.
94        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        // SAFETY: every Win32 call below takes integer args plus the local
104        // `exit_code` out-pointer; the handle is null-checked and closed on
105        // every return path, so no resource leaks or invalid pointers occur.
106        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
124/// Ask a process to terminate gracefully (SIGTERM on Unix, nothing on Windows
125/// since we prefer HTTP shutdown; the caller should have already tried that).
126pub fn terminate_gracefully(pid: u32) -> Result<()> {
127    #[cfg(unix)]
128    {
129        // SAFETY: `kill` takes the PID and signal by value; no pointer is
130        // dereferenced and errors surface via the return value.
131        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
146/// Unconditionally kill a process.
147pub fn force_kill(pid: u32) -> Result<()> {
148    #[cfg(unix)]
149    {
150        // SAFETY: `kill` takes the PID and signal by value; no pointer is
151        // dereferenced and errors surface via the return value.
152        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        // SAFETY: the Win32 calls take integer args only; the handle is
169        // null-checked and closed before returning on every path.
170        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
191/// PIDs this process must never signal: itself, its ancestor chain, and every
192/// member of its own process group.
193///
194/// The ancestor chain matters whenever `lean-ctx stop`/`dev-install` runs
195/// *under* lean-ctx itself — the shell hook routes commands through a
196/// `lean-ctx -c` wrapper, so the process tree is
197/// `lean-ctx -c … → sh → lean-ctx dev-install`. Excluding only `getpid()`
198/// SIGTERMed the wrapper parent, which took the whole pipeline down mid-run
199/// (exit 143) before autostart was re-enabled (#714).
200///
201/// The process *group* matters because agent harnesses (Cursor's shell) can
202/// reparent intermediaries to PID 1 mid-run — the `ps ppid` walk then stops
203/// before reaching the outer wrapper, but the wrapper still shares the
204/// foreground pgid; signalling it kills the pipeline all the same (#714
205/// follow-up, reproduced twice on the first fix).
206fn 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        // SAFETY: getpgrp() takes no arguments and cannot fail.
232        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
255/// Find all PIDs of processes whose executable name matches `name`.
256/// Excludes the current process and its ancestor chain (#714).
257pub 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        // Exact name match first
264        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        // Also find processes where the full command line contains the binary path
273        // (catches processes launched via absolute path, e.g. /Users/x/.local/bin/lean-ctx)
274        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
328/// Returns PIDs that are NOT MCP stdio servers (safe to kill during `lean-ctx stop`).
329/// MCP servers are child processes of the IDE and must not be killed — the IDE
330/// will immediately respawn them, causing a kill loop that requires a reboot.
331pub fn find_killable_pids(name: &str) -> Vec<u32> {
332    killable_excluding_mcp(find_pids_by_name(name), &find_mcp_server_pids(name))
333}
334
335/// Pure set-difference: every PID in `all` that is not an MCP server PID. Split
336/// out from [`find_killable_pids`] so the IDE-protection invariant — the
337/// MCP-stdio server is never returned as killable (#1036) — is unit-testable
338/// without spawning real processes.
339fn 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        // MCP stdio servers: bare `lean-ctx` with no subcommand (or just `mcp`)
381        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        // Hook observer/rewriter processes spawned by IDE
391        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
401/// Kill non-MCP processes matching `name` (SIGTERM then SIGKILL).
402/// Returns count of killed processes.
403pub 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        // #1036: the IDE-owned MCP stdio server PID must never be returned as
470        // killable, so `cmd_dev_install`'s force-kill cannot drop the editor's
471        // MCP connection.
472        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    /// #714 follow-up: agent harnesses reparent intermediaries to PID 1, so
485    /// the ppid walk alone misses the outer wrapper — the shared foreground
486    /// process group must be protected too.
487    #[cfg(unix)]
488    #[test]
489    fn protected_pids_cover_own_process_group() {
490        // SAFETY: getpgrp() takes no arguments and cannot fail.
491        let pgid = unsafe { libc::getpgrp() };
492        // Bracket `protected_self_pids` with two group snapshots. A single
493        // snapshot is racy under `cargo test` parallelism: sibling tests spawn
494        // short-lived subprocesses (git/ps/pgrep) that transiently join this
495        // foreground process group, so a member seen once may already be gone
496        // when `protected_self_pids` runs — or appear only afterwards. Only PIDs
497        // present in BOTH snapshots were alive across the whole window and must
498        // therefore be covered by the protected set.
499        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; // transient foreign subprocess from a parallel test
505            }
506            assert!(
507                protected.contains(&pid),
508                "stable group member {pid} missing from protected set"
509            );
510        }
511    }
512
513    /// #714: `stop`/`dev-install` running *under* a lean-ctx shell wrapper
514    /// (`lean-ctx -c … → sh → lean-ctx dev-install`) must not SIGTERM its own
515    /// ancestor chain — that killed the pipeline mid-run (exit 143) before
516    /// autostart was re-enabled.
517    #[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            // The direct parent (cargo's test runner) must be protected too.
524            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        // Regardless of what matches by name, the returned set must be
543        // disjoint from the protected self/ancestor set (#714).
544        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}