Skip to main content

cortexkit_test_support/
scratch.rs

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