1use std::fs::{self, File, OpenOptions};
22use std::io;
23use std::path::{Path, PathBuf};
24use std::time::{Duration, Instant};
25
26use anyhow::{Context, Result};
27
28use crate::errors::ReflexError;
29
30pub const TMP_SUFFIX: &str = ".tmp";
32
33pub const INDEX_LOCK_FILE: &str = "index.lock";
35
36pub fn tmp_path_for(final_path: &Path) -> PathBuf {
40 let mut name = final_path
41 .file_name()
42 .map(|n| n.to_os_string())
43 .unwrap_or_default();
44 name.push(TMP_SUFFIX);
45 final_path.with_file_name(name)
46}
47
48pub fn atomic_replace(tmp: &Path, final_path: &Path) -> io::Result<()> {
56 let mut last_err = match fs::rename(tmp, final_path) {
57 Ok(()) => return Ok(()),
58 Err(e) => e,
59 };
60
61 if cfg!(windows) {
62 for delay_ms in [20u64, 40, 80, 160, 320] {
63 std::thread::sleep(Duration::from_millis(delay_ms));
64 match fs::rename(tmp, final_path) {
65 Ok(()) => return Ok(()),
66 Err(e) => last_err = e,
67 }
68 }
69 log::warn!(
70 "atomic rename of {} failed after retries ({}); falling back to non-atomic copy",
71 final_path.display(),
72 last_err
73 );
74 fs::copy(tmp, final_path)?;
75 let _ = fs::remove_file(tmp);
76 return Ok(());
77 }
78
79 Err(last_err)
80}
81
82pub fn remove_stale_tmp(dir: &Path) {
86 let entries = match fs::read_dir(dir) {
87 Ok(e) => e,
88 Err(_) => return,
89 };
90 for entry in entries.flatten() {
91 let path = entry.path();
92 let is_tmp = path
93 .file_name()
94 .and_then(|n| n.to_str())
95 .map(|n| n.ends_with(TMP_SUFFIX))
96 .unwrap_or(false);
97 if is_tmp && path.is_file() {
98 match fs::remove_file(&path) {
99 Ok(()) => log::info!("Removed stale temp file {}", path.display()),
100 Err(e) => log::warn!("Could not remove stale temp file {}: {}", path.display(), e),
101 }
102 }
103 }
104}
105
106#[derive(Debug)]
110pub struct IndexLock {
111 file: File,
112 path: PathBuf,
113}
114
115impl IndexLock {
116 pub fn lock_path(cache_dir: &Path) -> PathBuf {
118 cache_dir.join(INDEX_LOCK_FILE)
119 }
120
121 pub fn try_acquire(cache_dir: &Path) -> Result<Option<IndexLock>> {
125 fs::create_dir_all(cache_dir)
126 .with_context(|| format!("Failed to create {}", cache_dir.display()))?;
127 let path = Self::lock_path(cache_dir);
128 let file = OpenOptions::new()
129 .create(true)
130 .read(true)
131 .write(true)
132 .truncate(false)
133 .open(&path)
134 .with_context(|| format!("Failed to open {}", path.display()))?;
135 match file.try_lock() {
136 Ok(()) => Ok(Some(IndexLock { file, path })),
137 Err(std::fs::TryLockError::WouldBlock) => Ok(None),
138 Err(std::fs::TryLockError::Error(e)) => {
139 Err(e).with_context(|| format!("Failed to lock {}", path.display()))
140 }
141 }
142 }
143
144 pub fn acquire_with_timeout(cache_dir: &Path, timeout: Duration) -> Result<IndexLock> {
148 let start = Instant::now();
149 loop {
150 if let Some(lock) = Self::try_acquire(cache_dir)? {
151 return Ok(lock);
152 }
153 if start.elapsed() >= timeout {
154 return Err(ReflexError::IndexLocked(
155 Self::lock_path(cache_dir).display().to_string(),
156 )
157 .into());
158 }
159 std::thread::sleep(Duration::from_millis(100));
160 }
161 }
162
163 pub fn path(&self) -> &Path {
165 &self.path
166 }
167}
168
169impl Drop for IndexLock {
170 fn drop(&mut self) {
171 let _ = self.file.unlock();
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use std::io::Write;
181 use tempfile::TempDir;
182
183 #[test]
184 fn tmp_path_is_sibling_with_suffix() {
185 let p = Path::new("/a/b/content.bin");
186 assert_eq!(tmp_path_for(p), PathBuf::from("/a/b/content.bin.tmp"));
187 }
188
189 #[test]
190 fn atomic_replace_moves_bytes_over_final() {
191 let dir = TempDir::new().unwrap();
192 let final_path = dir.path().join("f.bin");
193 fs::write(&final_path, b"old").unwrap();
194 let tmp = tmp_path_for(&final_path);
195 fs::write(&tmp, b"new-bytes").unwrap();
196 atomic_replace(&tmp, &final_path).unwrap();
197 assert_eq!(fs::read(&final_path).unwrap(), b"new-bytes");
198 assert!(!tmp.exists());
199 }
200
201 #[test]
202 fn remove_stale_tmp_only_touches_tmp_files() {
203 let dir = TempDir::new().unwrap();
204 fs::write(dir.path().join("content.bin"), b"keep").unwrap();
205 fs::write(dir.path().join("content.bin.tmp"), b"stale").unwrap();
206 remove_stale_tmp(dir.path());
207 assert!(dir.path().join("content.bin").exists());
208 assert!(!dir.path().join("content.bin.tmp").exists());
209 }
210
211 #[test]
212 fn second_acquire_in_other_process_scope_is_none_then_released() {
213 let dir = TempDir::new().unwrap();
217 let first = IndexLock::try_acquire(dir.path()).unwrap();
218 assert!(first.is_some());
219 let second = IndexLock::try_acquire(dir.path()).unwrap();
220 assert!(second.is_none(), "lock must be exclusive while held");
221 drop(first);
222 let third = IndexLock::try_acquire(dir.path()).unwrap();
223 assert!(third.is_some(), "lock must be released on drop");
224 }
225
226 #[test]
227 fn acquire_with_timeout_reports_index_locked() {
228 let dir = TempDir::new().unwrap();
229 let _held = IndexLock::try_acquire(dir.path()).unwrap().unwrap();
230 let err = IndexLock::acquire_with_timeout(dir.path(), Duration::from_millis(250))
231 .expect_err("must time out");
232 let re = err
233 .downcast_ref::<ReflexError>()
234 .expect("typed ReflexError");
235 assert_eq!(re.kind(), "IndexLocked");
236 assert!(re.to_string().contains(INDEX_LOCK_FILE));
237 }
238
239 #[test]
240 fn lock_file_is_not_truncated_or_required_to_be_empty() {
241 let dir = TempDir::new().unwrap();
242 let path = IndexLock::lock_path(dir.path());
243 let mut f = File::create(&path).unwrap();
244 f.write_all(b"12345").unwrap();
245 drop(f);
246 let lock = IndexLock::try_acquire(dir.path()).unwrap().unwrap();
247 assert_eq!(lock.path(), path);
248 }
249}