use std::path::Path;
use std::time::Duration;
use crate::ensure::{Endpoint, EnsureDaemonOptions, probe_endpoint};
use crate::pidfile;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplaceOutcome {
NoDaemonRunning,
UpToDate { version: String },
Replaced { old: String, new: String },
Skipped { reason: String },
}
#[must_use]
pub fn is_stale(running: &str, installed: &str) -> bool {
match (parse3(running), parse3(installed)) {
(Some(r), Some(i)) => r < i,
_ => true,
}
}
#[must_use]
pub fn should_replace(stale: bool, force: bool) -> bool {
stale || force
}
fn parse3(v: &str) -> Option<(u64, u64, u64)> {
let core = v.trim().trim_start_matches('v');
let mut it = core.split('.').map(|s| s.split('-').next().unwrap_or(s));
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
let c = it.next().unwrap_or("0").parse().ok()?;
Some((a, b, c))
}
fn endpoint_string(ep: &Endpoint) -> String {
match ep {
Endpoint::UnixSocket { path } => path.display().to_string(),
Endpoint::WindowsPipe { name } => name.clone(),
}
}
pub fn hard_kill(pid: u32) -> std::io::Result<()> {
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status();
std::thread::sleep(Duration::from_millis(800));
if pidfile::pid_alive(pid) {
let _ = std::process::Command::new("kill")
.args(["-KILL", &pid.to_string()])
.status();
}
Ok(())
}
#[cfg(windows)]
{
let out = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output()?;
if out.status.success() {
Ok(())
} else {
Err(std::io::Error::other(
String::from_utf8_lossy(&out.stderr).to_string(),
))
}
}
}
#[must_use]
pub fn pid_belongs_to_daemon(pid: u32, state_dir: &Path) -> bool {
let needle = state_dir.to_string_lossy().to_string();
#[cfg(windows)]
{
let pat = needle.replace('[', "`[").replace(']', "`]");
let ps = format!(
"Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\" | \
Where-Object {{ $_.Name -eq 'terminal-commanderd.exe' -and \
$_.CommandLine -like '*{pat}*' }} | \
Select-Object -First 1 -ExpandProperty ProcessId"
);
std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &ps])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == pid.to_string())
.unwrap_or(false)
}
#[cfg(unix)]
{
std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "args="])
.output()
.ok()
.map(|o| {
let args = String::from_utf8_lossy(&o.stdout);
args.contains("terminal-commanderd") && args.contains(&needle)
})
.unwrap_or(false)
}
}
#[must_use]
pub fn find_daemon_pid_os(state_dir: &Path) -> Option<u32> {
let needle = state_dir.to_string_lossy().to_string();
#[cfg(windows)]
{
let pat = needle.replace('[', "`[").replace(']', "`]");
let ps = format!(
"Get-CimInstance Win32_Process -Filter \"Name='terminal-commanderd.exe'\" | \
Where-Object {{ $_.CommandLine -like '*{pat}*' }} | \
Select-Object -First 1 -ExpandProperty ProcessId"
);
let out = std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &ps])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
#[cfg(unix)]
{
let out = std::process::Command::new("pgrep")
.args(["-f", &format!("terminal-commanderd.*{needle}")])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.next()?
.trim()
.parse()
.ok()
}
}
pub async fn replace_if_stale(
opts: &EnsureDaemonOptions,
installed_version: &str,
force: bool,
) -> ReplaceOutcome {
if !probe_endpoint(&opts.endpoint).await {
return ReplaceOutcome::NoDaemonRunning;
}
let ep_str = endpoint_string(&opts.endpoint);
let (old_version, pid) = match pidfile::read_pidfile(&opts.state_dir) {
Some(rec) => {
if !should_replace(is_stale(&rec.version, installed_version), force) {
return ReplaceOutcome::UpToDate {
version: rec.version,
};
}
if rec.endpoint != ep_str {
return ReplaceOutcome::Skipped {
reason: format!(
"pidfile endpoint {:?} != target {:?}; refusing to kill",
rec.endpoint, ep_str
),
};
}
(rec.version, rec.pid)
}
None => {
match find_daemon_pid_os(&opts.state_dir) {
Some(pid) => ("pre-pidfile".to_owned(), pid),
None => {
return ReplaceOutcome::Skipped {
reason: "reachable daemon, no pidfile, no killable pid found".to_owned(),
};
}
}
}
};
if !pid_belongs_to_daemon(pid, &opts.state_dir) {
return ReplaceOutcome::Skipped {
reason: format!(
"pid {pid} no longer a terminal-commanderd bound to {:?}; \
refusing to kill (pid may have been recycled)",
opts.state_dir
),
};
}
if let Err(e) = hard_kill(pid) {
return ReplaceOutcome::Skipped {
reason: format!("hard-kill pid {pid} failed: {e}"),
};
}
for _ in 0..30 {
if !probe_endpoint(&opts.endpoint).await {
pidfile::remove_pidfile(&opts.state_dir);
return ReplaceOutcome::Replaced {
old: old_version,
new: installed_version.to_owned(),
};
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
ReplaceOutcome::Skipped {
reason: format!("killed pid {pid} but endpoint still reachable after 3s"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stale_compare() {
assert!(is_stale("0.1.13", "0.1.14"));
assert!(is_stale("0.1.13", "0.2.0"));
assert!(!is_stale("0.1.14", "0.1.14"));
assert!(!is_stale("0.2.0", "0.1.14"));
assert!(
is_stale("garbage", "0.1.14"),
"unparseable running => stale"
);
assert!(!is_stale("v0.1.14", "0.1.14"), "v-prefix tolerated");
}
#[test]
fn force_replaces_even_when_versions_match() {
assert!(!is_stale("0.1.18", "0.1.18"));
assert!(should_replace(
false, true
));
assert!(should_replace(true, false));
assert!(should_replace(true, true));
assert!(!should_replace(false, false));
}
#[test]
fn recycled_or_unrelated_pid_is_refused_at_kill() {
let unrelated_live_pid = std::process::id();
assert!(
!pid_belongs_to_daemon(
unrelated_live_pid,
std::path::Path::new("/tmp/tc-m4-not-a-daemon")
),
"a live pid that is not our daemon must be refused (no force-kill of a recycled pid)"
);
assert!(
!pid_belongs_to_daemon(0xFFFF_FFF0, std::path::Path::new("/tmp/tc-m4-not-a-daemon")),
"a dead/absent pid must be refused"
);
}
}