use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
const STALE: Duration = Duration::from_secs(10);
#[cfg(unix)]
const ENOTDIR: i32 = 20;
pub fn lock_path_for(target: &Path) -> PathBuf {
let mut p = target.as_os_str().to_os_string();
p.push(".lock");
PathBuf::from(p)
}
fn is_stale(meta: &fs::Metadata) -> bool {
meta.modified().is_ok_and(|mtime| {
SystemTime::now()
.duration_since(mtime)
.is_ok_and(|age| age > STALE)
})
}
fn reclaim(lock_path: &Path, meta: &fs::Metadata) {
if meta.is_dir() {
let _ = fs::remove_dir(lock_path);
} else {
let _ = fs::remove_file(lock_path);
}
}
fn backoff(attempt: u32) -> Duration {
let base_ms: u64 = 10;
let cap_ms: u64 = 500;
let sleep_ms = base_ms
.checked_shl(attempt.min(5))
.unwrap_or(cap_ms)
.min(cap_ms);
let jitter = (sleep_ms / 4).max(1);
Duration::from_millis(sleep_ms / 2 + jitter)
}
#[derive(Debug)]
#[must_use = "the lock is released as soon as the DirLock is dropped"]
pub struct DirLock {
lock_path: PathBuf,
}
impl DirLock {
pub fn acquire(lock_path: &Path, timeout: Duration) -> io::Result<Self> {
if let Some(parent) = lock_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
let start = Instant::now();
let mut attempt: u32 = 0;
loop {
match fs::create_dir(lock_path) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = fs::set_permissions(lock_path, fs::Permissions::from_mode(0o700));
}
return Ok(Self {
lock_path: lock_path.to_path_buf(),
});
}
Err(e) if is_already_exists(&e) => {
match fs::symlink_metadata(lock_path) {
Ok(meta) => {
if is_stale(&meta) {
reclaim(lock_path, &meta);
attempt = 0; }
}
Err(e) if e.kind() == io::ErrorKind::NotFound => attempt = 0,
Err(e) => return Err(e),
}
}
Err(e) => return Err(e),
}
if start.elapsed() >= timeout {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("timed out waiting for lock at {}", lock_path.display()),
));
}
std::thread::sleep(backoff(attempt));
attempt = attempt.saturating_add(1);
}
}
pub fn acquire_for(target: &Path, timeout: Duration) -> io::Result<Self> {
Self::acquire(&lock_path_for(target), timeout)
}
}
fn is_already_exists(e: &io::Error) -> bool {
if e.kind() == io::ErrorKind::AlreadyExists {
return true;
}
#[cfg(unix)]
{
e.raw_os_error() == Some(ENOTDIR)
}
#[cfg(not(unix))]
{
false
}
}
impl Drop for DirLock {
fn drop(&mut self) {
let _ = fs::remove_dir(&self.lock_path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_path_appends_dot_lock() {
assert_eq!(
lock_path_for(Path::new("/x/auth.json")),
PathBuf::from("/x/auth.json.lock")
);
assert_eq!(
lock_path_for(Path::new("/x/sessions/session-index")),
PathBuf::from("/x/sessions/session-index.lock")
);
}
#[test]
fn acquire_creates_dir_and_release_removes_it() {
let dir = tempfile::tempdir().expect("tempdir");
let lp = dir.path().join("auth.json.lock");
{
let _g = DirLock::acquire(&lp, Duration::from_secs(5)).expect("acquire");
assert!(lp.is_dir(), "lock should be a directory while held");
}
assert!(!lp.exists(), "lock directory should be removed on drop");
}
#[test]
fn second_acquire_times_out_while_held() {
let dir = tempfile::tempdir().expect("tempdir");
let lp = dir.path().join("auth.json.lock");
let _g = DirLock::acquire(&lp, Duration::from_secs(5)).expect("first acquire");
let err = DirLock::acquire(&lp, Duration::from_millis(200))
.expect_err("second acquire must time out while held");
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
}
#[test]
fn reclaims_stale_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let lp = dir.path().join("auth.json.lock");
fs::create_dir(&lp).expect("mkdir stale");
let old = SystemTime::now() - Duration::from_secs(30);
filetime_set(&lp, old);
let g =
DirLock::acquire(&lp, Duration::from_millis(500)).expect("should reclaim stale dir");
assert!(lp.is_dir());
drop(g);
}
#[test]
fn does_not_reclaim_fresh_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let lp = dir.path().join("auth.json.lock");
fs::create_dir(&lp).expect("mkdir fresh");
let err = DirLock::acquire(&lp, Duration::from_millis(200))
.expect_err("must not steal a fresh foreign lock");
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
}
#[test]
fn heals_stale_leftover_regular_file() {
let dir = tempfile::tempdir().expect("tempdir");
let lp = dir.path().join("auth.json.lock");
fs::write(&lp, b"").expect("write leftover regular file");
let old = SystemTime::now() - Duration::from_secs(30);
filetime_set(&lp, old);
assert!(lp.is_file());
{
let _g = DirLock::acquire(&lp, Duration::from_millis(500))
.expect("should heal stale regular file and acquire");
assert!(
lp.is_dir(),
"poisoning file must be replaced by a directory"
);
}
assert!(!lp.exists());
}
fn filetime_set(path: &Path, when: SystemTime) {
let f = fs::File::open(path).expect("open for set_times");
let times = fs::FileTimes::new().set_modified(when).set_accessed(when);
f.set_times(times).expect("set_times");
}
}