tatara_lisp_script/scratch.rs
1//! `ScratchRegistry` — the interpreter OWNS every temp path it mints.
2//!
3//! ## The leak this exists to make unrepresentable (measured 2026-07-31)
4//!
5//! `tmp-dir` / `tmp-file` used to `create_dir_all` under `std::env::temp_dir()`
6//! and hand the path back as a bare `Value::Str`. Nothing owned it, so nothing
7//! ever removed it. On `rio` that produced:
8//!
9//! ```text
10//! ls -d /tmp/tatara-script-* | wc -l -> 21,608
11//! du -shc /tmp/tatara-script-* -> 13 GB
12//! oldest 05:02, newest 22:26, uptime 17h19m => ~1,250 dirs/hour, since boot
13//! ```
14//!
15//! That box mounts `/tmp` as a **48 GiB tmpfs on 29 GiB of RAM**, so those 13 GB
16//! were not disk — they were memory. Combined with ~25 GB of other scratch it
17//! filled RAM *and* all 31.9 GiB of swap (`SwapFree: 176 kB`), drove PSI
18//! `memory.full avg60` to 92%, and left the OOM killer as the only reclaim
19//! path — it killed `comin`'s `git` mid-deploy. A leaked temp dir is not a
20//! tidiness problem on a tmpfs host; it is a memory leak that takes the node
21//! down.
22//!
23//! ## Why a registry rather than "remember to delete it"
24//!
25//! The old signature made the leak the DEFAULT and correctness opt-in: a script
26//! had to remember an explicit delete, on every exit path including error. The
27//! registry inverts that. `scratch_dir` / `scratch_file` are the only
28//! constructors, they always record, and `Drop` always removes — so "created
29//! but never cleaned" has no representation. Correctness is what you get by
30//! doing nothing.
31//!
32//! ## Two failure modes, two mechanisms
33//!
34//! `Drop` covers normal exit, early `return`, and panic-unwind. It CANNOT cover
35//! `SIGKILL`, an OOM kill, or a power loss — and on the very host that
36//! motivated this, OOM kills were happening three times in six hours. So RAII
37//! alone would have left a residue that regrows. [`sweep_stale`] is the
38//! reconciler for that path: a bounded, best-effort sweep of *our own* prefix,
39//! old enough that no live process can still hold it. Invariant for the normal
40//! case, reconciler for the violent one.
41//!
42//! Escape hatch: set `TATARA_SCRIPT_KEEP_SCRATCH=1` to retain scratch for
43//! debugging. It is deliberately an env var rather than a Lisp argument —
44//! keeping is an operator's debugging choice, not a script's contract, and a
45//! script that could opt into leaking would reopen the class.
46
47use std::path::{Path, PathBuf};
48
49/// How old one of our scratch entries must be before [`sweep_stale`] will
50/// remove it. Generously above any plausible script runtime: the sweep must
51/// never race a *live* sibling process's scratch, and the cost of waiting is
52/// only a few hours of residue after a kill.
53const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60);
54
55/// Upper bound on entries removed in one sweep. A sweep runs at interpreter
56/// startup, so it must never become the dominant cost of running a script —
57/// on the measured host there were 21,608 entries, and unlinking all of them
58/// takes minutes. Bounded work per run, repeated across runs, converges
59/// without ever making one script pay for the whole backlog.
60const SWEEP_BUDGET: usize = 512;
61
62/// The filename prefix every scratch entry carries. Sweeping matches on this,
63/// so it must never widen to something another tool could also produce.
64const PREFIX: &str = "tatara-script-";
65
66/// Owns the temp paths minted during one interpreter run and removes them on
67/// drop.
68///
69/// Holds `PathBuf`s rather than open handles deliberately: the Lisp side needs
70/// a *path string* it can pass to subprocesses, and a script legitimately
71/// creates, removes, and recreates files underneath a scratch dir. Ownership
72/// here is of the path's lifetime, not of a file descriptor.
73#[derive(Debug, Default)]
74pub struct ScratchRegistry {
75 paths: Vec<PathBuf>,
76 /// Distinguishes two scratch paths minted inside the same nanosecond.
77 /// `SystemTime::now()` is not guaranteed to advance between two adjacent
78 /// calls, and a script doing `(tmp-dir)` twice in a loop is ordinary.
79 seq: u64,
80}
81
82impl ScratchRegistry {
83 /// Mint an owned scratch DIRECTORY and return its path.
84 pub fn dir(&mut self) -> std::io::Result<PathBuf> {
85 let path = self.mint("");
86 std::fs::create_dir_all(&path)?;
87 self.paths.push(path.clone());
88 Ok(path)
89 }
90
91 /// Mint an owned scratch FILE (created empty) and return its path.
92 pub fn file(&mut self) -> std::io::Result<PathBuf> {
93 let path = self.mint(".tmp");
94 std::fs::write(&path, b"")?;
95 self.paths.push(path.clone());
96 Ok(path)
97 }
98
99 /// Build a unique path under the system temp dir.
100 ///
101 /// The name carries the pid as well as the clock: two concurrent
102 /// `tatara-script` processes can otherwise mint the same name from the same
103 /// nanosecond, and the loser's `Drop` would delete the winner's live
104 /// scratch. That is the same class of bug as the one fixed in ami-forge's
105 /// secret var-file (pid-only names colliding within a process) — here the
106 /// collision is across processes, so the pid is the fix rather than the
107 /// cause.
108 fn mint(&mut self, suffix: &str) -> PathBuf {
109 let now = std::time::SystemTime::now()
110 .duration_since(std::time::UNIX_EPOCH)
111 .map_or(0, |d| d.as_nanos());
112 let pid = std::process::id();
113 let seq = self.seq;
114 self.seq += 1;
115 std::env::temp_dir().join(format!("{PREFIX}{pid}-{now:x}-{seq}{suffix}"))
116 }
117
118 /// Number of live scratch entries. Exposed for tests.
119 #[must_use]
120 pub fn len(&self) -> usize {
121 self.paths.len()
122 }
123
124 /// Whether the registry currently owns nothing.
125 #[must_use]
126 pub fn is_empty(&self) -> bool {
127 self.paths.is_empty()
128 }
129}
130
131/// True when the operator asked to retain scratch for debugging.
132fn keep_requested() -> bool {
133 std::env::var_os("TATARA_SCRIPT_KEEP_SCRATCH").is_some_and(|v| v != "0" && v != "")
134}
135
136impl Drop for ScratchRegistry {
137 fn drop(&mut self) {
138 if keep_requested() {
139 return;
140 }
141 for p in self.paths.drain(..) {
142 // Best-effort on every path, and DELIBERATELY not short-circuiting
143 // on the first error: one undeletable entry (a busy mount, a
144 // permission change made by the script itself) must not strand
145 // every remaining entry. A failure here is also never propagated —
146 // a cleanup error must not mask the script's own exit status.
147 let _ = if p.is_dir() {
148 std::fs::remove_dir_all(&p)
149 } else {
150 std::fs::remove_file(&p)
151 };
152 }
153 }
154}
155
156/// Remove OUR OWN stale scratch left behind by processes that died without
157/// running `Drop` (SIGKILL, OOM kill, power loss).
158///
159/// Returns the number of entries removed. Best-effort throughout: this runs on
160/// the startup path of every script, so it must never fail a run and never
161/// dominate its cost.
162///
163/// Three safety properties, each load-bearing:
164/// - **Only our prefix.** Matching is on `tatara-script-`, so the sweep can
165/// never touch another tool's scratch — including the operator's own
166/// `/tmp/tmp.*` and build artifacts, which on the measured host were far
167/// larger than ours and are emphatically not ours to delete.
168/// - **Only genuinely old entries.** [`STALE_AFTER`] is hours, so a *live*
169/// sibling process's scratch is never eligible. An age check is what makes
170/// this safe under concurrency, where a pid check would not be — pids are
171/// reused.
172/// - **Bounded work.** At most [`SWEEP_BUDGET`] removals per run.
173pub fn sweep_stale() -> usize {
174 if keep_requested() {
175 return 0;
176 }
177 let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
178 return 0;
179 };
180 let now = std::time::SystemTime::now();
181 let mut removed = 0usize;
182 for entry in entries.flatten() {
183 if removed >= SWEEP_BUDGET {
184 break;
185 }
186 let name = entry.file_name();
187 let Some(name) = name.to_str() else { continue };
188 if !name.starts_with(PREFIX) {
189 continue;
190 }
191 if !is_stale(&entry, now) {
192 continue;
193 }
194 let path = entry.path();
195 let ok = if path.is_dir() {
196 std::fs::remove_dir_all(&path)
197 } else {
198 std::fs::remove_file(&path)
199 };
200 if ok.is_ok() {
201 removed += 1;
202 }
203 }
204 removed
205}
206
207/// Whether a directory entry is older than [`STALE_AFTER`].
208///
209/// Uses mtime rather than ctime/atime: a scratch dir being *written to* is
210/// evidence it is live, and mtime is the field that tracks that. An entry
211/// whose metadata cannot be read is treated as NOT stale — the safe direction,
212/// since the cost of skipping is a few leftover bytes and the cost of a false
213/// positive is deleting live scratch.
214fn is_stale(entry: &std::fs::DirEntry, now: std::time::SystemTime) -> bool {
215 let Ok(meta) = entry.metadata() else {
216 return false;
217 };
218 let Ok(mtime) = meta.modified() else {
219 return false;
220 };
221 now.duration_since(mtime).is_ok_and(|age| age >= STALE_AFTER)
222}
223
224/// Path helper for tests + callers that want to reason about our namespace.
225#[must_use]
226pub fn is_scratch_path(p: &Path) -> bool {
227 p.file_name()
228 .and_then(|n| n.to_str())
229 .is_some_and(|n| n.starts_with(PREFIX))
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 /// The whole point: a minted dir is gone once the registry drops.
237 #[test]
238 fn a_scratch_dir_is_removed_on_drop() {
239 let path = {
240 let mut r = ScratchRegistry::default();
241 let p = r.dir().expect("mint dir");
242 assert!(p.is_dir(), "the dir must exist while the registry lives");
243 p
244 };
245 assert!(
246 !path.exists(),
247 "a scratch dir must not outlive the interpreter — this is the leak \
248 that put 21,608 dirs and 13 GB into rio's tmpfs"
249 );
250 }
251
252 #[test]
253 fn a_scratch_file_is_removed_on_drop() {
254 let path = {
255 let mut r = ScratchRegistry::default();
256 let p = r.file().expect("mint file");
257 assert!(p.is_file());
258 p
259 };
260 assert!(!path.exists(), "a scratch file must not outlive the interpreter");
261 }
262
263 /// A dir the script filled must still be removable — `remove_dir_all`, not
264 /// `remove_dir`. The leaked dirs on rio were not empty; eight were 1.4 GB.
265 #[test]
266 fn a_non_empty_scratch_dir_is_still_removed() {
267 let path = {
268 let mut r = ScratchRegistry::default();
269 let p = r.dir().expect("mint dir");
270 std::fs::create_dir_all(p.join("nested/deeper")).expect("nest");
271 std::fs::write(p.join("nested/deeper/file.txt"), b"content").expect("write");
272 p
273 };
274 assert!(!path.exists(), "a non-empty scratch dir must still be removed");
275 }
276
277 /// Every entry is cleaned, not just the first — and the registry owns many.
278 #[test]
279 fn all_entries_are_removed_not_only_the_first() {
280 let paths: Vec<PathBuf> = {
281 let mut r = ScratchRegistry::default();
282 let v = (0..5).map(|_| r.dir().expect("mint")).collect::<Vec<_>>();
283 assert_eq!(r.len(), 5);
284 v
285 };
286 for p in paths {
287 assert!(!p.exists(), "{} survived", p.display());
288 }
289 }
290
291 /// Two paths minted back-to-back must differ. `SystemTime::now()` is not
292 /// guaranteed to advance between adjacent calls, so the sequence counter —
293 /// not the clock — is what guarantees this.
294 #[test]
295 fn two_paths_minted_in_the_same_instant_are_distinct() {
296 let mut r = ScratchRegistry::default();
297 let a = r.dir().expect("a");
298 let b = r.dir().expect("b");
299 assert_ne!(a, b, "a collision would make one script delete another's scratch");
300 assert_eq!(r.len(), 2);
301 }
302
303 /// The name must carry the pid, so two concurrent processes cannot collide
304 /// and delete each other's live scratch.
305 #[test]
306 fn the_path_is_process_scoped() {
307 let mut r = ScratchRegistry::default();
308 let p = r.dir().expect("mint");
309 let name = p.file_name().unwrap().to_string_lossy().to_string();
310 assert!(
311 name.contains(&std::process::id().to_string()),
312 "expected pid in {name:?}"
313 );
314 assert!(is_scratch_path(&p));
315 }
316
317 /// The sweep must never touch a path that is not ours. This is the property
318 /// that keeps it from deleting the operator's own /tmp work — which on the
319 /// measured host was ~25 GB and explicitly not ours to remove.
320 #[test]
321 fn the_sweep_ignores_paths_that_are_not_ours() {
322 let foreign = std::env::temp_dir().join(format!("NOT-OURS-{}", std::process::id()));
323 std::fs::create_dir_all(&foreign).expect("create foreign");
324 sweep_stale();
325 assert!(
326 foreign.exists(),
327 "the sweep must only ever match its own prefix"
328 );
329 let _ = std::fs::remove_dir_all(&foreign);
330 }
331
332 /// A freshly-created scratch entry is NOT stale — otherwise a sweep would
333 /// race a live sibling process and delete scratch still in use.
334 #[test]
335 fn the_sweep_does_not_remove_fresh_entries() {
336 let mut r = ScratchRegistry::default();
337 let p = r.dir().expect("mint");
338 sweep_stale();
339 assert!(
340 p.exists(),
341 "a live process's scratch must survive another process's sweep"
342 );
343 }
344}