2026-08-05 18:05:04 -04:00
|
|
|
//! Scratch directories for tests.
|
|
|
|
|
//!
|
|
|
|
|
//! Public and `#[doc(hidden)]` rather than `#[cfg(test)]`: the `tests/`
|
2026-08-09 16:25:43 -04:00
|
|
|
//! integration binaries and the GUI crate are separate compilation units, so
|
|
|
|
|
//! a test-gated item here would be invisible to them.
|
2026-08-05 18:05:04 -04:00
|
|
|
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Distinguishes directories requested within one process; a timestamp alone
|
|
|
|
|
/// lets two tests in the same millisecond collide.
|
2026-08-05 18:05:04 -04:00
|
|
|
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
|
|
2026-08-09 18:36:47 -04:00
|
|
|
/// The compressed body [`crate::db::repo::set_content_done`] wants, for tests
|
|
|
|
|
/// that only care that a sidecar row gets written.
|
|
|
|
|
///
|
|
|
|
|
/// Production callers compress a whole batch through one
|
|
|
|
|
/// [`crate::db::repo::DocEncoder`] before taking the connection lock; a test
|
|
|
|
|
/// writing one row has nothing to amortize and wants the one-liner.
|
|
|
|
|
pub fn zstd_of(text: &str) -> Option<Vec<u8>> {
|
|
|
|
|
crate::db::repo::encode_one(text, true).expect("zstd encode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
/// How old a leftover scratch directory must be before [`sweep_stale`] takes
|
|
|
|
|
/// it. Far longer than any test run, so a failure investigated the same day —
|
|
|
|
|
/// or the next morning — still has its tree.
|
|
|
|
|
const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(12 * 60 * 60);
|
|
|
|
|
|
|
|
|
|
/// Whether `name` is one of [`scratch_dir`]'s own directories.
|
|
|
|
|
///
|
|
|
|
|
/// Matched on the *shape* — `quicksearch-{tag}-{pid}-{seq}`, so the last two
|
|
|
|
|
/// dash-separated components must be numbers — rather than on the
|
|
|
|
|
/// `quicksearch-` prefix alone. `packaging/capture.sh` keeps its output in
|
|
|
|
|
/// `quicksearch-capture` in the same directory, and a prefix match would eat a
|
|
|
|
|
/// capture run's screenshots along with the litter.
|
|
|
|
|
fn is_scratch_name(name: &str) -> bool {
|
|
|
|
|
let Some(rest) = name.strip_prefix("quicksearch-") else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let numeric = |part: Option<&str>| {
|
|
|
|
|
part.is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
|
|
|
|
|
};
|
|
|
|
|
let mut tail = rest.rsplitn(3, '-');
|
|
|
|
|
// seq, then pid, and a tag must remain in front of them.
|
|
|
|
|
numeric(tail.next()) && numeric(tail.next()) && tail.next().is_some_and(|tag| !tag.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove scratch directories left by runs that are long over.
|
|
|
|
|
///
|
|
|
|
|
/// Nothing here cleans up on the way *out*: a failed test's tree is most of
|
|
|
|
|
/// the evidence, which is why [`scratch_dir`] deliberately leaves it. But
|
|
|
|
|
/// passing tests leave theirs too, and most never remove it — so the temp
|
|
|
|
|
/// directory grew by roughly three hundred directories per full run and had
|
|
|
|
|
/// accumulated some nine thousand of them. Where `/tmp` is a tmpfs that is
|
|
|
|
|
/// gigabytes of RAM, which slows the whole suite and pushes the
|
|
|
|
|
/// timing-sensitive tests toward their budgets.
|
|
|
|
|
///
|
|
|
|
|
/// Sweeping on the way *in* keeps both halves: this run's evidence survives,
|
|
|
|
|
/// and so does yesterday's, while nothing accumulates without bound. Only
|
|
|
|
|
/// [`scratch_dir`]'s own naming is touched.
|
|
|
|
|
fn sweep_stale() {
|
|
|
|
|
let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let now = std::time::SystemTime::now();
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let name = entry.file_name();
|
|
|
|
|
if !name.to_str().is_some_and(is_scratch_name) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let stale = entry
|
|
|
|
|
.metadata()
|
|
|
|
|
.and_then(|m| m.modified())
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|t| now.duration_since(t).ok())
|
|
|
|
|
.is_some_and(|age| age >= STALE_AFTER);
|
|
|
|
|
if stale {
|
|
|
|
|
// Best effort throughout: two test binaries starting together race
|
|
|
|
|
// on the same directory and one of them loses, which is fine.
|
|
|
|
|
std::fs::remove_dir_all(entry.path()).ok();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
/// A fresh, empty directory under the system temp dir, named for `tag`.
|
2026-08-17 19:26:18 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Not cleaned up on drop: when a test fails, the tree it built is most of
|
2026-08-17 19:26:18 -04:00
|
|
|
/// the evidence. Long-dead runs' trees are swept once per process instead —
|
|
|
|
|
/// see [`sweep_stale`]. Panics — a test that cannot create a directory has
|
|
|
|
|
/// nothing left to assert.
|
2026-08-05 18:05:04 -04:00
|
|
|
#[doc(hidden)]
|
|
|
|
|
pub fn scratch_dir(tag: &str) -> PathBuf {
|
2026-08-17 19:26:18 -04:00
|
|
|
static SWEPT: std::sync::Once = std::sync::Once::new();
|
|
|
|
|
SWEPT.call_once(sweep_stale);
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
let mut p = std::env::temp_dir();
|
|
|
|
|
p.push(format!(
|
|
|
|
|
"quicksearch-{}-{}-{}",
|
|
|
|
|
tag,
|
|
|
|
|
std::process::id(),
|
|
|
|
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
|
|
|
));
|
|
|
|
|
std::fs::create_dir_all(&p).expect("create scratch dir");
|
|
|
|
|
p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// [`scratch_dir`] canonicalized, for the tests that compare walked paths
|
|
|
|
|
/// against the root they were given. On macOS `/tmp` is a symlink to
|
|
|
|
|
/// `/private/tmp`, so an uncanonicalized root and a walked path disagree.
|
|
|
|
|
#[doc(hidden)]
|
|
|
|
|
pub fn scratch_dir_canonical(tag: &str) -> PathBuf {
|
|
|
|
|
std::fs::canonicalize(scratch_dir(tag)).expect("canonicalize scratch dir")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Write `body` to `path`, creating parent directories as needed.
|
|
|
|
|
#[doc(hidden)]
|
|
|
|
|
pub fn touch(path: &std::path::Path, body: &[u8]) {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent).expect("create parent dir");
|
|
|
|
|
}
|
|
|
|
|
std::fs::write(path, body).expect("write file");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Power-of-two bucket, so memory-map sizes group by what allocated them
|
|
|
|
|
/// rather than by their exact size. Shared by the memory probes.
|
|
|
|
|
#[doc(hidden)]
|
|
|
|
|
pub fn size_class(bytes: u64) -> String {
|
|
|
|
|
let mib = bytes as f64 / (1024.0 * 1024.0);
|
|
|
|
|
if mib < 1.0 {
|
|
|
|
|
"< 1 MiB".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
let bucket = 1u64 << (63 - (bytes / (1024 * 1024)).leading_zeros() as u64);
|
|
|
|
|
format!("~{} MiB", bucket)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[doc(hidden)]
|
|
|
|
|
pub fn mib(bytes: u64) -> String {
|
|
|
|
|
format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_call_gets_its_own_empty_directory() {
|
|
|
|
|
let a = scratch_dir("selftest");
|
|
|
|
|
let b = scratch_dir("selftest");
|
|
|
|
|
assert_ne!(a, b, "two calls must not collide");
|
|
|
|
|
for d in [&a, &b] {
|
|
|
|
|
assert!(d.is_dir());
|
|
|
|
|
assert_eq!(std::fs::read_dir(d).unwrap().count(), 0, "starts empty");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn touch_creates_missing_parents() {
|
|
|
|
|
let dir = scratch_dir("selftest-touch");
|
|
|
|
|
let deep = dir.join("a/b/c.txt");
|
|
|
|
|
touch(&deep, b"hi");
|
|
|
|
|
assert_eq!(std::fs::read(&deep).unwrap(), b"hi");
|
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
|
|
|
|
/// The sweep runs against a shared temp directory, so what it matches is
|
|
|
|
|
/// the whole safety argument. `quicksearch-capture` is the one that would
|
|
|
|
|
/// hurt: `packaging/capture.sh` puts a run's screenshots and screencasts
|
|
|
|
|
/// there, and a prefix match would delete them mid-capture.
|
|
|
|
|
#[test]
|
|
|
|
|
fn only_scratch_directories_are_swept() {
|
|
|
|
|
for ours in [
|
|
|
|
|
"quicksearch-coord-1234-0",
|
|
|
|
|
"quicksearch-stall-heavy-1001402-7",
|
|
|
|
|
"quicksearch-a-0-0",
|
|
|
|
|
// Tags contain dashes of their own; only the last two components
|
|
|
|
|
// are read as numbers.
|
|
|
|
|
"quicksearch-sniff-binary-db-2621744-1",
|
|
|
|
|
] {
|
|
|
|
|
assert!(is_scratch_name(ours), "{ours} should be swept");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for theirs in [
|
|
|
|
|
// The capture output directory, the reason this is a shape match.
|
|
|
|
|
"quicksearch-capture",
|
|
|
|
|
"quicksearch",
|
|
|
|
|
"quicksearch-",
|
|
|
|
|
// A tag but no pid/seq pair.
|
|
|
|
|
"quicksearch-coord",
|
|
|
|
|
"quicksearch-coord-1234",
|
|
|
|
|
// Numbers, but nothing in front of them to be a tag.
|
|
|
|
|
"quicksearch-1234-0",
|
|
|
|
|
// Not ours at all.
|
|
|
|
|
"cargo-install-abc-1-2",
|
|
|
|
|
"tmp-quicksearch-coord-1-2",
|
|
|
|
|
] {
|
|
|
|
|
assert!(!is_scratch_name(theirs), "{theirs} must not be swept");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fresh directories survive; only long-dead runs are collected. Uses a
|
|
|
|
|
/// hand-built name rather than `scratch_dir` so the assertion is about the
|
|
|
|
|
/// age gate and not about whatever else the suite has left lying around.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_sweep_keeps_recent_trees_and_takes_old_ones() {
|
|
|
|
|
let fresh = scratch_dir("sweep-fresh");
|
|
|
|
|
touch(&fresh.join("evidence.txt"), b"kept");
|
|
|
|
|
|
|
|
|
|
// Same shape, but back-dated past the threshold. `set_times` is the
|
|
|
|
|
// only way to age a directory without waiting twelve hours for it.
|
|
|
|
|
let old = std::env::temp_dir().join(format!(
|
|
|
|
|
"quicksearch-sweep-old-{}-{}",
|
|
|
|
|
std::process::id(),
|
|
|
|
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
|
|
|
));
|
|
|
|
|
std::fs::create_dir_all(&old).expect("create the aged directory");
|
|
|
|
|
let long_ago =
|
|
|
|
|
std::time::SystemTime::now() - STALE_AFTER - std::time::Duration::from_secs(60);
|
|
|
|
|
std::fs::File::open(&old)
|
|
|
|
|
.and_then(|d| {
|
|
|
|
|
d.set_times(
|
|
|
|
|
std::fs::FileTimes::new()
|
|
|
|
|
.set_accessed(long_ago)
|
|
|
|
|
.set_modified(long_ago),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.expect("back-date the aged directory");
|
|
|
|
|
|
|
|
|
|
sweep_stale();
|
|
|
|
|
|
|
|
|
|
assert!(fresh.exists(), "a fresh scratch tree was swept away");
|
|
|
|
|
assert!(!old.exists(), "a long-dead scratch tree survived the sweep");
|
|
|
|
|
|
|
|
|
|
std::fs::remove_dir_all(&fresh).ok();
|
|
|
|
|
}
|
2026-08-05 18:05:04 -04:00
|
|
|
}
|