use std::path::{Path, PathBuf};
const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60);
const SWEEP_BUDGET: usize = 512;
const PREFIX: &str = "tatara-script-";
#[derive(Debug, Default)]
pub struct ScratchRegistry {
paths: Vec<PathBuf>,
seq: u64,
}
impl ScratchRegistry {
pub fn dir(&mut self) -> std::io::Result<PathBuf> {
let path = self.mint("");
std::fs::create_dir_all(&path)?;
self.paths.push(path.clone());
Ok(path)
}
pub fn file(&mut self) -> std::io::Result<PathBuf> {
let path = self.mint(".tmp");
std::fs::write(&path, b"")?;
self.paths.push(path.clone());
Ok(path)
}
fn mint(&mut self, suffix: &str) -> PathBuf {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let pid = std::process::id();
let seq = self.seq;
self.seq += 1;
std::env::temp_dir().join(format!("{PREFIX}{pid}-{now:x}-{seq}{suffix}"))
}
#[must_use]
pub fn len(&self) -> usize {
self.paths.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
}
fn keep_requested() -> bool {
std::env::var_os("TATARA_SCRIPT_KEEP_SCRATCH").is_some_and(|v| v != "0" && v != "")
}
impl Drop for ScratchRegistry {
fn drop(&mut self) {
if keep_requested() {
return;
}
for p in self.paths.drain(..) {
let _ = if p.is_dir() {
std::fs::remove_dir_all(&p)
} else {
std::fs::remove_file(&p)
};
}
}
}
pub fn sweep_stale() -> usize {
if keep_requested() {
return 0;
}
let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
return 0;
};
let now = std::time::SystemTime::now();
let mut removed = 0usize;
for entry in entries.flatten() {
if removed >= SWEEP_BUDGET {
break;
}
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(PREFIX) {
continue;
}
if !is_stale(&entry, now) {
continue;
}
let path = entry.path();
let ok = if path.is_dir() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
if ok.is_ok() {
removed += 1;
}
}
removed
}
fn is_stale(entry: &std::fs::DirEntry, now: std::time::SystemTime) -> bool {
let Ok(meta) = entry.metadata() else {
return false;
};
let Ok(mtime) = meta.modified() else {
return false;
};
now.duration_since(mtime).is_ok_and(|age| age >= STALE_AFTER)
}
#[must_use]
pub fn is_scratch_path(p: &Path) -> bool {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(PREFIX))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_scratch_dir_is_removed_on_drop() {
let path = {
let mut r = ScratchRegistry::default();
let p = r.dir().expect("mint dir");
assert!(p.is_dir(), "the dir must exist while the registry lives");
p
};
assert!(
!path.exists(),
"a scratch dir must not outlive the interpreter — this is the leak \
that put 21,608 dirs and 13 GB into rio's tmpfs"
);
}
#[test]
fn a_scratch_file_is_removed_on_drop() {
let path = {
let mut r = ScratchRegistry::default();
let p = r.file().expect("mint file");
assert!(p.is_file());
p
};
assert!(!path.exists(), "a scratch file must not outlive the interpreter");
}
#[test]
fn a_non_empty_scratch_dir_is_still_removed() {
let path = {
let mut r = ScratchRegistry::default();
let p = r.dir().expect("mint dir");
std::fs::create_dir_all(p.join("nested/deeper")).expect("nest");
std::fs::write(p.join("nested/deeper/file.txt"), b"content").expect("write");
p
};
assert!(!path.exists(), "a non-empty scratch dir must still be removed");
}
#[test]
fn all_entries_are_removed_not_only_the_first() {
let paths: Vec<PathBuf> = {
let mut r = ScratchRegistry::default();
let v = (0..5).map(|_| r.dir().expect("mint")).collect::<Vec<_>>();
assert_eq!(r.len(), 5);
v
};
for p in paths {
assert!(!p.exists(), "{} survived", p.display());
}
}
#[test]
fn two_paths_minted_in_the_same_instant_are_distinct() {
let mut r = ScratchRegistry::default();
let a = r.dir().expect("a");
let b = r.dir().expect("b");
assert_ne!(a, b, "a collision would make one script delete another's scratch");
assert_eq!(r.len(), 2);
}
#[test]
fn the_path_is_process_scoped() {
let mut r = ScratchRegistry::default();
let p = r.dir().expect("mint");
let name = p.file_name().unwrap().to_string_lossy().to_string();
assert!(
name.contains(&std::process::id().to_string()),
"expected pid in {name:?}"
);
assert!(is_scratch_path(&p));
}
#[test]
fn the_sweep_ignores_paths_that_are_not_ours() {
let foreign = std::env::temp_dir().join(format!("NOT-OURS-{}", std::process::id()));
std::fs::create_dir_all(&foreign).expect("create foreign");
sweep_stale();
assert!(
foreign.exists(),
"the sweep must only ever match its own prefix"
);
let _ = std::fs::remove_dir_all(&foreign);
}
#[test]
fn the_sweep_does_not_remove_fresh_entries() {
let mut r = ScratchRegistry::default();
let p = r.dir().expect("mint");
sweep_stale();
assert!(
p.exists(),
"a live process's scratch must survive another process's sweep"
);
}
}