use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunningDaemon {
pub pid: u32,
pub version: String,
pub endpoint: String,
}
#[must_use]
pub fn pidfile_path(state_dir: &Path) -> PathBuf {
state_dir.join("terminal-commanderd.pid")
}
pub fn write_pidfile(state_dir: &Path, rec: &RunningDaemon) -> std::io::Result<()> {
std::fs::create_dir_all(state_dir)?;
let path = pidfile_path(state_dir);
let tmp = path.with_extension(format!("pid.tmp-{}", std::process::id()));
let bytes = serde_json::to_vec_pretty(rec).map_err(std::io::Error::other)?;
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &path)
}
pub fn remove_pidfile(state_dir: &Path) {
let _ = std::fs::remove_file(pidfile_path(state_dir));
}
#[must_use]
pub fn read_pidfile(state_dir: &Path) -> Option<RunningDaemon> {
let bytes = std::fs::read(pidfile_path(state_dir)).ok()?;
let rec: RunningDaemon = serde_json::from_slice(&bytes).ok()?;
if pid_alive(rec.pid) { Some(rec) } else { None }
}
#[must_use]
pub fn read_pidfile_raw(state_dir: &Path) -> Option<RunningDaemon> {
let bytes = std::fs::read(pidfile_path(state_dir)).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[must_use]
pub fn pid_alive(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(all(unix, not(target_os = "linux")))]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(windows)]
{
std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.output()
.map(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
csv_row_has_exact_pid(&stdout, pid)
})
.unwrap_or(false)
}
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn proc_cmdline(pid: u32) -> Option<String> {
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
join_proc_cmdline(&raw)
}
#[cfg(target_os = "linux")]
fn join_proc_cmdline(raw: &[u8]) -> Option<String> {
let joined = raw
.split(|&b| b == 0)
.filter(|seg| !seg.is_empty())
.map(|seg| String::from_utf8_lossy(seg).into_owned())
.collect::<Vec<String>>()
.join(" ");
if joined.is_empty() {
None
} else {
Some(joined)
}
}
#[cfg(windows)]
fn csv_row_has_exact_pid(stdout: &str, pid: u32) -> bool {
let want = pid.to_string();
stdout.lines().any(|line| {
line.split(',')
.nth(1)
.map(|f| f.trim().trim_matches('"') == want)
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn csv_pid_match_is_exact_not_substring() {
let row = "\"terminal-commanderd.exe\",\"1234\",\"Console\",\"1\",\"12,345 K\"";
assert!(csv_row_has_exact_pid(row, 1234), "exact pid must match");
assert!(
!csv_row_has_exact_pid(row, 123),
"substring of the pid/mem column must NOT match"
);
assert!(
!csv_row_has_exact_pid(row, 12),
"digits in the mem-usage column must NOT match"
);
assert!(!csv_row_has_exact_pid("INFO: No tasks are running.", 1234));
}
#[test]
fn write_read_roundtrip() {
let dir = std::env::temp_dir().join(format!("tc-pidfile-{}", std::process::id()));
let rec = RunningDaemon {
pid: std::process::id(),
version: "0.1.14".into(),
endpoint: "/tmp/x.sock".into(),
};
write_pidfile(&dir, &rec).unwrap();
let got = read_pidfile(&dir).unwrap();
assert_eq!(got, rec);
remove_pidfile(&dir);
assert!(read_pidfile(&dir).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn read_pidfile_raw_returns_dead_pid_contents() {
let dir = std::env::temp_dir().join(format!("tc-raw-{}", std::process::id()));
let rec = RunningDaemon {
pid: 999_999_999,
version: "0.1.0".into(),
endpoint: "x".into(),
};
write_pidfile(&dir, &rec).unwrap();
assert!(
read_pidfile(&dir).is_none(),
"read_pidfile still hides dead pids"
);
assert_eq!(
read_pidfile_raw(&dir),
Some(rec),
"raw must return contents regardless of liveness"
);
remove_pidfile(&dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dead_pid_reads_as_absent() {
let dir = std::env::temp_dir().join(format!("tc-pidfile-dead-{}", std::process::id()));
let rec = RunningDaemon {
pid: 999_999_999,
version: "0.1.0".into(),
endpoint: "x".into(),
};
write_pidfile(&dir, &rec).unwrap();
assert!(
read_pidfile(&dir).is_none(),
"a pidfile with a dead pid must read as absent"
);
remove_pidfile(&dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn pid_alive_true_for_self_false_for_absent() {
assert!(
pid_alive(std::process::id()),
"the running test process must read as alive"
);
assert!(
!pid_alive(0xFFFF_FFF0),
"an absent high pid must read as dead (no /proc entry)"
);
}
#[cfg(target_os = "linux")]
#[test]
fn proc_cmdline_reads_self_and_handles_absent() {
let mine = proc_cmdline(std::process::id());
assert!(mine.is_some(), "self cmdline must be readable on Linux");
assert!(!mine.unwrap().is_empty(), "self cmdline must be non-empty");
assert!(
proc_cmdline(0xFFFF_FFF0).is_none(),
"an absent pid yields no cmdline"
);
}
#[cfg(target_os = "linux")]
#[test]
fn join_proc_cmdline_parses_nul_separated_argv() {
let raw = b"terminal-commanderd\x00--data-dir\x00/tmp/tc (run)+[v1]\x00";
assert_eq!(
join_proc_cmdline(raw).as_deref(),
Some("terminal-commanderd --data-dir /tmp/tc (run)+[v1]")
);
assert_eq!(join_proc_cmdline(b""), None);
assert_eq!(join_proc_cmdline(b"\x00\x00"), None);
assert_eq!(join_proc_cmdline(b"solo").as_deref(), Some("solo"));
}
}