ref:main
//! Runner PID file: enables single-instance enforcement on `runner start`
//! and out-of-band stop via `runner stop`.
//!
//! Layout: `~/.anvil-runner/runner.pid` (overridable). The file contains
//! the runner process's PID as a single decimal line. `Guard` writes it on
//! acquisition and removes it on `Drop` so a clean exit leaves no trace.
//!
//! Stale detection: if the PID file exists but names a process that no
//! longer exists (or isn't an anvil binary on the platforms where we can
//! tell), we treat it as stale, warn, and overwrite.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
/// Default location: `~/.anvil-runner/runner.pid`.
pub fn default_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".anvil-runner")
.join("runner.pid")
}
/// Per-instance default. `default` keeps the legacy `runner.pid` so
/// single-instance hosts don't move; named instances get
/// `runner-<name>.pid` so multiple installs coexist.
pub fn instance_path(instance: &str) -> PathBuf {
let dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".anvil-runner");
if instance == "default" {
dir.join("runner.pid")
} else {
dir.join(format!("runner-{instance}.pid"))
}
}
/// Result of inspecting an existing PID file.
#[derive(Debug)]
pub enum Existing {
/// File is absent.
None,
/// File names a live process with this PID.
Live(i32),
/// File exists but the PID is dead (or unreadable). Caller may overwrite.
Stale(i32),
}
pub fn inspect(path: &Path) -> Existing {
let Ok(contents) = fs::read_to_string(path) else {
return Existing::None;
};
let Ok(pid) = contents.trim().parse::<i32>() else {
// Unparseable junk — treat as stale so a re-start can overwrite.
return Existing::Stale(0);
};
if pid_is_alive(pid) {
Existing::Live(pid)
} else {
Existing::Stale(pid)
}
}
#[cfg(unix)]
fn pid_is_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
// kill(pid, 0) tests existence without sending a signal: returns 0 if
// the process exists (regardless of whether we have permission to
// signal it), -1/ESRCH if it's gone, -1/EPERM if it exists but we're
// not allowed to signal — for our purposes the last counts as "alive".
let rc = unsafe { libc::kill(pid, 0) };
if rc == 0 {
return true;
}
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
errno == libc::EPERM
}
#[cfg(not(unix))]
fn pid_is_alive(_pid: i32) -> bool {
// Conservative on non-Unix: if a file is here, assume alive. Worst
// case is the user sees a "refused, looks like it's already running"
// and runs `anvil runner stop --force`.
true
}
/// RAII handle for the PID file. The file is written on `acquire` and
/// removed on `Drop`. If the process is killed (SIGKILL, panic in another
/// thread), the file is left on disk and treated as stale on the next
/// `inspect`.
pub struct Guard {
path: PathBuf,
}
impl Guard {
/// Write `pid` to `path`. Uses an atomic create-via-rename:
/// 1. Write to `<path>.tmp.<pid>` with O_EXCL semantics
/// 2. fsync
/// 3. rename onto `<path>` (atomic on POSIX filesystems)
///
/// Callers are responsible for inspecting the existing file first to
/// decide whether to proceed; this method overwrites unconditionally
/// because at this point inspect() has already classified the file
/// as None / Stale / live-with-different-PID.
pub fn acquire(path: PathBuf, pid: i32) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
// Per-process temp filename means concurrent acquires don't race
// on the temp name. The atomic rename at the end resolves which
// PID wins the file.
let tmp = path.with_extension(format!("tmp.{pid}"));
{
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp)?;
writeln!(file, "{pid}")?;
file.sync_all()?;
}
fs::rename(&tmp, &path)?;
Ok(Guard { path })
}
#[allow(dead_code)]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for Guard {
fn drop(&mut self) {
// Only remove the file if it still holds our PID. Without this
// check, a clobbering second writer that re-acquires the same
// path under us would have its file deleted when we Drop. The
// read-and-compare is racy in the strict sense but bounds the
// damage to the single window where someone else has rewritten
// the file with their own PID; in that case dropping should be
// a no-op anyway since we no longer "own" the file.
let my_pid = std::process::id().to_string();
if let Ok(contents) = fs::read_to_string(&self.path) {
if contents.trim() == my_pid {
let _ = fs::remove_file(&self.path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_pid_path(tag: &str) -> PathBuf {
// Per-test path so tests running in parallel don't race on the
// same file.
let dir = std::env::temp_dir().join(format!(
"anvil-runner-pid-test-{}-{tag}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir.join("runner.pid")
}
#[test]
fn inspect_absent_returns_none() {
let path = temp_pid_path("absent");
let _ = std::fs::remove_file(&path);
assert!(matches!(inspect(&path), Existing::None));
}
#[test]
fn inspect_garbage_returns_stale() {
let path = temp_pid_path("garbage");
std::fs::write(&path, "not-a-pid\n").unwrap();
assert!(matches!(inspect(&path), Existing::Stale(_)));
let _ = std::fs::remove_file(&path);
}
#[test]
fn inspect_dead_pid_returns_stale() {
let path = temp_pid_path("dead");
// PID 1 always exists on Unix — use a definitely-dead PID instead.
// 0x7FFFFFFF is the maximum signed-32-bit value; no system reaches it.
std::fs::write(&path, "2147483647\n").unwrap();
assert!(matches!(inspect(&path), Existing::Stale(_)));
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn inspect_live_pid_returns_live() {
let path = temp_pid_path("live");
let my_pid = std::process::id() as i32;
std::fs::write(&path, format!("{my_pid}\n")).unwrap();
assert!(matches!(inspect(&path), Existing::Live(p) if p == my_pid));
let _ = std::fs::remove_file(&path);
}
#[test]
fn guard_writes_pid_and_removes_on_drop_when_pid_matches() {
let path = temp_pid_path("guard");
let _ = std::fs::remove_file(&path);
let my_pid = std::process::id() as i32;
{
let guard = Guard::acquire(path.clone(), my_pid).unwrap();
assert!(path.exists());
let contents = std::fs::read_to_string(guard.path()).unwrap();
assert_eq!(contents.trim(), my_pid.to_string());
}
assert!(
!path.exists(),
"guard should remove file on drop when it still holds our PID"
);
}
#[test]
fn guard_leaves_file_alone_on_drop_when_pid_was_clobbered() {
let path = temp_pid_path("clobber");
let _ = std::fs::remove_file(&path);
let my_pid = std::process::id() as i32;
{
let _guard = Guard::acquire(path.clone(), my_pid).unwrap();
// Simulate another process replacing the file under us.
std::fs::write(&path, "99999\n").unwrap();
}
assert!(
path.exists(),
"guard should NOT remove a file containing someone else's PID"
);
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "99999");
let _ = std::fs::remove_file(&path);
}
}