cortexkit_test_support/
scratch.rs1use 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
6pub const STABLE_BIN_DIR: &str = "stable-bin";
8
9pub 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
26pub 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 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
113pub 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#[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 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 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}