use std::path::Path;
use crate::error::DurableError;
use crate::ids::ExecutionId;
#[cfg(unix)]
#[derive(Debug)]
pub struct ExecutionLock(#[allow(dead_code)] rustix::fd::OwnedFd);
#[cfg(unix)]
impl ExecutionLock {
pub(crate) fn acquire(lock_dir: &Path, id: ExecutionId) -> Result<Self, DurableError> {
use rustix::fs::{FlockOperation, Mode, OFlags};
std::fs::create_dir_all(lock_dir)
.map_err(|e| DurableError::storage("open_execution_exclusive", e))?;
let lock_path = lock_dir.join(format!("{id}.lock"));
let fd = rustix::fs::open(
&lock_path,
OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
)
.map_err(|e| DurableError::storage("open_execution_exclusive", std::io::Error::from(e)))?;
rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
if e == rustix::io::Errno::WOULDBLOCK {
let holder_pid = zeph_common::pidfile::read_pid_lenient(&lock_path).unwrap_or(0);
DurableError::ExecutionLocked {
execution_id: id,
holder_pid,
}
} else {
DurableError::storage("open_execution_exclusive", std::io::Error::from(e))
}
})?;
let _ = rustix::fs::ftruncate(&fd, 0);
let _ = rustix::io::write(&fd, std::process::id().to_string().as_bytes());
Ok(Self(fd))
}
}
#[cfg(not(unix))]
#[derive(Debug)]
pub struct ExecutionLock;
#[cfg(not(unix))]
impl ExecutionLock {
pub(crate) fn acquire(_lock_dir: &Path, _id: ExecutionId) -> Result<Self, DurableError> {
Ok(Self)
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn second_acquire_for_same_execution_fails() {
let dir = tempfile::tempdir().unwrap();
let id = ExecutionId::new();
let _first = ExecutionLock::acquire(dir.path(), id).expect("first acquire succeeds");
let err = ExecutionLock::acquire(dir.path(), id).expect_err("second acquire must fail");
assert!(
matches!(err, DurableError::ExecutionLocked { execution_id, .. } if execution_id == id),
"expected ExecutionLocked for the same execution_id, got {err:?}"
);
}
#[test]
fn second_acquire_reports_holder_pid() {
let dir = tempfile::tempdir().unwrap();
let id = ExecutionId::new();
let _first = ExecutionLock::acquire(dir.path(), id).expect("first acquire succeeds");
let err = ExecutionLock::acquire(dir.path(), id).expect_err("second acquire must fail");
let DurableError::ExecutionLocked { holder_pid, .. } = err else {
panic!("expected ExecutionLocked, got {err:?}");
};
assert_eq!(
holder_pid,
std::process::id(),
"holder_pid should report this test process's own pid (the only holder)"
);
}
#[test]
fn distinct_executions_do_not_contend() {
let dir = tempfile::tempdir().unwrap();
let a = ExecutionId::new();
let b = ExecutionId::new();
let _lock_a = ExecutionLock::acquire(dir.path(), a).expect("lock a succeeds");
let _lock_b =
ExecutionLock::acquire(dir.path(), b).expect("distinct execution_id does not block");
}
#[test]
fn reacquire_after_drop_succeeds() {
let dir = tempfile::tempdir().unwrap();
let id = ExecutionId::new();
{
let _first = ExecutionLock::acquire(dir.path(), id).expect("first acquire succeeds");
}
let _second =
ExecutionLock::acquire(dir.path(), id).expect("lock is released when guard drops");
}
#[test]
fn lock_file_is_not_unlinked_on_drop() {
let dir = tempfile::tempdir().unwrap();
let id = ExecutionId::new();
let lock_path = dir.path().join(format!("{id}.lock"));
{
let _guard = ExecutionLock::acquire(dir.path(), id).expect("acquire succeeds");
assert!(lock_path.exists(), "lock file must exist while held");
}
assert!(
lock_path.exists(),
"lock file must remain on disk after the guard drops (permanent sentinel)"
);
}
}