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(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HardKillOutcome {
Reaped,
Forced,
IdentitySkipped,
}
pub fn hard_kill(pid: u32, state_dir: &Path) -> std::io::Result<HardKillOutcome> {
#[cfg(unix)]
{
Ok(hard_kill_unix(
pid,
Duration::from_millis(800),
pidfile::pid_alive,
|p| pid_belongs_to_daemon(p, state_dir),
|p| {
let _ = std::process::Command::new("kill")
.args(["-TERM", &p.to_string()])
.status();
},
|p| {
let _ = std::process::Command::new("kill")
.args(["-KILL", &p.to_string()])
.status();
},
))
}
#[cfg(windows)]
{
if !pid_belongs_to_daemon(pid, state_dir) {
return Ok(HardKillOutcome::IdentitySkipped);
}
let out = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output()?;
if out.status.success() {
Ok(HardKillOutcome::Forced)
} else {
Err(std::io::Error::other(
String::from_utf8_lossy(&out.stderr).to_string(),
))
}
}
}
#[cfg(unix)]
fn hard_kill_unix(
pid: u32,
grace: Duration,
is_alive: impl Fn(u32) -> bool,
still_ours: impl Fn(u32) -> bool,
term: impl Fn(u32),
kill: impl Fn(u32),
) -> HardKillOutcome {
term(pid);
std::thread::sleep(grace);
if !is_alive(pid) {
return HardKillOutcome::Reaped;
}
if !still_ours(pid) {
return HardKillOutcome::IdentitySkipped;
}
kill(pid);
HardKillOutcome::Forced
}
#[must_use]
pub fn pid_belongs_to_daemon(pid: u32, state_dir: &Path) -> bool {
#[cfg(windows)]
{
let ps = format!(
"Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\" | ForEach-Object {{ \"$($_.Name)`t$($_.CommandLine)\" }}"
);
std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &ps])
.output()
.ok()
.map(|o| {
String::from_utf8_lossy(&o.stdout).lines().any(|line| {
let (name, cmdline) = line.split_once('\t').unwrap_or(("", line));
name.trim() == "terminal-commanderd.exe"
&& cmdline_is_our_daemon(cmdline, state_dir)
})
})
.unwrap_or(false)
}
#[cfg(unix)]
{
std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "args="])
.output()
.ok()
.map(|o| cmdline_is_our_daemon(&String::from_utf8_lossy(&o.stdout), state_dir))
.unwrap_or(false)
}
}
fn cmdline_is_our_daemon(args: &str, state_dir: &Path) -> bool {
if !args.contains("terminal-commanderd") {
return false;
}
let needle = state_dir.to_string_lossy();
contains_path_arg(args, needle.as_ref())
}
fn contains_path_arg(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let bytes = haystack.as_bytes();
let nlen = needle.len();
let mut from = 0;
while let Some(rel) = haystack[from..].find(needle) {
let i = from + rel;
let end = i + nlen;
let before_ok = i == 0 || is_arg_boundary(bytes[i - 1]);
let after_ok = end == bytes.len() || is_arg_boundary(bytes[end]);
if before_ok && after_ok {
return true;
}
from = i + 1;
}
false
}
fn is_arg_boundary(b: u8) -> bool {
b.is_ascii_whitespace() || b == b'=' || b == b'"' || b == b'\''
}
#[must_use]
pub fn find_daemon_pid_os(state_dir: &Path) -> Option<u32> {
#[cfg(windows)]
{
let ps = "Get-CimInstance Win32_Process -Filter \"Name='terminal-commanderd.exe'\" | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }";
let out = std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", ps])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|line| {
let (pid_s, cmdline) = line.split_once('\t')?;
let pid: u32 = pid_s.trim().parse().ok()?;
cmdline_is_our_daemon(cmdline, state_dir).then_some(pid)
})
}
#[cfg(unix)]
{
let out = std::process::Command::new("pgrep")
.args(["-f", "terminal-commanderd"])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.find(|&pid| pid_belongs_to_daemon(pid, state_dir))
}
}
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, &opts.state_dir) {
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"
);
}
#[cfg(unix)]
#[test]
fn sigkill_withheld_when_pid_recycled_during_grace() {
use std::cell::Cell;
let killed = Cell::new(false);
let outcome = hard_kill_unix(
4242,
Duration::from_millis(0),
|_| true, |_| false, |_| {}, |_| killed.set(true),
);
assert_eq!(outcome, HardKillOutcome::IdentitySkipped);
assert!(
!killed.get(),
"SIGKILL must NOT be sent to a recycled pid (kill-leg identity gate)"
);
}
#[cfg(unix)]
#[test]
fn sigkill_sent_when_still_our_daemon_after_grace() {
use std::cell::Cell;
let killed = Cell::new(false);
let outcome = hard_kill_unix(
4242,
Duration::from_millis(0),
|_| true, |_| true, |_| {},
|_| killed.set(true),
);
assert_eq!(outcome, HardKillOutcome::Forced);
assert!(
killed.get(),
"a live, still-ours daemon must be force-killed"
);
}
#[cfg(unix)]
#[test]
fn no_force_signal_when_graceful_reaped_it() {
use std::cell::Cell;
let killed = Cell::new(false);
let outcome = hard_kill_unix(
4242,
Duration::from_millis(0),
|_| false, |_| panic!("identity must not be probed once the pid is already gone"),
|_| {},
|_| killed.set(true),
);
assert_eq!(outcome, HardKillOutcome::Reaped);
assert!(!killed.get());
}
#[cfg(unix)]
#[test]
fn cmdline_match_is_literal_not_regex() {
let dir = std::path::Path::new("/tmp/tc (run)+[v1]/state.d");
let cmd = format!("terminal-commanderd --data-dir {}", dir.display());
assert!(
cmdline_is_our_daemon(&cmd, dir),
"a state_dir with regex metacharacters must match the cmdline verbatim"
);
assert!(!cmdline_is_our_daemon(
&cmd,
std::path::Path::new("/tmp/other")
));
assert!(
!cmdline_is_our_daemon(&format!("cat {}", dir.display()), dir),
"the daemon binary name is required, not just the path"
);
assert!(
!cmdline_is_our_daemon("terminal-commanderd --data-dir /tmp/elsewhere", dir),
"the exact state_dir is required, not just the binary name"
);
}
#[test]
fn cmdline_match_rejects_path_prefix_of_another_session() {
let base = std::path::Path::new("/home/u/.local/share/terminal-commanderd/state");
let seeded_cmd = "terminal-commanderd --data-dir /home/u/.local/share/terminal-commanderd/state/agent-1 start";
assert!(
!cmdline_is_our_daemon(seeded_cmd, base),
"a seeded session's daemon (state/agent-1) must not match the base session (state)"
);
let base_cmd =
"terminal-commanderd --data-dir /home/u/.local/share/terminal-commanderd/state start";
assert!(cmdline_is_our_daemon(base_cmd, base));
let seeded = base.join("agent-1");
assert!(!cmdline_is_our_daemon(base_cmd, &seeded));
}
#[test]
fn cmdline_match_handles_apostrophe_and_equals_forms() {
let dir = std::path::Path::new("/home/OBrien'X/state");
assert!(cmdline_is_our_daemon(
"terminal-commanderd --data-dir /home/OBrien'X/state start",
dir
));
assert!(cmdline_is_our_daemon(
"terminal-commanderd --data-dir=/home/OBrien'X/state",
dir
));
assert!(!cmdline_is_our_daemon(
"terminal-commanderd --data-dir /home/OBrien'X/state-2 start",
dir
));
}
}