Skip to main content

lean_ctx/ipc/
process.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4/// Immutable OS-level process identity used to defend durable PID references
5/// against PID reuse. A PID may be recycled for an unrelated process after its
6/// original owner exits; the start marker makes that transition observable.
7#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
8pub struct ProcessIdentity {
9    pub start_marker: u64,
10    pub executable: String,
11}
12
13/// Run a command with a hard timeout, capturing its output.
14///
15/// Returns `Some(output)` if the child exits within `timeout`, or `None` if it
16/// had to be killed (timed out) or could not be spawned. This is the safe way
17/// to invoke external control tools (`launchctl`, `systemctl`, a freshly
18/// installed binary's `--version`, …) that must never be able to hang a
19/// `lean-ctx` command — a wedged `launchctl` previously forced users to reboot.
20///
21/// Note: intended for commands with small output. The child's stdout/stderr are
22/// piped; a process that writes more than the pipe buffer (~64 KiB) without
23/// exiting could block. All current callers emit at most a few lines.
24pub fn run_with_timeout(
25    mut cmd: std::process::Command,
26    timeout: std::time::Duration,
27) -> Option<std::process::Output> {
28    use std::process::Stdio;
29    use std::time::Instant;
30
31    let mut child = cmd
32        .stdin(Stdio::null())
33        .stdout(Stdio::piped())
34        .stderr(Stdio::piped())
35        .spawn()
36        .ok()?;
37
38    let start = Instant::now();
39    loop {
40        match child.try_wait() {
41            // Process exited: pipes are at EOF, so reading output won't block.
42            Ok(Some(_)) => return child.wait_with_output().ok(),
43            Ok(None) => {
44                if start.elapsed() >= timeout {
45                    let _ = child.kill();
46                    let _ = child.wait();
47                    return None;
48                }
49                std::thread::sleep(std::time::Duration::from_millis(50));
50            }
51            Err(_) => return None,
52        }
53    }
54}
55
56/// Detached background spawn (daemon, proxy, background Cockpit): see
57/// [`crate::ipc::detached`] for the Windows job-object and handle rules
58/// (GL #545, #2176).
59pub use super::detached::{DetachedChild, DetachedStderr, spawn_detached};
60
61pub use lean_ctx_fs::process::is_alive;
62
63/// Returns a PID-reuse-safe identity when the operating system exposes one.
64///
65/// Callers must treat `None` as "not proven alive" for security-sensitive
66/// ownership checks. The supported desktop targets use an immutable process
67/// creation marker plus executable path; unsupported targets deliberately
68/// fail closed rather than trusting a bare PID.
69pub fn identity(pid: u32) -> Option<ProcessIdentity> {
70    #[cfg(target_os = "macos")]
71    {
72        macos_process_identity(pid)
73    }
74    #[cfg(target_os = "linux")]
75    {
76        linux_process_identity(pid)
77    }
78    #[cfg(windows)]
79    {
80        windows_process_identity(pid)
81    }
82    #[cfg(target_os = "freebsd")]
83    {
84        freebsd_process_identity(pid)
85    }
86    #[cfg(not(any(
87        target_os = "macos",
88        target_os = "linux",
89        target_os = "freebsd",
90        windows
91    )))]
92    {
93        let _ = pid;
94        None
95    }
96}
97
98/// #1947: `kern.proc.pid.<pid>` carries the process start time (`ki_start`),
99/// which a reused PID cannot share, and `kern.proc.pathname.<pid>` the
100/// executable. Without this FreeBSD fell into the fail-closed branch above,
101/// and agent-bus registration refused every tool call.
102#[cfg(target_os = "freebsd")]
103fn freebsd_process_identity(pid: u32) -> Option<ProcessIdentity> {
104    let info = freebsd_kinfo(pid)?;
105    let pid = info.ki_pid;
106    let start_marker = u64::try_from(info.ki_start.tv_sec)
107        .ok()?
108        .checked_mul(1_000_000)?
109        .checked_add(u64::try_from(info.ki_start.tv_usec).ok()?)?;
110    freebsd_identity_with_path(pid, start_marker)
111}
112
113#[cfg(target_os = "freebsd")]
114fn freebsd_kinfo(pid: u32) -> Option<libc::kinfo_proc> {
115    let pid = libc::c_int::try_from(pid).ok()?;
116
117    let mut info = std::mem::MaybeUninit::<libc::kinfo_proc>::zeroed();
118    let mut info_len = std::mem::size_of::<libc::kinfo_proc>();
119    let mib = [libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_PID, pid];
120    // SAFETY: `mib` holds four valid ints, `info` is a writable buffer of
121    // `info_len` bytes, and no new value is written.
122    let rc = unsafe {
123        libc::sysctl(
124            mib.as_ptr(),
125            4,
126            info.as_mut_ptr().cast(),
127            &mut info_len,
128            std::ptr::null(),
129            0,
130        )
131    };
132    if rc != 0 || info_len != std::mem::size_of::<libc::kinfo_proc>() {
133        return None;
134    }
135    // SAFETY: sysctl filled the full structure (checked above).
136    let info = unsafe { info.assume_init() };
137    (info.ki_pid == pid).then_some(info)
138}
139
140#[cfg(target_os = "freebsd")]
141fn freebsd_identity_with_path(pid: libc::c_int, start_marker: u64) -> Option<ProcessIdentity> {
142    let mut path = [0_u8; libc::PATH_MAX as usize];
143    let mut path_len = path.len();
144    let mib = [
145        libc::CTL_KERN,
146        libc::KERN_PROC,
147        libc::KERN_PROC_PATHNAME,
148        pid,
149    ];
150    // SAFETY: `path` is a writable buffer of `path_len` bytes.
151    let rc = unsafe {
152        libc::sysctl(
153            mib.as_ptr(),
154            4,
155            path.as_mut_ptr().cast(),
156            &mut path_len,
157            std::ptr::null(),
158            0,
159        )
160    };
161    if rc != 0 || path_len == 0 {
162        return None;
163    }
164    // The returned length includes the trailing NUL.
165    let bytes = path.get(..path_len)?;
166    let bytes = bytes.strip_suffix(&[0]).unwrap_or(bytes);
167    if bytes.is_empty() {
168        return None;
169    }
170    Some(ProcessIdentity {
171        start_marker,
172        executable: String::from_utf8_lossy(bytes).into_owned(),
173    })
174}
175
176/// True only when the live process still has the identity captured at
177/// registration. This is intentionally stricter than `is_alive(pid)`.
178pub fn matches_identity(pid: u32, expected: &ProcessIdentity) -> bool {
179    identity(pid).as_ref() == Some(expected)
180}
181
182/// The path a running lean-ctx image was installed under. An update or
183/// `dev-install` replaces the binary while services keep running the old
184/// image, and the OS then reports a different path for it: Linux
185/// `/proc/<pid>/exe` reads `…/lean-ctx (deleted)`, Windows reports the
186/// rename-aside sidecar `lean-ctx.old.exe` or its parked form
187/// `lean-ctx.old-<pid>-<nanos>.exe` (`core::updater::windows_sidecar`).
188/// Exactly those shapes map back to `lean-ctx`/`lean-ctx.exe`; every other
189/// path is returned unchanged.
190#[must_use]
191pub fn installed_executable_path(path: &str) -> String {
192    let path = path.strip_suffix(" (deleted)").unwrap_or(path);
193    let name_at = path.rfind(['/', '\\']).map_or(0, |i| i + 1);
194    let (dir, name) = path.split_at(name_at);
195    let lower = name.to_ascii_lowercase();
196    let sidecar = lower
197        .strip_prefix("lean-ctx.old")
198        .and_then(|rest| rest.strip_suffix(".exe"))
199        .is_some_and(is_parked_sidecar_suffix);
200    if sidecar {
201        format!("{dir}lean-ctx.exe")
202    } else {
203        path.to_string()
204    }
205}
206
207/// `""` (`lean-ctx.old.exe`) or `-<digits>-<digits>` (a parked sidecar).
208fn is_parked_sidecar_suffix(middle: &str) -> bool {
209    if middle.is_empty() {
210        return true;
211    }
212    let Some(rest) = middle.strip_prefix('-') else {
213        return false;
214    };
215    let mut parts = rest.split('-');
216    let digits =
217        |p: Option<&str>| p.is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
218    digits(parts.next()) && digits(parts.next()) && parts.next().is_none()
219}
220
221/// Two identities name the same process: the same creation marker and the
222/// same installed executable, also when an update renamed the running image
223/// in between (see [`installed_executable_path`]). Pure.
224#[must_use]
225pub fn same_process(expected: &ProcessIdentity, live: &ProcessIdentity) -> bool {
226    if expected.start_marker != live.start_marker {
227        return false;
228    }
229    let a = installed_executable_path(&expected.executable);
230    let b = installed_executable_path(&live.executable);
231    a == b || (cfg!(windows) && a.eq_ignore_ascii_case(&b))
232}
233
234/// `pid` is still the process `expected` recorded ([`same_process`]).
235#[must_use]
236pub fn is_same_process(pid: u32, expected: &ProcessIdentity) -> bool {
237    identity(pid).is_some_and(|live| same_process(expected, &live))
238}
239
240/// True when `pid` runs as the same user as this process (effective uid on
241/// Unix, token user SID on Windows). A record or a `/health` answer can name
242/// any PID; a lifecycle command only ever acts on its own user's processes,
243/// also when it runs elevated. Unknown means `false` (fail closed).
244#[must_use]
245pub fn owned_by_current_user(pid: u32) -> bool {
246    #[cfg(target_os = "macos")]
247    {
248        // SAFETY: geteuid has no preconditions and cannot fail.
249        let me = unsafe { libc::geteuid() };
250        macos_bsd_info(pid).is_some_and(|info| info.pbi_uid == me)
251    }
252    #[cfg(target_os = "linux")]
253    {
254        // SAFETY: geteuid has no preconditions and cannot fail.
255        let me = unsafe { libc::geteuid() };
256        std::fs::read_to_string(format!("/proc/{pid}/status"))
257            .ok()
258            .and_then(|status| {
259                let line = status.lines().find(|l| l.starts_with("Uid:"))?;
260                line.split_whitespace().nth(2)?.parse::<u32>().ok()
261            })
262            .is_some_and(|euid| euid == me)
263    }
264    #[cfg(target_os = "freebsd")]
265    {
266        // SAFETY: geteuid has no preconditions and cannot fail.
267        let me = unsafe { libc::geteuid() };
268        freebsd_kinfo(pid).is_some_and(|info| info.ki_uid == me)
269    }
270    #[cfg(windows)]
271    {
272        windows_owned_by_current_user(pid)
273    }
274    #[cfg(not(any(
275        target_os = "macos",
276        target_os = "linux",
277        target_os = "freebsd",
278        windows
279    )))]
280    {
281        let _ = pid;
282        false
283    }
284}
285
286/// The signal [`signal_same_process`] delivers.
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum StopSignal {
289    /// SIGTERM on Unix; Windows has no graceful signal for a detached
290    /// process, so this terminates as well (callers try HTTP shutdown first).
291    Terminate,
292    /// SIGKILL / `TerminateProcess`.
293    Kill,
294}
295
296/// Deliver `signal` to `pid` only while it is still the process `expected`
297/// describes. True when the signal was delivered.
298///
299/// The check and the signal are bound to one process object wherever the OS
300/// allows it, so a PID recycled in between is never hit:
301/// - Windows: one handle opened with query and terminate rights; the
302///   identity is read through that handle and `TerminateProcess` uses it.
303/// - Linux: a pidfd is opened first, the identity checked, then
304///   `pidfd_send_signal` targets the pidfd (kernel ≥ 5.3; older kernels fall
305///   back to the re-check + `kill` below).
306/// - macOS and other Unix: the identity is re-checked immediately before
307///   `kill`; the remaining window is microseconds.
308pub fn signal_same_process(pid: u32, expected: &ProcessIdentity, signal: StopSignal) -> bool {
309    if pid == 0 || pid == std::process::id() {
310        return false;
311    }
312    #[cfg(windows)]
313    {
314        // Windows has one way to stop a detached process; see StopSignal.
315        let _ = signal;
316        windows_terminate_same_process(pid, expected)
317    }
318    #[cfg(unix)]
319    {
320        let sig = match signal {
321            StopSignal::Terminate => libc::SIGTERM,
322            StopSignal::Kill => libc::SIGKILL,
323        };
324        let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
325            return false;
326        };
327        #[cfg(target_os = "linux")]
328        if let Some(delivered) = linux_pidfd_signal(raw_pid, expected, sig) {
329            return delivered;
330        }
331        // SAFETY: `kill` takes the PID and signal by value; no pointer is
332        // dereferenced and errors surface via the return value.
333        is_same_process(pid, expected) && unsafe { libc::kill(raw_pid, sig) } == 0
334    }
335    #[cfg(not(any(unix, windows)))]
336    {
337        let _ = (expected, signal);
338        false
339    }
340}
341
342/// `Some(delivered)` when pidfds are available, `None` to fall back.
343#[cfg(target_os = "linux")]
344fn linux_pidfd_signal(
345    pid: libc::pid_t,
346    expected: &ProcessIdentity,
347    sig: libc::c_int,
348) -> Option<bool> {
349    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
350
351    // SAFETY: pidfd_open takes a PID and flags by value and returns a new fd
352    // or -1; no pointer is involved.
353    let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
354    if fd < 0 {
355        return match std::io::Error::last_os_error().raw_os_error() {
356            // The process is gone: nothing to signal.
357            Some(libc::ESRCH) => Some(false),
358            // ENOSYS (kernel < 5.3), EPERM under seccomp, …: fall back.
359            _ => None,
360        };
361    }
362    let fd = libc::c_int::try_from(fd).ok()?;
363    // SAFETY: `fd` was just returned by pidfd_open and is owned here only.
364    let pidfd = unsafe { OwnedFd::from_raw_fd(fd) };
365    // The pidfd pins the process that held `pid` when it was opened. If that
366    // process is the expected one now, the signal below reaches it and no
367    // successor; if it already exited, pidfd_send_signal fails with ESRCH.
368    if !is_same_process(u32::try_from(pid).ok()?, expected) {
369        return Some(false);
370    }
371    // SAFETY: valid pidfd, plain signal number, null siginfo, zero flags.
372    let rc = unsafe {
373        libc::syscall(
374            libc::SYS_pidfd_send_signal,
375            pidfd.as_raw_fd(),
376            sig,
377            std::ptr::null::<libc::siginfo_t>(),
378            0,
379        )
380    };
381    Some(rc == 0)
382}
383
384#[cfg(target_os = "macos")]
385#[repr(C)]
386struct ProcBsdInfo {
387    pbi_flags: u32,
388    pbi_status: u32,
389    pbi_xstatus: u32,
390    pbi_pid: u32,
391    pbi_ppid: u32,
392    pbi_uid: u32,
393    pbi_gid: u32,
394    pbi_ruid: u32,
395    pbi_rgid: u32,
396    pbi_svuid: u32,
397    pbi_svgid: u32,
398    rfu_1: u32,
399    pbi_comm: [std::ffi::c_char; 16],
400    pbi_name: [std::ffi::c_char; 32],
401    pbi_nfiles: u32,
402    pbi_pgid: u32,
403    pbi_pjobc: u32,
404    e_tdev: u32,
405    e_tpgid: u32,
406    pbi_nice: i32,
407    pbi_start_tvsec: u64,
408    pbi_start_tvusec: u64,
409}
410
411#[cfg(target_os = "macos")]
412const PROC_PIDTBSDINFO: std::ffi::c_int = 3;
413
414#[cfg(target_os = "macos")]
415#[link(name = "proc")]
416unsafe extern "C" {
417    fn proc_pidinfo(
418        pid: std::ffi::c_int,
419        flavor: std::ffi::c_int,
420        arg: u64,
421        buffer: *mut std::ffi::c_void,
422        buffersize: std::ffi::c_int,
423    ) -> std::ffi::c_int;
424    fn proc_pidpath(
425        pid: std::ffi::c_int,
426        buffer: *mut std::ffi::c_void,
427        buffersize: u32,
428    ) -> std::ffi::c_int;
429}
430
431#[cfg(target_os = "macos")]
432fn macos_bsd_info(pid: u32) -> Option<ProcBsdInfo> {
433    let mut info = std::mem::MaybeUninit::<ProcBsdInfo>::zeroed();
434    let size = i32::try_from(std::mem::size_of::<ProcBsdInfo>()).ok()?;
435    // SAFETY: `info` points to a suitably sized writable C-compatible buffer.
436    let read = unsafe {
437        proc_pidinfo(
438            i32::try_from(pid).ok()?,
439            PROC_PIDTBSDINFO,
440            0,
441            info.as_mut_ptr().cast(),
442            size,
443        )
444    };
445    if read != size {
446        return None;
447    }
448    // SAFETY: `proc_pidinfo` returned the full structure size above.
449    Some(unsafe { info.assume_init() })
450}
451
452#[cfg(target_os = "macos")]
453fn macos_process_identity(pid: u32) -> Option<ProcessIdentity> {
454    let info = macos_bsd_info(pid)?;
455    let mut path = [0_u8; libc::PATH_MAX as usize];
456    // SAFETY: `path` is a writable byte buffer of the length supplied.
457    let path_len = unsafe {
458        proc_pidpath(
459            i32::try_from(pid).ok()?,
460            path.as_mut_ptr().cast(),
461            u32::try_from(path.len()).ok()?,
462        )
463    };
464    if path_len <= 0 {
465        return None;
466    }
467    let path_len = usize::try_from(path_len).ok()?;
468    let executable = String::from_utf8_lossy(path.get(..path_len)?).into_owned();
469    let start_marker = info
470        .pbi_start_tvsec
471        .checked_mul(1_000_000)?
472        .checked_add(info.pbi_start_tvusec)?;
473    Some(ProcessIdentity {
474        start_marker,
475        executable,
476    })
477}
478
479#[cfg(target_os = "linux")]
480fn linux_process_identity(pid: u32) -> Option<ProcessIdentity> {
481    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
482    let (_, fields) = stat.rsplit_once(')')?;
483    let start_marker = fields.split_whitespace().nth(19)?.parse().ok()?;
484    let executable = std::fs::read_link(format!("/proc/{pid}/exe"))
485        .ok()?
486        .to_string_lossy()
487        .into_owned();
488    Some(ProcessIdentity {
489        start_marker,
490        executable,
491    })
492}
493
494/// A process handle closed on drop.
495#[cfg(windows)]
496struct ProcessHandle(windows_sys::Win32::Foundation::HANDLE);
497
498#[cfg(windows)]
499impl ProcessHandle {
500    fn open(pid: u32, access: u32) -> Option<Self> {
501        use windows_sys::Win32::System::Threading::OpenProcess;
502        // SAFETY: OpenProcess takes integers only; a null result is rejected.
503        let handle = unsafe { OpenProcess(access, 0, pid) };
504        (!handle.is_null()).then_some(Self(handle))
505    }
506}
507
508#[cfg(windows)]
509impl Drop for ProcessHandle {
510    fn drop(&mut self) {
511        // SAFETY: the handle came from OpenProcess and is closed exactly once.
512        unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) };
513    }
514}
515
516#[cfg(windows)]
517fn windows_process_identity(pid: u32) -> Option<ProcessIdentity> {
518    use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
519    let handle = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION)?;
520    windows_identity_of(&handle)
521}
522
523/// `TerminateProcess` through the same handle the identity was verified on:
524/// the handle pins the process object, so a recycled PID cannot be hit.
525#[cfg(windows)]
526fn windows_terminate_same_process(pid: u32, expected: &ProcessIdentity) -> bool {
527    use windows_sys::Win32::System::Threading::{
528        PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, TerminateProcess,
529    };
530    let Some(handle) =
531        ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE)
532    else {
533        return false;
534    };
535    if !windows_identity_of(&handle).is_some_and(|live| same_process(expected, &live)) {
536        return false;
537    }
538    // SAFETY: valid handle opened with PROCESS_TERMINATE above.
539    unsafe { TerminateProcess(handle.0, 1) != 0 }
540}
541
542/// The user SID of `process`'s token, as bytes.
543#[cfg(windows)]
544pub(crate) fn windows_token_user_sid(
545    process: windows_sys::Win32::Foundation::HANDLE,
546) -> Option<Vec<u8>> {
547    use windows_sys::Win32::Foundation::CloseHandle;
548    use windows_sys::Win32::Security::{
549        GetLengthSid, GetTokenInformation, IsValidSid, TOKEN_QUERY, TOKEN_USER, TokenUser,
550    };
551    use windows_sys::Win32::System::Threading::OpenProcessToken;
552
553    let mut token = std::ptr::null_mut();
554    // SAFETY: `token` is a writable handle slot; it is closed below.
555    if unsafe { OpenProcessToken(process, TOKEN_QUERY, &raw mut token) } == 0 {
556        return None;
557    }
558    let sid = (|| {
559        let mut needed = 0_u32;
560        // SAFETY: a zero-length query only writes the required size.
561        unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &raw mut needed) };
562        if needed < u32::try_from(std::mem::size_of::<TOKEN_USER>()).ok()? || needed > 4096 {
563            return None;
564        }
565        let mut buf = vec![0_u64; (needed as usize).div_ceil(8)];
566        let buf_bytes = buf.len() * 8;
567        // SAFETY: `buf` is writable for at least `needed` bytes.
568        let ok = unsafe {
569            GetTokenInformation(
570                token,
571                TokenUser,
572                buf.as_mut_ptr().cast(),
573                needed,
574                &raw mut needed,
575            )
576        };
577        if ok == 0 {
578            return None;
579        }
580        // SAFETY: on success the aligned buffer starts with a TOKEN_USER.
581        let sid = unsafe { &*buf.as_ptr().cast::<TOKEN_USER>() }.User.Sid;
582        // SAFETY: the SID pointer comes from the successful query.
583        if sid.is_null() || unsafe { IsValidSid(sid) } == 0 {
584            return None;
585        }
586        // SAFETY: valid SID (checked above).
587        let len = unsafe { GetLengthSid(sid) } as usize;
588        let (start, base) = (sid as usize, buf.as_ptr() as usize);
589        if start < base || start + len > base + buf_bytes {
590            return None;
591        }
592        // SAFETY: the SID lies inside `buf` (bounds checked above).
593        Some(unsafe { std::slice::from_raw_parts(sid.cast::<u8>(), len) }.to_vec())
594    })();
595    // SAFETY: `token` came from OpenProcessToken and is still open.
596    unsafe { CloseHandle(token) };
597    sid
598}
599
600#[cfg(windows)]
601fn windows_owned_by_current_user(pid: u32) -> bool {
602    use windows_sys::Win32::System::Threading::{
603        GetCurrentProcess, PROCESS_QUERY_LIMITED_INFORMATION,
604    };
605    let Some(handle) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
606        return false;
607    };
608    // SAFETY: GetCurrentProcess returns a pseudo handle that needs no close.
609    let mine = windows_token_user_sid(unsafe { GetCurrentProcess() });
610    let theirs = windows_token_user_sid(handle.0);
611    mine.is_some() && mine == theirs
612}
613
614#[cfg(windows)]
615fn windows_identity_of(handle: &ProcessHandle) -> Option<ProcessIdentity> {
616    use std::os::windows::ffi::OsStringExt;
617    use windows_sys::Win32::Foundation::FILETIME;
618    use windows_sys::Win32::System::Threading::{GetProcessTimes, QueryFullProcessImageNameW};
619
620    let handle = handle.0;
621    let mut created = FILETIME::default();
622    let mut exited = FILETIME::default();
623    let mut kernel = FILETIME::default();
624    let mut user = FILETIME::default();
625    // SAFETY: all output pointers refer to initialized local storage.
626    let times_ok = unsafe {
627        GetProcessTimes(
628            handle,
629            &raw mut created,
630            &raw mut exited,
631            &raw mut kernel,
632            &raw mut user,
633        )
634    } != 0;
635    let mut path = vec![0_u16; 32_768];
636    let mut path_len = u32::try_from(path.len()).ok()?;
637    // SAFETY: `path` is a writable UTF-16 buffer and `path_len` describes it.
638    let path_ok =
639        unsafe { QueryFullProcessImageNameW(handle, 0, path.as_mut_ptr(), &raw mut path_len) != 0 };
640    if !times_ok || !path_ok {
641        return None;
642    }
643    let executable = std::ffi::OsString::from_wide(path.get(..usize::try_from(path_len).ok()?)?)
644        .to_string_lossy()
645        .into_owned();
646    let start_marker = (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime);
647    Some(ProcessIdentity {
648        start_marker,
649        executable,
650    })
651}
652
653/// Ask a process to terminate gracefully (SIGTERM on Unix, nothing on Windows
654/// since we prefer HTTP shutdown; the caller should have already tried that).
655pub fn terminate_gracefully(pid: u32) -> Result<()> {
656    #[cfg(unix)]
657    {
658        // SAFETY: `kill` takes the PID and signal by value; no pointer is
659        // dereferenced and errors surface via the return value.
660        let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
661        if ret != 0 {
662            anyhow::bail!(
663                "Failed to send SIGTERM to PID {pid}: {}",
664                std::io::Error::last_os_error()
665            );
666        }
667        Ok(())
668    }
669    #[cfg(windows)]
670    {
671        force_kill(pid)
672    }
673}
674
675/// Unconditionally kill a process.
676pub fn force_kill(pid: u32) -> Result<()> {
677    #[cfg(unix)]
678    {
679        // SAFETY: `kill` takes the PID and signal by value; no pointer is
680        // dereferenced and errors surface via the return value.
681        let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
682        if ret != 0 {
683            anyhow::bail!(
684                "Failed to send SIGKILL to PID {pid}: {}",
685                std::io::Error::last_os_error()
686            );
687        }
688        Ok(())
689    }
690    #[cfg(windows)]
691    {
692        use windows_sys::Win32::Foundation::CloseHandle;
693        use windows_sys::Win32::System::Threading::{
694            OpenProcess, PROCESS_TERMINATE, TerminateProcess,
695        };
696
697        // SAFETY: the Win32 calls take integer args only; the handle is
698        // null-checked and closed before returning on every path.
699        unsafe {
700            let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
701            if handle.is_null() {
702                anyhow::bail!(
703                    "Failed to open PID {pid} for termination: {}",
704                    std::io::Error::last_os_error()
705                );
706            }
707            let ok = TerminateProcess(handle, 1);
708            CloseHandle(handle);
709            if ok == 0 {
710                anyhow::bail!(
711                    "Failed to terminate PID {pid}: {}",
712                    std::io::Error::last_os_error()
713                );
714            }
715            Ok(())
716        }
717    }
718}
719
720/// PIDs this process must never signal: itself, its ancestor chain, and every
721/// member of its own process group.
722///
723/// The ancestor chain matters whenever `lean-ctx stop`/`dev-install` runs
724/// *under* lean-ctx itself — the shell hook routes commands through a
725/// `lean-ctx -c` wrapper, so the process tree is
726/// `lean-ctx -c … → sh → lean-ctx dev-install`. Excluding only `getpid()`
727/// SIGTERMed the wrapper parent, which took the whole pipeline down mid-run
728/// (exit 143) before autostart was re-enabled (#714).
729///
730/// The process *group* matters because agent harnesses (Cursor's shell) can
731/// reparent intermediaries to PID 1 mid-run — the `ps ppid` walk then stops
732/// before reaching the outer wrapper, but the wrapper still shares the
733/// foreground pgid; signalling it kills the pipeline all the same (#714
734/// follow-up, reproduced twice on the first fix).
735fn protected_self_pids() -> std::collections::HashSet<u32> {
736    let mut protected = std::collections::HashSet::new();
737    protected.insert(std::process::id());
738    #[cfg(unix)]
739    {
740        let mut pid = std::process::id();
741        for _ in 0..16 {
742            let Ok(output) = std::process::Command::new("ps")
743                .args(["-o", "ppid=", "-p", &pid.to_string()])
744                .output()
745            else {
746                break;
747            };
748            let Ok(ppid) = String::from_utf8_lossy(&output.stdout)
749                .trim()
750                .parse::<u32>()
751            else {
752                break;
753            };
754            if ppid <= 1 || !protected.insert(ppid) {
755                break;
756            }
757            pid = ppid;
758        }
759
760        // SAFETY: getpgrp() takes no arguments and cannot fail.
761        let own_pgid = unsafe { libc::getpgrp() };
762        if own_pgid > 0 {
763            protected.extend(process_group_pids(own_pgid));
764        }
765    }
766    protected
767}
768
769#[cfg(unix)]
770fn process_group_pids(pgid: libc::pid_t) -> Vec<u32> {
771    std::process::Command::new("pgrep")
772        .args(["-g", &pgid.to_string()])
773        .output()
774        .ok()
775        .map(|output| {
776            String::from_utf8_lossy(&output.stdout)
777                .lines()
778                .filter_map(|line| line.trim().parse::<u32>().ok())
779                .collect()
780        })
781        .unwrap_or_default()
782}
783
784/// Find all PIDs of processes whose executable name matches `name`.
785/// Excludes the current process and its ancestor chain (#714).
786pub fn find_pids_by_name(name: &str) -> Vec<u32> {
787    let protected = protected_self_pids();
788    let mut pids = Vec::new();
789
790    #[cfg(unix)]
791    {
792        // Only this user's processes: `stop` and `uninstall` act for the caller
793        // and never reach another account's lean-ctx, even when run as root.
794        // SAFETY: getuid has no preconditions and cannot fail.
795        let uid = unsafe { libc::getuid() }.to_string();
796
797        // Exact name match first
798        if let Ok(output) = std::process::Command::new("pgrep")
799            .args(["-U", &uid, "-x", name])
800            .output()
801        {
802            collect_pids(&output.stdout, &protected, &mut pids);
803        }
804
805        // Also find processes where the full command line contains the binary path
806        // (catches processes launched via absolute path, e.g. /Users/x/.local/bin/lean-ctx)
807        if let Ok(output) = std::process::Command::new("pgrep")
808            .args(["-U", &uid])
809            .arg("-f")
810            .arg(format!("/{name}(\\s|$)"))
811            .output()
812        {
813            collect_pids(&output.stdout, &protected, &mut pids);
814        }
815
816        pids.sort_unstable();
817        pids.dedup();
818    }
819
820    #[cfg(windows)]
821    {
822        if let Ok(output) = std::process::Command::new("tasklist")
823            .args([
824                "/FI",
825                &format!("IMAGENAME eq {name}.exe"),
826                "/FO",
827                "CSV",
828                "/NH",
829            ])
830            .output()
831        {
832            let stdout = String::from_utf8_lossy(&output.stdout);
833            for line in stdout.lines() {
834                let parts: Vec<&str> = line.split(',').collect();
835                if parts.len() >= 2 {
836                    let pid_str = parts[1].trim().trim_matches('"');
837                    if let Ok(pid) = pid_str.parse::<u32>() {
838                        if !protected.contains(&pid) {
839                            pids.push(pid);
840                        }
841                    }
842                }
843            }
844        }
845    }
846
847    pids
848}
849
850#[cfg(unix)]
851fn collect_pids(stdout: &[u8], protected: &std::collections::HashSet<u32>, out: &mut Vec<u32>) {
852    let text = String::from_utf8_lossy(stdout);
853    for line in text.lines() {
854        if let Ok(pid) = line.trim().parse::<u32>()
855            && !protected.contains(&pid)
856        {
857            out.push(pid);
858        }
859    }
860}
861
862mod services;
863pub use services::*;
864
865#[cfg(test)]
866mod tests;