use std::fs::{File, OpenOptions, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::{Error, Result};
pub const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
const RETRY_INTERVAL: Duration = Duration::from_millis(25);
#[derive(Debug)]
pub struct LockGuard {
file: File,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
fn open_lock_file(path: &Path) -> Result<File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
OpenOptions::new()
.create(true)
.read(true)
.append(true) .open(path)
.map_err(|e| Error::io(path, e))
}
fn acquire(path: &Path, exclusive: bool, timeout: Duration) -> Result<LockGuard> {
let file = open_lock_file(path)?;
let start = Instant::now();
loop {
let attempt = if exclusive {
file.try_lock()
} else {
file.try_lock_shared()
};
match attempt {
Ok(()) => return Ok(LockGuard { file }),
Err(TryLockError::WouldBlock) => {}
Err(TryLockError::Error(e)) => return Err(Error::io(path, e)),
}
if start.elapsed() >= timeout {
return Err(Error::RegistryBusy {
path: path.to_path_buf(),
timeout_ms: timeout.as_millis() as u64,
});
}
std::thread::sleep(RETRY_INTERVAL);
}
}
pub fn shared(lock_path: &Path, timeout: Duration) -> Result<LockGuard> {
acquire(lock_path, false, timeout)
}
pub fn exclusive(lock_path: &Path, timeout: Duration) -> Result<LockGuard> {
acquire(lock_path, true, timeout)
}
pub fn write_atomic(dest: &Path, bytes: &[u8]) -> Result<()> {
let parent = dest
.parent()
.ok_or_else(|| Error::io(dest, std::io::Error::other("path has no parent")))?;
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
let tmp: PathBuf = parent.join(format!(
"{}.tmp-{}",
dest.file_name().unwrap_or_default().to_string_lossy(),
std::process::id()
));
let result = (|| -> Result<()> {
let mut f = File::create(&tmp).map_err(|e| Error::io(&tmp, e))?;
f.write_all(bytes).map_err(|e| Error::io(&tmp, e))?;
f.sync_all().map_err(|e| Error::io(&tmp, e))?;
drop(f);
rename_with_retry(&tmp, dest)?;
sync_dir(parent);
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
fn rename_with_retry(from: &Path, to: &Path) -> Result<()> {
let start = Instant::now();
loop {
match std::fs::rename(from, to) {
Ok(()) => return Ok(()),
Err(e) if cfg!(windows) && start.elapsed() < Duration::from_secs(2) => {
tracing::debug!("rename retry after {e}");
std::thread::sleep(RETRY_INTERVAL);
}
Err(e) => return Err(Error::io(to, e)),
}
}
}
fn sync_dir(dir: &Path) {
#[cfg(unix)]
{
if let Ok(d) = File::open(dir) {
let _ = d.sync_all();
}
}
#[cfg(not(unix))]
{
let _ = dir;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exclusive_blocks_exclusive() {
let tmp = tempfile::tempdir().unwrap();
let lock_path = tmp.path().join("registry.lock");
let _g1 = exclusive(&lock_path, DEFAULT_LOCK_TIMEOUT).unwrap();
let err = exclusive(&lock_path, Duration::from_millis(100)).unwrap_err();
assert!(matches!(err, Error::RegistryBusy { .. }));
assert_eq!(err.kind(), crate::ErrorKind::RegistryBusy);
}
#[test]
fn shared_allows_shared() {
let tmp = tempfile::tempdir().unwrap();
let lock_path = tmp.path().join("registry.lock");
let _g1 = shared(&lock_path, DEFAULT_LOCK_TIMEOUT).unwrap();
let _g2 = shared(&lock_path, Duration::from_millis(100)).unwrap();
}
#[test]
fn lock_released_on_drop() {
let tmp = tempfile::tempdir().unwrap();
let lock_path = tmp.path().join("registry.lock");
drop(exclusive(&lock_path, DEFAULT_LOCK_TIMEOUT).unwrap());
let _g = exclusive(&lock_path, Duration::from_millis(100)).unwrap();
}
#[test]
fn write_atomic_replaces_content() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("registry.json");
write_atomic(&dest, b"one").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"one");
write_atomic(&dest, b"two").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"two");
let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp-"))
.collect();
assert!(leftovers.is_empty());
}
}