use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub(crate) const GRACE: Duration = Duration::from_secs(3);
const POLL: Duration = Duration::from_millis(100);
pub(crate) fn read_pid_file(pid_path: &Path) -> Option<(u32, Option<PathBuf>)> {
let contents = std::fs::read_to_string(pid_path).ok()?;
parse_pid_file(&contents)
}
pub(crate) fn parse_pid_file(contents: &str) -> Option<(u32, Option<PathBuf>)> {
let mut lines = contents.lines();
let pid = parse_pid(lines.next()?)?;
let exe = lines
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from);
Some((pid, exe))
}
pub(crate) fn parse_pid(contents: &str) -> Option<u32> {
let trimmed = contents.trim();
let pid: u32 = trimmed.parse().ok()?;
if pid == 0 { None } else { Some(pid) }
}
#[cfg(unix)]
#[must_use]
pub(crate) fn is_process_alive(pid: u32) -> bool {
let Ok(raw) = i32::try_from(pid) else {
return false;
};
if raw <= 0 {
return false;
}
let target = nix::unistd::Pid::from_raw(raw);
!matches!(
nix::sys::signal::kill(target, None),
Err(e) if e != nix::errno::Errno::EPERM
)
}
#[cfg(not(unix))]
#[must_use]
pub(crate) fn is_process_alive(_pid: u32) -> bool {
false
}
#[cfg(target_os = "linux")]
fn strip_deleted_marker(path: PathBuf) -> PathBuf {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
const MARKER: &[u8] = b" (deleted)";
match path.as_os_str().as_bytes().strip_suffix(MARKER) {
Some(base) => PathBuf::from(std::ffi::OsString::from_vec(base.to_vec())),
None => path,
}
}
#[cfg(target_os = "linux")]
fn exe_path_of_pid(pid: u32) -> Option<PathBuf> {
let raw = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?;
Some(strip_deleted_marker(raw))
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
fn exe_path_of_pid(pid: u32) -> Option<PathBuf> {
let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
let raw = i32::try_from(pid).ok()?;
let buf_len = u32::try_from(buf.len()).ok()?;
let n = unsafe { libc::proc_pidpath(raw, buf.as_mut_ptr().cast::<libc::c_void>(), buf_len) };
if n <= 0 {
return None;
}
let len = usize::try_from(n).ok()?;
let path = std::str::from_utf8(buf.get(..len)?).ok()?;
Some(PathBuf::from(path))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn exe_path_of_pid(_pid: u32) -> Option<PathBuf> {
None
}
fn exe_matches(recorded: Option<&Path>, live: Option<&Path>) -> bool {
let (Some(recorded), Some(live)) = (recorded, live) else {
return false;
};
recorded == live || std::fs::canonicalize(live).is_ok_and(|c| c == *recorded)
}
#[cfg(unix)]
fn signal(pid: u32, sig: nix::sys::signal::Signal) -> bool {
let Ok(raw) = i32::try_from(pid) else {
return false;
};
if raw <= 0 {
return false;
}
nix::sys::signal::kill(nix::unistd::Pid::from_raw(raw), sig).is_ok()
}
#[cfg(not(unix))]
fn signal(_pid: u32, _sig: ()) -> bool {
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KillOutcome {
NotRunning,
TermExited,
KilledExited,
StillAlive,
Unverified(u32),
}
pub(crate) async fn terminate_orphan(pid_path: &Path) -> KillOutcome {
let Some((pid, recorded_exe)) = read_pid_file(pid_path) else {
return KillOutcome::NotRunning;
};
terminate_known(pid, recorded_exe.as_deref()).await
}
pub(crate) async fn terminate_known(pid: u32, recorded_exe: Option<&Path>) -> KillOutcome {
if !is_process_alive(pid) {
return KillOutcome::NotRunning;
}
let live_exe = exe_path_of_pid(pid);
if !exe_matches(recorded_exe, live_exe.as_deref()) {
return KillOutcome::Unverified(pid);
}
#[cfg(unix)]
let _ = signal(pid, nix::sys::signal::Signal::SIGTERM);
if wait_for_exit(pid, GRACE).await {
return KillOutcome::TermExited;
}
let live_exe = exe_path_of_pid(pid);
if !exe_matches(recorded_exe, live_exe.as_deref()) {
return KillOutcome::Unverified(pid);
}
#[cfg(unix)]
let _ = signal(pid, nix::sys::signal::Signal::SIGKILL);
if wait_for_exit(pid, GRACE).await {
KillOutcome::KilledExited
} else {
KillOutcome::StillAlive
}
}
pub(crate) async fn wait_for_exit(pid: u32, budget: Duration) -> bool {
let deadline = Instant::now()
.checked_add(budget)
.unwrap_or_else(Instant::now);
loop {
if !is_process_alive(pid) {
return true;
}
if Instant::now() >= deadline {
return false;
}
tokio::time::sleep(POLL).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pid_accepts_plain_integer() {
assert_eq!(parse_pid("12345"), Some(12345));
}
#[test]
fn parse_pid_tolerates_trailing_newline_and_whitespace() {
assert_eq!(parse_pid("12345\n"), Some(12345));
assert_eq!(parse_pid(" 678 "), Some(678));
}
#[test]
fn parse_pid_rejects_zero() {
assert_eq!(parse_pid("0"), None);
}
#[test]
fn parse_pid_rejects_garbage() {
assert_eq!(parse_pid(""), None);
assert_eq!(parse_pid("not-a-pid"), None);
assert_eq!(parse_pid("-1"), None);
assert_eq!(parse_pid("12.5"), None);
}
#[test]
fn read_pid_file_missing_is_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.pid");
assert_eq!(read_pid_file(&path), None);
}
#[test]
fn read_pid_file_round_trips_pid_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.pid");
std::fs::write(&path, "424242").unwrap();
assert_eq!(read_pid_file(&path), Some((424_242, None)));
}
#[test]
fn parse_pid_file_two_line_keeps_exe() {
let (pid, exe) = parse_pid_file("424242\n/opt/astrid/astrid-daemon\n").unwrap();
assert_eq!(pid, 424_242);
assert_eq!(exe, Some(PathBuf::from("/opt/astrid/astrid-daemon")));
}
#[test]
fn parse_pid_file_one_line_has_no_exe() {
assert_eq!(parse_pid_file("424242\n"), Some((424_242, None)));
assert_eq!(parse_pid_file("424242\n \n"), Some((424_242, None)));
}
#[test]
fn parse_pid_file_rejects_garbage_first_line() {
assert_eq!(parse_pid_file("nope\n/path"), None);
assert_eq!(parse_pid_file(""), None);
}
#[test]
fn exe_matches_only_when_both_present_and_equal() {
let a = PathBuf::from("/opt/astrid/astrid-daemon");
let b = PathBuf::from("/usr/bin/something-else");
assert!(exe_matches(Some(&a), Some(&a)));
assert!(!exe_matches(Some(&a), Some(&b)));
assert!(!exe_matches(None, Some(&a))); assert!(!exe_matches(Some(&a), None)); assert!(!exe_matches(None, None));
}
#[test]
fn exe_matches_survives_deleted_binary() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("astrid-daemon");
assert!(!gone.exists(), "test precondition: path must not exist");
assert!(exe_matches(Some(&gone), Some(&gone)));
let other = dir.path().join("other-daemon");
assert!(!exe_matches(Some(&gone), Some(&other)));
}
#[cfg(unix)]
#[test]
fn exe_matches_canonicalizes_live_when_file_exists() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("astrid-daemon");
std::fs::write(&real, b"#!/bin/true\n").unwrap();
let recorded = std::fs::canonicalize(&real).unwrap();
let link = dir.path().join("astrid-daemon-link");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert_ne!(link, recorded);
assert!(exe_matches(Some(&recorded), Some(&link)));
let other = dir.path().join("unrelated");
std::fs::write(&other, b"x").unwrap();
assert!(!exe_matches(Some(&recorded), Some(&other)));
}
#[cfg(target_os = "linux")]
#[test]
fn strip_deleted_marker_removes_only_the_suffix() {
assert_eq!(
strip_deleted_marker(PathBuf::from("/opt/astrid/astrid-daemon (deleted)")),
PathBuf::from("/opt/astrid/astrid-daemon")
);
assert_eq!(
strip_deleted_marker(PathBuf::from("/opt/astrid/astrid-daemon")),
PathBuf::from("/opt/astrid/astrid-daemon")
);
assert_eq!(
strip_deleted_marker(PathBuf::from("/opt/deleted/astrid-daemon")),
PathBuf::from("/opt/deleted/astrid-daemon")
);
}
#[tokio::test]
async fn terminate_orphan_refuses_mismatched_exe_for_live_pid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.pid");
let me = std::process::id();
std::fs::write(
&path,
format!("{me}\n/nonexistent/definitely-not-astrid-daemon"),
)
.unwrap();
assert_eq!(
terminate_orphan(&path).await,
KillOutcome::Unverified(me),
"a live PID with a mismatched exe must not be signalled"
);
assert!(is_process_alive(me));
}
#[tokio::test]
async fn terminate_orphan_refuses_live_pid_without_recorded_exe() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.pid");
let me = std::process::id();
std::fs::write(&path, format!("{me}")).unwrap();
assert_eq!(terminate_orphan(&path).await, KillOutcome::Unverified(me));
assert!(is_process_alive(me));
}
#[test]
fn read_pid_file_garbage_is_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.pid");
std::fs::write(&path, "garbage\n").unwrap();
assert_eq!(read_pid_file(&path), None);
}
#[cfg(unix)]
#[test]
fn current_process_is_alive() {
assert!(is_process_alive(std::process::id()));
}
#[cfg(unix)]
#[test]
fn nonexistent_pid_is_dead() {
assert!(!is_process_alive(0)); assert!(!is_process_alive(2_000_000_000));
}
#[tokio::test]
async fn terminate_orphan_missing_pidfile_is_notrunning() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.pid");
assert_eq!(terminate_orphan(&path).await, KillOutcome::NotRunning);
}
#[tokio::test]
async fn terminate_orphan_dead_pid_is_notrunning() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.pid");
std::fs::write(&path, "2000000000").unwrap();
assert_eq!(terminate_orphan(&path).await, KillOutcome::NotRunning);
}
#[test]
fn escalation_identity_gate_refuses_on_mismatch() {
let recorded = PathBuf::from("/opt/astrid/astrid-daemon");
let recycled = PathBuf::from("/usr/bin/unrelated-process");
assert!(
!exe_matches(Some(&recorded), Some(&recycled)),
"escalation must refuse to SIGKILL when the live exe no longer matches"
);
assert!(exe_matches(Some(&recorded), Some(&recorded)));
}
}