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 [
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#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct ServiceProcess {
184 pub pid: u32,
185 pub kind: ServiceKind,
186 pub args: Vec<String>,
189 pub(super) identity: ProcessIdentity,
190}
191
192impl ServiceProcess {
193 #[must_use]
195 pub fn is_running(&self) -> bool {
196 is_same_process(self.pid, &self.identity)
197 }
198
199 pub fn signal(&self, signal: StopSignal) -> bool {
201 signal_same_process(self.pid, &self.identity, signal)
202 }
203}
204
205#[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
216pub(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#[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#[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#[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 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 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 unsafe { CloseHandle(handle) };
330 return None;
331 }
332 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 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 unsafe { CloseHandle(handle) };
349 if status < 0 {
350 return None;
351 }
352 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 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#[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#[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
425pub(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 find_pids_by_name("lean-ctx")
437 }
438}
439
440#[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 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 let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
460 entry.dwSize = u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).unwrap_or(0);
461 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 more = unsafe { Process32NextW(snapshot, &raw mut entry) } != 0;
475 }
476 unsafe { CloseHandle(snapshot) };
478 pids
479}
480
481#[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#[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#[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#[derive(Debug, Default, Clone, PartialEq, Eq)]
551pub struct ServiceStopReport {
552 pub stopped: Vec<ServiceProcess>,
554 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 #[must_use]
575 pub fn stopped_line(&self) -> Option<String> {
576 describe_services("Stopped lean-ctx services", &self.stopped)
577 }
578
579 #[must_use]
581 pub fn survivors_line(&self) -> Option<String> {
582 describe_services("Could not stop", &self.survivors)
583 }
584
585 #[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
604pub fn stop_services(owned: &[RecordedPid], sweep: ServiceSweep) -> ServiceStopReport {
608 stop_selected(find_service_processes(owned, sweep))
609}
610
611pub 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}