use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use thiserror::Error;
const LOCK_FILE: &str = ".setup.lock";
const SENTINEL_FILE: &str = ".setup.lock.sentinel";
#[derive(Debug, Error)]
pub enum LockError {
#[error(
"Setup is already running (PID {pid} @ {host}, started {start_time}). Use --force to override."
)]
Held {
pid: u32,
start_time: String,
host: String,
},
#[error("Lock acquisition io error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug)]
pub struct SetupLock {
fd: File,
#[allow(dead_code)]
pub(super) lock_path: PathBuf,
pub(super) sentinel_path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
struct Sentinel {
pid: u32,
start_time_nanos: u128,
host: String,
atomcode_version: String,
}
fn lock_dir(project_root: &Path) -> PathBuf {
project_root.join(".atomcode")
}
fn current_pid() -> u32 {
std::process::id()
}
fn hostname() -> String {
sysinfo::System::host_name().unwrap_or_else(|| "unknown".to_string())
}
fn current_start_time_nanos() -> u128 {
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
let pid = Pid::from_u32(current_pid());
let mut sys = System::new();
sys.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), false, ProcessRefreshKind::new());
sys.process(pid)
.map(|p| (p.start_time() as u128) * 1_000_000_000)
.unwrap_or(0)
}
fn read_sentinel(path: &Path) -> Option<Sentinel> {
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
fn process_alive_at(pid: u32, start_time_nanos: u128) -> bool {
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
let target = Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_processes_specifics(ProcessesToUpdate::Some(&[target]), false, ProcessRefreshKind::new());
match sys.process(target) {
Some(p) => (p.start_time() as u128) * 1_000_000_000 == start_time_nanos,
None => false,
}
}
impl SetupLock {
pub fn acquire(project_root: &Path, force: bool) -> Result<Self, LockError> {
let dir = lock_dir(project_root);
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join(LOCK_FILE);
let sentinel_path = dir.join(SENTINEL_FILE);
let sentinel_owner: Option<Sentinel> = read_sentinel(&sentinel_path);
let owner_alive = sentinel_owner
.as_ref()
.is_some_and(|s| process_alive_at(s.pid, s.start_time_nanos));
if let Some(meta) = sentinel_owner.as_ref() {
if owner_alive && !force {
return Err(LockError::Held {
pid: meta.pid,
start_time: format!("{} ns", meta.start_time_nanos),
host: meta.host.clone(),
});
}
if !owner_alive {
let _ = std::fs::remove_file(&sentinel_path);
}
}
let fd = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
if fd.try_lock_exclusive().is_err() {
let live_owner = read_sentinel(&sentinel_path);
return Err(match live_owner {
Some(meta) => LockError::Held {
pid: meta.pid,
start_time: format!("{} ns", meta.start_time_nanos),
host: meta.host,
},
None => LockError::Held {
pid: 0,
start_time: "concurrent (sentinel missing/corrupt)".to_string(),
host: hostname(),
},
});
}
if force && owner_alive {
if let Some(meta) = sentinel_owner.as_ref() {
tracing::warn!(
pid = meta.pid,
host = %meta.host,
"forced setup lock takeover after sibling released fs2 lock"
);
}
let _ = std::fs::remove_file(&sentinel_path);
}
let sentinel = Sentinel {
pid: current_pid(),
start_time_nanos: current_start_time_nanos(),
host: hostname(),
atomcode_version: env!("CARGO_PKG_VERSION").to_string(),
};
let json = serde_json::to_string(&sentinel).expect("Sentinel serialize never fails");
let mut f = File::create(&sentinel_path)?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
Ok(Self { fd, lock_path, sentinel_path })
}
}
impl Drop for SetupLock {
fn drop(&mut self) {
let _ = fs2::FileExt::unlock(&self.fd);
let _ = std::fs::remove_file(&self.sentinel_path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acquire_creates_lock_in_fresh_project() {
let dir = tempfile::tempdir().unwrap();
let lock = SetupLock::acquire(dir.path(), false).unwrap();
assert!(lock.lock_path.exists());
assert!(lock.sentinel_path.exists());
}
#[test]
fn second_acquire_fails_when_first_held() {
let dir = tempfile::tempdir().unwrap();
let _lock1 = SetupLock::acquire(dir.path(), false).unwrap();
let err = SetupLock::acquire(dir.path(), false).unwrap_err();
assert!(matches!(err, LockError::Held { .. }));
}
#[test]
fn drop_releases_lock_so_next_acquire_succeeds() {
let dir = tempfile::tempdir().unwrap();
{
let _lock1 = SetupLock::acquire(dir.path(), false).unwrap();
}
let _lock2 = SetupLock::acquire(dir.path(), false).unwrap();
}
#[test]
fn force_with_alive_holder_still_fails_if_fs2_held() {
let dir = tempfile::tempdir().unwrap();
let _lock1 = SetupLock::acquire(dir.path(), false).unwrap();
let err = SetupLock::acquire(dir.path(), true).unwrap_err();
match err {
LockError::Held { pid, .. } => {
assert_eq!(pid, std::process::id(), "Held should surface real holder pid, not 0");
}
other => panic!("expected Held, got {other:?}"),
}
}
#[test]
fn fs2_race_loses_holder_identity_gracefully() {
let dir = tempfile::tempdir().unwrap();
let _lock1 = SetupLock::acquire(dir.path(), false).unwrap();
let err = SetupLock::acquire(dir.path(), false).unwrap_err();
match err {
LockError::Held { pid, .. } => {
assert_eq!(pid, std::process::id());
}
other => panic!("expected Held with real pid, got {other:?}"),
}
}
}