use std::path::{Path, PathBuf};
fn run_dir_in(home: &Path) -> PathBuf {
home.join("run")
}
fn path_for_in(home: &Path, service: &str) -> PathBuf {
run_dir_in(home).join(format!("{service}.pid"))
}
pub fn path_for(service: &str) -> PathBuf {
path_for_in(&crate::identity::home(), service)
}
fn argv_of(pid: u32) -> Option<String> {
let proc_path = PathBuf::from(format!("/proc/{pid}/cmdline"));
if let Ok(raw) = std::fs::read(&proc_path) {
return Some(
raw.split(|b| *b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect::<Vec<_>>()
.join(" "),
);
}
let out = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
fn argv_is_service(argv: &str, service: &str) -> bool {
let mut parts = argv.split_whitespace();
let Some(exe) = parts.next() else {
return false;
};
let base = exe.rsplit('/').next().unwrap_or(exe);
if !(base.starts_with("gage") || base.starts_with("ctx") || base.starts_with("countersign")) {
return false;
}
parts.next() == Some(service)
}
pub fn running(service: &str) -> bool {
running_in(&crate::identity::home(), service)
}
fn running_in(home: &Path, service: &str) -> bool {
let Ok(text) = std::fs::read_to_string(path_for_in(home, service)) else {
return false;
};
let Ok(pid) = text.trim().parse::<u32>() else {
return false;
};
if pid == 0 {
return false;
}
if pid == std::process::id() {
return false;
}
match argv_of(pid) {
Some(argv) => argv_is_service(&argv, service),
None => false,
}
}
pub struct RunFile {
path: PathBuf,
}
impl RunFile {
pub fn claim(service: &str) -> Option<RunFile> {
Self::claim_in(&crate::identity::home(), service)
}
fn claim_in(home: &Path, service: &str) -> Option<RunFile> {
if running_in(home, service) {
return None;
}
let dir = run_dir_in(home);
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join(format!("{service}.pid"));
std::fs::write(&path, format!("{}\n", std::process::id())).ok()?;
Some(RunFile { path })
}
}
impl Drop for RunFile {
fn drop(&mut self) {
let mine = std::fs::read_to_string(&self.path)
.ok()
.and_then(|t| t.trim().parse::<u32>().ok())
== Some(std::process::id());
if mine {
let _ = std::fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_home(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("gage-runfile-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_record_holding_our_own_pid_is_not_something_else_running() {
let home = fixture_home("own-pid");
std::fs::create_dir_all(home.join("run")).unwrap();
std::fs::write(
home.join("run").join("ui.pid"),
format!("{}\n", std::process::id()),
)
.unwrap();
assert!(
!running_in(&home, "ui"),
"a process reported itself as the one already serving"
);
}
#[test]
fn a_claim_is_written_and_removed_again() {
let home = fixture_home("claim");
let p = home.join("run/lan.pid");
{
let _claim = RunFile::claim_in(&home, "lan").expect("the home is writable");
assert!(p.exists(), "the record was not written");
assert_eq!(
std::fs::read_to_string(&p).unwrap().trim(),
std::process::id().to_string()
);
}
assert!(!p.exists(), "dropping the claim left the record behind");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn a_record_for_a_dead_process_is_not_running() {
let home = fixture_home("dead");
std::fs::create_dir_all(home.join("run")).unwrap();
let mut child = std::process::Command::new("true").spawn().unwrap();
let pid = child.id();
child.wait().unwrap();
std::fs::write(home.join("run/lan.pid"), format!("{pid}\n")).unwrap();
assert!(
!running_in(&home, "lan"),
"a dead pid was reported as a running service"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn a_live_pid_that_is_not_the_service_is_not_running() {
let home = fixture_home("reuse");
std::fs::create_dir_all(home.join("run")).unwrap();
std::fs::write(home.join("run/ui.pid"), format!("{}\n", std::process::id())).unwrap();
assert!(
!running_in(&home, "ui"),
"a live pid belonging to something else was reported as the service"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn no_record_means_not_running() {
let home = fixture_home("absent");
assert!(!running_in(&home, "watch"));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn a_malformed_record_is_not_running() {
let home = fixture_home("junk");
std::fs::create_dir_all(home.join("run")).unwrap();
for junk in ["", " ", "not-a-pid", "0", "-1", "99999999999999999999"] {
std::fs::write(home.join("run/lan.pid"), junk).unwrap();
assert!(
!running_in(&home, "lan"),
"a record of {junk:?} was read as running"
);
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn one_services_record_says_nothing_about_another() {
let home = fixture_home("perservice");
let _claim = RunFile::claim_in(&home, "lan").unwrap();
assert!(!home.join("run/ui.pid").exists());
assert!(!running_in(&home, "ui"), "lan's record answered for ui");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn a_record_in_another_home_is_invisible() {
let mine = fixture_home("mine");
let theirs = fixture_home("theirs");
let _claim = RunFile::claim_in(&theirs, "lan").unwrap();
assert!(
!running_in(&mine, "lan"),
"another home's running service was reported as mine"
);
let _ = std::fs::remove_dir_all(&mine);
let _ = std::fs::remove_dir_all(&theirs);
}
#[test]
fn argv_matching_is_about_the_subcommand_not_a_substring() {
assert!(argv_is_service(
"/usr/local/bin/gage lan --port 7791",
"lan"
));
assert!(argv_is_service("gage lan", "lan"));
assert!(argv_is_service("/home/o/.local/bin/ctx lan", "lan"));
assert!(argv_is_service("countersign watch --every 120", "watch"));
assert!(!argv_is_service("gage ui --port 7777", "lan"));
assert!(!argv_is_service("gage send --file lan", "lan"));
assert!(!argv_is_service("gage open /tmp/lan", "lan"));
assert!(!argv_is_service("vim src/lan.rs", "lan"));
assert!(!argv_is_service("grep lan src/main.rs", "lan"));
assert!(!argv_is_service("/usr/bin/other lan", "lan"));
assert!(!argv_is_service("", "lan"));
}
}