Skip to main content

cortexkit_test_support/
scratch.rs

1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3use std::time::{Duration, Instant, SystemTime};
4use subc_os::process_identity::{liveness, process_start, Liveness, ProcessStart};
5
6/// Shared executable directory exempt from the scratch age sweep.
7pub const STABLE_BIN_DIR: &str = "stable-bin";
8
9/// Per-process scratch root, keyed by PID and the kernel's versioned start identity.
10/// Unsupported platforms use an unknown identity, which cleanup always preserves.
11pub fn scratch_root() -> PathBuf {
12    static ROOT: OnceLock<PathBuf> = OnceLock::new();
13    ROOT.get_or_init(|| {
14        let pid = std::process::id();
15        let start = process_start(pid)
16            .map(|s| s.to_string())
17            .unwrap_or_else(|| "unknown".into());
18        let path = shared_scratch_root().join(format!("k{pid}-{start}"));
19        std::fs::create_dir_all(&path).expect("create process scratch root");
20        real_directory(&path);
21        path
22    })
23    .clone()
24}
25
26/// Shared root under the system temp directory. Initialization sweeps stale fixtures.
27pub fn shared_scratch_root() -> PathBuf {
28    static ROOT: OnceLock<PathBuf> = OnceLock::new();
29    ROOT.get_or_init(|| {
30        let root = std::env::temp_dir().join("cortexkit-tests");
31        std::fs::create_dir_all(&root).expect("create test scratch root");
32        real_directory(&root);
33        sweep(&root);
34        root
35    })
36    .clone()
37}
38
39fn real_directory(path: &Path) {
40    let metadata = std::fs::symlink_metadata(path).expect("inspect scratch directory");
41    assert!(
42        metadata.is_dir() && !metadata.is_symlink(),
43        "scratch root must be a real directory: {}",
44        path.display()
45    );
46}
47
48fn process_root_liveness(name: &str) -> Liveness {
49    let Some((pid, start)) = name.strip_prefix('k').and_then(|n| n.split_once('-')) else {
50        return Liveness::Unknown;
51    };
52    let (Ok(pid), Some(start)) = (pid.parse(), ProcessStart::parse(start)) else {
53        return Liveness::Unknown;
54    };
55    liveness(pid, Some(&start))
56}
57
58fn sweep(root: &Path) {
59    let cutoff = SystemTime::now().checked_sub(Duration::from_secs(24 * 60 * 60));
60    let Ok(entries) = std::fs::read_dir(root) else {
61        return;
62    };
63    for entry in entries.flatten() {
64        if entry.file_name() == STABLE_BIN_DIR {
65            continue;
66        }
67        let path = entry.path();
68        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
69            continue;
70        };
71        if metadata.is_symlink() {
72            continue;
73        }
74        if metadata.is_dir() {
75            // Unknown names and old encodings are not proof that an owner died.
76            // Only an affirmative kernel liveness result permits process-root removal.
77            let name = entry.file_name();
78            let name = name.to_string_lossy();
79            if name.starts_with('k') || name.starts_with('p') {
80                if process_root_liveness(&name) == Liveness::Dead {
81                    let _ = std::fs::remove_dir_all(&path);
82                }
83                continue;
84            }
85        }
86        if cutoff.is_some_and(|cutoff| metadata.modified().is_ok_and(|mtime| mtime < cutoff)) {
87            if metadata.is_dir() {
88                if descendants_are_old(&path, cutoff.unwrap()) {
89                    let _ = std::fs::remove_dir_all(&path);
90                }
91            } else {
92                let _ = std::fs::remove_file(path);
93            }
94        }
95    }
96}
97
98fn descendants_are_old(path: &Path, cutoff: SystemTime) -> bool {
99    let Ok(mut entries) = std::fs::read_dir(path) else {
100        return false;
101    };
102    entries.all(|entry| {
103        let Ok(entry) = entry else { return false };
104        let Ok(metadata) = std::fs::symlink_metadata(entry.path()) else {
105            return false;
106        };
107        !metadata.is_symlink()
108            && metadata.modified().is_ok_and(|mtime| mtime < cutoff)
109            && (!metadata.is_dir() || descendants_are_old(&entry.path(), cutoff))
110    })
111}
112
113/// Wait for a PID to be positively known dead. Unknown never counts as gone.
114/// On unsupported platforms the result is false after the timeout.
115pub fn wait_until_gone(pid: i32, timeout: Duration) -> bool {
116    let Ok(pid) = u32::try_from(pid) else {
117        return false;
118    };
119    let started = process_start(pid);
120    let deadline = Instant::now() + timeout;
121    loop {
122        if liveness(pid, started.as_ref()) == Liveness::Dead {
123            return true;
124        }
125        if Instant::now() >= deadline {
126            return false;
127        }
128        std::thread::park_timeout(Duration::from_millis(10));
129    }
130}
131
132/// Owned scratch fixture. Drop removes it unless unwinding or explicitly kept.
133#[derive(Debug)]
134pub struct ScratchDir {
135    path: PathBuf,
136    kept: bool,
137}
138
139impl ScratchDir {
140    pub fn new(prefix: &str) -> Self {
141        use std::sync::atomic::{AtomicU64, Ordering};
142        static NEXT: AtomicU64 = AtomicU64::new(0);
143        assert!(!prefix.is_empty() && !prefix.contains('/') && !prefix.contains('\\'));
144        loop {
145            let path = scratch_root().join(format!(
146                "{prefix}-{}-{}",
147                std::process::id(),
148                NEXT.fetch_add(1, Ordering::Relaxed)
149            ));
150            match std::fs::create_dir(&path) {
151                Ok(()) => return Self { path, kept: false },
152                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
153                Err(e) => panic!("create scratch directory {}: {e}", path.display()),
154            }
155        }
156    }
157    pub fn path(&self) -> &Path {
158        &self.path
159    }
160    /// Preserve this fixture and print its path for investigation.
161    pub fn keep(mut self) -> PathBuf {
162        self.kept = true;
163        self.path.clone()
164    }
165}
166impl AsRef<Path> for ScratchDir {
167    fn as_ref(&self) -> &Path {
168        self.path()
169    }
170}
171impl std::ops::Deref for ScratchDir {
172    type Target = Path;
173    fn deref(&self) -> &Path {
174        self.path()
175    }
176}
177impl Drop for ScratchDir {
178    fn drop(&mut self) {
179        if self.kept || std::thread::panicking() {
180            eprintln!("ScratchDir preserved: {}", self.path.display());
181        } else if let Err(e) = std::fs::remove_dir_all(&self.path) {
182            eprintln!("remove scratch directory {}: {e}", self.path.display());
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    fn old(path: &Path) {
191        let mut options = std::fs::OpenOptions::new();
192        options.read(true);
193        #[cfg(windows)]
194        {
195            use std::os::windows::fs::OpenOptionsExt;
196            // Windows requires backup semantics to open a directory for timestamps.
197            const FILE_WRITE_ATTRIBUTES: u32 = 0x0000_0100;
198            const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
199            options
200                .access_mode(FILE_WRITE_ATTRIBUTES)
201                .custom_flags(FILE_FLAG_BACKUP_SEMANTICS);
202        }
203        options
204            .open(path)
205            .unwrap()
206            .set_times(
207                std::fs::FileTimes::new()
208                    .set_modified(SystemTime::now() - Duration::from_secs(48 * 3600)),
209            )
210            .unwrap();
211    }
212    #[test]
213    fn success_drop_removes_the_tree() {
214        let dir = ScratchDir::new("normal");
215        let path = dir.path().to_owned();
216        assert!(path.exists());
217        drop(dir);
218        assert!(!path.exists());
219    }
220    #[test]
221    fn keep_preserves_without_panic() {
222        let path = ScratchDir::new("keep").keep();
223        assert!(path.exists());
224        std::fs::remove_dir_all(path).unwrap();
225    }
226    #[test]
227    fn panic_preserves_the_tree() {
228        let mut path = None;
229        assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
230            let dir = ScratchDir::new("panic");
231            path = Some(dir.path().to_owned());
232            panic!("intentional panic");
233        }))
234        .is_err());
235        let path = path.unwrap();
236        assert!(path.exists());
237        std::fs::remove_dir_all(path).unwrap();
238    }
239    #[test]
240    fn unknown_encoding_root_is_kept() {
241        let dir = ScratchDir::new("sweep-unknown");
242        for name in [
243            "k0-future-v2-start",
244            "p999999-00",
245            "kgarbled",
246            "p-unparseable",
247        ] {
248            let path = dir.join(name);
249            std::fs::create_dir(&path).unwrap();
250            old(&path);
251            sweep(&dir);
252            assert!(path.exists(), "unknown root {name} removed");
253        }
254    }
255    #[cfg(unix)]
256    #[test]
257    fn reaped_child_root_is_removed() {
258        let dir = ScratchDir::new("sweep-reaped");
259        let mut child = std::process::Command::new("sleep")
260            .arg("30")
261            .spawn()
262            .unwrap();
263        let start = process_start(child.id()).unwrap();
264        let path = dir.join(format!("k{}-{start}", child.id()));
265        std::fs::create_dir(&path).unwrap();
266        child.kill().unwrap();
267        child.wait().unwrap();
268        sweep(&dir);
269        assert!(!path.exists());
270    }
271    #[cfg(unix)]
272    #[test]
273    fn live_pid_different_same_family_start_is_removed() {
274        let dir = ScratchDir::new("sweep-reuse");
275        let pid = std::process::id();
276        let start = process_start(pid).unwrap();
277        let other = if start.as_str().starts_with("linux-") {
278            "linux-v1-0"
279        } else {
280            "macos-v1-0-0"
281        };
282        assert_ne!(start.as_str(), other);
283        let live = dir.join(format!("k{pid}-{start}"));
284        let reused = dir.join(format!("k{pid}-{other}"));
285        for path in [&live, &reused] {
286            std::fs::create_dir(path).unwrap();
287        }
288        sweep(&dir);
289        assert!(live.exists());
290        assert!(!reused.exists());
291    }
292    #[test]
293    fn stable_bin_is_kept_and_old_files_are_removed() {
294        let dir = ScratchDir::new("sweep-age");
295        let stable = dir.join(STABLE_BIN_DIR);
296        std::fs::create_dir(&stable).unwrap();
297        std::fs::write(stable.join("probe"), "keep").unwrap();
298        old(&stable);
299        let stale = dir.join("old-file");
300        std::fs::write(&stale, "old").unwrap();
301        old(&stale);
302        let young = dir.join("young-file");
303        std::fs::write(&young, "young").unwrap();
304        let old_dir = dir.join("old-dir");
305        std::fs::create_dir(&old_dir).unwrap();
306        old(&old_dir);
307        let active = dir.join("old-parent");
308        std::fs::create_dir(&active).unwrap();
309        std::fs::write(active.join("young"), "keep").unwrap();
310        old(&active);
311        sweep(&dir);
312        assert!(stable.join("probe").exists());
313        assert!(!stale.exists());
314        assert!(!old_dir.exists());
315        assert!(active.join("young").exists());
316        assert!(young.exists());
317    }
318    #[cfg(unix)]
319    #[test]
320    fn symlink_is_kept() {
321        let dir = ScratchDir::new("sweep-link");
322        let target = ScratchDir::new("outside");
323        std::os::unix::fs::symlink(target.path(), dir.join("k123-linux-v1-0")).unwrap();
324        sweep(&dir);
325        assert!(std::fs::symlink_metadata(dir.join("k123-linux-v1-0"))
326            .unwrap()
327            .is_symlink());
328        assert!(target.exists());
329    }
330    #[cfg(target_os = "linux")]
331    #[test]
332    fn an_exited_unreaped_child_is_not_alive() {
333        let mut child = std::process::Command::new("true").spawn().unwrap();
334        let gone = wait_until_gone(child.id() as i32, Duration::from_secs(5));
335        let found = rustix::process::test_kill_process(
336            rustix::process::Pid::from_raw(child.id() as i32).unwrap(),
337        )
338        .is_ok();
339        child.wait().unwrap();
340        assert!(found);
341        assert!(gone);
342    }
343    #[cfg(unix)]
344    #[test]
345    fn a_running_child_is_alive() {
346        let mut child = std::process::Command::new("sleep")
347            .arg("30")
348            .spawn()
349            .unwrap();
350        let gone = wait_until_gone(child.id() as i32, Duration::from_millis(20));
351        child.kill().unwrap();
352        child.wait().unwrap();
353        assert!(!gone);
354    }
355}