use std::path::Path;
pub async fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let temp_path = temp_sibling(path);
tokio::fs::write(&temp_path, data).await?;
if let Err(e) = tokio::fs::rename(&temp_path, path).await {
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(e);
}
Ok(())
}
pub fn atomic_write_sync(path: &Path, data: &[u8]) -> std::io::Result<()> {
let temp_path = temp_sibling(path);
std::fs::write(&temp_path, data)?;
if let Err(e) = std::fs::rename(&temp_path, path) {
let _ = std::fs::remove_file(&temp_path);
return Err(e);
}
Ok(())
}
fn temp_sibling(path: &Path) -> std::path::PathBuf {
let random_suffix = fastrand::u64(..);
let file_name = path
.file_name()
.map_or_else(|| "file".to_string(), |n| n.to_string_lossy().to_string());
let temp_name = format!(".{file_name}.{random_suffix:016x}.tmp");
path.with_file_name(temp_name)
}
fn is_lock_contention_error(e: &std::io::Error) -> bool {
#[cfg(unix)]
{
let code = e.raw_os_error();
code == Some(libc::EAGAIN) || code == Some(libc::EWOULDBLOCK)
}
#[cfg(windows)]
{
e.raw_os_error() == Some(33)
}
#[cfg(not(any(unix, windows)))]
{
let _ = e;
false
}
}
const LOCK_FILE_NAME: &str = ".aperture.lock";
pub struct DirLock {
_file: std::fs::File,
}
impl DirLock {
pub fn acquire(dir: &Path) -> std::io::Result<Self> {
use fs2::FileExt;
let lock_path = dir.join(LOCK_FILE_NAME);
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)?;
file.lock_exclusive()?;
Ok(Self { _file: file })
}
pub fn try_acquire(dir: &Path) -> std::io::Result<Option<Self>> {
use fs2::FileExt;
let lock_path = dir.join(LOCK_FILE_NAME);
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)?;
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(Self { _file: file })),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(e) => {
if is_lock_contention_error(&e) {
return Ok(None);
}
Err(e)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_atomic_write_creates_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write(&path, b"hello world").await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn test_atomic_write_no_temp_files_left() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write(&path, b"data").await.unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.collect();
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].file_name().to_string_lossy().as_ref(),
"test.txt"
);
}
#[tokio::test]
async fn test_atomic_write_overwrites_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write(&path, b"first").await.unwrap();
atomic_write(&path, b"second").await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "second");
}
#[test]
fn test_atomic_write_sync_creates_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write_sync(&path, b"hello sync").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "hello sync");
}
#[test]
fn test_atomic_write_sync_no_temp_files_left() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write_sync(&path, b"data").unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.collect();
assert_eq!(entries.len(), 1);
}
#[test]
fn test_dir_lock_acquire_and_release() {
let dir = TempDir::new().unwrap();
let lock = DirLock::acquire(dir.path()).unwrap();
assert!(dir.path().join(LOCK_FILE_NAME).exists());
drop(lock);
assert!(dir.path().join(LOCK_FILE_NAME).exists());
}
#[test]
fn test_dir_lock_try_acquire() {
let dir = TempDir::new().unwrap();
let lock1 = DirLock::try_acquire(dir.path()).unwrap();
assert!(lock1.is_some());
let lock2 = DirLock::try_acquire(dir.path()).unwrap();
assert!(lock2.is_none());
drop(lock1);
let lock3 = DirLock::try_acquire(dir.path()).unwrap();
assert!(lock3.is_some());
}
#[tokio::test]
async fn test_concurrent_atomic_writes_no_corruption() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("concurrent.txt");
let mut handles = Vec::new();
for i in 0..20 {
let p = path.clone();
handles.push(tokio::spawn(async move {
let data = format!("writer-{i}-{}", "x".repeat(1000));
atomic_write(&p, data.as_bytes()).await.unwrap();
}));
}
for handle in handles {
handle.await.unwrap();
}
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(content.starts_with("writer-"));
assert!(content.ends_with(&"x".repeat(1000)));
}
#[test]
fn test_temp_sibling_uniqueness() {
let path = Path::new("/tmp/cache/test.json");
let t1 = temp_sibling(path);
let t2 = temp_sibling(path);
assert_eq!(t1.parent(), t2.parent());
assert_eq!(t1.parent().unwrap(), Path::new("/tmp/cache"));
let name1 = t1.file_name().unwrap().to_string_lossy();
assert!(name1.starts_with('.'));
assert!(name1.ends_with(".tmp"));
assert_ne!(t1, t2);
}
}