use std::path::{Path, PathBuf};
pub(crate) fn process_lock_path(state_dir: &Path) -> PathBuf {
state_dir.join(".forjar.lock")
}
pub fn acquire_process_lock(state_dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(state_dir).map_err(|e| format!("cannot create state dir: {e}"))?;
let lock_path = process_lock_path(state_dir);
let content = process_lock_content();
let exhausted = || {
format!(
"could not acquire state lock {} after {} attempts",
lock_path.display(),
MAX_LOCK_ACQUIRE_ATTEMPTS
)
};
for attempt in 0..MAX_LOCK_ACQUIRE_ATTEMPTS {
match try_create_lock(&lock_path, &content) {
Ok(()) => return Ok(()),
Err(LockAcquireError::Io(e)) => return Err(e),
Err(LockAcquireError::AlreadyExists) => {
match reap_or_reject_stale_lock(&lock_path)? {
ReapOutcome::Retry => {}
ReapOutcome::HeldByLivePid(msg) => return Err(msg),
}
}
}
if attempt + 1 < MAX_LOCK_ACQUIRE_ATTEMPTS {
std::thread::sleep(LOCK_ACQUIRE_BACKOFF);
}
}
Err(exhausted())
}
const MAX_LOCK_ACQUIRE_ATTEMPTS: u32 = 5;
const LOCK_ACQUIRE_BACKOFF: std::time::Duration = std::time::Duration::from_millis(50);
fn process_lock_content() -> String {
format!(
"pid: {}\nstarted_at: {}\n",
std::process::id(),
crate::tripwire::eventlog::now_iso8601()
)
}
enum LockAcquireError {
AlreadyExists,
Io(String),
}
fn try_create_lock(lock_path: &Path, content: &str) -> Result<(), LockAcquireError> {
use std::io::Write;
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)
{
Ok(mut f) => f
.write_all(content.as_bytes())
.map_err(|e| LockAcquireError::Io(format!("cannot write lock file: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
Err(LockAcquireError::AlreadyExists)
}
Err(e) => Err(LockAcquireError::Io(format!(
"cannot create lock file: {e}"
))),
}
}
#[derive(Debug)]
enum ReapOutcome {
Retry,
HeldByLivePid(String),
}
enum LockOwner {
Live(u32),
Stale,
}
fn classify_lock_owner(content: &str, is_running: impl Fn(u32) -> bool) -> LockOwner {
match parse_lock_pid(content) {
Some(pid) if is_running(pid) => LockOwner::Live(pid),
_ => LockOwner::Stale,
}
}
fn reap_or_reject_stale_lock(lock_path: &Path) -> Result<ReapOutcome, String> {
let content = match std::fs::read_to_string(lock_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ReapOutcome::Retry),
Err(e) => return Err(format!("cannot read lock file: {e}")),
};
if let LockOwner::Live(pid) = classify_lock_owner(&content, is_pid_running) {
return Ok(ReapOutcome::HeldByLivePid(format!(
"state directory is locked by PID {} ({}). \
If this is stale, run: forjar apply --force-unlock",
pid,
lock_path.display()
)));
}
reap_stale_if_unchanged(lock_path, &content)
}
fn reap_stale_if_unchanged(lock_path: &Path, observed: &str) -> Result<ReapOutcome, String> {
match std::fs::read_to_string(lock_path) {
Ok(current) if current == observed => match std::fs::remove_file(lock_path) {
Ok(()) => Ok(ReapOutcome::Retry),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ReapOutcome::Retry),
Err(e) => Err(format!(
"cannot remove stale lock {}: {} \
(run: forjar apply --force-unlock)",
lock_path.display(),
e
)),
},
Ok(_) => Ok(ReapOutcome::Retry),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ReapOutcome::Retry),
Err(e) => Err(format!("cannot re-read lock file: {e}")),
}
}
pub fn release_process_lock(state_dir: &Path) {
let lock_path = process_lock_path(state_dir);
let _ = std::fs::remove_file(&lock_path);
}
pub fn force_unlock(state_dir: &Path) -> Result<(), String> {
let lock_path = process_lock_path(state_dir);
if !lock_path.exists() {
return Ok(());
}
std::fs::remove_file(&lock_path).map_err(|e| format!("cannot remove lock file: {e}"))
}
pub(crate) fn parse_lock_pid(content: &str) -> Option<u32> {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("pid:") {
return rest.trim().parse().ok();
}
}
None
}
fn is_pid_running(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(test)]
mod tests;