use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const RETRY_INTERVAL: Duration = Duration::from_millis(50);
pub const LOCK_FILE_NAME: &str = ".djogi-migrations-lock";
#[derive(Debug)]
pub enum GuardError {
Timeout {
path: PathBuf,
timeout: Duration,
holder_pid: Option<i32>,
},
Io {
path: PathBuf,
source: std::io::Error,
},
Flock { path: PathBuf, errno: i32 },
#[cfg(not(unix))]
WindowsUnsupported,
}
impl std::fmt::Display for GuardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GuardError::Timeout {
path,
timeout,
holder_pid,
} => match holder_pid {
Some(pid) => write!(
f,
"workspace migration lock at {path} held by another invocation \
(PID {pid}); waited {timeout:?} before giving up",
path = path.display(),
),
None => write!(
f,
"workspace migration lock at {path} held by another invocation \
(PID unknown — lock file empty or unreadable); waited {timeout:?} \
before giving up",
path = path.display(),
),
},
GuardError::Io { path, source } => write!(
f,
"I/O error on workspace migration lock {path}: {source}",
path = path.display(),
),
GuardError::Flock { path, errno } => write!(
f,
"flock(2) on workspace migration lock {path} failed (errno={errno})",
path = path.display(),
),
#[cfg(not(unix))]
GuardError::WindowsUnsupported => write!(
f,
"workspace migration lock: Windows support deferred; build on Linux \
or macOS, or run the migration tooling under WSL"
),
}
}
}
impl std::error::Error for GuardError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
GuardError::Io { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Debug)]
pub struct WorkspaceGuard {
file: Option<File>,
path: PathBuf,
}
impl WorkspaceGuard {
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for WorkspaceGuard {
fn drop(&mut self) {
if let Some(f) = self.file.take() {
drop(f);
}
}
}
pub fn acquire(path: &Path, timeout: Duration) -> Result<WorkspaceGuard, GuardError> {
#[cfg(unix)]
{
acquire_unix(path, timeout)
}
#[cfg(not(unix))]
{
let _ = (path, timeout);
Err(GuardError::WindowsUnsupported)
}
}
#[cfg(unix)]
fn acquire_unix(path: &Path, timeout: Duration) -> Result<WorkspaceGuard, GuardError> {
use std::os::unix::io::AsRawFd;
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
return Err(GuardError::Io {
path: parent.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"workspace migration lock parent directory does not exist",
),
});
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(|e| GuardError::Io {
path: path.to_path_buf(),
source: e,
})?;
let fd = file.as_raw_fd();
let deadline = Instant::now() + timeout;
loop {
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
let mut owned = file;
write_pid(&mut owned, path)?;
return Ok(WorkspaceGuard {
file: Some(owned),
path: path.to_path_buf(),
});
}
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
if errno != libc::EWOULDBLOCK && errno != libc::EAGAIN {
return Err(GuardError::Flock {
path: path.to_path_buf(),
errno,
});
}
if Instant::now() >= deadline {
let holder_pid = read_pid(path).ok();
return Err(GuardError::Timeout {
path: path.to_path_buf(),
timeout,
holder_pid,
});
}
std::thread::sleep(RETRY_INTERVAL);
}
}
#[cfg(unix)]
fn write_pid(file: &mut File, path: &Path) -> Result<(), GuardError> {
let pid: i32 = std::process::id() as i32;
file.set_len(0).map_err(|e| GuardError::Io {
path: path.to_path_buf(),
source: e,
})?;
file.seek(SeekFrom::Start(0)).map_err(|e| GuardError::Io {
path: path.to_path_buf(),
source: e,
})?;
let line = format!("{pid}\n");
file.write_all(line.as_bytes())
.map_err(|e| GuardError::Io {
path: path.to_path_buf(),
source: e,
})?;
file.flush().map_err(|e| GuardError::Io {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
fn read_pid(path: &Path) -> Result<i32, std::io::Error> {
let mut f = File::open(path)?;
let mut buf = String::new();
f.read_to_string(&mut buf)?;
let trimmed = buf.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"lock file is empty",
));
}
let bytes = trimmed.as_bytes();
let (sign_offset, _negative) = if bytes[0] == b'-' {
(1, true)
} else {
(0, false)
};
if sign_offset == bytes.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"lock file PID is just a sign",
));
}
for &b in &bytes[sign_offset..] {
if !b.is_ascii_digit() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"lock file PID is not a decimal integer",
));
}
}
trimmed
.parse::<i32>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
fn temp_lock_path() -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("djogi-guard-test-{stamp}.lock"))
}
#[test]
fn acquire_writes_pid_to_lock_file() {
let path = temp_lock_path();
let guard = acquire(&path, Duration::from_secs(1)).expect("acquire");
let contents = std::fs::read_to_string(&path).expect("read");
let trimmed = contents.trim();
let pid: i32 = trimmed.parse().expect("parse pid");
assert_eq!(pid, std::process::id() as i32);
drop(guard);
let _ = std::fs::remove_file(&path);
}
#[test]
fn second_acquirer_times_out_with_holder_pid() {
let path = temp_lock_path();
let release = Arc::new(AtomicBool::new(false));
let release_clone = release.clone();
let path_clone = path.clone();
let holder_thread = std::thread::spawn(move || {
let _g = acquire(&path_clone, Duration::from_secs(2)).expect("first acquire");
while !release_clone.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(10));
}
});
std::thread::sleep(Duration::from_millis(100));
let err =
acquire(&path, Duration::from_millis(200)).expect_err("second acquire must time out");
match err {
GuardError::Timeout { holder_pid, .. } => {
assert_eq!(holder_pid, Some(std::process::id() as i32));
}
other => panic!("expected Timeout, got {other:?}"),
}
release.store(true, Ordering::Release);
holder_thread.join().expect("holder thread join");
let _ = std::fs::remove_file(&path);
}
#[test]
fn release_on_drop_lets_next_acquirer_succeed() {
let path = temp_lock_path();
{
let _g = acquire(&path, Duration::from_secs(1)).expect("first acquire");
} let g = acquire(&path, Duration::from_millis(200)).expect("second acquire");
drop(g);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_pid_accepts_trailing_newline() {
let path = temp_lock_path();
std::fs::write(&path, "12345\n").unwrap();
assert_eq!(read_pid(&path).unwrap(), 12345);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_pid_accepts_no_trailing_newline() {
let path = temp_lock_path();
std::fs::write(&path, "12345").unwrap();
assert_eq!(read_pid(&path).unwrap(), 12345);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_pid_rejects_non_digit() {
let path = temp_lock_path();
std::fs::write(&path, "12a45").unwrap();
let err = read_pid(&path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_pid_rejects_empty() {
let path = temp_lock_path();
std::fs::write(&path, "").unwrap();
let err = read_pid(&path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
let _ = std::fs::remove_file(&path);
}
}