use std::fs::{self, File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crate::errors::ReflexError;
pub const TMP_SUFFIX: &str = ".tmp";
pub const INDEX_LOCK_FILE: &str = "index.lock";
pub fn tmp_path_for(final_path: &Path) -> PathBuf {
let mut name = final_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(TMP_SUFFIX);
final_path.with_file_name(name)
}
pub fn atomic_replace(tmp: &Path, final_path: &Path) -> io::Result<()> {
let mut last_err = match fs::rename(tmp, final_path) {
Ok(()) => return Ok(()),
Err(e) => e,
};
if cfg!(windows) {
for delay_ms in [20u64, 40, 80, 160, 320] {
std::thread::sleep(Duration::from_millis(delay_ms));
match fs::rename(tmp, final_path) {
Ok(()) => return Ok(()),
Err(e) => last_err = e,
}
}
log::warn!(
"atomic rename of {} failed after retries ({}); falling back to non-atomic copy",
final_path.display(),
last_err
);
fs::copy(tmp, final_path)?;
let _ = fs::remove_file(tmp);
return Ok(());
}
Err(last_err)
}
pub fn remove_stale_tmp(dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let is_tmp = path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(TMP_SUFFIX))
.unwrap_or(false);
if is_tmp && path.is_file() {
match fs::remove_file(&path) {
Ok(()) => log::info!("Removed stale temp file {}", path.display()),
Err(e) => log::warn!("Could not remove stale temp file {}: {}", path.display(), e),
}
}
}
}
#[derive(Debug)]
pub struct IndexLock {
file: File,
path: PathBuf,
}
impl IndexLock {
pub fn lock_path(cache_dir: &Path) -> PathBuf {
cache_dir.join(INDEX_LOCK_FILE)
}
pub fn try_acquire(cache_dir: &Path) -> Result<Option<IndexLock>> {
fs::create_dir_all(cache_dir)
.with_context(|| format!("Failed to create {}", cache_dir.display()))?;
let path = Self::lock_path(cache_dir);
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("Failed to open {}", path.display()))?;
match file.try_lock() {
Ok(()) => Ok(Some(IndexLock { file, path })),
Err(std::fs::TryLockError::WouldBlock) => Ok(None),
Err(std::fs::TryLockError::Error(e)) => {
Err(e).with_context(|| format!("Failed to lock {}", path.display()))
}
}
}
pub fn acquire_with_timeout(cache_dir: &Path, timeout: Duration) -> Result<IndexLock> {
let start = Instant::now();
loop {
if let Some(lock) = Self::try_acquire(cache_dir)? {
return Ok(lock);
}
if start.elapsed() >= timeout {
return Err(ReflexError::IndexLocked(
Self::lock_path(cache_dir).display().to_string(),
)
.into());
}
std::thread::sleep(Duration::from_millis(100));
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for IndexLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn tmp_path_is_sibling_with_suffix() {
let p = Path::new("/a/b/content.bin");
assert_eq!(tmp_path_for(p), PathBuf::from("/a/b/content.bin.tmp"));
}
#[test]
fn atomic_replace_moves_bytes_over_final() {
let dir = TempDir::new().unwrap();
let final_path = dir.path().join("f.bin");
fs::write(&final_path, b"old").unwrap();
let tmp = tmp_path_for(&final_path);
fs::write(&tmp, b"new-bytes").unwrap();
atomic_replace(&tmp, &final_path).unwrap();
assert_eq!(fs::read(&final_path).unwrap(), b"new-bytes");
assert!(!tmp.exists());
}
#[test]
fn remove_stale_tmp_only_touches_tmp_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("content.bin"), b"keep").unwrap();
fs::write(dir.path().join("content.bin.tmp"), b"stale").unwrap();
remove_stale_tmp(dir.path());
assert!(dir.path().join("content.bin").exists());
assert!(!dir.path().join("content.bin.tmp").exists());
}
#[test]
fn second_acquire_in_other_process_scope_is_none_then_released() {
let dir = TempDir::new().unwrap();
let first = IndexLock::try_acquire(dir.path()).unwrap();
assert!(first.is_some());
let second = IndexLock::try_acquire(dir.path()).unwrap();
assert!(second.is_none(), "lock must be exclusive while held");
drop(first);
let third = IndexLock::try_acquire(dir.path()).unwrap();
assert!(third.is_some(), "lock must be released on drop");
}
#[test]
fn acquire_with_timeout_reports_index_locked() {
let dir = TempDir::new().unwrap();
let _held = IndexLock::try_acquire(dir.path()).unwrap().unwrap();
let err = IndexLock::acquire_with_timeout(dir.path(), Duration::from_millis(250))
.expect_err("must time out");
let re = err
.downcast_ref::<ReflexError>()
.expect("typed ReflexError");
assert_eq!(re.kind(), "IndexLocked");
assert!(re.to_string().contains(INDEX_LOCK_FILE));
}
#[test]
fn lock_file_is_not_truncated_or_required_to_be_empty() {
let dir = TempDir::new().unwrap();
let path = IndexLock::lock_path(dir.path());
let mut f = File::create(&path).unwrap();
f.write_all(b"12345").unwrap();
drop(f);
let lock = IndexLock::try_acquire(dir.path()).unwrap().unwrap();
assert_eq!(lock.path(), path);
}
}