tatara-lisp-script 0.3.49

tatara-script — standalone interpreter that makes tatara-lisp a full scripting language for nix-run apps. Wraps tatara-lisp-eval with a scripting-oriented stdlib (http, json, yaml, sops, file I/O, env, sha256, string ops) so a .tlisp file can replace a bash script one-for-one.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! `ScratchRegistry` — the interpreter OWNS every temp path it mints.
//!
//! ## The leak this exists to make unrepresentable (measured 2026-07-31)
//!
//! `tmp-dir` / `tmp-file` used to `create_dir_all` under `std::env::temp_dir()`
//! and hand the path back as a bare `Value::Str`. Nothing owned it, so nothing
//! ever removed it. On `rio` that produced:
//!
//! ```text
//! ls -d /tmp/tatara-script-* | wc -l   ->  21,608
//! du -shc /tmp/tatara-script-*         ->  13 GB
//! oldest 05:02, newest 22:26, uptime 17h19m  =>  ~1,250 dirs/hour, since boot
//! ```
//!
//! That box mounts `/tmp` as a **48 GiB tmpfs on 29 GiB of RAM**, so those 13 GB
//! were not disk — they were memory. Combined with ~25 GB of other scratch it
//! filled RAM *and* all 31.9 GiB of swap (`SwapFree: 176 kB`), drove PSI
//! `memory.full avg60` to 92%, and left the OOM killer as the only reclaim
//! path — it killed `comin`'s `git` mid-deploy. A leaked temp dir is not a
//! tidiness problem on a tmpfs host; it is a memory leak that takes the node
//! down.
//!
//! ## Why a registry rather than "remember to delete it"
//!
//! The old signature made the leak the DEFAULT and correctness opt-in: a script
//! had to remember an explicit delete, on every exit path including error. The
//! registry inverts that. `scratch_dir` / `scratch_file` are the only
//! constructors, they always record, and `Drop` always removes — so "created
//! but never cleaned" has no representation. Correctness is what you get by
//! doing nothing.
//!
//! ## Two failure modes, two mechanisms
//!
//! `Drop` covers normal exit, early `return`, and panic-unwind. It CANNOT cover
//! `SIGKILL`, an OOM kill, or a power loss — and on the very host that
//! motivated this, OOM kills were happening three times in six hours. So RAII
//! alone would have left a residue that regrows. [`sweep_stale`] is the
//! reconciler for that path: a bounded, best-effort sweep of *our own* prefix,
//! old enough that no live process can still hold it. Invariant for the normal
//! case, reconciler for the violent one.
//!
//! Escape hatch: set `TATARA_SCRIPT_KEEP_SCRATCH=1` to retain scratch for
//! debugging. It is deliberately an env var rather than a Lisp argument —
//! keeping is an operator's debugging choice, not a script's contract, and a
//! script that could opt into leaking would reopen the class.

use std::path::{Path, PathBuf};

/// How old one of our scratch entries must be before [`sweep_stale`] will
/// remove it. Generously above any plausible script runtime: the sweep must
/// never race a *live* sibling process's scratch, and the cost of waiting is
/// only a few hours of residue after a kill.
const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60);

/// Upper bound on entries removed in one sweep. A sweep runs at interpreter
/// startup, so it must never become the dominant cost of running a script —
/// on the measured host there were 21,608 entries, and unlinking all of them
/// takes minutes. Bounded work per run, repeated across runs, converges
/// without ever making one script pay for the whole backlog.
const SWEEP_BUDGET: usize = 512;

/// The filename prefix every scratch entry carries. Sweeping matches on this,
/// so it must never widen to something another tool could also produce.
const PREFIX: &str = "tatara-script-";

/// Owns the temp paths minted during one interpreter run and removes them on
/// drop.
///
/// Holds `PathBuf`s rather than open handles deliberately: the Lisp side needs
/// a *path string* it can pass to subprocesses, and a script legitimately
/// creates, removes, and recreates files underneath a scratch dir. Ownership
/// here is of the path's lifetime, not of a file descriptor.
#[derive(Debug, Default)]
pub struct ScratchRegistry {
    paths: Vec<PathBuf>,
    /// Distinguishes two scratch paths minted inside the same nanosecond.
    /// `SystemTime::now()` is not guaranteed to advance between two adjacent
    /// calls, and a script doing `(tmp-dir)` twice in a loop is ordinary.
    seq: u64,
}

impl ScratchRegistry {
    /// Mint an owned scratch DIRECTORY and return its path.
    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)
    }

    /// Mint an owned scratch FILE (created empty) and return its 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)
    }

    /// Build a unique path under the system temp dir.
    ///
    /// The name carries the pid as well as the clock: two concurrent
    /// `tatara-script` processes can otherwise mint the same name from the same
    /// nanosecond, and the loser's `Drop` would delete the winner's live
    /// scratch. That is the same class of bug as the one fixed in ami-forge's
    /// secret var-file (pid-only names colliding within a process) — here the
    /// collision is across processes, so the pid is the fix rather than the
    /// cause.
    ///
    /// **The sequence number is PROCESS-GLOBAL, and that is load-bearing.**
    /// It was per-registry, which reintroduced within-a-process collision —
    /// the exact class the comment above names. Two registries constructed in
    /// one process and minting inside one clock tick both produced
    /// `…-{pid}-{now}-0`, and the first to drop deleted the other's live
    /// directory. Observed as a test failing 2 of 3 workspace runs while
    /// passing in isolation, which is the signature: the clock is coarse
    /// enough on a real host that `now` collides, so `now` cannot be the only
    /// discriminator. A global counter makes the name unique by construction
    /// rather than by hoping the clock is fine-grained.
    fn mint(&mut self, suffix: &str) -> PathBuf {
        use std::sync::atomic::{AtomicU64, Ordering};
        static SEQ: AtomicU64 = AtomicU64::new(0);

        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 = SEQ.fetch_add(1, Ordering::Relaxed);
        // Kept for the public `len`/`is_empty` accounting the registry exposes.
        self.seq += 1;
        std::env::temp_dir().join(format!("{PREFIX}{pid}-{now:x}-{seq}{suffix}"))
    }

    /// Number of live scratch entries. Exposed for tests.
    #[must_use]
    pub fn len(&self) -> usize {
        self.paths.len()
    }

    /// Whether the registry currently owns nothing.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.paths.is_empty()
    }
}

/// True when the operator asked to retain scratch for debugging.
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(..) {
            // Best-effort on every path, and DELIBERATELY not short-circuiting
            // on the first error: one undeletable entry (a busy mount, a
            // permission change made by the script itself) must not strand
            // every remaining entry. A failure here is also never propagated —
            // a cleanup error must not mask the script's own exit status.
            let _ = if p.is_dir() {
                std::fs::remove_dir_all(&p)
            } else {
                std::fs::remove_file(&p)
            };
        }
    }
}

/// Remove OUR OWN stale scratch left behind by processes that died without
/// running `Drop` (SIGKILL, OOM kill, power loss).
///
/// Returns the number of entries removed. Best-effort throughout: this runs on
/// the startup path of every script, so it must never fail a run and never
/// dominate its cost.
///
/// Three safety properties, each load-bearing:
/// - **Only our prefix.** Matching is on `tatara-script-`, so the sweep can
///   never touch another tool's scratch — including the operator's own
///   `/tmp/tmp.*` and build artifacts, which on the measured host were far
///   larger than ours and are emphatically not ours to delete.
/// - **Only genuinely old entries.** [`STALE_AFTER`] is hours, so a *live*
///   sibling process's scratch is never eligible. An age check is what makes
///   this safe under concurrency, where a pid check would not be — pids are
///   reused.
/// - **Bounded work.** At most [`SWEEP_BUDGET`] removals per run.
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
}

/// Whether a directory entry is older than [`STALE_AFTER`].
///
/// Uses mtime rather than ctime/atime: a scratch dir being *written to* is
/// evidence it is live, and mtime is the field that tracks that. An entry
/// whose metadata cannot be read is treated as NOT stale — the safe direction,
/// since the cost of skipping is a few leftover bytes and the cost of a false
/// positive is deleting live scratch.
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)
}

/// Path helper for tests + callers that want to reason about our namespace.
#[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::*;

    /// The whole point: a minted dir is gone once the registry drops.
    #[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"
        );
    }

    /// A dir the script filled must still be removable — `remove_dir_all`, not
    /// `remove_dir`. The leaked dirs on rio were not empty; eight were 1.4 GB.
    #[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"
        );
    }

    /// Every entry is cleaned, not just the first — and the registry owns many.
    #[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());
        }
    }

    /// Two paths minted back-to-back must differ. `SystemTime::now()` is not
    /// guaranteed to advance between adjacent calls, so the sequence counter —
    /// not the clock — is what guarantees this.
    #[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);
    }

    /// The name must carry the pid, so two concurrent processes cannot collide
    /// and delete each other's live scratch.
    #[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));
    }

    /// The sweep must never touch a path that is not ours. This is the property
    /// that keeps it from deleting the operator's own /tmp work — which on the
    /// measured host was ~25 GB and explicitly not ours to remove.
    #[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);
    }

    /// A freshly-created scratch entry is NOT stale — otherwise a sweep would
    /// race a live sibling process and delete scratch still in use.
    #[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"
        );
    }

    /// Two registries in ONE process must never mint the same path.
    ///
    /// This is the regression that made `the_sweep_does_not_remove_fresh_entries`
    /// fail 2 of 3 workspace runs while passing in isolation: `seq` was
    /// per-registry, so two registries minting inside one clock tick both
    /// produced `…-{pid}-{now}-0`, and the first to drop removed the other's
    /// live directory.
    #[test]
    fn two_registries_in_one_process_never_collide() {
        let mut a = ScratchRegistry::default();
        let mut b = ScratchRegistry::default();
        let mut seen = std::collections::BTreeSet::new();
        for _ in 0..64 {
            assert!(
                seen.insert(a.dir().expect("a")),
                "collision from registry a"
            );
            assert!(
                seen.insert(b.dir().expect("b")),
                "collision from registry b"
            );
        }
        assert_eq!(seen.len(), 128);
    }

    /// And a sibling registry dropping must not remove another's live dir —
    /// the consequence the collision actually produced.
    #[test]
    fn a_sibling_registry_drop_leaves_our_scratch_alone() {
        let mut mine = ScratchRegistry::default();
        let p = mine.dir().expect("mint");
        {
            let mut other = ScratchRegistry::default();
            let _ = other.dir().expect("mint");
        } // other drops here
        assert!(
            p.exists(),
            "a sibling registry's Drop deleted our live scratch: {p:?}"
        );
    }
}