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 if path.is_dir() && path.file_name().and_then(|n| n.to_str()) == Some("trigram_temp") {
105 match fs::remove_dir_all(&path) {
106 Ok(()) => log::info!("Removed stale partial index directory {}", path.display()),
107 Err(e) => log::warn!(
108 "Could not remove stale partial index directory {}: {}",
109 path.display(),
110 e
111 ),
112 }
113 }
114 }
115}
116
117#[derive(Debug)]
121pub struct IndexLock {
122 file: File,
123 path: PathBuf,
124}
125
126impl IndexLock {
127 pub fn lock_path(cache_dir: &Path) -> PathBuf {
129 cache_dir.join(INDEX_LOCK_FILE)
130 }
131
132 pub fn try_acquire(cache_dir: &Path) -> Result<Option<IndexLock>> {
136 fs::create_dir_all(cache_dir)
137 .with_context(|| format!("Failed to create {}", cache_dir.display()))?;
138 let path = Self::lock_path(cache_dir);
139 let file = OpenOptions::new()
140 .create(true)
141 .read(true)
142 .write(true)
143 .truncate(false)
144 .open(&path)
145 .with_context(|| format!("Failed to open {}", path.display()))?;
146 match file.try_lock() {
147 Ok(()) => Ok(Some(IndexLock { file, path })),
148 Err(std::fs::TryLockError::WouldBlock) => Ok(None),
149 Err(std::fs::TryLockError::Error(e)) => {
150 Err(e).with_context(|| format!("Failed to lock {}", path.display()))
151 }
152 }
153 }
154
155 pub fn acquire_with_timeout(cache_dir: &Path, timeout: Duration) -> Result<IndexLock> {
159 let start = Instant::now();
160 loop {
161 if let Some(lock) = Self::try_acquire(cache_dir)? {
162 return Ok(lock);
163 }
164 if start.elapsed() >= timeout {
165 return Err(ReflexError::IndexLocked(
166 Self::lock_path(cache_dir).display().to_string(),
167 )
168 .into());
169 }
170 std::thread::sleep(Duration::from_millis(100));
171 }
172 }
173
174 pub fn path(&self) -> &Path {
176 &self.path
177 }
178}
179
180impl Drop for IndexLock {
181 fn drop(&mut self) {
182 let _ = self.file.unlock();
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use std::io::Write;
192 use tempfile::TempDir;
193
194 #[test]
195 fn tmp_path_is_sibling_with_suffix() {
196 let p = Path::new("/a/b/content.bin");
197 assert_eq!(tmp_path_for(p), PathBuf::from("/a/b/content.bin.tmp"));
198 }
199
200 #[test]
201 fn atomic_replace_moves_bytes_over_final() {
202 let dir = TempDir::new().unwrap();
203 let final_path = dir.path().join("f.bin");
204 fs::write(&final_path, b"old").unwrap();
205 let tmp = tmp_path_for(&final_path);
206 fs::write(&tmp, b"new-bytes").unwrap();
207 atomic_replace(&tmp, &final_path).unwrap();
208 assert_eq!(fs::read(&final_path).unwrap(), b"new-bytes");
209 assert!(!tmp.exists());
210 }
211
212 #[test]
213 fn remove_stale_tmp_only_touches_tmp_files() {
214 let dir = TempDir::new().unwrap();
215 fs::write(dir.path().join("content.bin"), b"keep").unwrap();
216 fs::write(dir.path().join("content.bin.tmp"), b"stale").unwrap();
217 remove_stale_tmp(dir.path());
218 assert!(dir.path().join("content.bin").exists());
219 assert!(!dir.path().join("content.bin.tmp").exists());
220 }
221
222 #[test]
223 fn second_acquire_in_other_process_scope_is_none_then_released() {
224 let dir = TempDir::new().unwrap();
228 let first = IndexLock::try_acquire(dir.path()).unwrap();
229 assert!(first.is_some());
230 let second = IndexLock::try_acquire(dir.path()).unwrap();
231 assert!(second.is_none(), "lock must be exclusive while held");
232 drop(first);
233 let third = IndexLock::try_acquire(dir.path()).unwrap();
234 assert!(third.is_some(), "lock must be released on drop");
235 }
236
237 #[test]
238 fn acquire_with_timeout_reports_index_locked() {
239 let dir = TempDir::new().unwrap();
240 let _held = IndexLock::try_acquire(dir.path()).unwrap().unwrap();
241 let err = IndexLock::acquire_with_timeout(dir.path(), Duration::from_millis(250))
242 .expect_err("must time out");
243 let re = err
244 .downcast_ref::<ReflexError>()
245 .expect("typed ReflexError");
246 assert_eq!(re.kind(), "IndexLocked");
247 assert!(re.to_string().contains(INDEX_LOCK_FILE));
248 }
249
250 #[test]
251 fn lock_file_is_not_truncated_or_required_to_be_empty() {
252 let dir = TempDir::new().unwrap();
253 let path = IndexLock::lock_path(dir.path());
254 let mut f = File::create(&path).unwrap();
255 f.write_all(b"12345").unwrap();
256 drop(f);
257 let lock = IndexLock::try_acquire(dir.path()).unwrap().unwrap();
258 assert_eq!(lock.path(), path);
259 }
260}