use std::io::{ErrorKind, Read, Write};
use std::path::Path;
use octl_core::{RunLock, RunPaths};
use crate::error::CliError;
fn reject_pid_symlink(path: &Path) -> Result<(), CliError> {
match std::fs::symlink_metadata(path) {
Ok(md) if md.file_type().is_symlink() => Err(CliError::system(
"pid_file_symlink_rejected",
format!(
"supervisor pid file {} is a symlink (refusing to follow it)",
path.display()
),
)),
Ok(_) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(CliError::system(
"io_error",
format!("stat {}: {}", path.display(), e),
)),
}
}
fn read_pid_string(path: &Path) -> Option<String> {
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
octl_core::nofollow(&mut opts);
let mut f = opts.open(path).ok()?;
let mut s = String::new();
f.read_to_string(&mut s).ok()?;
Some(s)
}
pub fn write_pid(path: &Path, pid: u32) -> Result<(), CliError> {
let parent = path.parent().ok_or_else(|| {
CliError::system(
"io_error",
format!("pid path {} has no parent", path.display()),
)
})?;
std::fs::create_dir_all(parent)
.map_err(|e| CliError::system("io_error", format!("mkdir {}: {}", parent.display(), e)))?;
reject_pid_symlink(path)?;
let contents = if let Some(st) = crate::supervise::watchdog::pid_start_time(pid) {
format!("{pid} {st}")
} else {
tracing::warn!(
target: "orchestratectl::supervise",
pid,
"could not read own start_time; writing legacy pid file (no recycle defense)"
);
pid.to_string()
};
let tmp = parent.join(format!(".supervisor.pid.tmp.{}", std::process::id()));
write_tmp_exclusive(&tmp, contents.as_bytes())?;
std::fs::rename(&tmp, path)
.map_err(|e| CliError::system("io_error", format!("rename {}: {}", path.display(), e)))?;
Ok(())
}
fn write_tmp_exclusive(tmp: &Path, bytes: &[u8]) -> Result<(), CliError> {
let open = || {
let mut opts = std::fs::OpenOptions::new();
opts.create_new(true).write(true);
octl_core::nofollow(&mut opts);
opts.open(tmp)
};
let mut f = match open() {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
std::fs::remove_file(tmp).map_err(|e| {
CliError::system("io_error", format!("rm stale {}: {}", tmp.display(), e))
})?;
open().map_err(|e| {
CliError::system("io_error", format!("write {}: {}", tmp.display(), e))
})?
}
Err(e) => {
return Err(CliError::system(
"io_error",
format!("write {}: {}", tmp.display(), e),
))
}
};
f.write_all(bytes)
.map_err(|e| CliError::system("io_error", format!("write {}: {}", tmp.display(), e)))?;
Ok(())
}
pub fn claim_pid_atomic(paths: &RunPaths, our_pid: u32) -> Result<(), CliError> {
let _guard = RunLock::acquire(&paths.lock())
.map_err(|e| CliError::system("lock_error", format!("acquire run lock: {e}")))?;
let pid_path = paths.supervisor_pid();
if let Some((existing, start_time)) = read_pid_record(&pid_path) {
if pid_live_with_identity(existing, start_time) {
let hint = if start_time.is_none() {
"; this is a legacy pid file with no identity, so a recycled \
pid cannot be ruled out — remove supervisor.pid if it is stale"
} else {
" (kill it or use `run reattach`)"
};
return Err(CliError::system(
"supervisor_already_running",
format!(
"supervisor pid {existing} for run {} is alive{hint}",
paths.run_id.as_str(),
),
));
}
tracing::warn!(
target: "orchestratectl::supervise",
stale_pid = existing,
"claiming run: removing stale supervisor.pid"
);
}
write_pid(&pid_path, our_pid)
}
pub(crate) fn to_pid_t(pid: u32) -> Option<libc::pid_t> {
if pid == 0 || pid > libc::pid_t::MAX as u32 {
return None;
}
Some(pid as libc::pid_t)
}
pub fn read_pid(path: &Path) -> Option<u32> {
let s = read_pid_string(path)?;
let pid = s.split_whitespace().next()?.parse::<u32>().ok()?;
to_pid_t(pid).map(|_| pid)
}
pub fn read_pid_record(path: &Path) -> Option<(u32, Option<u64>)> {
let s = read_pid_string(path)?;
let mut it = s.split_whitespace();
let pid = it.next()?.parse::<u32>().ok()?;
to_pid_t(pid)?;
let start_time = it.next().and_then(|t| t.parse::<u64>().ok());
Some((pid, start_time))
}
pub fn pid_live_with_identity(pid: u32, recorded_start_time: Option<u64>) -> bool {
if !pid_alive(pid) {
return false;
}
match recorded_start_time {
Some(recorded) => match crate::supervise::watchdog::pid_start_time(pid) {
Some(actual) => recorded.abs_diff(actual) <= 1,
None => true,
},
None => true,
}
}
pub fn remove_if_owner(path: &Path, expected_pid: u32) {
if read_pid(path) == Some(expected_pid) {
let _ = std::fs::remove_file(path);
}
}
pub fn pid_alive(pid: u32) -> bool {
let Some(pid_t) = to_pid_t(pid) else {
return false;
};
let rc = unsafe { libc::kill(pid_t, 0) };
if rc == 0 {
return true;
}
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
errno == libc::EPERM
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn round_trip_write_read() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("supervisor.pid");
write_pid(&p, 12345).unwrap();
assert_eq!(read_pid(&p), Some(12345));
}
#[test]
fn missing_file_reads_none() {
let dir = TempDir::new().unwrap();
assert_eq!(read_pid(&dir.path().join("missing")), None);
}
#[test]
fn remove_if_owner_matches() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("supervisor.pid");
write_pid(&p, 999).unwrap();
remove_if_owner(&p, 1234); assert!(p.exists());
remove_if_owner(&p, 999);
assert!(!p.exists());
}
#[test]
fn own_pid_is_alive() {
assert!(pid_alive(std::process::id()));
}
#[test]
fn pid_zero_is_dead() {
assert!(!pid_alive(0));
}
#[test]
fn out_of_range_pid_is_dead_and_unreadable() {
assert!(!pid_alive(u32::MAX), "u32::MAX would cast to -1");
assert!(!pid_alive((i32::MAX as u32) + 1));
assert!(to_pid_t(u32::MAX).is_none());
assert!(to_pid_t(0).is_none());
assert!(to_pid_t(i32::MAX as u32).is_some());
let dir = TempDir::new().unwrap();
let p = dir.path().join("supervisor.pid");
std::fs::write(&p, "4294967295").unwrap();
assert_eq!(read_pid(&p), None, "out-of-range pid must read as absent");
assert_eq!(read_pid_record(&p), None);
}
#[cfg(unix)]
#[test]
fn write_pid_rejects_a_symlinked_pid_file() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let target = dir.path().join("outside.pid");
let p = dir.path().join("supervisor.pid");
symlink(&target, &p).unwrap();
let err = write_pid(&p, 4321).expect_err("must refuse a symlinked pid file");
assert_eq!(err.code, "pid_file_symlink_rejected");
assert!(!target.exists(), "write must not follow the symlink");
}
#[cfg(unix)]
#[test]
fn read_pid_does_not_follow_a_symlink() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let target = dir.path().join("outside.pid");
std::fs::write(&target, "4321 100").unwrap();
let p = dir.path().join("supervisor.pid");
symlink(&target, &p).unwrap();
assert_eq!(read_pid(&p), None, "symlinked pid file must read as absent");
assert_eq!(read_pid_record(&p), None);
}
#[test]
fn concurrent_claim_exactly_one_wins() {
use std::sync::{Arc, Barrier};
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("01jxsnap000000000000000000");
std::fs::create_dir_all(&run_dir).unwrap();
let run_id = "01jxsnap000000000000000000";
let our_pid = std::process::id();
const N: usize = 8;
let barrier = Arc::new(Barrier::new(N));
let handles: Vec<_> = (0..N)
.map(|_| {
let rd = run_dir.clone();
let b = Arc::clone(&barrier);
std::thread::spawn(move || {
let paths = RunPaths::new(rd, run_id).unwrap();
b.wait();
claim_pid_atomic(&paths, our_pid)
})
})
.collect();
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let wins = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(wins, 1, "exactly one concurrent claim must win, got {wins}");
for r in &results {
if let Err(e) = r {
assert_eq!(
e.code, "supervisor_already_running",
"loser must report supervisor_already_running, got {}",
e.code
);
}
}
let p = run_dir.join("supervisor.pid");
assert_eq!(read_pid(&p), Some(our_pid));
}
#[test]
fn claim_migrates_legacy_plain_integer_pid_file() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("01jxsnap000000000000000000");
std::fs::create_dir_all(&run_dir).unwrap();
let paths = RunPaths::new(run_dir.clone(), "01jxsnap000000000000000000").unwrap();
let pid_path = run_dir.join("supervisor.pid");
std::fs::write(&pid_path, "2147483646").unwrap();
assert_eq!(read_pid_record(&pid_path), Some((2_147_483_646, None)));
let our_pid = std::process::id();
claim_pid_atomic(&paths, our_pid).expect("claim over a dead legacy pid succeeds");
let (pid, start) = read_pid_record(&pid_path).unwrap();
assert_eq!(pid, our_pid);
assert!(
start.is_some(),
"claim must upgrade the legacy file to carry a start-time"
);
}
}