Skip to main content

videre_core/
io_timeout.rs

1use std::path::Path;
2use std::sync::mpsc;
3use std::thread;
4use std::time::{Duration, Instant};
5
6/// Default ceiling for any single blocking file/subprocess operation that
7/// touches a path supplied by the caller (e.g. a scanned media file). Chosen
8/// to comfortably exceed a slow spinning disk or network share while still
9/// surfacing a stale/disconnected mount point (which otherwise blocks the
10/// underlying syscall forever on macOS) within one command's run.
11pub const DEFAULT_IO_TIMEOUT: Duration = Duration::from_secs(20);
12
13/// The operation did not complete within the given timeout. The spawned
14/// worker thread is left to run to completion in the background (there is
15/// no safe way to cancel a blocked syscall from the outside); this trades a
16/// leaked thread for never hanging the caller.
17pub struct TimedOut;
18
19/// Runs `f` on a helper thread and waits up to `timeout` for it to finish.
20/// Use this to bound any blocking call (`std::fs::*`, `image::open`, a
21/// subprocess `.wait()`) that could otherwise block indefinitely against an
22/// unresponsive mount point.
23pub fn run_with_timeout<T, F>(timeout: Duration, f: F) -> Result<T, TimedOut>
24where
25    F: FnOnce() -> T + Send + 'static,
26    T: Send + 'static,
27{
28    let (tx, rx) = mpsc::channel();
29    thread::spawn(move || {
30        let _ = tx.send(f());
31    });
32    rx.recv_timeout(timeout).map_err(|_| TimedOut)
33}
34
35/// Outcome of waiting on a child process with a deadline.
36#[derive(Debug, PartialEq, Eq)]
37pub enum WaitOutcome {
38    Success,
39    Failed,
40    TimedOut,
41}
42
43/// Polls `child` for completion, killing it if it hasn't exited within
44/// `timeout`. Unlike a raw blocking `.wait()`/`.status()`, this guarantees
45/// the caller gets control back within roughly `timeout` even if the child
46/// itself is stuck on an unresponsive mount point.
47pub fn wait_with_timeout(child: &mut std::process::Child, timeout: Duration) -> WaitOutcome {
48    let start = Instant::now();
49    loop {
50        match child.try_wait() {
51            Ok(Some(status)) => {
52                return if status.success() {
53                    WaitOutcome::Success
54                } else {
55                    WaitOutcome::Failed
56                };
57            }
58            Ok(None) => {
59                if start.elapsed() >= timeout {
60                    let _ = child.kill();
61                    let _ = child.wait();
62                    return WaitOutcome::TimedOut;
63                }
64                thread::sleep(Duration::from_millis(50));
65            }
66            Err(_) => return WaitOutcome::Failed,
67        }
68    }
69}
70
71/// Whether a missing file's absence can be trusted as a real deletion.
72///
73/// `false` when the parent directory is *also* missing: that means the
74/// directory, or the whole volume, is gone rather than this one file having
75/// been deleted. `videre prune` uses this to avoid deleting every row for an
76/// unmounted drive, which additionally destroys the embeddings and cached
77/// thumbnails for those hashes (hours of recompute, against minutes to
78/// re-scan the rows themselves).
79///
80/// Deliberately not a mount-table lookup. On macOS `/Volumes` reports the same
81/// filesystem as `/` when nothing is mounted there, so an unmounted volume
82/// leaves nothing to query; telling "unmounted" from "deleted directory" apart
83/// exactly needs either platform-specific enumeration or state recorded at
84/// scan time. This rule needs neither and behaves identically on Linux.
85///
86/// Bounded by `run_with_timeout`, because a stale NFS or SMB mount can hang
87/// `metadata` indefinitely and a safety check that hangs is not a safety
88/// check. A timeout returns `false`: an unanswerable question must never
89/// authorise a deletion.
90///
91/// A path with no parent (`/`, or a bare relative name) also returns `false`,
92/// since there is nothing to corroborate the absence against.
93pub fn absence_is_trustworthy(path: &Path) -> bool {
94    let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
95        return false;
96    };
97    let parent = parent.to_path_buf();
98    run_with_timeout(DEFAULT_IO_TIMEOUT, move || parent.is_dir()).unwrap_or(false)
99}
100
101#[cfg(test)]
102mod absence_tests {
103    use super::*;
104
105    fn tmp(name: &str) -> std::path::PathBuf {
106        let d = std::env::temp_dir().join(format!("videre-absence-{}-{name}", std::process::id()));
107        std::fs::create_dir_all(&d).unwrap();
108        d
109    }
110
111    #[test]
112    fn a_missing_file_in_an_existing_directory_is_trustworthy() {
113        let dir = tmp("present");
114        assert!(absence_is_trustworthy(&dir.join("gone.jpg")));
115        let _ = std::fs::remove_dir_all(&dir);
116    }
117
118    #[test]
119    fn a_missing_file_in_a_missing_directory_is_not() {
120        // The unmounted-volume shape: neither the file nor its parent exists.
121        let dir = tmp("absent");
122        let nested = dir.join("subdir");
123        assert!(!absence_is_trustworthy(&nested.join("gone.jpg")));
124        let _ = std::fs::remove_dir_all(&dir);
125    }
126
127    #[test]
128    fn a_real_present_file_is_trustworthy_too() {
129        // The function only judges the parent; callers ask it about paths they
130        // already know are missing, but it must not depend on that.
131        let dir = tmp("realfile");
132        let f = dir.join("here.jpg");
133        std::fs::write(&f, b"x").unwrap();
134        assert!(absence_is_trustworthy(&f));
135        let _ = std::fs::remove_dir_all(&dir);
136    }
137
138    #[test]
139    fn a_path_without_a_usable_parent_is_not_trustworthy() {
140        // Nothing to corroborate the absence against, so refuse rather than
141        // authorise a deletion.
142        assert!(!absence_is_trustworthy(Path::new("/")));
143        assert!(!absence_is_trustworthy(Path::new("bare-name.jpg")));
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn returns_ok_when_operation_finishes_before_timeout() {
153        let result = run_with_timeout(Duration::from_secs(1), || 42);
154        assert!(result.is_ok());
155        assert_eq!(result.ok(), Some(42));
156    }
157
158    #[test]
159    fn returns_timed_out_when_operation_exceeds_timeout() {
160        let result = run_with_timeout(Duration::from_millis(50), || {
161            thread::sleep(Duration::from_secs(5));
162            42
163        });
164        assert!(result.is_err());
165    }
166
167    #[test]
168    fn wait_with_timeout_returns_success_for_fast_process() {
169        let mut child = std::process::Command::new("true").spawn().unwrap();
170        assert_eq!(
171            wait_with_timeout(&mut child, Duration::from_secs(5)),
172            WaitOutcome::Success
173        );
174    }
175
176    #[test]
177    fn wait_with_timeout_kills_and_returns_timed_out_for_slow_process() {
178        let mut child = std::process::Command::new("sleep")
179            .arg("5")
180            .spawn()
181            .unwrap();
182        let start = Instant::now();
183        assert_eq!(
184            wait_with_timeout(&mut child, Duration::from_millis(200)),
185            WaitOutcome::TimedOut
186        );
187        assert!(start.elapsed() < Duration::from_secs(2));
188    }
189}