use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use fs2::FileExt;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LockError {
#[error("another socket-patch process is operating in this directory")]
Held,
#[error("failed to open lock file at {path:?}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug)]
#[must_use = "the lock is released when this guard is dropped"]
pub struct LockGuard {
_file: std::fs::File,
}
pub fn acquire(socket_dir: &Path, timeout: Duration) -> Result<LockGuard, LockError> {
let path = socket_dir.join("apply.lock");
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|source| LockError::Io {
path: path.clone(),
source,
})?;
let deadline = Instant::now() + timeout;
loop {
match file.try_lock_exclusive() {
Ok(()) => return Ok(LockGuard { _file: file }),
Err(ref e) if is_lock_contended(e) => {
let now = Instant::now();
if now >= deadline {
return Err(LockError::Held);
}
let remaining = deadline - now;
std::thread::sleep(remaining.min(Duration::from_millis(100)));
}
Err(source) => {
return Err(LockError::Io {
path: path.clone(),
source,
});
}
}
}
}
fn is_lock_contended(err: &std::io::Error) -> bool {
err.raw_os_error() == fs2::lock_contended_error().raw_os_error()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_acquire_succeeds() {
let dir = tempfile::tempdir().unwrap();
let guard = acquire(dir.path(), Duration::ZERO).unwrap();
assert!(dir.path().join("apply.lock").is_file());
drop(guard);
}
#[test]
fn second_concurrent_acquire_is_held() {
let dir = tempfile::tempdir().unwrap();
let _first = acquire(dir.path(), Duration::ZERO).unwrap();
let err = acquire(dir.path(), Duration::ZERO).unwrap_err();
assert!(matches!(err, LockError::Held));
}
#[test]
fn drop_releases_lock() {
let dir = tempfile::tempdir().unwrap();
{
let _g = acquire(dir.path(), Duration::ZERO).unwrap();
} let again = acquire(dir.path(), Duration::ZERO);
assert!(again.is_ok());
}
#[test]
fn missing_socket_dir_surfaces_io() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
let err = acquire(&missing, Duration::ZERO).unwrap_err();
match err {
LockError::Io { source, .. } => {
assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
}
_ => panic!("expected Io error, got {:?}", err),
}
}
#[test]
fn timeout_held() {
let dir = tempfile::tempdir().unwrap();
let _first = acquire(dir.path(), Duration::ZERO).unwrap();
let start = Instant::now();
let err = acquire(dir.path(), Duration::from_millis(250)).unwrap_err();
let elapsed = start.elapsed();
assert!(matches!(err, LockError::Held));
assert!(
elapsed >= Duration::from_millis(200),
"expected at least 200ms wait, got {:?}",
elapsed
);
}
#[test]
fn contended_sentinel_is_classified_as_contention() {
assert!(is_lock_contended(&fs2::lock_contended_error()));
}
#[test]
fn genuine_io_errors_are_not_contention() {
use std::io::{Error, ErrorKind};
assert!(!is_lock_contended(&Error::from(ErrorKind::NotFound)));
assert!(!is_lock_contended(&Error::from(
ErrorKind::PermissionDenied
)));
let eintr = Error::from_raw_os_error(4);
if eintr.raw_os_error() != fs2::lock_contended_error().raw_os_error() {
assert!(!is_lock_contended(&eintr));
}
}
#[test]
fn zero_timeout_does_not_sleep_before_held() {
let dir = tempfile::tempdir().unwrap();
let _first = acquire(dir.path(), Duration::ZERO).unwrap();
let start = Instant::now();
let err = acquire(dir.path(), Duration::ZERO).unwrap_err();
let elapsed = start.elapsed();
assert!(matches!(err, LockError::Held));
assert!(
elapsed < Duration::from_millis(100),
"non-blocking acquire should not sleep, took {:?}",
elapsed
);
}
#[test]
fn wait_respects_deadline_without_full_quantum_overshoot() {
let dir = tempfile::tempdir().unwrap();
let _first = acquire(dir.path(), Duration::ZERO).unwrap();
let start = Instant::now();
let err = acquire(dir.path(), Duration::from_millis(150)).unwrap_err();
let elapsed = start.elapsed();
assert!(matches!(err, LockError::Held));
assert!(
elapsed >= Duration::from_millis(150),
"should wait at least the budget, got {:?}",
elapsed
);
assert!(
elapsed < Duration::from_millis(450),
"clamped sleep should keep us near the budget, got {:?}",
elapsed
);
}
}