videre_core/io_timeout.rs
1use std::path::Path;
2use std::sync::mpsc;
3use std::sync::OnceLock;
4use std::thread;
5use std::time::{Duration, Instant};
6
7/// Default ceiling for any single blocking file/subprocess operation that
8/// touches a path supplied by the caller (e.g. a scanned media file). Chosen
9/// to comfortably exceed a slow spinning disk or network share while still
10/// surfacing a stale/disconnected mount point (which otherwise blocks the
11/// underlying syscall forever on macOS) within one command's run.
12pub const DEFAULT_IO_TIMEOUT: Duration = Duration::from_secs(20);
13
14/// Assumed floor throughput for a whole-file read, in MB/s, used to scale the
15/// timeout to the size of the file.
16///
17/// Deliberately far under real hardware: a USB SSD measured 158 MB/s on the
18/// library that produced this mechanism. It is a floor, not an estimate, so a
19/// degraded link still finishes rather than being declared unreachable.
20pub const MIN_READ_RATE_MB_S_DEFAULT: u64 = 20;
21
22/// Ceiling for a `stat`, which is *not* proportional to file size, so unlike a
23/// read it is correctly bounded by a constant.
24///
25/// This is the liveness check: on a stale or disconnected mount `fs::metadata`
26/// is itself one of the calls that blocks forever, so a mount that answers it
27/// promptly can be trusted to have reported a real size.
28pub const STAT_TIMEOUT: Duration = Duration::from_secs(5);
29
30static MIN_READ_RATE_OVERRIDE: OnceLock<u64> = OnceLock::new();
31
32/// Overrides the assumed floor read rate, from config. Call once at startup,
33/// before any scan begins; later calls are ignored, as with the qlmanage
34/// concurrency override this mirrors.
35pub fn set_min_read_rate_mb_s(rate: u64) {
36 let _ = MIN_READ_RATE_OVERRIDE.set(rate);
37}
38
39/// Pure resolution of the effective rate, split out from the `OnceLock` so the
40/// "override if present, else default" logic is unit-testable without touching
41/// process-wide state. Same split as `heic::resolve_qlmanage_concurrency`.
42fn resolve_min_read_rate(override_val: Option<u64>) -> u64 {
43 match override_val {
44 // A zero rate would mean an unbounded timeout, reintroducing the hang
45 // the whole mechanism exists to prevent. The config layer rejects it;
46 // this refuses it again rather than trusting a single gate.
47 Some(0) | None => MIN_READ_RATE_MB_S_DEFAULT,
48 Some(n) => n,
49 }
50}
51
52pub fn min_read_rate_mb_s() -> u64 {
53 resolve_min_read_rate(MIN_READ_RATE_OVERRIDE.get().copied())
54}
55
56/// How long a whole-file read of `size_bytes` may take before it is considered
57/// stalled rather than merely large.
58///
59/// A constant ceiling cannot tell those apart. Measured 2026-08-12: a healthy
60/// 3.7 GB video on a drive sustaining 158 MB/s needs ~23s, and was being
61/// skipped by a fixed 20s cap with a message blaming the drive. File sizes do
62/// not change, so such a file was skipped on every run, forever.
63///
64/// Never returns less than `DEFAULT_IO_TIMEOUT`, so small files behave exactly
65/// as before. Total by construction: saturating arithmetic (a debug build
66/// panics on overflow, and computing a timeout must never be the thing that
67/// crashes a scan) and a zero rate falls back to the default rather than
68/// dividing by zero or returning an unbounded timeout. The config layer
69/// rejects a zero rate too; this stays total regardless of who calls it.
70pub fn timeout_for_size(size_bytes: u64, rate_mb_s: u64) -> Duration {
71 let rate = if rate_mb_s == 0 {
72 MIN_READ_RATE_MB_S_DEFAULT
73 } else {
74 rate_mb_s
75 };
76 let bytes_per_sec = rate.saturating_mul(1_000_000);
77 let secs = size_bytes / bytes_per_sec.max(1);
78 Duration::from_secs(secs).max(DEFAULT_IO_TIMEOUT)
79}
80
81/// The operation did not complete within the given timeout. The spawned
82/// worker thread is left to run to completion in the background (there is
83/// no safe way to cancel a blocked syscall from the outside); this trades a
84/// leaked thread for never hanging the caller.
85pub struct TimedOut;
86
87/// Runs `f` on a helper thread and waits up to `timeout` for it to finish.
88/// Use this to bound any blocking call (`std::fs::*`, `image::open`, a
89/// subprocess `.wait()`) that could otherwise block indefinitely against an
90/// unresponsive mount point.
91pub fn run_with_timeout<T, F>(timeout: Duration, f: F) -> Result<T, TimedOut>
92where
93 F: FnOnce() -> T + Send + 'static,
94 T: Send + 'static,
95{
96 let (tx, rx) = mpsc::channel();
97 thread::spawn(move || {
98 let _ = tx.send(f());
99 });
100 rx.recv_timeout(timeout).map_err(|_| TimedOut)
101}
102
103/// Runs `f` with a timeout scaled to the size of `path`.
104///
105/// For operations that read a file *whole*, where duration really is
106/// proportional to size. Not for decoding: a QuickLook poster frame reads a
107/// fraction of a video, so scaling by full size would hand a multi-GB file
108/// minutes for work that should take a second, and QuickLook hanging on a
109/// container with no video track is a known failure mode this project already
110/// had to bound.
111///
112/// The `stat` is bounded separately and *first*, by a constant. That ordering
113/// is the safety property: a dead mount fails there, in `STAT_TIMEOUT`, and the
114/// read is never attempted, so a large file on a dead mount cannot hang for its
115/// scaled timeout. A failed or timed-out `stat` is reported as `TimedOut`
116/// rather than guessed around.
117pub fn run_with_timeout_for_path<T, F>(path: &Path, f: F) -> Result<T, TimedOut>
118where
119 F: FnOnce() -> T + Send + 'static,
120 T: Send + 'static,
121{
122 run_with_timeout_for_path_detailed(path, f).map_err(|_| TimedOut)
123}
124
125/// Which phase ran out of time, and how long it was given.
126///
127/// Exists so a caller can describe the failure without asking the filesystem
128/// again. `hash_file` used to format its message by calling `std::fs::metadata`
129/// **unbounded** on the very path that had just timed out - on a stale mount
130/// that is the call that blocks forever, so the error handler hung in exactly
131/// the scenario the timeout was protecting against.
132#[derive(Debug, PartialEq, Eq, Clone, Copy)]
133pub enum TimedOutAfter {
134 /// The `stat` never returned: the path is unreachable, not merely slow.
135 Stat(Duration),
136 /// `stat` succeeded and the work itself overran its size-scaled budget.
137 Read(Duration),
138}
139
140impl TimedOutAfter {
141 pub fn duration(self) -> Duration {
142 match self {
143 TimedOutAfter::Stat(d) | TimedOutAfter::Read(d) => d,
144 }
145 }
146
147 /// Phrasing that distinguishes "the drive did not answer" from "this was
148 /// slow", which the single old message could not.
149 pub fn describe(self, path: &Path) -> String {
150 match self {
151 TimedOutAfter::Stat(d) => format!(
152 "could not read {} after {}s (the drive did not respond - is it connected?)",
153 path.display(),
154 d.as_secs()
155 ),
156 TimedOutAfter::Read(d) => format!(
157 "timed out reading {} after {}s (file may be unreachable - is its drive connected?)",
158 path.display(),
159 d.as_secs()
160 ),
161 }
162 }
163}
164
165/// `run_with_timeout_for_path`, reporting which phase timed out and after how
166/// long, so the caller never has to touch the filesystem to explain itself.
167pub fn run_with_timeout_for_path_detailed<T, F>(path: &Path, f: F) -> Result<T, TimedOutAfter>
168where
169 F: FnOnce() -> T + Send + 'static,
170 T: Send + 'static,
171{
172 let owned = path.to_path_buf();
173 let size = run_with_timeout(STAT_TIMEOUT, move || {
174 std::fs::metadata(&owned).map(|m| m.len()).ok()
175 })
176 .map_err(|_| TimedOutAfter::Stat(STAT_TIMEOUT))?
177 .ok_or(TimedOutAfter::Stat(STAT_TIMEOUT))?;
178 let budget = timeout_for_size(size, min_read_rate_mb_s());
179 run_with_timeout(budget, f).map_err(|_| TimedOutAfter::Read(budget))
180}
181
182/// Outcome of waiting on a child process with a deadline.
183#[derive(Debug, PartialEq, Eq)]
184pub enum WaitOutcome {
185 Success,
186 Failed,
187 TimedOut,
188}
189
190/// Polls `child` for completion, killing it if it hasn't exited within
191/// `timeout`. Unlike a raw blocking `.wait()`/`.status()`, this guarantees
192/// the caller gets control back within roughly `timeout` even if the child
193/// itself is stuck on an unresponsive mount point.
194pub fn wait_with_timeout(child: &mut std::process::Child, timeout: Duration) -> WaitOutcome {
195 let start = Instant::now();
196 loop {
197 match child.try_wait() {
198 Ok(Some(status)) => {
199 return if status.success() {
200 WaitOutcome::Success
201 } else {
202 WaitOutcome::Failed
203 };
204 }
205 Ok(None) => {
206 if start.elapsed() >= timeout {
207 let _ = child.kill();
208 let _ = child.wait();
209 return WaitOutcome::TimedOut;
210 }
211 thread::sleep(Duration::from_millis(50));
212 }
213 Err(_) => return WaitOutcome::Failed,
214 }
215 }
216}
217
218/// Whether a missing file's absence can be trusted as a real deletion.
219///
220/// `false` when the parent directory is *also* missing: that means the
221/// directory, or the whole volume, is gone rather than this one file having
222/// been deleted. `videre prune` uses this to avoid deleting every row for an
223/// unmounted drive, which additionally destroys the embeddings and cached
224/// thumbnails for those hashes (hours of recompute, against minutes to
225/// re-scan the rows themselves).
226///
227/// Deliberately not a mount-table lookup. On macOS `/Volumes` reports the same
228/// filesystem as `/` when nothing is mounted there, so an unmounted volume
229/// leaves nothing to query; telling "unmounted" from "deleted directory" apart
230/// exactly needs either platform-specific enumeration or state recorded at
231/// scan time. This rule needs neither and behaves identically on Linux.
232///
233/// Bounded by `run_with_timeout`, because a stale NFS or SMB mount can hang
234/// `metadata` indefinitely and a safety check that hangs is not a safety
235/// check. A timeout returns `false`: an unanswerable question must never
236/// authorise a deletion.
237///
238/// A path with no parent (`/`, or a bare relative name) also returns `false`,
239/// since there is nothing to corroborate the absence against.
240pub fn absence_is_trustworthy(path: &Path) -> bool {
241 let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
242 return false;
243 };
244 let parent = parent.to_path_buf();
245 run_with_timeout(DEFAULT_IO_TIMEOUT, move || parent.is_dir()).unwrap_or(false)
246}
247
248#[cfg(test)]
249mod absence_tests {
250 use super::*;
251
252 fn tmp(name: &str) -> std::path::PathBuf {
253 let d = std::env::temp_dir().join(format!("videre-absence-{}-{name}", std::process::id()));
254 std::fs::create_dir_all(&d).unwrap();
255 d
256 }
257
258 #[test]
259 fn a_missing_file_in_an_existing_directory_is_trustworthy() {
260 let dir = tmp("present");
261 assert!(absence_is_trustworthy(&dir.join("gone.jpg")));
262 let _ = std::fs::remove_dir_all(&dir);
263 }
264
265 #[test]
266 fn a_missing_file_in_a_missing_directory_is_not() {
267 // The unmounted-volume shape: neither the file nor its parent exists.
268 let dir = tmp("absent");
269 let nested = dir.join("subdir");
270 assert!(!absence_is_trustworthy(&nested.join("gone.jpg")));
271 let _ = std::fs::remove_dir_all(&dir);
272 }
273
274 #[test]
275 fn a_real_present_file_is_trustworthy_too() {
276 // The function only judges the parent; callers ask it about paths they
277 // already know are missing, but it must not depend on that.
278 let dir = tmp("realfile");
279 let f = dir.join("here.jpg");
280 std::fs::write(&f, b"x").unwrap();
281 assert!(absence_is_trustworthy(&f));
282 let _ = std::fs::remove_dir_all(&dir);
283 }
284
285 #[test]
286 fn a_path_without_a_usable_parent_is_not_trustworthy() {
287 // Nothing to corroborate the absence against, so refuse rather than
288 // authorise a deletion.
289 assert!(!absence_is_trustworthy(Path::new("/")));
290 assert!(!absence_is_trustworthy(Path::new("bare-name.jpg")));
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn returns_ok_when_operation_finishes_before_timeout() {
300 let result = run_with_timeout(Duration::from_secs(1), || 42);
301 assert!(result.is_ok());
302 assert_eq!(result.ok(), Some(42));
303 }
304
305 #[test]
306 fn returns_timed_out_when_operation_exceeds_timeout() {
307 let result = run_with_timeout(Duration::from_millis(50), || {
308 thread::sleep(Duration::from_secs(5));
309 42
310 });
311 assert!(result.is_err());
312 }
313
314 #[test]
315 fn wait_with_timeout_returns_success_for_fast_process() {
316 let mut child = std::process::Command::new("true").spawn().unwrap();
317 assert_eq!(
318 wait_with_timeout(&mut child, Duration::from_secs(5)),
319 WaitOutcome::Success
320 );
321 }
322
323 #[test]
324 fn wait_with_timeout_kills_and_returns_timed_out_for_slow_process() {
325 let mut child = std::process::Command::new("sleep")
326 .arg("5")
327 .spawn()
328 .unwrap();
329 let start = Instant::now();
330 assert_eq!(
331 wait_with_timeout(&mut child, Duration::from_millis(200)),
332 WaitOutcome::TimedOut
333 );
334 assert!(start.elapsed() < Duration::from_secs(2));
335 }
336}
337
338#[cfg(test)]
339mod size_timeout_tests {
340 use super::*;
341
342 #[test]
343 fn a_small_file_gets_exactly_the_old_constant() {
344 // Nothing may get *less* time than before this existed.
345 assert_eq!(timeout_for_size(0, 20), DEFAULT_IO_TIMEOUT);
346 assert_eq!(timeout_for_size(1_000_000, 20), DEFAULT_IO_TIMEOUT);
347 // The crossover: below 400 MB at 20 MB/s the default still wins.
348 assert_eq!(timeout_for_size(399_000_000, 20), DEFAULT_IO_TIMEOUT);
349 }
350
351 #[test]
352 fn the_file_that_produced_this_bug_now_gets_enough_time() {
353 // 3.7 GB, skipped on a drive measured at 158 MB/s where a full read
354 // needs ~23s, against a fixed 20s cap.
355 let t = timeout_for_size(3_700_000_000, 20);
356 assert_eq!(t.as_secs(), 185);
357 assert!(t.as_secs() > 23, "must exceed the real read time");
358 }
359
360 #[test]
361 fn the_largest_file_in_the_measured_library_is_bounded_and_finite() {
362 assert_eq!(timeout_for_size(5_720_000_000, 20).as_secs(), 286);
363 }
364
365 #[test]
366 fn a_zero_rate_falls_back_rather_than_dividing_by_zero() {
367 // An unbounded timeout would reintroduce the hang this prevents.
368 assert_eq!(timeout_for_size(3_700_000_000, 0).as_secs(), 185);
369 }
370
371 #[test]
372 fn absurd_sizes_neither_panic_nor_overflow() {
373 // A debug build panics on overflowing arithmetic, and computing a
374 // timeout must never be the thing that crashes a scan.
375 let t = timeout_for_size(u64::MAX, 1);
376 assert!(t >= DEFAULT_IO_TIMEOUT);
377 assert_eq!(timeout_for_size(u64::MAX, u64::MAX), DEFAULT_IO_TIMEOUT);
378 }
379
380 #[test]
381 fn resolve_uses_the_override_but_refuses_zero() {
382 assert_eq!(resolve_min_read_rate(Some(50)), 50);
383 assert_eq!(resolve_min_read_rate(None), MIN_READ_RATE_MB_S_DEFAULT);
384 assert_eq!(resolve_min_read_rate(Some(0)), MIN_READ_RATE_MB_S_DEFAULT);
385 }
386
387 #[test]
388 fn a_dead_path_fails_at_the_stat_rather_than_running_the_body() {
389 // The safety property: no size means no read, so a large file on a
390 // dead mount cannot hang for its scaled timeout.
391 let r = run_with_timeout_for_path(
392 std::path::Path::new("/nonexistent/videre/definitely-not-here"),
393 || 42,
394 );
395 assert!(r.is_err());
396 }
397
398 #[test]
399 fn a_real_file_runs_the_body() {
400 let d = std::env::temp_dir().join(format!("videre-sz-{}", std::process::id()));
401 std::fs::create_dir_all(&d).unwrap();
402 let f = d.join("x.bin");
403 std::fs::write(&f, b"hello").unwrap();
404 assert_eq!(run_with_timeout_for_path(&f, || 42).ok(), Some(42));
405 let _ = std::fs::remove_dir_all(&d);
406 }
407}
408
409#[cfg(test)]
410mod timeout_reporting_tests {
411 use super::*;
412
413 #[test]
414 fn an_unreachable_path_reports_the_stat_phase_not_a_read() {
415 // A path that cannot be stat'd fails in the stat phase. The old code
416 // reported every failure as "timed out reading ... after 20s", which
417 // named the wrong phase and the wrong duration.
418 let missing = Path::new("/nonexistent-videre-test/definitely/not/here");
419 let r = run_with_timeout_for_path_detailed(missing, || 1u8);
420 assert_eq!(r.unwrap_err(), TimedOutAfter::Stat(STAT_TIMEOUT));
421 }
422
423 #[test]
424 fn the_reported_duration_is_the_one_that_was_applied() {
425 assert_eq!(TimedOutAfter::Stat(STAT_TIMEOUT).duration(), STAT_TIMEOUT);
426 let d = Duration::from_secs(185);
427 assert_eq!(TimedOutAfter::Read(d).duration(), d);
428 }
429
430 #[test]
431 fn describe_distinguishes_a_dead_drive_from_a_slow_file() {
432 let p = Path::new("/some/file.mov");
433 let stat = TimedOutAfter::Stat(STAT_TIMEOUT).describe(p);
434 let read = TimedOutAfter::Read(Duration::from_secs(185)).describe(p);
435 assert!(stat.contains("did not respond"), "{stat}");
436 assert!(read.contains("185s"), "{read}");
437 assert_ne!(stat, read);
438 }
439}