cortexkit_test_support/
scratch.rs1use 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
7pub const STABLE_BIN_DIR: &str = "stable-bin";
9
10pub 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
27pub 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 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
114pub 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
133pub 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
143pub 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#[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 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 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}