use super::{
ProcessIdentity, StopSignal, identity, installed_executable_path, is_same_process,
owned_by_current_user, protected_self_pids, same_process, signal_same_process,
};
#[cfg(not(windows))]
use super::{find_pids_by_name, run_with_timeout};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ServiceKind {
Daemon,
Proxy,
HttpServer,
Cockpit,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ServiceSweep {
Restartable,
All,
Only(ServiceKind),
}
impl ServiceSweep {
#[must_use]
pub fn includes(self, kind: ServiceKind) -> bool {
match self {
Self::All => true,
Self::Restartable => matches!(kind, ServiceKind::Daemon | ServiceKind::Proxy),
Self::Only(only) => kind == only,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecordedPid {
pub pid: u32,
pub identity: Option<ProcessIdentity>,
}
impl RecordedPid {
#[must_use]
pub fn bare(pid: u32) -> Self {
Self {
pid,
identity: None,
}
}
}
pub(super) fn split_command_line(command: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut has_word = false;
for ch in command.chars() {
match ch {
'"' => {
in_quotes = !in_quotes;
has_word = true;
}
c if c.is_whitespace() && !in_quotes => {
if has_word {
words.push(std::mem::take(&mut current));
has_word = false;
}
}
c => {
current.push(c);
has_word = true;
}
}
}
if has_word {
words.push(current);
}
words
}
pub(super) fn is_lean_ctx_executable(path: &str) -> bool {
let path = installed_executable_path(path);
let name = path.rsplit(['/', '\\']).next().unwrap_or(&path);
let name = name.to_ascii_lowercase();
name == "lean-ctx" || name == "lean-ctx.exe"
}
pub(super) fn service_args(command: &str) -> Vec<String> {
let words = split_command_line(command);
words
.iter()
.position(|w| is_lean_ctx_executable(w))
.map(|at| words[at + 1..].to_vec())
.unwrap_or_default()
}
#[must_use]
pub fn classify_service_cmdline(command: &str) -> Option<ServiceKind> {
let words = split_command_line(command);
let exe_at = words.iter().position(|w| is_lean_ctx_executable(w))?;
let args = &words[exe_at + 1..];
let has = |flag: &str| args.iter().any(|a| a == flag);
match args.first()?.as_str() {
"serve" => {
if [
"--stop",
"--status",
"--daemon",
"-d",
"--print-socket",
"--help",
"-h",
]
.iter()
.any(|f| has(f))
{
None
} else if has("--_foreground-daemon") {
Some(ServiceKind::Daemon)
} else {
Some(ServiceKind::HttpServer)
}
}
"proxy" => (args.get(1).map(String::as_str) == Some("start")).then_some(ServiceKind::Proxy),
"cockpit" | "dashboard" => has("--foreground").then_some(ServiceKind::Cockpit),
_ => None,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServiceProcess {
pub pid: u32,
pub kind: ServiceKind,
pub args: Vec<String>,
pub(super) identity: ProcessIdentity,
}
impl ServiceProcess {
#[must_use]
pub fn is_running(&self) -> bool {
is_same_process(self.pid, &self.identity)
}
pub fn signal(&self, signal: StopSignal) -> bool {
signal_same_process(self.pid, &self.identity, signal)
}
}
#[derive(Clone, Debug)]
pub(super) struct Candidate {
pub(super) pid: u32,
pub(super) command: String,
pub(super) live: Option<ProcessIdentity>,
pub(super) recorded: Option<ProcessIdentity>,
pub(super) same_user: bool,
}
pub(super) fn select_services(
candidates: Vec<Candidate>,
protected: &std::collections::HashSet<u32>,
sweep: ServiceSweep,
) -> Vec<ServiceProcess> {
candidates
.into_iter()
.filter(|c| !protected.contains(&c.pid) && c.same_user)
.filter_map(|c| {
let identity = c.live?;
if !is_lean_ctx_executable(&identity.executable) {
return None;
}
if let Some(recorded) = &c.recorded
&& !same_process(recorded, &identity)
{
return None;
}
let kind = classify_service_cmdline(&c.command)?;
sweep.includes(kind).then(|| ServiceProcess {
pid: c.pid,
kind,
args: service_args(&c.command),
identity,
})
})
.collect()
}
#[cfg(unix)]
pub(super) fn command_lines(pids: &[u32]) -> Vec<(u32, String)> {
if pids.is_empty() {
return Vec::new();
}
let list = pids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let mut cmd = std::process::Command::new("ps");
cmd.args(["-o", "pid=", "-o", "command=", "-p", &list]);
let Some(out) = run_with_timeout(cmd, std::time::Duration::from_secs(5)) else {
return Vec::new();
};
parse_ps_pid_command(&String::from_utf8_lossy(&out.stdout), pids)
}
#[cfg(unix)]
pub(super) fn parse_ps_pid_command(stdout: &str, pids: &[u32]) -> Vec<(u32, String)> {
stdout
.lines()
.filter_map(|line| {
let line = line.trim_start();
let (pid, command) = line.split_once(char::is_whitespace)?;
let pid = pid.parse::<u32>().ok()?;
let command = command.trim();
(pids.contains(&pid) && !command.is_empty()).then(|| (pid, command.to_string()))
})
.collect()
}
#[cfg(windows)]
pub(super) fn command_lines(pids: &[u32]) -> Vec<(u32, String)> {
pids.iter()
.filter_map(|&pid| windows_command_line(pid).map(|c| (pid, c)))
.filter(|(_, c)| !c.trim().is_empty())
.collect()
}
#[cfg(windows)]
pub(super) fn windows_command_line(pid: u32) -> Option<String> {
use windows_sys::Wdk::System::Threading::{
NtQueryInformationProcess, ProcessCommandLineInformation,
};
use windows_sys::Win32::Foundation::{CloseHandle, UNICODE_STRING};
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
const MAX_BYTES: u32 = 1 << 20;
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return None;
}
let mut needed = 0_u32;
unsafe {
NtQueryInformationProcess(
handle,
ProcessCommandLineInformation,
std::ptr::null_mut(),
0,
&raw mut needed,
);
}
if needed == 0 || needed > MAX_BYTES {
unsafe { CloseHandle(handle) };
return None;
}
let mut buf = vec![0_u64; (needed as usize).div_ceil(8)];
let buf_bytes = buf.len() * 8;
let buf_len = u32::try_from(buf_bytes).unwrap_or(MAX_BYTES);
let mut written = 0_u32;
let status = unsafe {
NtQueryInformationProcess(
handle,
ProcessCommandLineInformation,
buf.as_mut_ptr().cast(),
buf_len,
&raw mut written,
)
};
unsafe { CloseHandle(handle) };
if status < 0 {
return None;
}
let header = unsafe { &*buf.as_ptr().cast::<UNICODE_STRING>() };
let chars = usize::from(header.Length) / 2;
let start = header.Buffer as usize;
let base = buf.as_ptr() as usize;
if header.Buffer.is_null() || start < base || start + chars * 2 > base + buf_bytes {
return None;
}
let text = unsafe { std::slice::from_raw_parts(header.Buffer, chars) };
Some(String::from_utf16_lossy(text))
}
#[cfg(not(any(unix, windows)))]
pub(super) fn command_lines(_pids: &[u32]) -> Vec<(u32, String)> {
Vec::new()
}
#[must_use]
pub fn find_service_processes(owned: &[RecordedPid], sweep: ServiceSweep) -> Vec<ServiceProcess> {
let mut recorded: std::collections::BTreeMap<u32, Option<ProcessIdentity>> =
std::collections::BTreeMap::new();
for record in owned.iter().filter(|r| r.pid > 0) {
let slot = recorded.entry(record.pid).or_default();
if slot.is_none() {
slot.clone_from(&record.identity);
}
}
let pids: Vec<u32> = recorded.keys().copied().collect();
let candidates = command_lines(&pids)
.into_iter()
.map(|(pid, command)| Candidate {
pid,
command,
live: identity(pid),
recorded: recorded.get(&pid).cloned().flatten(),
same_user: owned_by_current_user(pid),
})
.collect();
select_services(candidates, &protected_self_pids(), sweep)
}
#[must_use]
pub fn recorded_process(
pid: u32,
recorded: &ProcessIdentity,
kind: ServiceKind,
) -> Option<ServiceProcess> {
if pid == 0 || protected_self_pids().contains(&pid) || !owned_by_current_user(pid) {
return None;
}
let live = identity(pid)?;
(is_lean_ctx_executable(&live.executable) && same_process(recorded, &live)).then(|| {
ServiceProcess {
pid,
kind,
args: Vec::new(),
identity: live,
}
})
}
pub(super) fn lean_ctx_process_pids() -> Vec<u32> {
#[cfg(windows)]
{
windows_lean_ctx_process_pids()
}
#[cfg(not(windows))]
{
find_pids_by_name("lean-ctx")
}
}
#[cfg(windows)]
pub(super) fn windows_lean_ctx_process_pids() -> Vec<u32> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
TH32CS_SNAPPROCESS,
};
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE || snapshot.is_null() {
return Vec::new();
}
let protected = protected_self_pids();
let mut pids = Vec::new();
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).unwrap_or(0);
let mut more = unsafe { Process32FirstW(snapshot, &raw mut entry) } != 0;
while more {
let len = entry
.szExeFile
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..len]);
if is_lean_ctx_executable(&name) && !protected.contains(&entry.th32ProcessID) {
pids.push(entry.th32ProcessID);
}
more = unsafe { Process32NextW(snapshot, &raw mut entry) } != 0;
}
unsafe { CloseHandle(snapshot) };
pids
}
#[must_use]
pub fn find_unrecorded_services(recorded: &[u32]) -> Vec<ServiceProcess> {
let pids: Vec<u32> = lean_ctx_process_pids()
.into_iter()
.filter(|pid| !recorded.contains(pid))
.collect();
let candidates = command_lines(&pids)
.into_iter()
.map(|(pid, command)| Candidate {
pid,
command,
live: identity(pid),
recorded: None,
same_user: owned_by_current_user(pid),
})
.collect();
select_services(candidates, &protected_self_pids(), ServiceSweep::All)
}
#[must_use]
pub fn unrecorded_services_lines(
services: &[ServiceProcess],
windows: bool,
) -> Option<(String, String)> {
let what = describe_services(
"Still running, not started by this instance (left alone)",
services,
)?;
let pids: Vec<String> = services.iter().map(|s| s.pid.to_string()).collect();
let how = if windows {
format!("Stop them with: taskkill /PID {}", pids.join(" /PID "))
} else {
format!("Stop them with: kill {}", pids.join(" "))
};
Some((what, how))
}
#[must_use]
pub fn proxy_respawn_args(
stopped: &[ServiceProcess],
autostart_restarted: bool,
) -> Option<Vec<String>> {
if autostart_restarted {
return None;
}
stopped
.iter()
.find(|s| s.kind == ServiceKind::Proxy)
.map(|s| s.args.clone())
.filter(|args| args.first().map(String::as_str) == Some("proxy"))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ServiceStopReport {
pub stopped: Vec<ServiceProcess>,
pub survivors: Vec<ServiceProcess>,
}
impl ServiceKind {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Daemon => "daemon",
Self::Proxy => "proxy",
Self::HttpServer => "HTTP server",
Self::Cockpit => "Cockpit",
}
}
}
impl ServiceStopReport {
#[must_use]
pub fn stopped_line(&self) -> Option<String> {
describe_services("Stopped lean-ctx services", &self.stopped)
}
#[must_use]
pub fn survivors_line(&self) -> Option<String> {
describe_services("Could not stop", &self.survivors)
}
#[must_use]
pub fn survivor_pids(&self) -> Vec<u32> {
self.survivors.iter().map(|s| s.pid).collect()
}
}
pub(super) fn describe_services(prefix: &str, services: &[ServiceProcess]) -> Option<String> {
if services.is_empty() {
return None;
}
let list = services
.iter()
.map(|s| format!("{} (PID {})", s.kind.label(), s.pid))
.collect::<Vec<_>>()
.join(", ");
Some(format!("{prefix}: {list}."))
}
pub fn stop_services(owned: &[RecordedPid], sweep: ServiceSweep) -> ServiceStopReport {
stop_selected(find_service_processes(owned, sweep))
}
pub fn stop_selected(targets: Vec<ServiceProcess>) -> ServiceStopReport {
if targets.is_empty() {
return ServiceStopReport::default();
}
for target in &targets {
target.signal(StopSignal::Terminate);
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline && targets.iter().any(ServiceProcess::is_running) {
std::thread::sleep(std::time::Duration::from_millis(100));
}
let mut killed = false;
for target in targets.iter().filter(|t| t.is_running()) {
killed |= target.signal(StopSignal::Kill);
}
if killed {
std::thread::sleep(std::time::Duration::from_millis(300));
}
let (survivors, stopped) = targets.into_iter().partition(ServiceProcess::is_running);
ServiceStopReport { stopped, survivors }
}