Skip to main content

magi/
cache.rs

1//! Durable ownership of the shared Cargo build cache (`CARGO_TARGET_DIR`).
2//!
3//! One cache, many borrowers: an implement wave's candidates, a review
4//! round's E2E, the final gate, a human's `magi review` invoked by hand — all
5//! of them may point `CARGO_TARGET_DIR` at the same directory, sometimes from
6//! *different* worktrees, sometimes from a different `magi run` entirely. Two
7//! failures came out of sharing it with no ownership record at all:
8//!
9//! - A read-only reviewer seat that inherited `CARGO_TARGET_DIR` tried to
10//!   write into it, was refused by its own sandbox, and reported the refusal
11//!   as a defect in the code under review rather than a property of its own
12//!   seat. See `graph::wave`, which now only hands the variable to a seat
13//!   that [`crate::agent::Invocation::allow_write`] actually permits to use.
14//! - Two source trees building the same package name/version into one
15//!   `CARGO_TARGET_DIR` in sequence can leave a stale artifact from the
16//!   *older* worktree looking fresh to Cargo, and a later `cargo test` runs
17//!   binaries compiled from source nobody is looking at. [`ensure_fresh`]
18//!   selectively `cargo clean -p`s the workspace's own packages — never the
19//!   downloaded dependency graph — the moment the recorded source identity
20//!   for a cache directory changes.
21//!
22//! Both are consequences of one missing fact: *who is using this cache right
23//! now, and against which source*. This module is that fact, made durable
24//! (a JSON file per cache directory, surviving a process restart) and cheap
25//! to ask (`classify`, `in_use`, `inventory`).
26//!
27//! ## Reclaiming a dead owner
28//!
29//! Liveness is [`crate::proc::pid_alive`] — the same conservative check
30//! [`crate::daemon::sweep_stale_claims`] uses for the queue's own claim
31//! locks: every uncertain outcome reads as alive, and a lease whose file
32//! cannot even be parsed is never reclaimed automatically ([`Status::Unknown`]).
33//! This is deliberately the same policy as the queue's `.lock` files, not a
34//! new one — a bare lock file was never trusted alone there either; it is
35//! always paired with a liveness check.
36//!
37//! ## What this module does not do
38//!
39//! It does not reap a build's orphaned grandchildren once magi kills the
40//! seat that started them at a timeout — that is process-tree ownership, a
41//! different problem with its own owner elsewhere. A lease held by a process
42//! whose pid has exited is `Stale` and reclaimable *as a lease*, whether or
43//! not a grandchild is still writing files under the cache; this module only
44//! ever answers "who owns the cache directory", not "is every process that
45//! might still be touching it definitely gone".
46
47use std::path::{Path, PathBuf};
48use std::time::Duration;
49
50use anyhow::{Context as _, Result, bail};
51use jiff::Timestamp;
52use serde::{Deserialize, Serialize};
53
54use crate::proc::{self, Quiet as _};
55
56/// Who is holding, or wants, a cache directory.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Owner {
59    /// The run this borrow belongs to.
60    pub run: String,
61    /// The graph node, e.g. `implement`, `review`, `gate`.
62    pub node: String,
63    /// Seat key, or a fixed label for a borrow that is not seat-shaped
64    /// (`"e2e"`, `"gate"`, `"janitor"`).
65    pub seat: String,
66    /// Process id of the magi process holding the lease — never the spawned
67    /// agent CLI or `cargo` child, both of which end well before this
68    /// process's own async task returns and releases the guard.
69    pub pid: u32,
70    /// The worktree the borrower is building from, for the report and for
71    /// [`ensure_fresh`]'s identity comparison.
72    pub worktree: String,
73    /// The commit the borrower is building, same purpose as `worktree`.
74    pub head: String,
75}
76
77impl Owner {
78    /// An owner for the current process.
79    #[must_use]
80    pub fn here(run: &str, node: &str, seat: &str, worktree: &Path, head: &str) -> Owner {
81        Owner {
82            run: run.to_owned(),
83            node: node.to_owned(),
84            seat: seat.to_owned(),
85            pid: std::process::id(),
86            worktree: worktree.display().to_string(),
87            head: head.to_owned(),
88        }
89    }
90}
91
92/// One held lease, as written to disk.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94struct LeaseFile {
95    /// The literal cache path, kept alongside the owner so [`inventory`] can
96    /// report it without reversing the filename — the filename is a lossy
97    /// slug, not the path itself.
98    cache_dir: String,
99    owner: Owner,
100    acquired_at: Timestamp,
101}
102
103/// Where a cache directory's classification lands.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Status {
106    /// No lease file, or one whose owner is confirmed gone.
107    Free,
108    /// A live owner holds it.
109    Active(Owner),
110    /// A lease file names an owner whose pid is confirmed gone. Reclaimable.
111    Stale(Owner),
112    /// A lease file exists but could not be trusted — unreadable or
113    /// unparseable. Never reclaimed automatically; a human decides.
114    Unknown,
115}
116
117/// Why [`try_acquire`] did not hand back a [`Guard`].
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Busy {
120    /// Held by a confirmed-live owner.
121    Active(Owner),
122    /// A lease is present but could not be trusted.
123    Unknown,
124    /// Lost a race with another acquirer; the caller should just try again.
125    Contended,
126}
127
128impl Busy {
129    /// One line for an event log or an error message.
130    #[must_use]
131    pub fn describe(&self) -> String {
132        match self {
133            Busy::Active(o) => format!(
134                "held by run {} node {} seat {} (pid {})",
135                o.run, o.node, o.seat, o.pid
136            ),
137            Busy::Unknown => {
138                "an unreadable lease is present; refusing to guess who holds it".to_owned()
139            }
140            Busy::Contended => "lost a race for the lease; retrying".to_owned(),
141        }
142    }
143}
144
145/// A held lease. Releases on drop, so a panicking or early-returning caller
146/// never leaves the cache permanently marked busy — the same guarantee
147/// [`crate::queue::Claim`] gives the task queue's own lock file.
148#[derive(Debug)]
149pub struct Guard {
150    path: PathBuf,
151    released: bool,
152}
153
154impl Guard {
155    fn new(path: PathBuf) -> Guard {
156        Guard {
157            path,
158            released: false,
159        }
160    }
161
162    /// Release explicitly. Equivalent to dropping the guard; spelled out for
163    /// call sites where "the build finished, release now" reads better than
164    /// waiting for scope exit.
165    pub fn release(mut self) {
166        self.do_release();
167    }
168
169    fn do_release(&mut self) {
170        if !self.released {
171            let _ = std::fs::remove_file(&self.path);
172            self.released = true;
173        }
174    }
175}
176
177impl Drop for Guard {
178    fn drop(&mut self) {
179        self.do_release();
180    }
181}
182
183/// The directory leases live under, inside the magi home — never inside the
184/// cache directory itself, so [`crate::disk::prune_dir`]'s oldest-first sweep
185/// of the cache can never delete a lease file it does not know exists.
186fn leases_dir(home: &Path) -> PathBuf {
187    home.join("cache-leases")
188}
189
190/// A stable, filesystem-safe name for one cache directory's lease file.
191/// Human-legible prefix (so a directory listing is self-explanatory) plus a
192/// hash suffix (so two paths that sanitize to the same prefix — unlikely,
193/// but not impossible on a long path — never collide).
194fn slug(cache_dir: &Path) -> String {
195    let norm = normalize(cache_dir);
196    let mut readable: String = norm
197        .chars()
198        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
199        .collect();
200    readable.truncate(80);
201    use std::hash::{Hash, Hasher};
202    let mut hasher = std::collections::hash_map::DefaultHasher::new();
203    norm.hash(&mut hasher);
204    format!("{readable}-{:08x}", hasher.finish() as u32)
205}
206
207/// Best-effort canonical form of a cache path, for hashing only — never
208/// shown to a human, who gets the literal path out of the lease file's own
209/// `cache_dir` field instead. Falls back to the literal string when the
210/// directory does not exist yet, so a lease can be taken before the first
211/// build creates it.
212fn normalize(p: &Path) -> String {
213    std::fs::canonicalize(p)
214        .map(|p| p.display().to_string())
215        .unwrap_or_else(|_| p.display().to_string())
216        .replace('\\', "/")
217        .to_ascii_lowercase()
218}
219
220fn lease_path(home: &Path, cache_dir: &Path) -> PathBuf {
221    leases_dir(home).join(format!("{}.json", slug(cache_dir)))
222}
223
224fn identity_path(home: &Path, cache_dir: &Path) -> PathBuf {
225    leases_dir(home).join(format!("{}.identity.json", slug(cache_dir)))
226}
227
228fn catalog_path(home: &Path, cache_dir: &Path) -> PathBuf {
229    leases_dir(home).join(format!("{}.catalog.json", slug(cache_dir)))
230}
231
232/// A cache directory magi has leased before, remembered past the point its
233/// lease file is gone.
234///
235/// A lease file's whole reason to exist is contention, and [`Guard::release`]
236/// deletes it the moment nobody holds it — correctly, since a stale lock file
237/// left lying around is exactly the bug this module exists to avoid repeating
238/// (see the module docs on the queue's own `.lock` files). But that means a
239/// cache directory nobody is actively borrowing is, from a lease file alone,
240/// indistinguishable from one magi never touched: both read as
241/// [`Status::Free`]. `magi-land6`, `magi-land7` and `magi-landtimedout` — idle
242/// Cargo caches worth tens of gigabytes each, found only by a manual sweep of
243/// `Temp` during the 2026-09-12 recovery — are exactly this: real, orphaned,
244/// magi-managed caches that [`inventory`] could not see because nothing was
245/// still holding them. This record is what closes that gap: written on every
246/// successful acquire, never deleted by [`Guard`], so a cleanup surface can
247/// still find and size a cache days after the last borrower let go of it.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249struct CatalogRecord {
250    cache_dir: String,
251    last_owner: Owner,
252    last_used_at: Timestamp,
253}
254
255/// Remember `cache_dir` as one magi has leased, for [`inventory`] to find
256/// after the lease itself is gone. Best-effort: a write that fails costs a
257/// future cleanup listing one entry, never the acquire this rides along
258/// with — an operator missing an inventory row is a much smaller problem than
259/// a build that failed to get its cache.
260fn record_catalog(home: &Path, cache_dir: &Path, owner: &Owner) {
261    let path = catalog_path(home, cache_dir);
262    let record = CatalogRecord {
263        cache_dir: cache_dir.display().to_string(),
264        last_owner: owner.clone(),
265        last_used_at: Timestamp::now(),
266    };
267    let Ok(body) = serde_json::to_string_pretty(&record) else {
268        return;
269    };
270    let tmp = path.with_extension("json.tmp");
271    if std::fs::write(&tmp, &body).is_ok() {
272        let _ = std::fs::rename(&tmp, &path);
273    }
274}
275
276/// Read whatever JSON is at `path` as a [`CatalogRecord`], or `None` when it
277/// is missing or does not parse — an unreadable catalog entry costs
278/// [`inventory`] one row, not a wrong answer about who owns anything, since
279/// ownership is always decided from the lease file, never the catalog.
280fn read_catalog(path: &Path) -> Option<CatalogRecord> {
281    let body = std::fs::read_to_string(path).ok()?;
282    serde_json::from_str(&body).ok()
283}
284
285/// Read whatever JSON is at `path` as a [`LeaseFile`], or `None` when it is
286/// missing or does not parse.
287fn read_lease(path: &Path) -> Option<LeaseFile> {
288    let body = std::fs::read_to_string(path).ok()?;
289    serde_json::from_str(&body).ok()
290}
291
292/// Best-effort read of just the `cache_dir` field, for [`inventory`] to
293/// report a path even when the rest of the record — the owner — does not
294/// parse. A corrupt lease is still evidence of *which* directory is in a
295/// state nobody can vouch for.
296fn peek_cache_dir(path: &Path) -> Option<String> {
297    let body = std::fs::read_to_string(path).ok()?;
298    let value: serde_json::Value = serde_json::from_str(&body).ok()?;
299    value
300        .get("cache_dir")
301        .and_then(|v| v.as_str())
302        .map(str::to_owned)
303}
304
305/// Classify the lease at `path`, using the real process-liveness query.
306fn classify(path: &Path) -> Status {
307    classify_with(path, proc::pid_alive)
308}
309
310/// [`classify`] with its process-liveness query supplied by the caller —
311/// mirrors [`crate::daemon::sweep_stale_claims_with`], which exists for the
312/// identical reason: a real "confirmed dead" pid cannot be produced portably
313/// from a test (an out-of-range value reads as *unavailable*, not dead, to
314/// `tasklist`/`kill -0`, and the conservative policy those already apply -
315/// correctly - treats an unavailable query as alive). Production code always
316/// goes through [`classify`]; this is what a test calls directly to assert
317/// the classification logic against an injected answer instead.
318fn classify_with<F: Fn(u32) -> bool>(path: &Path, alive: F) -> Status {
319    if !path.exists() {
320        return Status::Free;
321    }
322    let Some(lease) = read_lease(path) else {
323        return Status::Unknown;
324    };
325    let this_process = std::process::id();
326    if lease.owner.pid == this_process || alive(lease.owner.pid) {
327        Status::Active(lease.owner)
328    } else {
329        Status::Stale(lease.owner)
330    }
331}
332
333/// Write a brand-new lease file, atomically — `create_new` refuses to
334/// overwrite an existing one, exactly like [`crate::queue::Queue::claim`]'s
335/// task lock, which this mirrors on purpose rather than inventing a second
336/// exclusion primitive in the same codebase.
337fn write_new(path: &Path, cache_dir: &Path, owner: &Owner) -> std::io::Result<()> {
338    use std::io::Write as _;
339    let mut f = std::fs::OpenOptions::new()
340        .write(true)
341        .create_new(true)
342        .open(path)?;
343    let lease = LeaseFile {
344        cache_dir: cache_dir.display().to_string(),
345        owner: owner.clone(),
346        acquired_at: Timestamp::now(),
347    };
348    let body = serde_json::to_string_pretty(&lease).unwrap_or_default();
349    f.write_all(body.as_bytes())?;
350    Ok(())
351}
352
353/// What [`try_acquire`] returned.
354pub enum AcquireOutcome {
355    /// The lease is now held by the caller.
356    Acquired(Guard),
357    /// Somebody else has it, or its state could not be trusted.
358    Busy(Busy),
359}
360
361/// Take the lease for `cache_dir` if nobody live holds it, reclaiming a
362/// stale one first. One attempt — a caller that wants to wait uses
363/// [`wait_for`], which is this in a loop bounded by a budget.
364pub fn try_acquire(home: &Path, cache_dir: &Path, owner: &Owner) -> Result<AcquireOutcome> {
365    try_acquire_with(home, cache_dir, owner, proc::pid_alive)
366}
367
368/// [`try_acquire`] with its process-liveness query supplied by the caller —
369/// see [`classify_with`] for why this split exists.
370fn try_acquire_with<F: Fn(u32) -> bool + Copy>(
371    home: &Path,
372    cache_dir: &Path,
373    owner: &Owner,
374    alive: F,
375) -> Result<AcquireOutcome> {
376    let dir = leases_dir(home);
377    std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
378    let path = dir.join(format!("{}.json", slug(cache_dir)));
379
380    // Two attempts: the first is the common case (free, or held by someone
381    // live); the second only runs after reclaiming a confirmed-stale lease,
382    // so this can never loop more than once per call — a caller that keeps
383    // losing the race to genuinely live contenders is exactly what `Busy`
384    // reports back, not something this function spins on.
385    for _ in 0..2 {
386        match write_new(&path, cache_dir, owner) {
387            Ok(()) => {
388                record_catalog(home, cache_dir, owner);
389                return Ok(AcquireOutcome::Acquired(Guard::new(path)));
390            }
391            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
392            Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
393        }
394        match classify_with(&path, alive) {
395            Status::Free => {} // reclaimed between the write and the read; loop once more
396            Status::Stale(_) => {
397                let _ = std::fs::remove_file(&path);
398            }
399            Status::Active(o) => return Ok(AcquireOutcome::Busy(Busy::Active(o))),
400            Status::Unknown => return Ok(AcquireOutcome::Busy(Busy::Unknown)),
401        }
402    }
403    Ok(AcquireOutcome::Busy(Busy::Contended))
404}
405
406/// Is `cache_dir` in use right now — held by a live owner, or in a state
407/// nobody can vouch for? The conservative half of the janitor's prune guard:
408/// [`Status::Unknown`] refuses exactly like [`Status::Active`], because a
409/// lease this process cannot read is not evidence the directory is free.
410#[must_use]
411pub fn in_use(home: &Path, cache_dir: &Path) -> bool {
412    let path = lease_path(home, cache_dir);
413    matches!(classify(&path), Status::Active(_) | Status::Unknown)
414}
415
416/// Prefix of the machine hold a task gets when its run stopped because this
417/// lease stayed busy past `[graph] timeout_resource_wait`. `triage` reads it to
418/// release the task once the lease is free; keep both in sync.
419pub const RESOURCE_HOLD: &str = "[resource] ";
420
421/// Acquire the lease for `cache_dir`, waiting out contention rather than
422/// failing on the first busy owner — but never past `budget`, and the wait
423/// comes out of that same budget rather than a second, unbounded one. A
424/// caller already has a node timeout; this is that timeout, not a new clock
425/// next to it, which is what keeps a wait from becoming the "無期限待機"
426/// AGENTS.md's own build-cache section warns is never acceptable. The error
427/// on timeout names who is still holding it, for the event this gets logged
428/// into.
429pub async fn wait_for(
430    home: &Path,
431    cache_dir: &Path,
432    owner: &Owner,
433    budget: Duration,
434    poll: Duration,
435) -> Result<Guard> {
436    match wait_for_unless(home, cache_dir, owner, budget, poll, &|| false).await? {
437        Some(g) => Ok(g),
438        None => bail!("the wait for the build cache was cancelled"),
439    }
440}
441
442/// [`wait_for`] that also gives up, with `Ok(None)`, as soon as `cancel`
443/// returns true. `cancel` is looked at once per `poll`, so a park request is
444/// honoured within a poll interval instead of after the whole budget. The
445/// lease is never held on that path: nothing was acquired.
446pub async fn wait_for_unless(
447    home: &Path,
448    cache_dir: &Path,
449    owner: &Owner,
450    budget: Duration,
451    poll: Duration,
452    cancel: &(dyn Fn() -> bool + Sync),
453) -> Result<Option<Guard>> {
454    let start = std::time::Instant::now();
455    loop {
456        match try_acquire(home, cache_dir, owner)? {
457            AcquireOutcome::Acquired(g) => return Ok(Some(g)),
458            AcquireOutcome::Busy(busy) => {
459                if cancel() {
460                    return Ok(None);
461                }
462                let elapsed = start.elapsed();
463                if elapsed >= budget {
464                    bail!(
465                        "timed out after {}s waiting for the build cache at {} ({})",
466                        budget.as_secs(),
467                        cache_dir.display(),
468                        busy.describe()
469                    );
470                }
471                tokio::time::sleep(poll.min(budget - elapsed)).await;
472            }
473        }
474    }
475}
476
477/// One cache directory's state, for `a0fc`'s capacity/cleanup inventory (and
478/// `magi cache show`, eventually). Every lease file on disk is reported —
479/// including ones this process itself does not want to touch — because the
480/// question this answers is "what is registered right now", not "what can I
481/// safely act on".
482#[derive(Debug, Clone)]
483pub struct Entry {
484    /// The literal cache path this entry describes.
485    pub cache_dir: String,
486    /// What [`classify`] made of the lease registered against it.
487    pub status: EntryStatus,
488}
489
490/// [`Entry::status`]'s possible values.
491#[derive(Debug, Clone)]
492pub enum EntryStatus {
493    /// Held by a confirmed-live owner.
494    Active(Owner),
495    /// Present, but the owner is confirmed gone — safe to reclaim.
496    Stale(Owner),
497    /// Present, but unreadable — never assume safe to reclaim.
498    Unknown,
499    /// No live lease right now — the borrower released it cleanly — but the
500    /// catalog remembers this path was leased before, by the owner named
501    /// here. Safe to reclaim: nothing holds it, and nothing has to guess that
502    /// from the lease file's mere absence, which is indistinguishable from a
503    /// path magi never touched (see [`CatalogRecord`]'s doc).
504    Idle(Owner),
505}
506
507/// Every cache directory magi knows about under `home`, whether or not
508/// anything holds it right now. A lease file reports the current borrower;
509/// once released it is gone (by design — see [`Guard::release`]), so a
510/// directory nobody is borrowing is reported from the catalog instead,
511/// carrying its last known owner rather than nothing at all. There is
512/// nothing here for a directory this process has never leased: `inventory`
513/// answers "what has magi registered", not "what looks like a build cache".
514#[must_use]
515pub fn inventory(home: &Path) -> Vec<Entry> {
516    inventory_with(home, proc::pid_alive)
517}
518
519/// [`inventory`] with its process-liveness query supplied by the caller —
520/// see [`classify_with`] for why this split exists.
521fn inventory_with<F: Fn(u32) -> bool + Copy>(home: &Path, alive: F) -> Vec<Entry> {
522    let dir = leases_dir(home);
523    let Ok(rd) = std::fs::read_dir(&dir) else {
524        return Vec::new();
525    };
526    let mut out = Vec::new();
527    for entry in rd.flatten() {
528        let path = entry.path();
529        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
530            continue;
531        };
532        if !name.ends_with(".json")
533            || name.ends_with(".identity.json")
534            || name.ends_with(".catalog.json")
535        {
536            continue;
537        }
538        let status = match classify_with(&path, alive) {
539            Status::Free => continue,
540            Status::Active(o) => EntryStatus::Active(o),
541            Status::Stale(o) => EntryStatus::Stale(o),
542            Status::Unknown => EntryStatus::Unknown,
543        };
544        // A lease so corrupted that not even its `cache_dir` field can be
545        // read still has to be findable: the lease file's own name is a
546        // one-way slug of the path it was for, so the file itself - not the
547        // path it can no longer name - is what a human or `a0fc` gets
548        // pointed at.
549        let cache_dir = peek_cache_dir(&path)
550            .unwrap_or_else(|| format!("(unreadable lease file: {})", path.display()));
551        out.push(Entry { cache_dir, status });
552    }
553
554    // Second pass: a cache directory whose lease was cleanly released is
555    // still registered, in the catalog, as long as its lease file has not
556    // been reacquired since. Skip anything the first pass already reported —
557    // a live or stale lease always outranks a catalog record, which only
558    // ever describes the *last* borrower, not the current one.
559    if let Ok(rd) = std::fs::read_dir(&dir) {
560        for entry in rd.flatten() {
561            let path = entry.path();
562            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
563                continue;
564            };
565            let Some(stem) = name.strip_suffix(".catalog.json") else {
566                continue;
567            };
568            if dir.join(format!("{stem}.json")).exists() {
569                continue;
570            }
571            if let Some(record) = read_catalog(&path) {
572                out.push(Entry {
573                    cache_dir: record.cache_dir,
574                    status: EntryStatus::Idle(record.last_owner),
575                });
576            }
577        }
578    }
579
580    out.sort_by(|a, b| a.cache_dir.cmp(&b.cache_dir));
581    out
582}
583
584/// Prune `cache_dir` to `limit`, but only while holding the lease — a prune
585/// that raced a live build would delete files a compile in flight still
586/// needs, on top of confusing whatever built them about why its own output
587/// vanished. `Ok(None)` when the cache is in use right now; that is not an
588/// error, it is the janitor's next pass catching it once the borrower is
589/// done. Never touches a lease file itself — they live outside `cache_dir`
590/// (see [`leases_dir`]), so [`crate::disk::prune_dir`]'s own sweep can never
591/// reach one.
592pub fn maintenance_prune(
593    home: &Path,
594    cache_dir: &Path,
595    limit: u64,
596) -> Result<Option<crate::disk::Prune>> {
597    let owner = Owner {
598        run: "maintenance".to_owned(),
599        node: "prune".to_owned(),
600        seat: "janitor".to_owned(),
601        pid: std::process::id(),
602        worktree: String::new(),
603        head: String::new(),
604    };
605    match try_acquire(home, cache_dir, &owner)? {
606        AcquireOutcome::Busy(_) => Ok(None),
607        AcquireOutcome::Acquired(guard) => {
608            let result = crate::disk::prune_dir(cache_dir, limit)?;
609            guard.release();
610            Ok(Some(result))
611        }
612    }
613}
614
615/// The source a build against a cache directory was last known to come from.
616/// Compared by [`needs_refresh`] on every acquire, so a cache directory that
617/// only ever sees one worktree/head pair never pays a clean it does not need.
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619pub struct Identity {
620    /// The worktree the build ran from.
621    pub worktree: String,
622    /// The commit it built.
623    pub head: String,
624}
625
626impl Identity {
627    /// Build an identity from a worktree path and the commit it is at.
628    #[must_use]
629    pub fn new(worktree: &Path, head: &str) -> Identity {
630        Identity {
631            worktree: worktree.display().to_string(),
632            head: head.to_owned(),
633        }
634    }
635}
636
637/// Has the source building against `cache_dir` changed since the last record?
638/// True (needs a refresh) whenever nothing was ever recorded — the
639/// conservative default for a cache directory this process has not tracked
640/// before.
641#[must_use]
642pub fn needs_refresh(home: &Path, cache_dir: &Path, current: &Identity) -> bool {
643    let path = identity_path(home, cache_dir);
644    let Ok(body) = std::fs::read_to_string(path) else {
645        return true;
646    };
647    match serde_json::from_str::<Identity>(&body) {
648        Ok(recorded) => &recorded != current,
649        Err(_) => true,
650    }
651}
652
653/// Persist `identity` as the last known source for `cache_dir`.
654pub fn record_identity(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
655    let path = identity_path(home, cache_dir);
656    if let Some(parent) = path.parent() {
657        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
658    }
659    let body = serde_json::to_string_pretty(identity).context("serialize cache identity")?;
660    let tmp = path.with_extension("json.tmp");
661    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
662    std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
663    Ok(())
664}
665
666/// Forget the recorded source identity for `cache_dir`, so the next
667/// [`ensure_fresh`] call cannot skip its clean on the strength of a stale
668/// match.
669///
670/// For a builder that does not itself have one coherent (worktree, head) to
671/// record - an implement or fix wave, where several different worktrees
672/// deliberately share the cache concurrently - there is nothing correct to
673/// write in place of the old identity. Removing the record is still correct:
674/// it costs the next tracked caller ([`ensure_fresh`] at `e2e`/`gate`) one
675/// clean it might not have strictly needed, in exchange for never trusting a
676/// match against a write this module never observed. Best-effort: a stale
677/// record surviving a failed removal is no worse than the record this
678/// replaces.
679pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
680    let _ = std::fs::remove_file(identity_path(home, cache_dir));
681}
682
683/// Parse `cargo metadata --no-deps`'s JSON for the names of packages defined
684/// in the workspace itself — never a dependency, which is exactly the
685/// distinction that keeps a freshness fix from also discarding a downloaded
686/// crate's compiled artifacts on every worktree switch. Pure, so the parse
687/// is tested against fixture text without a `cargo` on the test machine.
688#[must_use]
689pub fn parse_workspace_package_names(metadata_json: &str) -> Vec<String> {
690    let Ok(value) = serde_json::from_str::<serde_json::Value>(metadata_json) else {
691        return Vec::new();
692    };
693    value
694        .get("packages")
695        .and_then(|p| p.as_array())
696        .map(|packages| {
697            packages
698                .iter()
699                .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
700                .map(str::to_owned)
701                .collect()
702        })
703        .unwrap_or_default()
704}
705
706/// Every `--manifest-path` the given verify commands pass, in order of first
707/// appearance, without duplicates. Understands `--manifest-path X` and
708/// `--manifest-path=X`, with `'` / `"` quoting. Pure.
709#[must_use]
710pub fn extract_manifest_paths(commands: &[String]) -> Vec<PathBuf> {
711    const KEY: &str = "--manifest-path";
712    let mut out: Vec<PathBuf> = Vec::new();
713    for command in commands {
714        let mut rest = command.as_str();
715        while let Some(at) = rest.find(KEY) {
716            rest = &rest[at + KEY.len()..];
717            let value = if let Some(r) = rest.strip_prefix('=') {
718                r
719            } else if rest.starts_with(char::is_whitespace) {
720                rest.trim_start()
721            } else {
722                continue;
723            };
724            let value = if let Some(s) = value.strip_prefix('\'') {
725                s.split('\'').next().unwrap_or("")
726            } else if let Some(s) = value.strip_prefix('"') {
727                s.split('"').next().unwrap_or("")
728            } else {
729                let end = value.find(char::is_whitespace).unwrap_or(value.len());
730                &value[..end]
731            };
732            if !value.is_empty() {
733                let path = PathBuf::from(value);
734                if !out.contains(&path) {
735                    out.push(path);
736                }
737            }
738        }
739    }
740    out
741}
742
743/// Where cargo is asked about the workspace's own packages.
744#[derive(Debug, Clone, PartialEq, Eq)]
745pub enum Target {
746    /// The worktree root, as before (no `--manifest-path`).
747    Root,
748    /// A manifest named by a verify command, resolved against the worktree.
749    Manifest(PathBuf),
750}
751
752/// Decide which manifests `cargo metadata` / `cargo clean -p` run against: the
753/// root when it has a `Cargo.toml`, plus every verify-named manifest that
754/// exists. Nothing at all is a valid answer (no crate, or one the commands do
755/// not name). Pure apart from `exists`.
756fn plan_targets(
757    worktree: &Path,
758    manifests: &[PathBuf],
759    exists: impl Fn(&Path) -> Result<bool>,
760) -> Result<Vec<Target>> {
761    let mut targets = Vec::new();
762    if exists(&worktree.join("Cargo.toml"))? {
763        targets.push(Target::Root);
764    }
765    for m in manifests {
766        let full = if m.is_absolute() {
767            m.clone()
768        } else {
769            worktree.join(m)
770        };
771        if exists(&full)? {
772            targets.push(Target::Manifest(full));
773        } else {
774            tracing::warn!(
775                manifest = %m.display(),
776                "build cache: --manifest-path from a verify command does not exist; skipped"
777            );
778        }
779    }
780    Ok(targets)
781}
782
783/// Existence of a file: only NotFound counts as "no"; any other I/O error
784/// propagates so an unreadable tree is never mistaken for an empty one.
785fn file_exists(path: &Path) -> Result<bool> {
786    match std::fs::metadata(path) {
787        Ok(m) => Ok(m.is_file()),
788        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
789        Err(e) => Err(e).with_context(|| format!("stat {}", path.display())),
790    }
791}
792
793/// Selectively invalidate the workspace's own compiled artifacts in
794/// `cache_dir` — `cargo clean -p <name> --target-dir <cache_dir>` for every
795/// package `cargo metadata` reports as local to `worktree`, never a bare
796/// `cargo clean` (which would throw away every dependency's compile too).
797/// Returns `None` when there is no manifest to ask (no cargo is spawned).
798/// Real subprocess execution: never called from a test, only from
799/// [`ensure_fresh`] in the running binary.
800fn refresh_stale_packages(
801    worktree: &Path,
802    cache_dir: &Path,
803    manifests: &[PathBuf],
804) -> Result<Option<Vec<String>>> {
805    let targets = plan_targets(worktree, manifests, file_exists)?;
806    if targets.is_empty() {
807        return Ok(None);
808    }
809    let mut all = Vec::new();
810    let mut failed = Vec::new();
811    for target in &targets {
812        let manifest_arg = match target {
813            Target::Root => None,
814            Target::Manifest(m) => Some(m),
815        };
816        let mut cmd = std::process::Command::new("cargo");
817        cmd.args(["metadata", "--no-deps", "--format-version", "1"]);
818        if let Some(m) = manifest_arg {
819            cmd.arg("--manifest-path").arg(m);
820        }
821        let meta = cmd
822            .current_dir(worktree)
823            .quiet()
824            .output()
825            .context("run `cargo metadata`")?;
826        if !meta.status.success() {
827            bail!(
828                "cargo metadata failed: {}",
829                String::from_utf8_lossy(&meta.stderr)
830            );
831        }
832        let names = parse_workspace_package_names(&String::from_utf8_lossy(&meta.stdout));
833        for name in &names {
834            let mut clean = std::process::Command::new("cargo");
835            clean.arg("clean").arg("-p").arg(name);
836            if let Some(m) = manifest_arg {
837                clean.arg("--manifest-path").arg(m);
838            }
839            let out = clean
840                .arg("--target-dir")
841                .arg(cache_dir)
842                .current_dir(worktree)
843                .quiet()
844                .output()
845                .with_context(|| format!("cargo clean -p {name}"))?;
846            if !out.status.success() {
847                // A failure here — a Windows test executable still holding its
848                // own file open is the case the evidence log records — means the
849                // stale artifact this was meant to remove may still be sitting
850                // in `cache_dir`. Collecting it rather than only warning is what
851                // lets `ensure_fresh` refuse to record the new identity: the
852                // next reuse must not be told this cache is confirmed to match
853                // `identity` when a piece of the *previous* one could not be
854                // proven gone.
855                failed.push(format!(
856                    "{name}: {}",
857                    String::from_utf8_lossy(&out.stderr).trim()
858                ));
859            }
860        }
861        for n in names {
862            if !all.contains(&n) {
863                all.push(n);
864            }
865        }
866    }
867    if !failed.is_empty() {
868        bail!(
869            "cargo clean -p failed for {} package(s): {}",
870            failed.len(),
871            failed.join("; ")
872        );
873    }
874    Ok(Some(all))
875}
876
877/// What cargo itself writes into a target directory it creates.
878const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
879# This file is a cache directory tag created by cargo.\n\
880# For information about cache directory tags see https://bford.info/cachedir/\n";
881
882/// Recreate `CACHEDIR.TAG` in a cargo target directory that lost it, so
883/// `cargo clean -p` stops refusing. Returns whether a tag was written.
884///
885/// Heals caches an older prune already broke. Safe because the caller holds the
886/// lease, `cache_dir` is the directory magi itself handed cargo as its target,
887/// and it is only done when `.rustc_info.json` proves cargo has built there — a
888/// misconfigured path is never dressed up as a cargo target. An existing tag is
889/// never touched (`create_new`), and a missing directory is left for cargo.
890fn restore_cachedir_tag(cache_dir: &Path) -> Result<bool> {
891    use std::io::Write as _;
892    if !cache_dir.is_dir() || !cache_dir.join(".rustc_info.json").is_file() {
893        return Ok(false);
894    }
895    let tag = cache_dir.join("CACHEDIR.TAG");
896    match std::fs::OpenOptions::new()
897        .write(true)
898        .create_new(true)
899        .open(&tag)
900    {
901        Ok(mut f) => {
902            f.write_all(CACHEDIR_TAG.as_bytes())
903                .with_context(|| format!("write {}", tag.display()))?;
904            Ok(true)
905        }
906        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
907        Err(e) => Err(e).with_context(|| format!("create {}", tag.display())),
908    }
909}
910
911/// Guarantee that a build against `cache_dir` from `(worktree, head)` never
912/// silently reuses another source's compiled output: clean the workspace's
913/// own packages out of the cache when the recorded identity disagrees, then
914/// record the new one. A no-op — no subprocess spawned — when the identity
915/// already matches, which is the common case once a cache directory settles
916/// on one worktree for a while.
917///
918/// Called with the lease already held: this is a mutation of the cache
919/// directory's contents, and it must never race a concurrent build the same
920/// way a plain `cargo clean` run by hand would not.
921pub fn ensure_fresh(
922    home: &Path,
923    cache_dir: &Path,
924    identity: &Identity,
925    manifests: &[PathBuf],
926) -> Result<()> {
927    match restore_cachedir_tag(cache_dir) {
928        Ok(true) => tracing::info!(
929            cache = %cache_dir.display(),
930            "build cache: restored a missing CACHEDIR.TAG"
931        ),
932        Ok(false) => {}
933        // Not fatal: `cargo clean -p` below reports the real consequence.
934        Err(e) => tracing::warn!(error = %e, "build cache: could not restore CACHEDIR.TAG"),
935    }
936    if needs_refresh(home, cache_dir, identity) {
937        let worktree = PathBuf::from(&identity.worktree);
938        match refresh_stale_packages(&worktree, cache_dir, manifests)? {
939            Some(cleaned) => tracing::info!(
940                ?cleaned,
941                cache = %cache_dir.display(),
942                "build cache: source identity changed; cleaned the workspace's own packages before reuse"
943            ),
944            None => {
945                // No Cargo.toml at the root and none named by a verify
946                // command: there is nothing to clean by name, and a crate
947                // we could not locate may have left stale output behind, so
948                // the identity is not vouched for either.
949                tracing::warn!(
950                    cache = %cache_dir.display(),
951                    "build cache: no Cargo manifest found for the freshness check; skipping the package clean"
952                );
953                invalidate_identity(home, cache_dir);
954                return Ok(());
955            }
956        }
957    }
958    record_identity(home, cache_dir, identity)
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    fn owner(pid: u32) -> Owner {
966        Owner {
967            run: "r1".to_owned(),
968            node: "gate".to_owned(),
969            seat: "gate".to_owned(),
970            pid,
971            worktree: "/w".to_owned(),
972            head: "deadbeef".to_owned(),
973        }
974    }
975
976    #[test]
977    fn a_missing_cachedir_tag_is_restored_only_for_a_cargo_target() {
978        let t = tempfile::TempDir::new().expect("temp");
979        let cache = t.path().join("cache");
980        assert!(!restore_cachedir_tag(&cache).expect("missing dir"));
981        assert!(!cache.exists(), "a missing directory is left for cargo");
982
983        std::fs::create_dir_all(&cache).expect("mkdir");
984        assert!(!restore_cachedir_tag(&cache).expect("no rustc info"));
985        assert!(!cache.join("CACHEDIR.TAG").exists());
986
987        std::fs::write(cache.join(".rustc_info.json"), "{}").expect("info");
988        assert!(restore_cachedir_tag(&cache).expect("restore"));
989        let body = std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag");
990        assert_eq!(
991            body.lines().next(),
992            Some("Signature: 8a477f597d28d172789f06886806bc55")
993        );
994
995        std::fs::write(cache.join("CACHEDIR.TAG"), "custom").expect("custom");
996        assert!(!restore_cachedir_tag(&cache).expect("existing"));
997        assert_eq!(
998            std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag"),
999            "custom"
1000        );
1001    }
1002
1003    #[test]
1004    fn an_uncontended_lease_is_acquired_and_freed_on_release() {
1005        let home = tempfile::TempDir::new().expect("temp");
1006        let cache = home.path().join("cache");
1007        let this = std::process::id();
1008        match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1009            AcquireOutcome::Acquired(g) => {
1010                assert!(in_use(home.path(), &cache), "held while the guard lives");
1011                g.release();
1012            }
1013            AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1014        }
1015        assert!(!in_use(home.path(), &cache), "freed after release");
1016    }
1017
1018    #[test]
1019    fn a_lease_held_by_a_live_pid_is_reported_active_and_refuses_a_second_acquire() {
1020        let home = tempfile::TempDir::new().expect("temp");
1021        let cache = home.path().join("cache");
1022        let this = std::process::id();
1023        // Acquire as one owner, then try again as a different one — same
1024        // live pid (this test process), different run/seat, which is exactly
1025        // "another owner, still alive" without needing to fork a process.
1026        let _first =
1027            try_acquire(home.path(), &cache, &owner(this)).expect("first acquire succeeds");
1028        let mut second_owner = owner(this);
1029        second_owner.run = "r2".to_owned();
1030        match try_acquire(home.path(), &cache, &second_owner).expect("no io error") {
1031            AcquireOutcome::Busy(Busy::Active(held_by)) => assert_eq!(held_by.run, "r1"),
1032            other => panic!("expected Busy::Active, got a different outcome: {other:?}"),
1033        }
1034        assert!(in_use(home.path(), &cache));
1035    }
1036
1037    impl std::fmt::Debug for AcquireOutcome {
1038        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1039            match self {
1040                AcquireOutcome::Acquired(_) => write!(f, "Acquired"),
1041                AcquireOutcome::Busy(b) => write!(f, "Busy({b:?})"),
1042            }
1043        }
1044    }
1045
1046    #[test]
1047    fn a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer() {
1048        let home = tempfile::TempDir::new().expect("temp");
1049        let cache = home.path().join("cache");
1050        // Liveness is injected rather than asked of the real OS - a "known
1051        // dead" pid cannot be produced portably (see `classify_with`'s doc):
1052        // an out-of-range value reads as *unavailable* to `tasklist`, not
1053        // dead, and the conservative policy then reports it alive, which is
1054        // exactly what made this test flaky before this fix.
1055        let dead_owner = owner(999_999);
1056        let path = lease_path(home.path(), &cache);
1057        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1058        write_new(&path, &cache, &dead_owner).expect("seed a stale lease");
1059        assert_eq!(
1060            classify_with(&path, |_| false),
1061            Status::Stale(dead_owner.clone())
1062        );
1063
1064        match try_acquire_with(home.path(), &cache, &owner(std::process::id()), |_| false)
1065            .expect("acquire")
1066        {
1067            AcquireOutcome::Acquired(_) => {}
1068            other => panic!("stale lease should have been reclaimed: {other:?}"),
1069        }
1070    }
1071
1072    #[test]
1073    fn an_unreadable_lease_is_unknown_and_never_reclaimed() {
1074        let home = tempfile::TempDir::new().expect("temp");
1075        let cache = home.path().join("cache");
1076        let path = lease_path(home.path(), &cache);
1077        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1078        std::fs::write(&path, b"not json").unwrap();
1079        assert_eq!(classify(&path), Status::Unknown);
1080        assert!(in_use(home.path(), &cache), "unknown counts as in use");
1081        match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("no io error") {
1082            AcquireOutcome::Busy(Busy::Unknown) => {}
1083            other => panic!("expected Busy::Unknown, got {other:?}"),
1084        }
1085    }
1086
1087    #[tokio::test]
1088    async fn waiting_for_a_busy_lease_times_out_within_its_own_budget() {
1089        let home = tempfile::TempDir::new().expect("temp");
1090        let cache = home.path().join("cache");
1091        let _held = try_acquire(home.path(), &cache, &owner(std::process::id()))
1092            .expect("acquire")
1093            .pipe();
1094        let mut waiter = owner(std::process::id());
1095        waiter.run = "r2".to_owned();
1096        let started = std::time::Instant::now();
1097        let err = wait_for(
1098            home.path(),
1099            &cache,
1100            &waiter,
1101            Duration::from_millis(150),
1102            Duration::from_millis(20),
1103        )
1104        .await
1105        .expect_err("still held, must time out");
1106        assert!(started.elapsed() < Duration::from_secs(2), "bounded wait");
1107        assert!(
1108            err.to_string().contains("r1"),
1109            "names the current holder: {err}"
1110        );
1111    }
1112
1113    #[tokio::test]
1114    async fn a_wait_succeeds_as_soon_as_the_lease_is_released() {
1115        let home = tempfile::TempDir::new().expect("temp");
1116        let cache = home.path().join("cache");
1117        let guard =
1118            match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire") {
1119                AcquireOutcome::Acquired(g) => g,
1120                AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1121            };
1122        let home_path = home.path().to_path_buf();
1123        let cache_path = cache.clone();
1124        let mut waiter = owner(std::process::id());
1125        waiter.run = "r2".to_owned();
1126        let wait = tokio::spawn(async move {
1127            wait_for(
1128                &home_path,
1129                &cache_path,
1130                &waiter,
1131                Duration::from_secs(5),
1132                Duration::from_millis(10),
1133            )
1134            .await
1135        });
1136        tokio::time::sleep(Duration::from_millis(50)).await;
1137        guard.release();
1138        let acquired = wait.await.expect("task").expect("acquire after release");
1139        acquired.release();
1140    }
1141
1142    #[test]
1143    fn inventory_reports_active_stale_and_unknown_but_not_free() {
1144        let home = tempfile::TempDir::new().expect("temp");
1145        let active_cache = home.path().join("active");
1146        let stale_cache = home.path().join("stale");
1147        let unknown_cache = home.path().join("unknown");
1148
1149        let _held =
1150            try_acquire(home.path(), &active_cache, &owner(std::process::id())).expect("acquire");
1151        let stale_path = lease_path(home.path(), &stale_cache);
1152        std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1153        write_new(&stale_path, &stale_cache, &owner(999_999)).unwrap();
1154        let unknown_path = lease_path(home.path(), &unknown_cache);
1155        std::fs::write(&unknown_path, b"garbage").unwrap();
1156
1157        // Liveness injected as `false` for everyone but this test process -
1158        // see `a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer`
1159        // for why the real OS query cannot portably produce a "confirmed
1160        // dead" answer.
1161        let entries = inventory_with(home.path(), |pid| pid == std::process::id());
1162        assert_eq!(entries.len(), 3, "{entries:?}");
1163        let by_dir = |dir: &Path| {
1164            entries
1165                .iter()
1166                .find(|e| e.cache_dir == dir.display().to_string())
1167                .unwrap_or_else(|| panic!("no entry for {}", dir.display()))
1168        };
1169        assert!(matches!(
1170            by_dir(&active_cache).status,
1171            EntryStatus::Active(_)
1172        ));
1173        assert!(matches!(by_dir(&stale_cache).status, EntryStatus::Stale(_)));
1174        // A lease this corrupted cannot name its own `cache_dir` - the point
1175        // of this third case - so it is found by status instead of by path,
1176        // and its reported path must still point somewhere a human can act
1177        // on: the lease file itself.
1178        let unknown = entries
1179            .iter()
1180            .find(|e| matches!(e.status, EntryStatus::Unknown))
1181            .unwrap_or_else(|| panic!("no Unknown entry: {entries:?}"));
1182        assert!(
1183            unknown
1184                .cache_dir
1185                .contains(&unknown_path.display().to_string()),
1186            "{unknown:?}"
1187        );
1188    }
1189
1190    #[test]
1191    fn a_released_lease_is_reported_idle_from_the_catalog_not_dropped_entirely() {
1192        let home = tempfile::TempDir::new().expect("temp");
1193        let cache = home.path().join("cache");
1194        let this = std::process::id();
1195
1196        match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1197            AcquireOutcome::Acquired(g) => g.release(),
1198            AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1199        }
1200
1201        // The lease file is gone - `in_use` reads this path as free - but the
1202        // catalog written on acquire must still name it as a cache magi once
1203        // leased, exactly the gap the 2026-09-12 recovery evidence found:
1204        // an orphaned cache indistinguishable from one that never existed.
1205        assert!(!in_use(home.path(), &cache));
1206        let entries = inventory_with(home.path(), |pid| pid == this);
1207        let entry = entries
1208            .iter()
1209            .find(|e| e.cache_dir == cache.display().to_string())
1210            .unwrap_or_else(|| panic!("no entry for a released cache: {entries:?}"));
1211        match &entry.status {
1212            EntryStatus::Idle(o) => assert_eq!(o.run, "r1"),
1213            other => panic!("expected Idle, got {other:?}"),
1214        }
1215    }
1216
1217    #[test]
1218    fn reacquiring_a_released_cache_reports_active_not_idle() {
1219        let home = tempfile::TempDir::new().expect("temp");
1220        let cache = home.path().join("cache");
1221        let this = std::process::id();
1222        match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1223            AcquireOutcome::Acquired(g) => g.release(),
1224            AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1225        }
1226        let _held = try_acquire(home.path(), &cache, &owner(this)).expect("reacquire");
1227        let entries = inventory_with(home.path(), |pid| pid == this);
1228        assert_eq!(
1229            entries.len(),
1230            1,
1231            "the catalog row must not duplicate the live lease: {entries:?}"
1232        );
1233        assert!(matches!(entries[0].status, EntryStatus::Active(_)));
1234    }
1235
1236    #[test]
1237    fn maintenance_prune_refuses_a_cache_a_live_owner_holds() {
1238        let home = tempfile::TempDir::new().expect("temp");
1239        let cache = home.path().join("cache");
1240        std::fs::create_dir_all(&cache).unwrap();
1241        std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1242        let _held = try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire");
1243
1244        let result = maintenance_prune(home.path(), &cache, 1).expect("no io error");
1245        assert!(
1246            result.is_none(),
1247            "must not prune while a live owner holds it"
1248        );
1249        assert!(cache.join("big").exists(), "nothing was deleted");
1250    }
1251
1252    #[test]
1253    fn maintenance_prune_acts_once_the_cache_is_free_and_releases_after() {
1254        let home = tempfile::TempDir::new().expect("temp");
1255        let cache = home.path().join("cache");
1256        std::fs::create_dir_all(&cache).unwrap();
1257        std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1258
1259        let pruned = maintenance_prune(home.path(), &cache, 1)
1260            .expect("no io error")
1261            .expect("cache was free");
1262        assert!(pruned.freed > 0);
1263        assert!(
1264            !in_use(home.path(), &cache),
1265            "the maintenance lease was released"
1266        );
1267    }
1268
1269    #[test]
1270    fn identity_drift_is_detected_once_and_then_settles() {
1271        let home = tempfile::TempDir::new().expect("temp");
1272        let cache = home.path().join("cache");
1273        let a = Identity {
1274            worktree: "/w/a".to_owned(),
1275            head: "aaaa".to_owned(),
1276        };
1277        let b = Identity {
1278            worktree: "/w/b".to_owned(),
1279            head: "bbbb".to_owned(),
1280        };
1281        assert!(
1282            needs_refresh(home.path(), &cache, &a),
1283            "nothing recorded yet"
1284        );
1285        record_identity(home.path(), &cache, &a).expect("record");
1286        assert!(
1287            !needs_refresh(home.path(), &cache, &a),
1288            "same identity, no refresh needed"
1289        );
1290        assert!(needs_refresh(home.path(), &cache, &b), "different source");
1291        record_identity(home.path(), &cache, &b).expect("record");
1292        assert!(!needs_refresh(home.path(), &cache, &b));
1293    }
1294
1295    #[test]
1296    fn invalidating_forgets_a_recorded_identity_so_the_next_check_refreshes() {
1297        let home = tempfile::TempDir::new().expect("temp");
1298        let cache = home.path().join("cache");
1299        let a = Identity {
1300            worktree: "/w/a".to_owned(),
1301            head: "aaaa".to_owned(),
1302        };
1303        record_identity(home.path(), &cache, &a).expect("record");
1304        assert!(!needs_refresh(home.path(), &cache, &a));
1305
1306        // An untracked writer (an implement/fix wave, which shares the cache
1307        // across several worktrees at once and so has no single identity of
1308        // its own to record) touched the cache in between; the next tracked
1309        // caller must not trust the old match anymore.
1310        invalidate_identity(home.path(), &cache);
1311        assert!(
1312            needs_refresh(home.path(), &cache, &a),
1313            "invalidation must not be skippable by asking about the same identity again"
1314        );
1315
1316        // Invalidating a cache directory nothing ever recorded is a no-op,
1317        // not an error.
1318        invalidate_identity(home.path(), &home.path().join("never-recorded"));
1319    }
1320
1321    #[test]
1322    fn workspace_package_names_are_read_from_cargo_metadata_json() {
1323        let fixture = r#"{
1324            "packages": [
1325                {"name": "magi", "version": "0.1.0"},
1326                {"name": "magi-cli", "version": "0.1.0"}
1327            ],
1328            "workspace_members": []
1329        }"#;
1330        let mut names = parse_workspace_package_names(fixture);
1331        names.sort();
1332        assert_eq!(names, vec!["magi".to_owned(), "magi-cli".to_owned()]);
1333        assert_eq!(
1334            parse_workspace_package_names("not json"),
1335            Vec::<String>::new()
1336        );
1337        assert_eq!(parse_workspace_package_names("{}"), Vec::<String>::new());
1338    }
1339
1340    #[test]
1341    fn slugs_are_stable_and_filesystem_safe() {
1342        let a = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1343        let b = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1344        assert_eq!(a, b, "same input, same slug");
1345        assert!(
1346            a.chars()
1347                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
1348            "filesystem-safe: {a}"
1349        );
1350    }
1351
1352    #[test]
1353    fn busy_active_describes_the_holder() {
1354        let b = Busy::Active(owner(123));
1355        let s = b.describe();
1356        assert!(
1357            s.contains("r1") && s.contains("gate") && s.contains("123"),
1358            "{s}"
1359        );
1360    }
1361
1362    trait Pipe: Sized {
1363        fn pipe(self) -> Guard;
1364    }
1365    impl Pipe for AcquireOutcome {
1366        fn pipe(self) -> Guard {
1367            match self {
1368                AcquireOutcome::Acquired(g) => g,
1369                AcquireOutcome::Busy(b) => panic!("expected Acquired, got Busy({b:?})"),
1370            }
1371        }
1372    }
1373
1374    fn cmds(v: &[&str]) -> Vec<String> {
1375        v.iter().map(|s| (*s).to_owned()).collect()
1376    }
1377
1378    #[test]
1379    fn manifest_paths_are_read_in_every_spelling() {
1380        let got = extract_manifest_paths(&cmds(&[
1381            "cargo test --manifest-path tools/a/Cargo.toml",
1382            "cargo build --manifest-path=tools/b/Cargo.toml --release",
1383            "cargo fmt --manifest-path 'q q/Cargo.toml'",
1384            "cargo clippy --manifest-path \"d/Cargo.toml\" && cargo test --manifest-path tools/a/Cargo.toml",
1385        ]));
1386        let want: Vec<PathBuf> = [
1387            "tools/a/Cargo.toml",
1388            "tools/b/Cargo.toml",
1389            "q q/Cargo.toml",
1390            "d/Cargo.toml",
1391        ]
1392        .iter()
1393        .map(PathBuf::from)
1394        .collect();
1395        assert_eq!(got, want);
1396        assert!(extract_manifest_paths(&cmds(&["cargo test", "cd x && cargo test"])).is_empty());
1397    }
1398
1399    #[test]
1400    fn plan_targets_covers_blog_root_both_and_none() {
1401        let w = Path::new("/w");
1402        let only = |set: &'static [&'static str]| {
1403            move |p: &Path| Ok(set.iter().any(|s| p == Path::new(s)))
1404        };
1405        let m = vec![PathBuf::from("tools/postprocess/Cargo.toml")];
1406        let blog = plan_targets(w, &m, only(&["/w/tools/postprocess/Cargo.toml"])).unwrap();
1407        assert_eq!(
1408            blog,
1409            vec![Target::Manifest(PathBuf::from(
1410                "/w/tools/postprocess/Cargo.toml"
1411            ))]
1412        );
1413        let root = plan_targets(w, &[], only(&["/w/Cargo.toml"])).unwrap();
1414        assert_eq!(root, vec![Target::Root]);
1415        let both = plan_targets(
1416            w,
1417            &m,
1418            only(&["/w/Cargo.toml", "/w/tools/postprocess/Cargo.toml"]),
1419        )
1420        .unwrap();
1421        assert_eq!(both.len(), 2);
1422        assert_eq!(both[0], Target::Root);
1423        assert!(plan_targets(w, &m, only(&[])).unwrap().is_empty());
1424        assert!(plan_targets(w, &[], only(&[])).unwrap().is_empty());
1425    }
1426
1427    #[test]
1428    fn a_repo_without_a_reachable_manifest_passes_untouched() {
1429        let home = tempfile::TempDir::new().expect("temp");
1430        let wt = tempfile::TempDir::new().expect("temp");
1431        // Only a nested crate, which no verify command names.
1432        std::fs::create_dir_all(wt.path().join("sub")).unwrap();
1433        std::fs::write(wt.path().join("sub/Cargo.toml"), "").unwrap();
1434        let cache = home.path().join("cache");
1435        std::fs::create_dir_all(&cache).unwrap();
1436        std::fs::write(cache.join("artifact"), "x").unwrap();
1437        let old = Identity {
1438            worktree: "/w/old".to_owned(),
1439            head: "old".to_owned(),
1440        };
1441        record_identity(home.path(), &cache, &old).unwrap();
1442        let id = Identity::new(wt.path(), "head");
1443        ensure_fresh(home.path(), &cache, &id, &[]).expect("must not fail");
1444        assert_eq!(
1445            std::fs::read_to_string(cache.join("artifact")).unwrap(),
1446            "x"
1447        );
1448        assert!(
1449            needs_refresh(home.path(), &cache, &old),
1450            "identity was invalidated, not recorded"
1451        );
1452        assert!(needs_refresh(home.path(), &cache, &id));
1453    }
1454}