use std::fs::{self, File, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use crate::error::AgentConfigError;
#[cfg(not(test))]
const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(test)]
const LOCK_TIMEOUT: Duration = Duration::from_secs(1);
const RETRY_DELAY: Duration = Duration::from_millis(5);
#[derive(Debug)]
pub(crate) struct FileLock {
path: PathBuf,
_file: File,
}
impl FileLock {
pub(crate) fn acquire(target: &Path) -> Result<Self, AgentConfigError> {
let lock_path = lock_path_for(target)?;
if let Some(parent) = lock_path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|e| AgentConfigError::io(parent, e))?;
}
}
let started = Instant::now();
loop {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut file) => {
writeln!(file, "pid={}", std::process::id())
.map_err(|e| AgentConfigError::io(&lock_path, e))?;
file.sync_all()
.map_err(|e| AgentConfigError::io(&lock_path, e))?;
return Ok(Self {
path: lock_path,
_file: file,
});
}
Err(e) if is_lock_contention(&lock_path, &e) => {
if started.elapsed() >= LOCK_TIMEOUT {
return Err(AgentConfigError::LockTimeout { path: lock_path });
}
thread::sleep(RETRY_DELAY);
}
Err(e) => return Err(AgentConfigError::io(&lock_path, e)),
}
}
}
}
fn is_lock_contention(_lock_path: &Path, error: &std::io::Error) -> bool {
if error.kind() == std::io::ErrorKind::AlreadyExists {
return true;
}
cfg!(windows) && error.kind() == std::io::ErrorKind::PermissionDenied
}
impl Drop for FileLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
pub(crate) fn with_lock<T>(
target: &Path,
f: impl FnOnce() -> Result<T, AgentConfigError>,
) -> Result<T, AgentConfigError> {
let _guard = FileLock::acquire(target)?;
f()
}
fn lock_path_for(target: &Path) -> Result<PathBuf, AgentConfigError> {
let file_name = target
.file_name()
.ok_or_else(|| {
AgentConfigError::PathResolution(format!("path has no file name: {target:?}"))
})?
.to_string_lossy();
let parent = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
Ok(parent.join(format!(".{file_name}.agent-config.lock")))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn with_lock_runs_closure_and_removes_lock() {
let dir = tempdir().unwrap();
let target = dir.path().join("config.json");
let lock = lock_path_for(&target).unwrap();
let value = with_lock(&target, || Ok::<_, AgentConfigError>(42)).unwrap();
assert_eq!(value, 42);
assert!(!lock.exists());
}
#[test]
fn second_lock_times_out_when_lock_file_is_stale() {
let dir = tempdir().unwrap();
let target = dir.path().join("config.json");
let lock = lock_path_for(&target).unwrap();
fs::write(&lock, b"stale").unwrap();
let err = FileLock::acquire(&target).unwrap_err();
assert!(matches!(err, AgentConfigError::LockTimeout { .. }));
}
}