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