1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum ServiceKind {
28 Daemon,
31 Proxy,
33 HttpServer,
36 Cockpit,
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum ServiceSweep {
43 Restartable,
46 All,
49 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#[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
86pub(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
118pub(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
128pub(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#[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 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#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct ServiceProcess {
176 pub pid: u32,
177 pub kind: ServiceKind,
178 pub args: Vec<String>,
181 pub(super) identity: ProcessIdentity,
182}
183
184impl ServiceProcess {
185 #[must_use]
187 pub fn is_running(&self) -> bool {
188 is_same_process(self.pid, &self.identity)
189 }
190
191 pub fn signal(&self, signal: StopSignal) -> bool {
193 signal_same_process(self.pid, &self.identity, signal)
194 }
195}
196
197#[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
208pub(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#[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#[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#[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 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 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 unsafe { CloseHandle(handle) };
322 return None;
323 }
324 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 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 unsafe { CloseHandle(handle) };
341 if status < 0 {
342 return None;
343 }
344 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 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#[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#[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
417pub(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 find_pids_by_name("lean-ctx")
429 }
430}
431
432#[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 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 let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
452 entry.dwSize = u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).unwrap_or(0);
453 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 more = unsafe { Process32NextW(snapshot, &raw mut entry) } != 0;
467 }
468 unsafe { CloseHandle(snapshot) };
470 pids
471}
472
473#[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#[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#[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#[derive(Debug, Default, Clone, PartialEq, Eq)]
543pub struct ServiceStopReport {
544 pub stopped: Vec<ServiceProcess>,
546 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 #[must_use]
567 pub fn stopped_line(&self) -> Option<String> {
568 describe_services("Stopped lean-ctx services", &self.stopped)
569 }
570
571 #[must_use]
573 pub fn survivors_line(&self) -> Option<String> {
574 describe_services("Could not stop", &self.survivors)
575 }
576
577 #[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
596pub fn stop_services(owned: &[RecordedPid], sweep: ServiceSweep) -> ServiceStopReport {
600 stop_selected(find_service_processes(owned, sweep))
601}
602
603pub 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}