Skip to main content

lean_ctx/ipc/process/
services.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Recorded-service selection and stopping (#2176): classify lean-ctx
3//! service command lines, gate recorded PIDs by identity and user, list
4//! unrecorded services, stop only what this instance owns.
5
6use super::{
7    ProcessIdentity, StopSignal, identity, installed_executable_path, is_same_process,
8    owned_by_current_user, protected_self_pids, same_process, signal_same_process,
9};
10#[cfg(not(windows))]
11use super::{find_pids_by_name, run_with_timeout};
12
13/// A long-lived lean-ctx background service that the lifecycle commands
14/// (`stop`, `restart`, `config apply`, `uninstall`, `dev-install`) own and may
15/// stop (#2176).
16///
17/// Candidates are only PIDs this instance recorded (daemon PID file, proxy
18/// `/health`, Cockpit launch record — `daemon::owned_service_pids`), and each
19/// must also pass this command-line allowlist and an identity check; never
20/// "every `lean-ctx` process": the MCP stdio servers of running AI-tool sessions (bare
21/// `lean-ctx`, `lean-ctx mcp`), hook processes and in-flight command runners
22/// (`lean-ctx -c …`, #1292) are not services and are never selected. On
23/// Windows the former MCP blocklist relied on `wmic`, which current Windows
24/// releases no longer ship; every failed lookup made Claude Code's MCP servers
25/// "orphans" that `restart` terminated.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum ServiceKind {
28    /// `lean-ctx serve --_foreground-daemon` — the daemon `daemon start`,
29    /// the autostart unit and editors start.
30    Daemon,
31    /// `lean-ctx proxy start …` — the API proxy.
32    Proxy,
33    /// `lean-ctx serve …` without the daemon marker — an HTTP server the user
34    /// started deliberately.
35    HttpServer,
36    /// `lean-ctx cockpit --foreground …` — the detached Cockpit server.
37    Cockpit,
38}
39
40/// Which services a lifecycle command stops.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum ServiceSweep {
43    /// The services the command starts again afterwards (`restart`,
44    /// `config apply`): daemon and proxy.
45    Restartable,
46    /// Every lean-ctx background service (`stop`, `uninstall`,
47    /// `dev-install`).
48    All,
49    /// One kind only: `daemon stop` (the daemon PID file), `proxy stop`
50    /// and the managed-proxy port hand-off (the `/health` PID).
51    Only(ServiceKind),
52}
53
54impl ServiceSweep {
55    #[must_use]
56    pub fn includes(self, kind: ServiceKind) -> bool {
57        match self {
58            Self::All => true,
59            Self::Restartable => matches!(kind, ServiceKind::Daemon | ServiceKind::Proxy),
60            Self::Only(only) => kind == only,
61        }
62    }
63}
64
65/// A PID a lean-ctx instance recorded for one of its services, with the
66/// process identity recorded next to it when the record carries one (the
67/// daemon identity file, the Cockpit launch record). A record without an
68/// identity (a `/health` answer, a daemon PID file of an earlier release) is
69/// gated by command line, executable, user and live identity only.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct RecordedPid {
72    pub pid: u32,
73    pub identity: Option<ProcessIdentity>,
74}
75
76impl RecordedPid {
77    #[must_use]
78    pub fn bare(pid: u32) -> Self {
79        Self {
80            pid,
81            identity: None,
82        }
83    }
84}
85
86/// Split a process command line into words. Double quotes group a word with
87/// spaces (Windows `"C:\Program Files\…\lean-ctx.exe"`); backslashes are path
88/// separators, not escapes.
89pub(super) fn split_command_line(command: &str) -> Vec<String> {
90    let mut words = Vec::new();
91    let mut current = String::new();
92    let mut in_quotes = false;
93    let mut has_word = false;
94    for ch in command.chars() {
95        match ch {
96            '"' => {
97                in_quotes = !in_quotes;
98                has_word = true;
99            }
100            c if c.is_whitespace() && !in_quotes => {
101                if has_word {
102                    words.push(std::mem::take(&mut current));
103                    has_word = false;
104                }
105            }
106            c => {
107                current.push(c);
108                has_word = true;
109            }
110        }
111    }
112    if has_word {
113        words.push(current);
114    }
115    words
116}
117
118/// True when `path`'s file name is the lean-ctx executable (`lean-ctx`, or
119/// `lean-ctx.exe` in any case), also as the image an update replaced
120/// ([`installed_executable_path`]).
121pub(super) fn is_lean_ctx_executable(path: &str) -> bool {
122    let path = installed_executable_path(path);
123    let name = path.rsplit(['/', '\\']).next().unwrap_or(&path);
124    let name = name.to_ascii_lowercase();
125    name == "lean-ctx" || name == "lean-ctx.exe"
126}
127
128/// The arguments after the lean-ctx executable in `command`.
129pub(super) fn service_args(command: &str) -> Vec<String> {
130    let words = split_command_line(command);
131    words
132        .iter()
133        .position(|w| is_lean_ctx_executable(w))
134        .map(|at| words[at + 1..].to_vec())
135        .unwrap_or_default()
136}
137
138/// Classify a process command line as a lean-ctx background service, or
139/// `None` for everything else — MCP stdio servers, hooks, command runners,
140/// one-shot CLI calls and foreign programs. Pure, so the selection rules are
141/// covered on every platform.
142///
143/// The executable is the first word whose file name is `lean-ctx`: `ps`
144/// prints Unix paths unquoted, so a directory with a space splits the path
145/// into several words of which only the last names the binary.
146#[must_use]
147pub fn classify_service_cmdline(command: &str) -> Option<ServiceKind> {
148    let words = split_command_line(command);
149    let exe_at = words.iter().position(|w| is_lean_ctx_executable(w))?;
150    let args = &words[exe_at + 1..];
151    let has = |flag: &str| args.iter().any(|a| a == flag);
152    match args.first()?.as_str() {
153        "serve" => {
154            // `serve --stop/--status/--daemon` are one-shot CLI calls.
155            if ["--stop", "--status", "--daemon", "-d", "--help", "-h"]
156                .iter()
157                .any(|f| has(f))
158            {
159                None
160            } else if has("--_foreground-daemon") {
161                Some(ServiceKind::Daemon)
162            } else {
163                Some(ServiceKind::HttpServer)
164            }
165        }
166        "proxy" => (args.get(1).map(String::as_str) == Some("start")).then_some(ServiceKind::Proxy),
167        "cockpit" | "dashboard" => has("--foreground").then_some(ServiceKind::Cockpit),
168        _ => None,
169    }
170}
171
172/// One process selected for a lifecycle stop, with the identity it had when
173/// it was selected so a recycled PID is never signalled.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct ServiceProcess {
176    pub pid: u32,
177    pub kind: ServiceKind,
178    /// The arguments after the executable, e.g. `proxy start --port=4444`;
179    /// `restart` starts a proxy it stopped again with these.
180    pub args: Vec<String>,
181    pub(super) identity: ProcessIdentity,
182}
183
184impl ServiceProcess {
185    /// The selected process still runs (same PID, same identity).
186    #[must_use]
187    pub fn is_running(&self) -> bool {
188        is_same_process(self.pid, &self.identity)
189    }
190
191    /// Deliver `signal` only while the PID is still this process.
192    pub fn signal(&self, signal: StopSignal) -> bool {
193        signal_same_process(self.pid, &self.identity, signal)
194    }
195}
196
197/// One process considered for a lifecycle stop or listing: what the OS says
198/// about it now, and the identity its record carried, if any.
199#[derive(Clone, Debug)]
200pub(super) struct Candidate {
201    pub(super) pid: u32,
202    pub(super) command: String,
203    pub(super) live: Option<ProcessIdentity>,
204    pub(super) recorded: Option<ProcessIdentity>,
205    pub(super) same_user: bool,
206}
207
208/// Pure selection: keep the candidates that are lean-ctx background
209/// services `sweep` includes. A candidate is dropped when it is protected
210/// (this process, its ancestors, its process group), runs as another user,
211/// has an unknown identity (fail closed), has an identity executable that is
212/// not lean-ctx (a shell whose arguments merely mention the binary), or no
213/// longer is the process its record names (PID reuse).
214pub(super) fn select_services(
215    candidates: Vec<Candidate>,
216    protected: &std::collections::HashSet<u32>,
217    sweep: ServiceSweep,
218) -> Vec<ServiceProcess> {
219    candidates
220        .into_iter()
221        .filter(|c| !protected.contains(&c.pid) && c.same_user)
222        .filter_map(|c| {
223            let identity = c.live?;
224            if !is_lean_ctx_executable(&identity.executable) {
225                return None;
226            }
227            if let Some(recorded) = &c.recorded
228                && !same_process(recorded, &identity)
229            {
230                return None;
231            }
232            let kind = classify_service_cmdline(&c.command)?;
233            sweep.includes(kind).then(|| ServiceProcess {
234                pid: c.pid,
235                kind,
236                args: service_args(&c.command),
237                identity,
238            })
239        })
240        .collect()
241}
242
243/// Command lines of `pids`, as `(pid, command line)`, from one `ps` call. A
244/// PID whose command line cannot be read is left out, so it is never
245/// selected.
246#[cfg(unix)]
247pub(super) fn command_lines(pids: &[u32]) -> Vec<(u32, String)> {
248    if pids.is_empty() {
249        return Vec::new();
250    }
251    let list = pids
252        .iter()
253        .map(u32::to_string)
254        .collect::<Vec<_>>()
255        .join(",");
256    let mut cmd = std::process::Command::new("ps");
257    cmd.args(["-o", "pid=", "-o", "command=", "-p", &list]);
258    let Some(out) = run_with_timeout(cmd, std::time::Duration::from_secs(5)) else {
259        return Vec::new();
260    };
261    parse_ps_pid_command(&String::from_utf8_lossy(&out.stdout), pids)
262}
263
264/// Parse `ps -o pid= -o command=` output, keeping only the asked-for PIDs.
265#[cfg(unix)]
266pub(super) fn parse_ps_pid_command(stdout: &str, pids: &[u32]) -> Vec<(u32, String)> {
267    stdout
268        .lines()
269        .filter_map(|line| {
270            let line = line.trim_start();
271            let (pid, command) = line.split_once(char::is_whitespace)?;
272            let pid = pid.parse::<u32>().ok()?;
273            let command = command.trim();
274            (pids.contains(&pid) && !command.is_empty()).then(|| (pid, command.to_string()))
275        })
276        .collect()
277}
278
279/// Windows has no `ps`, and `wmic` is gone from Windows Server 2025 and
280/// current Windows 11 — the old MCP blocklist failed open on exactly that
281/// (#2176). The command line is read in-process with
282/// `NtQueryInformationProcess(ProcessCommandLineInformation)`: no external
283/// tool, no PowerShell execution policy, and `PROCESS_QUERY_LIMITED_INFORMATION`
284/// suffices for the caller's own processes.
285#[cfg(windows)]
286pub(super) fn command_lines(pids: &[u32]) -> Vec<(u32, String)> {
287    pids.iter()
288        .filter_map(|&pid| windows_command_line(pid).map(|c| (pid, c)))
289        .filter(|(_, c)| !c.trim().is_empty())
290        .collect()
291}
292
293#[cfg(windows)]
294pub(super) fn windows_command_line(pid: u32) -> Option<String> {
295    use windows_sys::Wdk::System::Threading::{
296        NtQueryInformationProcess, ProcessCommandLineInformation,
297    };
298    use windows_sys::Win32::Foundation::{CloseHandle, UNICODE_STRING};
299    use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
300
301    const MAX_BYTES: u32 = 1 << 20;
302
303    // SAFETY: the handle is null-checked and closed on every path below.
304    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
305    if handle.is_null() {
306        return None;
307    }
308    let mut needed = 0_u32;
309    // SAFETY: a zero-length query only writes the required size to `needed`.
310    unsafe {
311        NtQueryInformationProcess(
312            handle,
313            ProcessCommandLineInformation,
314            std::ptr::null_mut(),
315            0,
316            &raw mut needed,
317        );
318    }
319    if needed == 0 || needed > MAX_BYTES {
320        // SAFETY: `handle` came from OpenProcess and is still open.
321        unsafe { CloseHandle(handle) };
322        return None;
323    }
324    // u64 storage keeps the UNICODE_STRING header aligned.
325    let mut buf = vec![0_u64; (needed as usize).div_ceil(8)];
326    let buf_bytes = buf.len() * 8;
327    let buf_len = u32::try_from(buf_bytes).unwrap_or(MAX_BYTES);
328    let mut written = 0_u32;
329    // SAFETY: `buf` is writable for `buf_len` bytes, which is passed along.
330    let status = unsafe {
331        NtQueryInformationProcess(
332            handle,
333            ProcessCommandLineInformation,
334            buf.as_mut_ptr().cast(),
335            buf_len,
336            &raw mut written,
337        )
338    };
339    // SAFETY: `handle` came from OpenProcess and is still open.
340    unsafe { CloseHandle(handle) };
341    if status < 0 {
342        return None;
343    }
344    // SAFETY: on success the buffer starts with an initialised UNICODE_STRING.
345    let header = unsafe { &*buf.as_ptr().cast::<UNICODE_STRING>() };
346    let chars = usize::from(header.Length) / 2;
347    let start = header.Buffer as usize;
348    let base = buf.as_ptr() as usize;
349    if header.Buffer.is_null() || start < base || start + chars * 2 > base + buf_bytes {
350        return None;
351    }
352    // SAFETY: the bounds were checked above; the text lies inside `buf`.
353    let text = unsafe { std::slice::from_raw_parts(header.Buffer, chars) };
354    Some(String::from_utf16_lossy(text))
355}
356
357#[cfg(not(any(unix, windows)))]
358pub(super) fn command_lines(_pids: &[u32]) -> Vec<(u32, String)> {
359    Vec::new()
360}
361
362/// Of the recorded `owned` PIDs (daemon PID file, proxy `/health` on this
363/// instance's port, Cockpit launch record), the ones that still are lean-ctx
364/// background services `sweep` includes. Command line, executable, user and
365/// identity (the recorded one, where the record has it) gate every PID
366/// again, so a stale record whose PID now belongs to an MCP server, a command
367/// runner or a foreign process is never selected. Never this process or its
368/// ancestors, never a process whose identity cannot be read.
369#[must_use]
370pub fn find_service_processes(owned: &[RecordedPid], sweep: ServiceSweep) -> Vec<ServiceProcess> {
371    let mut recorded: std::collections::BTreeMap<u32, Option<ProcessIdentity>> =
372        std::collections::BTreeMap::new();
373    for record in owned.iter().filter(|r| r.pid > 0) {
374        let slot = recorded.entry(record.pid).or_default();
375        if slot.is_none() {
376            slot.clone_from(&record.identity);
377        }
378    }
379    let pids: Vec<u32> = recorded.keys().copied().collect();
380    let candidates = command_lines(&pids)
381        .into_iter()
382        .map(|(pid, command)| Candidate {
383            pid,
384            command,
385            live: identity(pid),
386            recorded: recorded.get(&pid).cloned().flatten(),
387            same_user: owned_by_current_user(pid),
388        })
389        .collect();
390    select_services(candidates, &protected_self_pids(), sweep)
391}
392
393/// The process a record written by that process itself names (the Cockpit
394/// launch record), proven by the identity in the record rather than by a
395/// command-line kind: this user, lean-ctx executable, same process as
396/// recorded, never this process or its ancestors. `None` otherwise.
397#[must_use]
398pub fn recorded_process(
399    pid: u32,
400    recorded: &ProcessIdentity,
401    kind: ServiceKind,
402) -> Option<ServiceProcess> {
403    if pid == 0 || protected_self_pids().contains(&pid) || !owned_by_current_user(pid) {
404        return None;
405    }
406    let live = identity(pid)?;
407    (is_lean_ctx_executable(&live.executable) && same_process(recorded, &live)).then(|| {
408        ServiceProcess {
409            pid,
410            kind,
411            args: Vec::new(),
412            identity: live,
413        }
414    })
415}
416
417/// PIDs of every process of this user whose image is lean-ctx, including an
418/// image an update renamed. Listing only — callers never signal these.
419pub(super) fn lean_ctx_process_pids() -> Vec<u32> {
420    #[cfg(windows)]
421    {
422        windows_lean_ctx_process_pids()
423    }
424    #[cfg(not(windows))]
425    {
426        // Same user only (pgrep -U); `comm` keeps `lean-ctx` after the binary
427        // was replaced, and the `-f` pattern catches absolute-path launches.
428        find_pids_by_name("lean-ctx")
429    }
430}
431
432/// Toolhelp snapshot instead of `tasklist`: no external tool, and the image
433/// names an update leaves behind (`lean-ctx.old.exe`, parked sidecars) are
434/// included. The user check happens in the selection.
435#[cfg(windows)]
436pub(super) fn windows_lean_ctx_process_pids() -> Vec<u32> {
437    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
438    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
439        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
440        TH32CS_SNAPPROCESS,
441    };
442
443    // SAFETY: plain flags; the snapshot handle is checked and closed below.
444    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
445    if snapshot == INVALID_HANDLE_VALUE || snapshot.is_null() {
446        return Vec::new();
447    }
448    let protected = protected_self_pids();
449    let mut pids = Vec::new();
450    // SAFETY: PROCESSENTRY32W is plain data; zeroed is a valid initial state.
451    let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
452    entry.dwSize = u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).unwrap_or(0);
453    // SAFETY: valid snapshot handle and a sized, writable entry.
454    let mut more = unsafe { Process32FirstW(snapshot, &raw mut entry) } != 0;
455    while more {
456        let len = entry
457            .szExeFile
458            .iter()
459            .position(|&c| c == 0)
460            .unwrap_or(entry.szExeFile.len());
461        let name = String::from_utf16_lossy(&entry.szExeFile[..len]);
462        if is_lean_ctx_executable(&name) && !protected.contains(&entry.th32ProcessID) {
463            pids.push(entry.th32ProcessID);
464        }
465        // SAFETY: as above.
466        more = unsafe { Process32NextW(snapshot, &raw mut entry) } != 0;
467    }
468    // SAFETY: `snapshot` came from CreateToolhelp32Snapshot and is still open.
469    unsafe { CloseHandle(snapshot) };
470    pids
471}
472
473/// lean-ctx background services of this user that no record of this
474/// instance names (`recorded`): a foreground `lean-ctx serve`, a proxy on
475/// another port, another data dir's daemon, a duplicate from a start race.
476///
477/// Enumerate and classify only — the lifecycle commands never stop these
478/// (owner decision 02.11.: only recorded services are ever signalled); `stop`,
479/// `uninstall` and `doctor` list them with the way to stop them. MCP stdio
480/// servers, hooks and command runners are not services and are never listed.
481#[must_use]
482pub fn find_unrecorded_services(recorded: &[u32]) -> Vec<ServiceProcess> {
483    let pids: Vec<u32> = lean_ctx_process_pids()
484        .into_iter()
485        .filter(|pid| !recorded.contains(pid))
486        .collect();
487    let candidates = command_lines(&pids)
488        .into_iter()
489        .map(|(pid, command)| Candidate {
490            pid,
491            command,
492            live: identity(pid),
493            recorded: None,
494            same_user: owned_by_current_user(pid),
495        })
496        .collect();
497    select_services(candidates, &protected_self_pids(), ServiceSweep::All)
498}
499
500/// The warning `stop`/`uninstall` print and `doctor` shows for
501/// [`find_unrecorded_services`], or `None` when there are none:
502/// `(what is running, how to stop it)`. Pure; `windows` picks the hint.
503#[must_use]
504pub fn unrecorded_services_lines(
505    services: &[ServiceProcess],
506    windows: bool,
507) -> Option<(String, String)> {
508    let what = describe_services(
509        "Still running, not started by this instance (left alone)",
510        services,
511    )?;
512    let pids: Vec<String> = services.iter().map(|s| s.pid.to_string()).collect();
513    let how = if windows {
514        format!("Stop them with: taskkill /PID {}", pids.join(" /PID "))
515    } else {
516        format!("Stop them with: kill {}", pids.join(" "))
517    };
518    Some((what, how))
519}
520
521/// What `restart`/`config apply` does about a proxy it stopped: start it
522/// again with the arguments it ran with, unless the proxy autostart service
523/// brought it back (`autostart_restarted`). Without this, `restart` left the
524/// proxy down on Windows and wherever no autostart is installed (#2176
525/// review). `None` when there is nothing to start. Pure.
526#[must_use]
527pub fn proxy_respawn_args(
528    stopped: &[ServiceProcess],
529    autostart_restarted: bool,
530) -> Option<Vec<String>> {
531    if autostart_restarted {
532        return None;
533    }
534    stopped
535        .iter()
536        .find(|s| s.kind == ServiceKind::Proxy)
537        .map(|s| s.args.clone())
538        .filter(|args| args.first().map(String::as_str) == Some("proxy"))
539}
540
541/// What [`stop_services`] did.
542#[derive(Debug, Default, Clone, PartialEq, Eq)]
543pub struct ServiceStopReport {
544    /// Services that were running and are gone now.
545    pub stopped: Vec<ServiceProcess>,
546    /// Services that survived terminate and kill.
547    pub survivors: Vec<ServiceProcess>,
548}
549
550impl ServiceKind {
551    #[must_use]
552    pub fn label(self) -> &'static str {
553        match self {
554            Self::Daemon => "daemon",
555            Self::Proxy => "proxy",
556            Self::HttpServer => "HTTP server",
557            Self::Cockpit => "Cockpit",
558        }
559    }
560}
561
562impl ServiceStopReport {
563    /// One line naming each service that was stopped, e.g.
564    /// `Stopped lean-ctx services: daemon (PID 812), proxy (PID 900).`, or
565    /// `None` when nothing was running.
566    #[must_use]
567    pub fn stopped_line(&self) -> Option<String> {
568        describe_services("Stopped lean-ctx services", &self.stopped)
569    }
570
571    /// One line naming each service that is still running, or `None`.
572    #[must_use]
573    pub fn survivors_line(&self) -> Option<String> {
574        describe_services("Could not stop", &self.survivors)
575    }
576
577    /// PIDs of the services that are still running.
578    #[must_use]
579    pub fn survivor_pids(&self) -> Vec<u32> {
580        self.survivors.iter().map(|s| s.pid).collect()
581    }
582}
583
584pub(super) fn describe_services(prefix: &str, services: &[ServiceProcess]) -> Option<String> {
585    if services.is_empty() {
586        return None;
587    }
588    let list = services
589        .iter()
590        .map(|s| format!("{} (PID {})", s.kind.label(), s.pid))
591        .collect::<Vec<_>>()
592        .join(", ");
593    Some(format!("{prefix}: {list}."))
594}
595
596/// Stop the recorded lean-ctx background services (`owned`) that `sweep`
597/// includes (see [`find_service_processes`] and [`stop_selected`]). Nothing
598/// is ever selected by process name (#2176).
599pub fn stop_services(owned: &[RecordedPid], sweep: ServiceSweep) -> ServiceStopReport {
600    stop_selected(find_service_processes(owned, sweep))
601}
602
603/// Stop selected services: graceful termination first, a kill for those
604/// still running after a grace period. Every signal goes through
605/// [`signal_same_process`], so a PID the OS reused in between is left alone.
606pub fn stop_selected(targets: Vec<ServiceProcess>) -> ServiceStopReport {
607    if targets.is_empty() {
608        return ServiceStopReport::default();
609    }
610    for target in &targets {
611        target.signal(StopSignal::Terminate);
612    }
613    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
614    while std::time::Instant::now() < deadline && targets.iter().any(ServiceProcess::is_running) {
615        std::thread::sleep(std::time::Duration::from_millis(100));
616    }
617    let mut killed = false;
618    for target in targets.iter().filter(|t| t.is_running()) {
619        killed |= target.signal(StopSignal::Kill);
620    }
621    if killed {
622        std::thread::sleep(std::time::Duration::from_millis(300));
623    }
624
625    let (survivors, stopped) = targets.into_iter().partition(ServiceProcess::is_running);
626    ServiceStopReport { stopped, survivors }
627}