use std::path::{Path, PathBuf};
use std::time::Duration;
use fs4::fs_std::FileExt;
use sha2::{Digest, Sha256};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const RETRY_INTERVAL: Duration = Duration::from_millis(10);
pub struct FileLock {
_file: std::fs::File,
}
impl FileLock {
pub async fn acquire(path: &Path) -> std::io::Result<Self> {
Self::acquire_with_timeout(path, DEFAULT_TIMEOUT).await
}
pub async fn acquire_with_timeout(path: &Path, timeout: Duration) -> std::io::Result<Self> {
let lock_path = lock_file_path(path)?;
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
let deadline = tokio::time::Instant::now() + timeout;
loop {
match file.try_lock_exclusive() {
Ok(true) => return Ok(Self { _file: file }),
Ok(false) => {
if tokio::time::Instant::now() >= deadline {
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
format!(
"timed out after {timeout:?} waiting for the file lock on {} \
(another agent editing the same file?)",
path.display()
),
));
}
tokio::time::sleep(RETRY_INTERVAL).await;
}
Err(err) => return Err(err),
}
}
}
}
fn lock_file_path(path: &Path) -> std::io::Result<PathBuf> {
let canonical = match std::fs::canonicalize(path) {
Ok(real) => real,
Err(_) => {
let parent = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
let parent = std::fs::canonicalize(parent)?;
let name = path.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("path has no file name: {}", path.display()),
)
})?;
parent.join(name)
}
};
let digest = hex::encode(Sha256::digest(canonical.to_string_lossy().as_bytes()));
Ok(std::env::temp_dir()
.join("theway-file-locks")
.join(format!("{digest}.lock")))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn second_acquire_waits_until_first_release() {
let dir = tempdir().unwrap();
let p = dir.path().join("shared.txt");
let first = FileLock::acquire(&p).await.unwrap();
assert!(
FileLock::acquire_with_timeout(&p, Duration::from_millis(200))
.await
.is_err()
);
drop(first);
assert!(
FileLock::acquire_with_timeout(&p, Duration::from_secs(5))
.await
.is_ok()
);
}
#[tokio::test]
async fn lock_identity_survives_target_rewrites() {
let dir = tempdir().unwrap();
let p = dir.path().join("t.txt");
std::fs::write(&p, "v1").unwrap();
let lock_before = lock_file_path(&p).unwrap();
let first = FileLock::acquire(&p).await.unwrap();
let tmp = dir.path().join(".tmp");
std::fs::write(&tmp, "v2").unwrap();
std::fs::rename(&tmp, &p).unwrap();
assert_eq!(lock_file_path(&p).unwrap(), lock_before);
assert!(
FileLock::acquire_with_timeout(&p, Duration::from_millis(200))
.await
.is_err()
);
drop(first);
assert!(
FileLock::acquire_with_timeout(&p, Duration::from_secs(5))
.await
.is_ok()
);
}
#[tokio::test]
async fn acquire_never_creates_the_target_file() {
let dir = tempdir().unwrap();
let p = dir.path().join("does-not-exist.txt");
let _lock = FileLock::acquire(&p).await.unwrap();
assert!(!p.exists(), "locking must not create the target file");
assert!(lock_file_path(&p).unwrap().exists());
}
}