use anyhow::{Context, Result, anyhow};
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub const RCH_FLEET_WAIT_SECS_ENV: &str = "RCH_FLEET_WAIT_SECS";
const LOCK_FILENAME: &str = "fleet_op.lock";
static LOCK_TOKEN_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub struct FleetLockGuard {
path: PathBuf,
body: String,
}
impl Drop for FleetLockGuard {
fn drop(&mut self) {
if let Err(err) = remove_lock_if_unchanged(&self.path, &self.body) {
tracing::warn!(
path = %self.path.display(),
error = %err,
"failed to remove fleet lock on drop"
);
}
}
}
#[derive(Debug, Clone)]
pub struct LockHolder {
pub pid: u32,
pub operation: String,
pub started_at_unix: u64,
body: String,
}
impl LockHolder {
pub fn describe(&self) -> String {
let age = unix_now().saturating_sub(self.started_at_unix);
format!("pid={} op={} age={}s", self.pid, self.operation, age)
}
}
pub fn acquire(operation: &str) -> Result<FleetLockGuard> {
let dir = lock_dir()?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create fleet lock directory {:?}", dir))?;
let path = dir.join(LOCK_FILENAME);
let wait_total = read_wait_secs();
let deadline = Instant::now() + wait_total;
let mut attempted_stale_sweep = false;
loop {
match try_create_lock(&path, operation) {
Ok(body) => return Ok(FleetLockGuard { path, body }),
Err(LockError::AlreadyHeld(holder)) => {
if !attempted_stale_sweep
&& !pid_is_alive(holder.pid)
&& try_sweep_stale(&path, &holder)?
{
attempted_stale_sweep = true;
continue;
}
if Instant::now() >= deadline {
return Err(anyhow!(
"Another fleet operation is in progress ({}). \
Lock file: {}. Wait for it to finish or set \
{}=<seconds> to block for longer.",
holder.describe(),
path.display(),
RCH_FLEET_WAIT_SECS_ENV
));
}
std::thread::sleep(Duration::from_millis(500));
}
Err(LockError::Io(err)) => {
return Err(anyhow!(err).context(format!(
"failed to acquire fleet lock at {}",
path.display()
)));
}
}
}
}
#[derive(Debug)]
enum LockError {
AlreadyHeld(LockHolder),
Io(std::io::Error),
}
fn try_create_lock(path: &Path, operation: &str) -> Result<String, LockError> {
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut file) => {
let payload = render_lock_body(operation);
file.write_all(payload.as_bytes()).map_err(LockError::Io)?;
Ok(payload)
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
match read_holder(path) {
Ok(holder) => Err(LockError::AlreadyHeld(holder)),
Err(_) => {
Err(LockError::AlreadyHeld(LockHolder {
pid: 0,
operation: "unknown".to_string(),
started_at_unix: 0,
body: String::new(),
}))
}
}
}
Err(err) => Err(LockError::Io(err)),
}
}
fn try_sweep_stale(path: &Path, holder: &LockHolder) -> Result<bool> {
match std::fs::read_to_string(path) {
Ok(contents) => {
if contents == holder.body {
match parse_holder(&contents) {
Some(now) if now.pid == holder.pid => {
std::fs::remove_file(path).with_context(|| {
format!("failed to remove stale fleet lock {:?}", path)
})?;
tracing::warn!(
stale_pid = holder.pid,
lock = %path.display(),
"removed stale fleet lock owned by non-running pid"
);
Ok(true)
}
_ => Ok(false),
}
} else {
Ok(false)
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(err) => Err(err).with_context(|| format!("failed to read fleet lock {:?}", path)),
}
}
fn remove_lock_if_unchanged(path: &Path, expected_body: &str) -> std::io::Result<()> {
match std::fs::read_to_string(path) {
Ok(current) if current == expected_body => {
std::fs::remove_file(path)?;
Ok(())
}
Ok(_) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn render_lock_body(operation: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO);
let sequence = LOCK_TOKEN_SEQUENCE.fetch_add(1, Ordering::Relaxed);
format!(
"{} {} {} {:x}-{:x}\n",
std::process::id(),
sanitize_operation(operation),
now.as_secs(),
now.as_nanos(),
sequence
)
}
fn read_holder(path: &Path) -> Result<LockHolder> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
parse_holder(&contents).ok_or_else(|| anyhow!("malformed lock file: {:?}", contents))
}
fn parse_holder(contents: &str) -> Option<LockHolder> {
let line = contents.lines().next()?;
let mut parts = line.split_whitespace();
let pid: u32 = parts.next()?.parse().ok()?;
let operation = parts.next()?.to_string();
let started: u64 = parts.next().unwrap_or("0").parse().unwrap_or(0);
Some(LockHolder {
pid,
operation,
started_at_unix: started,
body: contents.to_string(),
})
}
fn lock_dir() -> Result<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") {
let xdg = PathBuf::from(xdg);
if !xdg.as_os_str().is_empty() {
return Ok(xdg.join("rch"));
}
}
let base = std::env::var_os("TMPDIR").unwrap_or_else(|| "/tmp".into());
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
Ok(PathBuf::from(base).join(format!("rch-{}", user)))
}
fn pid_is_alive(pid: u32) -> bool {
if pid == 0 {
return false;
}
if cfg!(target_os = "linux") {
Path::new(&format!("/proc/{}", pid)).exists()
} else {
true
}
}
fn sanitize_operation(op: &str) -> String {
op.chars()
.map(|c| if c.is_ascii_whitespace() { '_' } else { c })
.collect()
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn read_wait_secs() -> Duration {
std::env::var(RCH_FLEET_WAIT_SECS_ENV)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
fn with_temp_runtime<F: FnOnce(&Path)>(f: F) {
let tmp = tempfile::tempdir().expect("tempdir");
let lock_path = tmp.path().join("rch").join(LOCK_FILENAME);
std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
f(&lock_path);
}
#[test]
fn try_create_lock_succeeds_when_unheld() {
with_temp_runtime(|path| {
let body = try_create_lock(path, "fleet-deploy").expect("acquire");
assert!(path.exists());
assert_eq!(std::fs::read_to_string(path).unwrap(), body);
let holder = read_holder(path).expect("holder");
assert_eq!(holder.pid, std::process::id());
assert_eq!(holder.operation, "fleet-deploy");
});
}
#[test]
fn try_create_lock_fails_when_held() {
with_temp_runtime(|path| {
try_create_lock(path, "fleet-deploy").expect("first");
let result = try_create_lock(path, "fleet-deploy");
assert!(matches!(result, Err(LockError::AlreadyHeld(_))));
if let Err(LockError::AlreadyHeld(h)) = result {
assert_eq!(h.pid, std::process::id());
assert_eq!(h.operation, "fleet-deploy");
}
});
}
#[test]
fn stale_lock_is_swept_when_pid_is_dead() {
with_temp_runtime(|path| {
std::fs::write(path, "0 fleet-deploy 1700000000\n").unwrap();
let holder = read_holder(path).unwrap();
assert!(!pid_is_alive(holder.pid));
let swept = try_sweep_stale(path, &holder).unwrap();
assert!(swept);
assert!(!path.exists());
});
}
#[test]
fn guard_drop_preserves_replaced_lock_body() {
with_temp_runtime(|path| {
let body = try_create_lock(path, "fleet-deploy").expect("acquire");
let guard = FleetLockGuard {
path: path.to_path_buf(),
body,
};
let replacement = "0 fleet-drain 1700000000 replacement\n";
std::fs::write(path, replacement).unwrap();
drop(guard);
assert_eq!(std::fs::read_to_string(path).unwrap(), replacement);
});
}
#[test]
fn stale_sweep_preserves_changed_lock_body() {
with_temp_runtime(|path| {
std::fs::write(path, "0 fleet-deploy 1700000000 original\n").unwrap();
let holder = read_holder(path).unwrap();
std::fs::write(path, "0 fleet-drain 1700000000 replacement\n").unwrap();
let swept = try_sweep_stale(path, &holder).unwrap();
assert!(!swept);
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"0 fleet-drain 1700000000 replacement\n"
);
});
}
#[test]
fn render_lock_body_uses_unique_tokens() {
let body1 = render_lock_body("fleet-deploy");
let body2 = render_lock_body("fleet-deploy");
assert_ne!(body1, body2);
assert!(parse_holder(&body1).is_some());
assert!(parse_holder(&body2).is_some());
}
#[test]
fn parse_holder_roundtrips() {
let h = parse_holder("12345 fleet-deploy 1700000000 token\n").unwrap();
assert_eq!(h.pid, 12345);
assert_eq!(h.operation, "fleet-deploy");
assert_eq!(h.started_at_unix, 1700000000);
}
#[test]
fn parse_holder_accepts_legacy_three_field_locks() {
let h = parse_holder("12345 fleet-deploy 1700000000\n").unwrap();
assert_eq!(h.pid, 12345);
assert_eq!(h.operation, "fleet-deploy");
assert_eq!(h.started_at_unix, 1700000000);
}
#[test]
fn parse_holder_rejects_garbage() {
assert!(parse_holder("").is_none());
assert!(parse_holder("not-a-pid fleet-deploy 0").is_none());
}
#[test]
fn sanitize_operation_strips_whitespace() {
assert_eq!(sanitize_operation("fleet deploy"), "fleet_deploy");
assert_eq!(sanitize_operation("a\tb\nc"), "a_b_c");
}
}