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/--print-socket` are one-shot CLI calls.
155            if [
156                "--stop",
157                "--status",
158                "--daemon",
159                "-d",
160                "--print-socket",
161                "--help",
162                "-h",
163            ]
164            .iter()
165            .any(|f| has(f))
166            {
167                None
168            } else if has("--_foreground-daemon") {
169                Some(ServiceKind::Daemon)
170            } else {
171                Some(ServiceKind::HttpServer)
172            }
173        }
174        "proxy" => (args.get(1).map(String::as_str) == Some("start")).then_some(ServiceKind::Proxy),
175        "cockpit" | "dashboard" => has("--foreground").then_some(ServiceKind::Cockpit),
176        _ => None,
177    }
178}
179
180/// One process selected for a lifecycle stop, with the identity it had when
181/// it was selected so a recycled PID is never signalled.
182#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct ServiceProcess {
184    pub pid: u32,
185    pub kind: ServiceKind,
186    /// The arguments after the executable, e.g. `proxy start --port=4444`;
187    /// `restart` starts a proxy it stopped again with these.
188    pub args: Vec<String>,
189    pub(super) identity: ProcessIdentity,
190}
191
192impl ServiceProcess {
193    /// The selected process still runs (same PID, same identity).
194    #[must_use]
195    pub fn is_running(&self) -> bool {
196        is_same_process(self.pid, &self.identity)
197    }
198
199    /// Deliver `signal` only while the PID is still this process.
200    pub fn signal(&self, signal: StopSignal) -> bool {
201        signal_same_process(self.pid, &self.identity, signal)
202    }
203}
204
205/// One process considered for a lifecycle stop or listing: what the OS says
206/// about it now, and the identity its record carried, if any.
207#[derive(Clone, Debug)]
208pub(super) struct Candidate {
209    pub(super) pid: u32,
210    pub(super) command: String,
211    pub(super) live: Option<ProcessIdentity>,
212    pub(super) recorded: Option<ProcessIdentity>,
213    pub(super) same_user: bool,
214}
215
216/// Pure selection: keep the candidates that are lean-ctx background
217/// services `sweep` includes. A candidate is dropped when it is protected
218/// (this process, its ancestors, its process group), runs as another user,
219/// has an unknown identity (fail closed), has an identity executable that is
220/// not lean-ctx (a shell whose arguments merely mention the binary), or no
221/// longer is the process its record names (PID reuse).
222pub(super) fn select_services(
223    candidates: Vec<Candidate>,
224    protected: &std::collections::HashSet<u32>,
225    sweep: ServiceSweep,
226) -> Vec<ServiceProcess> {
227    candidates
228        .into_iter()
229        .filter(|c| !protected.contains(&c.pid) && c.same_user)
230        .filter_map(|c| {
231            let identity = c.live?;
232            if !is_lean_ctx_executable(&identity.executable) {
233                return None;
234            }
235            if let Some(recorded) = &c.recorded
236                && !same_process(recorded, &identity)
237            {
238                return None;
239            }
240            let kind = classify_service_cmdline(&c.command)?;
241            sweep.includes(kind).then(|| ServiceProcess {
242                pid: c.pid,
243                kind,
244                args: service_args(&c.command),
245                identity,
246            })
247        })
248        .collect()
249}
250
251/// Command lines of `pids`, as `(pid, command line)`, from one `ps` call. A
252/// PID whose command line cannot be read is left out, so it is never
253/// selected.
254#[cfg(unix)]
255pub(super) fn command_lines(pids: &[u32]) -> Vec<(u32, String)> {
256    if pids.is_empty() {
257        return Vec::new();
258    }
259    let list = pids
260        .iter()
261        .map(u32::to_string)
262        .collect::<Vec<_>>()
263        .join(",");
264    let mut cmd = std::process::Command::new("ps");
265    cmd.args(["-o", "pid=", "-o", "command=", "-p", &list]);
266    let Some(out) = run_with_timeout(cmd, std::time::Duration::from_secs(5)) else {
267        return Vec::new();
268    };
269    parse_ps_pid_command(&String::from_utf8_lossy(&out.stdout), pids)
270}
271
272/// Parse `ps -o pid= -o command=` output, keeping only the asked-for PIDs.
273#[cfg(unix)]
274pub(super) fn parse_ps_pid_command(stdout: &str, pids: &[u32]) -> Vec<(u32, String)> {
275    stdout
276        .lines()
277        .filter_map(|line| {
278            let line = line.trim_start();
279            let (pid, command) = line.split_once(char::is_whitespace)?;
280            let pid = pid.parse::<u32>().ok()?;
281            let command = command.trim();
282            (pids.contains(&pid) && !command.is_empty()).then(|| (pid, command.to_string()))
283        })
284        .collect()
285}
286
287/// Windows has no `ps`, and `wmic` is gone from Windows Server 2025 and
288/// current Windows 11 — the old MCP blocklist failed open on exactly that
289/// (#2176). The command line is read in-process with
290/// `NtQueryInformationProcess(ProcessCommandLineInformation)`: no external
291/// tool, no PowerShell execution policy, and `PROCESS_QUERY_LIMITED_INFORMATION`
292/// suffices for the caller's own processes.
293#[cfg(windows)]
294pub(super) fn command_lines(pids: &[u32]) -> Vec<(u32, String)> {
295    pids.iter()
296        .filter_map(|&pid| windows_command_line(pid).map(|c| (pid, c)))
297        .filter(|(_, c)| !c.trim().is_empty())
298        .collect()
299}
300
301#[cfg(windows)]
302pub(super) fn windows_command_line(pid: u32) -> Option<String> {
303    use windows_sys::Wdk::System::Threading::{
304        NtQueryInformationProcess, ProcessCommandLineInformation,
305    };
306    use windows_sys::Win32::Foundation::{CloseHandle, UNICODE_STRING};
307    use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
308
309    const MAX_BYTES: u32 = 1 << 20;
310
311    // SAFETY: the handle is null-checked and closed on every path below.
312    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
313    if handle.is_null() {
314        return None;
315    }
316    let mut needed = 0_u32;
317    // SAFETY: a zero-length query only writes the required size to `needed`.
318    unsafe {
319        NtQueryInformationProcess(
320            handle,
321            ProcessCommandLineInformation,
322            std::ptr::null_mut(),
323            0,
324            &raw mut needed,
325        );
326    }
327    if needed == 0 || needed > MAX_BYTES {
328        // SAFETY: `handle` came from OpenProcess and is still open.
329        unsafe { CloseHandle(handle) };
330        return None;
331    }
332    // u64 storage keeps the UNICODE_STRING header aligned.
333    let mut buf = vec![0_u64; (needed as usize).div_ceil(8)];
334    let buf_bytes = buf.len() * 8;
335    let buf_len = u32::try_from(buf_bytes).unwrap_or(MAX_BYTES);
336    let mut written = 0_u32;
337    // SAFETY: `buf` is writable for `buf_len` bytes, which is passed along.
338    let status = unsafe {
339        NtQueryInformationProcess(
340            handle,
341            ProcessCommandLineInformation,
342            buf.as_mut_ptr().cast(),
343            buf_len,
344            &raw mut written,
345        )
346    };
347    // SAFETY: `handle` came from OpenProcess and is still open.
348    unsafe { CloseHandle(handle) };
349    if status < 0 {
350        return None;
351    }
352    // SAFETY: on success the buffer starts with an initialised UNICODE_STRING.
353    let header = unsafe { &*buf.as_ptr().cast::<UNICODE_STRING>() };
354    let chars = usize::from(header.Length) / 2;
355    let start = header.Buffer as usize;
356    let base = buf.as_ptr() as usize;
357    if header.Buffer.is_null() || start < base || start + chars * 2 > base + buf_bytes {
358        return None;
359    }
360    // SAFETY: the bounds were checked above; the text lies inside `buf`.
361    let text = unsafe { std::slice::from_raw_parts(header.Buffer, chars) };
362    Some(String::from_utf16_lossy(text))
363}
364
365#[cfg(not(any(unix, windows)))]
366pub(super) fn command_lines(_pids: &[u32]) -> Vec<(u32, String)> {
367    Vec::new()
368}
369
370/// Of the recorded `owned` PIDs (daemon PID file, proxy `/health` on this
371/// instance's port, Cockpit launch record), the ones that still are lean-ctx
372/// background services `sweep` includes. Command line, executable, user and
373/// identity (the recorded one, where the record has it) gate every PID
374/// again, so a stale record whose PID now belongs to an MCP server, a command
375/// runner or a foreign process is never selected. Never this process or its
376/// ancestors, never a process whose identity cannot be read.
377#[must_use]
378pub fn find_service_processes(owned: &[RecordedPid], sweep: ServiceSweep) -> Vec<ServiceProcess> {
379    let mut recorded: std::collections::BTreeMap<u32, Option<ProcessIdentity>> =
380        std::collections::BTreeMap::new();
381    for record in owned.iter().filter(|r| r.pid > 0) {
382        let slot = recorded.entry(record.pid).or_default();
383        if slot.is_none() {
384            slot.clone_from(&record.identity);
385        }
386    }
387    let pids: Vec<u32> = recorded.keys().copied().collect();
388    let candidates = command_lines(&pids)
389        .into_iter()
390        .map(|(pid, command)| Candidate {
391            pid,
392            command,
393            live: identity(pid),
394            recorded: recorded.get(&pid).cloned().flatten(),
395            same_user: owned_by_current_user(pid),
396        })
397        .collect();
398    select_services(candidates, &protected_self_pids(), sweep)
399}
400
401/// The process a record written by that process itself names (the Cockpit
402/// launch record), proven by the identity in the record rather than by a
403/// command-line kind: this user, lean-ctx executable, same process as
404/// recorded, never this process or its ancestors. `None` otherwise.
405#[must_use]
406pub fn recorded_process(
407    pid: u32,
408    recorded: &ProcessIdentity,
409    kind: ServiceKind,
410) -> Option<ServiceProcess> {
411    if pid == 0 || protected_self_pids().contains(&pid) || !owned_by_current_user(pid) {
412        return None;
413    }
414    let live = identity(pid)?;
415    (is_lean_ctx_executable(&live.executable) && same_process(recorded, &live)).then(|| {
416        ServiceProcess {
417            pid,
418            kind,
419            args: Vec::new(),
420            identity: live,
421        }
422    })
423}
424
425/// PIDs of every process of this user whose image is lean-ctx, including an
426/// image an update renamed. Listing only — callers never signal these.
427pub(super) fn lean_ctx_process_pids() -> Vec<u32> {
428    #[cfg(windows)]
429    {
430        windows_lean_ctx_process_pids()
431    }
432    #[cfg(not(windows))]
433    {
434        // Same user only (pgrep -U); `comm` keeps `lean-ctx` after the binary
435        // was replaced, and the `-f` pattern catches absolute-path launches.
436        find_pids_by_name("lean-ctx")
437    }
438}
439
440/// Toolhelp snapshot instead of `tasklist`: no external tool, and the image
441/// names an update leaves behind (`lean-ctx.old.exe`, parked sidecars) are
442/// included. The user check happens in the selection.
443#[cfg(windows)]
444pub(super) fn windows_lean_ctx_process_pids() -> Vec<u32> {
445    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
446    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
447        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
448        TH32CS_SNAPPROCESS,
449    };
450
451    // SAFETY: plain flags; the snapshot handle is checked and closed below.
452    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
453    if snapshot == INVALID_HANDLE_VALUE || snapshot.is_null() {
454        return Vec::new();
455    }
456    let protected = protected_self_pids();
457    let mut pids = Vec::new();
458    // SAFETY: PROCESSENTRY32W is plain data; zeroed is a valid initial state.
459    let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
460    entry.dwSize = u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).unwrap_or(0);
461    // SAFETY: valid snapshot handle and a sized, writable entry.
462    let mut more = unsafe { Process32FirstW(snapshot, &raw mut entry) } != 0;
463    while more {
464        let len = entry
465            .szExeFile
466            .iter()
467            .position(|&c| c == 0)
468            .unwrap_or(entry.szExeFile.len());
469        let name = String::from_utf16_lossy(&entry.szExeFile[..len]);
470        if is_lean_ctx_executable(&name) && !protected.contains(&entry.th32ProcessID) {
471            pids.push(entry.th32ProcessID);
472        }
473        // SAFETY: as above.
474        more = unsafe { Process32NextW(snapshot, &raw mut entry) } != 0;
475    }
476    // SAFETY: `snapshot` came from CreateToolhelp32Snapshot and is still open.
477    unsafe { CloseHandle(snapshot) };
478    pids
479}
480
481/// lean-ctx background services of this user that no record of this
482/// instance names (`recorded`): a foreground `lean-ctx serve`, a proxy on
483/// another port, another data dir's daemon, a duplicate from a start race.
484///
485/// Enumerate and classify only — the lifecycle commands never stop these
486/// (owner decision 02.11.: only recorded services are ever signalled); `stop`,
487/// `uninstall` and `doctor` list them with the way to stop them. MCP stdio
488/// servers, hooks and command runners are not services and are never listed.
489#[must_use]
490pub fn find_unrecorded_services(recorded: &[u32]) -> Vec<ServiceProcess> {
491    let pids: Vec<u32> = lean_ctx_process_pids()
492        .into_iter()
493        .filter(|pid| !recorded.contains(pid))
494        .collect();
495    let candidates = command_lines(&pids)
496        .into_iter()
497        .map(|(pid, command)| Candidate {
498            pid,
499            command,
500            live: identity(pid),
501            recorded: None,
502            same_user: owned_by_current_user(pid),
503        })
504        .collect();
505    select_services(candidates, &protected_self_pids(), ServiceSweep::All)
506}
507
508/// The warning `stop`/`uninstall` print and `doctor` shows for
509/// [`find_unrecorded_services`], or `None` when there are none:
510/// `(what is running, how to stop it)`. Pure; `windows` picks the hint.
511#[must_use]
512pub fn unrecorded_services_lines(
513    services: &[ServiceProcess],
514    windows: bool,
515) -> Option<(String, String)> {
516    let what = describe_services(
517        "Still running, not started by this instance (left alone)",
518        services,
519    )?;
520    let pids: Vec<String> = services.iter().map(|s| s.pid.to_string()).collect();
521    let how = if windows {
522        format!("Stop them with: taskkill /PID {}", pids.join(" /PID "))
523    } else {
524        format!("Stop them with: kill {}", pids.join(" "))
525    };
526    Some((what, how))
527}
528
529/// What `restart`/`config apply` does about a proxy it stopped: start it
530/// again with the arguments it ran with, unless the proxy autostart service
531/// brought it back (`autostart_restarted`). Without this, `restart` left the
532/// proxy down on Windows and wherever no autostart is installed (#2176
533/// review). `None` when there is nothing to start. Pure.
534#[must_use]
535pub fn proxy_respawn_args(
536    stopped: &[ServiceProcess],
537    autostart_restarted: bool,
538) -> Option<Vec<String>> {
539    if autostart_restarted {
540        return None;
541    }
542    stopped
543        .iter()
544        .find(|s| s.kind == ServiceKind::Proxy)
545        .map(|s| s.args.clone())
546        .filter(|args| args.first().map(String::as_str) == Some("proxy"))
547}
548
549/// What [`stop_services`] did.
550#[derive(Debug, Default, Clone, PartialEq, Eq)]
551pub struct ServiceStopReport {
552    /// Services that were running and are gone now.
553    pub stopped: Vec<ServiceProcess>,
554    /// Services that survived terminate and kill.
555    pub survivors: Vec<ServiceProcess>,
556}
557
558impl ServiceKind {
559    #[must_use]
560    pub fn label(self) -> &'static str {
561        match self {
562            Self::Daemon => "daemon",
563            Self::Proxy => "proxy",
564            Self::HttpServer => "HTTP server",
565            Self::Cockpit => "Cockpit",
566        }
567    }
568}
569
570impl ServiceStopReport {
571    /// One line naming each service that was stopped, e.g.
572    /// `Stopped lean-ctx services: daemon (PID 812), proxy (PID 900).`, or
573    /// `None` when nothing was running.
574    #[must_use]
575    pub fn stopped_line(&self) -> Option<String> {
576        describe_services("Stopped lean-ctx services", &self.stopped)
577    }
578
579    /// One line naming each service that is still running, or `None`.
580    #[must_use]
581    pub fn survivors_line(&self) -> Option<String> {
582        describe_services("Could not stop", &self.survivors)
583    }
584
585    /// PIDs of the services that are still running.
586    #[must_use]
587    pub fn survivor_pids(&self) -> Vec<u32> {
588        self.survivors.iter().map(|s| s.pid).collect()
589    }
590}
591
592pub(super) fn describe_services(prefix: &str, services: &[ServiceProcess]) -> Option<String> {
593    if services.is_empty() {
594        return None;
595    }
596    let list = services
597        .iter()
598        .map(|s| format!("{} (PID {})", s.kind.label(), s.pid))
599        .collect::<Vec<_>>()
600        .join(", ");
601    Some(format!("{prefix}: {list}."))
602}
603
604/// Stop the recorded lean-ctx background services (`owned`) that `sweep`
605/// includes (see [`find_service_processes`] and [`stop_selected`]). Nothing
606/// is ever selected by process name (#2176).
607pub fn stop_services(owned: &[RecordedPid], sweep: ServiceSweep) -> ServiceStopReport {
608    stop_selected(find_service_processes(owned, sweep))
609}
610
611/// Stop selected services: graceful termination first, a kill for those
612/// still running after a grace period. Every signal goes through
613/// [`signal_same_process`], so a PID the OS reused in between is left alone.
614pub fn stop_selected(targets: Vec<ServiceProcess>) -> ServiceStopReport {
615    if targets.is_empty() {
616        return ServiceStopReport::default();
617    }
618    for target in &targets {
619        target.signal(StopSignal::Terminate);
620    }
621    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
622    while std::time::Instant::now() < deadline && targets.iter().any(ServiceProcess::is_running) {
623        std::thread::sleep(std::time::Duration::from_millis(100));
624    }
625    let mut killed = false;
626    for target in targets.iter().filter(|t| t.is_running()) {
627        killed |= target.signal(StopSignal::Kill);
628    }
629    if killed {
630        std::thread::sleep(std::time::Duration::from_millis(300));
631    }
632
633    let (survivors, stopped) = targets.into_iter().partition(ServiceProcess::is_running);
634    ServiceStopReport { stopped, survivors }
635}