use std::path::{Path, PathBuf};
use std::time::Instant;
#[cfg(unix)]
use std::time::Duration;
use anyhow::{Context, Result};
use crate::error::AtomwriteError;
pub struct LockGuard {
target: PathBuf,
#[cfg(unix)]
_file: std::fs::File,
pub held_ms: u64,
}
impl std::fmt::Debug for LockGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LockGuard")
.field("target", &self.target)
.field("held_ms", &self.held_ms)
.finish_non_exhaustive()
}
}
#[cfg(unix)]
impl Drop for LockGuard {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd;
let fd = self._file.as_raw_fd();
#[allow(deprecated)]
let _ = nix::fcntl::flock(fd, nix::fcntl::FlockArg::Unlock);
}
}
#[cfg(not(unix))]
impl Drop for LockGuard {
fn drop(&mut self) {
}
}
pub fn acquire_exclusive(target: &Path, timeout_ms: u64) -> Result<LockGuard> {
let start = Instant::now();
let lock_path = sidecar_path(target);
let parent = lock_path
.parent()
.ok_or_else(|| AtomwriteError::InternalError {
reason: format!("lock path has no parent: {}", lock_path.display()),
})?;
std::fs::create_dir_all(parent).with_context(|| {
format!(
"cannot create parent directory for lock {}",
lock_path.display()
)
})?;
let lock_file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("cannot open lock file {}", lock_path.display()))?;
let held_ms = try_acquire_loop(&lock_file, timeout_ms, start)?;
tracing::debug!(
target = %target.display(),
lock = %lock_path.display(),
held_ms,
"advisory lock acquired"
);
Ok(LockGuard {
target: target.to_path_buf(),
#[cfg(unix)]
_file: lock_file,
held_ms,
})
}
#[cfg(unix)]
fn try_acquire_loop(file: &std::fs::File, timeout_ms: u64, start: Instant) -> Result<u64> {
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let deadline = Duration::from_millis(timeout_ms);
let mut attempts: u32 = 0;
loop {
#[allow(deprecated)]
match nix::fcntl::flock(fd, nix::fcntl::FlockArg::LockExclusiveNonblock) {
Ok(()) => return Ok(start.elapsed().as_millis() as u64),
Err(nix::Error::EAGAIN) => {
let elapsed = start.elapsed();
if elapsed >= deadline {
return Err(AtomwriteError::LockTimeout {
path: std::path::PathBuf::from("<target>"),
timeout_ms,
}
.into());
}
attempts += 1;
let sleep_ms = if attempts < 20 { 10 } else { 50 };
std::thread::sleep(Duration::from_millis(sleep_ms));
}
Err(e) => {
return Err(AtomwriteError::Io {
source: std::io::Error::from_raw_os_error(e as i32),
}
.into());
}
}
}
}
#[cfg(not(unix))]
fn try_acquire_loop(_file: &std::fs::File, _timeout_ms: u64, start: Instant) -> Result<u64> {
Ok(start.elapsed().as_millis() as u64)
}
fn sidecar_path(target: &Path) -> PathBuf {
let parent = target.parent().unwrap_or_else(|| Path::new("."));
let filename = target
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
parent.join(format!(".{filename}.atomwrite.lock"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidecar_path_appends_dot_atomwrite_lock() {
let target = Path::new("/tmp/foo.txt");
let lock = sidecar_path(target);
assert_eq!(lock, Path::new("/tmp/.foo.txt.atomwrite.lock"));
}
#[test]
fn acquire_release_cycle() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test.txt");
std::fs::write(&target, "hello").unwrap();
{
let guard = acquire_exclusive(&target, 1000).unwrap();
assert!(guard.held_ms < 1000);
}
let guard2 = acquire_exclusive(&target, 1000).unwrap();
drop(guard2);
}
#[cfg(unix)]
#[test]
fn second_acquire_blocks_until_release() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("concurrent.txt");
std::fs::write(&target, "shared").unwrap();
let guard1 = acquire_exclusive(&target, 1000).unwrap();
let start = std::time::Instant::now();
let result = acquire_exclusive(&target, 50);
let elapsed = start.elapsed().as_millis();
assert!(result.is_err(), "second acquire must time out");
assert!(
elapsed >= 50,
"must wait at least the configured timeout, got {elapsed}ms"
);
let err = result.unwrap_err();
let ae = err.downcast_ref::<AtomwriteError>();
assert!(
matches!(ae, Some(AtomwriteError::LockTimeout { .. })),
"expected LockTimeout, got: {err:?}"
);
drop(guard1);
}
}