1use std::path::{Path, PathBuf};
43use std::time::Duration;
44
45use anyhow::{Context as _, Result, bail};
46use jiff::Timestamp;
47use serde::{Deserialize, Serialize};
48
49use crate::proc::{self, Quiet as _};
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Owner {
54 pub run: String,
56 pub node: String,
58 pub seat: String,
61 pub pid: u32,
65 pub worktree: String,
68 pub head: String,
70}
71
72impl Owner {
73 #[must_use]
75 pub fn here(run: &str, node: &str, seat: &str, worktree: &Path, head: &str) -> Owner {
76 Owner {
77 run: run.to_owned(),
78 node: node.to_owned(),
79 seat: seat.to_owned(),
80 pid: std::process::id(),
81 worktree: worktree.display().to_string(),
82 head: head.to_owned(),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89struct LeaseFile {
90 cache_dir: String,
94 owner: Owner,
95 acquired_at: Timestamp,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum Status {
101 Free,
103 Active(Owner),
105 Stale(Owner),
107 Unknown,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Busy {
115 Active(Owner),
117 Unknown,
119 Contended,
121 Outputs,
123}
124
125impl Busy {
126 #[must_use]
128 pub fn describe(&self) -> String {
129 match self {
130 Busy::Active(o) => format!(
131 "held by run {} node {} seat {} (pid {})",
132 o.run, o.node, o.seat, o.pid
133 ),
134 Busy::Unknown => {
135 "an unreadable lease is present; refusing to guess who holds it".to_owned()
136 }
137 Busy::Outputs => {
138 "cache outputs are still owned by running tools or process cleanup".to_owned()
139 }
140 Busy::Contended => "lost a race for the lease; retrying".to_owned(),
141 }
142 }
143}
144
145#[derive(Debug)]
149pub struct Guard {
150 path: PathBuf,
151 released: bool,
152 outputs: Option<crate::retention::OutputLease>,
153}
154
155impl Guard {
156 fn new(path: PathBuf) -> Guard {
157 Guard {
158 path,
159 released: false,
160 outputs: None,
161 }
162 }
163
164 pub fn release(mut self) {
168 self.do_release();
169 }
170
171 fn do_release(&mut self) {
172 if !self.released {
173 let _ = std::fs::remove_file(&self.path);
174 self.released = true;
175 }
176 }
177}
178
179impl Drop for Guard {
180 fn drop(&mut self) {
181 self.do_release();
182 }
183}
184
185fn leases_dir(home: &Path) -> PathBuf {
189 home.join("cache-leases")
190}
191
192fn slug(cache_dir: &Path) -> String {
197 let norm = normalize(cache_dir);
198 let mut readable: String = norm
199 .chars()
200 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
201 .collect();
202 readable.truncate(80);
203 use std::hash::{Hash, Hasher};
204 let mut hasher = std::collections::hash_map::DefaultHasher::new();
205 norm.hash(&mut hasher);
206 format!("{readable}-{:08x}", hasher.finish() as u32)
207}
208
209fn normalize(p: &Path) -> String {
215 std::fs::canonicalize(p)
216 .map(|p| p.display().to_string())
217 .unwrap_or_else(|_| p.display().to_string())
218 .replace('\\', "/")
219 .to_ascii_lowercase()
220}
221
222fn lease_path(home: &Path, cache_dir: &Path) -> PathBuf {
223 leases_dir(home).join(format!("{}.json", slug(cache_dir)))
224}
225
226fn identity_path(home: &Path, cache_dir: &Path) -> PathBuf {
227 leases_dir(home).join(format!("{}.identity.json", slug(cache_dir)))
228}
229
230fn catalog_path(home: &Path, cache_dir: &Path) -> PathBuf {
231 leases_dir(home).join(format!("{}.catalog.json", slug(cache_dir)))
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
251struct CatalogRecord {
252 cache_dir: String,
253 last_owner: Owner,
254 last_used_at: Timestamp,
255}
256
257fn record_catalog(home: &Path, cache_dir: &Path, owner: &Owner) {
263 let path = catalog_path(home, cache_dir);
264 let record = CatalogRecord {
265 cache_dir: cache_dir.display().to_string(),
266 last_owner: owner.clone(),
267 last_used_at: Timestamp::now(),
268 };
269 let Ok(body) = serde_json::to_string_pretty(&record) else {
270 return;
271 };
272 let tmp = path.with_extension("json.tmp");
273 if std::fs::write(&tmp, &body).is_ok() {
274 let _ = std::fs::rename(&tmp, &path);
275 }
276}
277
278fn read_catalog(path: &Path) -> Option<CatalogRecord> {
283 let body = std::fs::read_to_string(path).ok()?;
284 serde_json::from_str(&body).ok()
285}
286
287fn read_lease(path: &Path) -> Option<LeaseFile> {
290 let body = std::fs::read_to_string(path).ok()?;
291 serde_json::from_str(&body).ok()
292}
293
294fn peek_cache_dir(path: &Path) -> Option<String> {
299 let body = std::fs::read_to_string(path).ok()?;
300 let value: serde_json::Value = serde_json::from_str(&body).ok()?;
301 value
302 .get("cache_dir")
303 .and_then(|v| v.as_str())
304 .map(str::to_owned)
305}
306
307fn classify(path: &Path) -> Status {
309 classify_with(path, proc::pid_alive)
310}
311
312fn classify_with<F: Fn(u32) -> bool>(path: &Path, alive: F) -> Status {
321 if !path.exists() {
322 return Status::Free;
323 }
324 let Some(lease) = read_lease(path) else {
325 return Status::Unknown;
326 };
327 let this_process = std::process::id();
328 if lease.owner.pid == this_process || alive(lease.owner.pid) {
329 Status::Active(lease.owner)
330 } else {
331 Status::Stale(lease.owner)
332 }
333}
334
335fn write_new(path: &Path, cache_dir: &Path, owner: &Owner) -> std::io::Result<()> {
340 use std::io::Write as _;
341 let mut f = std::fs::OpenOptions::new()
342 .write(true)
343 .create_new(true)
344 .open(path)?;
345 let lease = LeaseFile {
346 cache_dir: cache_dir.display().to_string(),
347 owner: owner.clone(),
348 acquired_at: Timestamp::now(),
349 };
350 let body = serde_json::to_string_pretty(&lease).unwrap_or_default();
351 f.write_all(body.as_bytes())?;
352 Ok(())
353}
354
355pub enum AcquireOutcome {
357 Acquired(Guard),
359 Busy(Busy),
361}
362
363pub fn try_acquire(home: &Path, cache_dir: &Path, owner: &Owner) -> Result<AcquireOutcome> {
367 let Ok(_outputs) = crate::retention::OutputLease::cache(home, cache_dir, true) else {
368 return Ok(AcquireOutcome::Busy(Busy::Outputs));
369 };
370 try_acquire_with(home, cache_dir, owner, proc::pid_alive)
371}
372
373pub fn try_acquire_maintenance(
376 home: &Path,
377 cache_dir: &Path,
378 owner: &Owner,
379) -> Result<AcquireOutcome> {
380 let Ok(outputs) = crate::retention::OutputLease::cache(home, cache_dir, true) else {
381 return Ok(AcquireOutcome::Busy(Busy::Outputs));
382 };
383 match try_acquire_with(home, cache_dir, owner, proc::pid_alive)? {
384 AcquireOutcome::Acquired(mut guard) => {
385 guard.outputs = Some(outputs);
386 Ok(AcquireOutcome::Acquired(guard))
387 }
388 busy => Ok(busy),
389 }
390}
391
392fn try_acquire_with<F: Fn(u32) -> bool + Copy>(
395 home: &Path,
396 cache_dir: &Path,
397 owner: &Owner,
398 alive: F,
399) -> Result<AcquireOutcome> {
400 let dir = leases_dir(home);
401 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
402 let path = dir.join(format!("{}.json", slug(cache_dir)));
403
404 for _ in 0..2 {
410 match write_new(&path, cache_dir, owner) {
411 Ok(()) => {
412 record_catalog(home, cache_dir, owner);
413 return Ok(AcquireOutcome::Acquired(Guard::new(path)));
414 }
415 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
416 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
417 }
418 match classify_with(&path, alive) {
419 Status::Free => {} Status::Stale(_) => {
421 let _ = std::fs::remove_file(&path);
422 }
423 Status::Active(o) => return Ok(AcquireOutcome::Busy(Busy::Active(o))),
424 Status::Unknown => return Ok(AcquireOutcome::Busy(Busy::Unknown)),
425 }
426 }
427 Ok(AcquireOutcome::Busy(Busy::Contended))
428}
429
430#[must_use]
435pub fn in_use(home: &Path, cache_dir: &Path) -> bool {
436 let path = lease_path(home, cache_dir);
437 matches!(classify(&path), Status::Active(_) | Status::Unknown)
438 || crate::retention::OutputLease::cache(home, cache_dir, true).is_err()
439}
440
441pub const RESOURCE_HOLD: &str = "[resource] ";
445
446pub async fn wait_for(
455 home: &Path,
456 cache_dir: &Path,
457 owner: &Owner,
458 budget: Duration,
459 poll: Duration,
460) -> Result<Guard> {
461 match wait_for_unless(home, cache_dir, owner, budget, poll, &|| false).await? {
462 Some(g) => Ok(g),
463 None => bail!("the wait for the build cache was cancelled"),
464 }
465}
466
467pub async fn wait_for_unless(
472 home: &Path,
473 cache_dir: &Path,
474 owner: &Owner,
475 budget: Duration,
476 poll: Duration,
477 cancel: &(dyn Fn() -> bool + Sync),
478) -> Result<Option<Guard>> {
479 let start = std::time::Instant::now();
480 loop {
481 match try_acquire(home, cache_dir, owner)? {
482 AcquireOutcome::Acquired(g) => return Ok(Some(g)),
483 AcquireOutcome::Busy(busy) => {
484 if cancel() {
485 return Ok(None);
486 }
487 let elapsed = start.elapsed();
488 if elapsed >= budget {
489 bail!(
490 "timed out after {}s waiting for the build cache at {} ({})",
491 budget.as_secs(),
492 cache_dir.display(),
493 busy.describe()
494 );
495 }
496 tokio::time::sleep(poll.min(budget - elapsed)).await;
497 }
498 }
499 }
500}
501
502#[derive(Debug, Clone)]
508pub struct Entry {
509 pub cache_dir: String,
511 pub status: EntryStatus,
513}
514
515#[derive(Debug, Clone)]
517pub enum EntryStatus {
518 Active(Owner),
520 Stale(Owner),
522 Unknown,
524 Idle(Owner),
530 Outputs(Owner),
533}
534
535#[must_use]
543pub fn inventory(home: &Path) -> Vec<Entry> {
544 let mut entries = inventory_with(home, proc::pid_alive);
545 for entry in &mut entries {
546 if let EntryStatus::Idle(owner) | EntryStatus::Stale(owner) = &entry.status
547 && crate::retention::OutputLease::cache(home, Path::new(&entry.cache_dir), true)
548 .is_err()
549 {
550 entry.status = EntryStatus::Outputs(owner.clone());
551 }
552 }
553 entries
554}
555
556fn inventory_with<F: Fn(u32) -> bool + Copy>(home: &Path, alive: F) -> Vec<Entry> {
559 let dir = leases_dir(home);
560 let Ok(rd) = std::fs::read_dir(&dir) else {
561 return Vec::new();
562 };
563 let mut out = Vec::new();
564 for entry in rd.flatten() {
565 let path = entry.path();
566 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
567 continue;
568 };
569 if !name.ends_with(".json")
570 || name.ends_with(".identity.json")
571 || name.ends_with(".catalog.json")
572 {
573 continue;
574 }
575 let status = match classify_with(&path, alive) {
576 Status::Free => continue,
577 Status::Active(o) => EntryStatus::Active(o),
578 Status::Stale(o) => EntryStatus::Stale(o),
579 Status::Unknown => EntryStatus::Unknown,
580 };
581 let cache_dir = peek_cache_dir(&path)
587 .unwrap_or_else(|| format!("(unreadable lease file: {})", path.display()));
588 out.push(Entry { cache_dir, status });
589 }
590
591 if let Ok(rd) = std::fs::read_dir(&dir) {
597 for entry in rd.flatten() {
598 let path = entry.path();
599 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
600 continue;
601 };
602 let Some(stem) = name.strip_suffix(".catalog.json") else {
603 continue;
604 };
605 if dir.join(format!("{stem}.json")).exists() {
606 continue;
607 }
608 if let Some(record) = read_catalog(&path) {
609 out.push(Entry {
610 cache_dir: record.cache_dir,
611 status: EntryStatus::Idle(record.last_owner),
612 });
613 }
614 }
615 }
616
617 out.sort_by(|a, b| a.cache_dir.cmp(&b.cache_dir));
618 out
619}
620
621pub fn maintenance_prune(
630 home: &Path,
631 cache_dir: &Path,
632 limit: u64,
633) -> Result<Option<crate::disk::Prune>> {
634 let owner = Owner {
635 run: "maintenance".to_owned(),
636 node: "prune".to_owned(),
637 seat: "janitor".to_owned(),
638 pid: std::process::id(),
639 worktree: String::new(),
640 head: String::new(),
641 };
642 match try_acquire_maintenance(home, cache_dir, &owner)? {
643 AcquireOutcome::Busy(_) => Ok(None),
644 AcquireOutcome::Acquired(guard) => {
645 let _cargo = crate::retention::cargo_locks(cache_dir)?;
646 let result = crate::disk::prune_dir(cache_dir, limit)?;
647 guard.release();
648 Ok(Some(result))
649 }
650 }
651}
652
653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657pub struct Identity {
658 pub worktree: String,
660 pub head: String,
662}
663
664impl Identity {
665 #[must_use]
667 pub fn new(worktree: &Path, head: &str) -> Identity {
668 Identity {
669 worktree: worktree.display().to_string(),
670 head: head.to_owned(),
671 }
672 }
673}
674
675#[must_use]
680pub fn needs_refresh(home: &Path, cache_dir: &Path, current: &Identity) -> bool {
681 let path = identity_path(home, cache_dir);
682 let Ok(body) = std::fs::read_to_string(path) else {
683 return true;
684 };
685 match serde_json::from_str::<Identity>(&body) {
686 Ok(recorded) => &recorded != current,
687 Err(_) => true,
688 }
689}
690
691pub fn record_identity(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
693 let path = identity_path(home, cache_dir);
694 if let Some(parent) = path.parent() {
695 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
696 }
697 let body = serde_json::to_string_pretty(identity).context("serialize cache identity")?;
698 let tmp = path.with_extension("json.tmp");
699 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
700 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
701 Ok(())
702}
703
704pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
718 let _ = std::fs::remove_file(identity_path(home, cache_dir));
719}
720
721#[must_use]
727pub fn parse_workspace_package_names(metadata_json: &str) -> Vec<String> {
728 let Ok(value) = serde_json::from_str::<serde_json::Value>(metadata_json) else {
729 return Vec::new();
730 };
731 value
732 .get("packages")
733 .and_then(|p| p.as_array())
734 .map(|packages| {
735 packages
736 .iter()
737 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
738 .map(str::to_owned)
739 .collect()
740 })
741 .unwrap_or_default()
742}
743
744#[must_use]
748pub fn extract_manifest_paths(commands: &[String]) -> Vec<PathBuf> {
749 const KEY: &str = "--manifest-path";
750 let mut out: Vec<PathBuf> = Vec::new();
751 for command in commands {
752 let mut rest = command.as_str();
753 while let Some(at) = rest.find(KEY) {
754 rest = &rest[at + KEY.len()..];
755 let value = if let Some(r) = rest.strip_prefix('=') {
756 r
757 } else if rest.starts_with(char::is_whitespace) {
758 rest.trim_start()
759 } else {
760 continue;
761 };
762 let value = if let Some(s) = value.strip_prefix('\'') {
763 s.split('\'').next().unwrap_or("")
764 } else if let Some(s) = value.strip_prefix('"') {
765 s.split('"').next().unwrap_or("")
766 } else {
767 let end = value.find(char::is_whitespace).unwrap_or(value.len());
768 &value[..end]
769 };
770 if !value.is_empty() {
771 let path = PathBuf::from(value);
772 if !out.contains(&path) {
773 out.push(path);
774 }
775 }
776 }
777 }
778 out
779}
780
781#[derive(Debug, Clone, PartialEq, Eq)]
783pub enum Target {
784 Root,
786 Manifest(PathBuf),
788}
789
790fn plan_targets(
795 worktree: &Path,
796 manifests: &[PathBuf],
797 exists: impl Fn(&Path) -> Result<bool>,
798) -> Result<Vec<Target>> {
799 let mut targets = Vec::new();
800 if exists(&worktree.join("Cargo.toml"))? {
801 targets.push(Target::Root);
802 }
803 for m in manifests {
804 let full = if m.is_absolute() {
805 m.clone()
806 } else {
807 worktree.join(m)
808 };
809 if exists(&full)? {
810 targets.push(Target::Manifest(full));
811 } else {
812 tracing::warn!(
813 manifest = %m.display(),
814 "build cache: --manifest-path from a verify command does not exist; skipped"
815 );
816 }
817 }
818 Ok(targets)
819}
820
821fn file_exists(path: &Path) -> Result<bool> {
824 match std::fs::metadata(path) {
825 Ok(m) => Ok(m.is_file()),
826 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
827 Err(e) => Err(e).with_context(|| format!("stat {}", path.display())),
828 }
829}
830
831fn refresh_stale_packages(
839 worktree: &Path,
840 cache_dir: &Path,
841 manifests: &[PathBuf],
842) -> Result<Option<Vec<String>>> {
843 let targets = plan_targets(worktree, manifests, file_exists)?;
844 if targets.is_empty() {
845 return Ok(None);
846 }
847 let mut all = Vec::new();
848 let mut failed = Vec::new();
849 for target in &targets {
850 let manifest_arg = match target {
851 Target::Root => None,
852 Target::Manifest(m) => Some(m),
853 };
854 let mut cmd = std::process::Command::new("cargo");
855 cmd.args(["metadata", "--no-deps", "--format-version", "1"]);
856 if let Some(m) = manifest_arg {
857 cmd.arg("--manifest-path").arg(m);
858 }
859 let meta = cmd
860 .current_dir(worktree)
861 .quiet()
862 .output()
863 .context("run `cargo metadata`")?;
864 if !meta.status.success() {
865 bail!(
866 "cargo metadata failed: {}",
867 String::from_utf8_lossy(&meta.stderr)
868 );
869 }
870 let names = parse_workspace_package_names(&String::from_utf8_lossy(&meta.stdout));
871 for name in &names {
872 let mut clean = std::process::Command::new("cargo");
873 clean.arg("clean").arg("-p").arg(name);
874 if let Some(m) = manifest_arg {
875 clean.arg("--manifest-path").arg(m);
876 }
877 let out = clean
878 .arg("--target-dir")
879 .arg(cache_dir)
880 .current_dir(worktree)
881 .quiet()
882 .output()
883 .with_context(|| format!("cargo clean -p {name}"))?;
884 if !out.status.success() {
885 failed.push(format!(
894 "{name}: {}",
895 String::from_utf8_lossy(&out.stderr).trim()
896 ));
897 }
898 }
899 for n in names {
900 if !all.contains(&n) {
901 all.push(n);
902 }
903 }
904 }
905 if !failed.is_empty() {
906 bail!(
907 "cargo clean -p failed for {} package(s): {}",
908 failed.len(),
909 failed.join("; ")
910 );
911 }
912 Ok(Some(all))
913}
914
915const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
917# This file is a cache directory tag created by cargo.\n\
918# For information about cache directory tags see https://bford.info/cachedir/\n";
919
920fn restore_cachedir_tag(cache_dir: &Path) -> Result<bool> {
929 use std::io::Write as _;
930 if !cache_dir.is_dir() || !cache_dir.join(".rustc_info.json").is_file() {
931 return Ok(false);
932 }
933 let tag = cache_dir.join("CACHEDIR.TAG");
934 match std::fs::OpenOptions::new()
935 .write(true)
936 .create_new(true)
937 .open(&tag)
938 {
939 Ok(mut f) => {
940 f.write_all(CACHEDIR_TAG.as_bytes())
941 .with_context(|| format!("write {}", tag.display()))?;
942 Ok(true)
943 }
944 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
945 Err(e) => Err(e).with_context(|| format!("create {}", tag.display())),
946 }
947}
948
949pub fn ensure_fresh(
960 home: &Path,
961 cache_dir: &Path,
962 identity: &Identity,
963 manifests: &[PathBuf],
964) -> Result<()> {
965 match restore_cachedir_tag(cache_dir) {
966 Ok(true) => tracing::info!(
967 cache = %cache_dir.display(),
968 "build cache: restored a missing CACHEDIR.TAG"
969 ),
970 Ok(false) => {}
971 Err(e) => tracing::warn!(error = %e, "build cache: could not restore CACHEDIR.TAG"),
973 }
974 if needs_refresh(home, cache_dir, identity) {
975 let worktree = PathBuf::from(&identity.worktree);
976 match refresh_stale_packages(&worktree, cache_dir, manifests)? {
977 Some(cleaned) => tracing::info!(
978 ?cleaned,
979 cache = %cache_dir.display(),
980 "build cache: source identity changed; cleaned the workspace's own packages before reuse"
981 ),
982 None => {
983 tracing::warn!(
988 cache = %cache_dir.display(),
989 "build cache: no Cargo manifest found for the freshness check; skipping the package clean"
990 );
991 invalidate_identity(home, cache_dir);
992 return Ok(());
993 }
994 }
995 }
996 record_identity(home, cache_dir, identity)
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002
1003 fn owner(pid: u32) -> Owner {
1004 Owner {
1005 run: "r1".to_owned(),
1006 node: "gate".to_owned(),
1007 seat: "gate".to_owned(),
1008 pid,
1009 worktree: "/w".to_owned(),
1010 head: "deadbeef".to_owned(),
1011 }
1012 }
1013
1014 #[test]
1015 fn a_missing_cachedir_tag_is_restored_only_for_a_cargo_target() {
1016 let t = tempfile::TempDir::new().expect("temp");
1017 let cache = t.path().join("cache");
1018 assert!(!restore_cachedir_tag(&cache).expect("missing dir"));
1019 assert!(!cache.exists(), "a missing directory is left for cargo");
1020
1021 std::fs::create_dir_all(&cache).expect("mkdir");
1022 assert!(!restore_cachedir_tag(&cache).expect("no rustc info"));
1023 assert!(!cache.join("CACHEDIR.TAG").exists());
1024
1025 std::fs::write(cache.join(".rustc_info.json"), "{}").expect("info");
1026 assert!(restore_cachedir_tag(&cache).expect("restore"));
1027 let body = std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag");
1028 assert_eq!(
1029 body.lines().next(),
1030 Some("Signature: 8a477f597d28d172789f06886806bc55")
1031 );
1032
1033 std::fs::write(cache.join("CACHEDIR.TAG"), "custom").expect("custom");
1034 assert!(!restore_cachedir_tag(&cache).expect("existing"));
1035 assert_eq!(
1036 std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag"),
1037 "custom"
1038 );
1039 }
1040
1041 #[test]
1042 fn an_uncontended_lease_is_acquired_and_freed_on_release() {
1043 let home = tempfile::TempDir::new().expect("temp");
1044 let cache = home.path().join("cache");
1045 let this = std::process::id();
1046 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1047 AcquireOutcome::Acquired(g) => {
1048 assert!(in_use(home.path(), &cache), "held while the guard lives");
1049 g.release();
1050 }
1051 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1052 }
1053 assert!(!in_use(home.path(), &cache), "freed after release");
1054 }
1055
1056 #[test]
1057 fn a_lease_held_by_a_live_pid_is_reported_active_and_refuses_a_second_acquire() {
1058 let home = tempfile::TempDir::new().expect("temp");
1059 let cache = home.path().join("cache");
1060 let this = std::process::id();
1061 let _first =
1065 try_acquire(home.path(), &cache, &owner(this)).expect("first acquire succeeds");
1066 let mut second_owner = owner(this);
1067 second_owner.run = "r2".to_owned();
1068 match try_acquire(home.path(), &cache, &second_owner).expect("no io error") {
1069 AcquireOutcome::Busy(Busy::Active(held_by)) => assert_eq!(held_by.run, "r1"),
1070 other => panic!("expected Busy::Active, got a different outcome: {other:?}"),
1071 }
1072 assert!(in_use(home.path(), &cache));
1073 }
1074
1075 impl std::fmt::Debug for AcquireOutcome {
1076 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1077 match self {
1078 AcquireOutcome::Acquired(_) => write!(f, "Acquired"),
1079 AcquireOutcome::Busy(b) => write!(f, "Busy({b:?})"),
1080 }
1081 }
1082 }
1083
1084 #[test]
1085 fn a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer() {
1086 let home = tempfile::TempDir::new().expect("temp");
1087 let cache = home.path().join("cache");
1088 let dead_owner = owner(999_999);
1094 let path = lease_path(home.path(), &cache);
1095 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1096 write_new(&path, &cache, &dead_owner).expect("seed a stale lease");
1097 assert_eq!(
1098 classify_with(&path, |_| false),
1099 Status::Stale(dead_owner.clone())
1100 );
1101
1102 match try_acquire_with(home.path(), &cache, &owner(std::process::id()), |_| false)
1103 .expect("acquire")
1104 {
1105 AcquireOutcome::Acquired(_) => {}
1106 other => panic!("stale lease should have been reclaimed: {other:?}"),
1107 }
1108 }
1109
1110 #[test]
1111 fn an_unreadable_lease_is_unknown_and_never_reclaimed() {
1112 let home = tempfile::TempDir::new().expect("temp");
1113 let cache = home.path().join("cache");
1114 let path = lease_path(home.path(), &cache);
1115 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1116 std::fs::write(&path, b"not json").unwrap();
1117 assert_eq!(classify(&path), Status::Unknown);
1118 assert!(in_use(home.path(), &cache), "unknown counts as in use");
1119 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("no io error") {
1120 AcquireOutcome::Busy(Busy::Unknown) => {}
1121 other => panic!("expected Busy::Unknown, got {other:?}"),
1122 }
1123 }
1124
1125 #[tokio::test]
1126 async fn waiting_for_a_busy_lease_times_out_within_its_own_budget() {
1127 let home = tempfile::TempDir::new().expect("temp");
1128 let cache = home.path().join("cache");
1129 let _held = try_acquire(home.path(), &cache, &owner(std::process::id()))
1130 .expect("acquire")
1131 .pipe();
1132 let mut waiter = owner(std::process::id());
1133 waiter.run = "r2".to_owned();
1134 let started = std::time::Instant::now();
1135 let err = wait_for(
1136 home.path(),
1137 &cache,
1138 &waiter,
1139 Duration::from_millis(150),
1140 Duration::from_millis(20),
1141 )
1142 .await
1143 .expect_err("still held, must time out");
1144 assert!(started.elapsed() < Duration::from_secs(2), "bounded wait");
1145 assert!(
1146 err.to_string().contains("r1"),
1147 "names the current holder: {err}"
1148 );
1149 }
1150
1151 #[tokio::test]
1152 async fn a_wait_succeeds_as_soon_as_the_lease_is_released() {
1153 let home = tempfile::TempDir::new().expect("temp");
1154 let cache = home.path().join("cache");
1155 let guard =
1156 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire") {
1157 AcquireOutcome::Acquired(g) => g,
1158 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1159 };
1160 let home_path = home.path().to_path_buf();
1161 let cache_path = cache.clone();
1162 let mut waiter = owner(std::process::id());
1163 waiter.run = "r2".to_owned();
1164 let wait = tokio::spawn(async move {
1165 wait_for(
1166 &home_path,
1167 &cache_path,
1168 &waiter,
1169 Duration::from_secs(5),
1170 Duration::from_millis(10),
1171 )
1172 .await
1173 });
1174 tokio::time::sleep(Duration::from_millis(50)).await;
1175 guard.release();
1176 let acquired = wait.await.expect("task").expect("acquire after release");
1177 acquired.release();
1178 }
1179
1180 #[test]
1181 fn inventory_reports_active_stale_and_unknown_but_not_free() {
1182 let home = tempfile::TempDir::new().expect("temp");
1183 let active_cache = home.path().join("active");
1184 let stale_cache = home.path().join("stale");
1185 let unknown_cache = home.path().join("unknown");
1186
1187 let _held =
1188 try_acquire(home.path(), &active_cache, &owner(std::process::id())).expect("acquire");
1189 let stale_path = lease_path(home.path(), &stale_cache);
1190 std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1191 write_new(&stale_path, &stale_cache, &owner(999_999)).unwrap();
1192 let unknown_path = lease_path(home.path(), &unknown_cache);
1193 std::fs::write(&unknown_path, b"garbage").unwrap();
1194
1195 let entries = inventory_with(home.path(), |pid| pid == std::process::id());
1200 assert_eq!(entries.len(), 3, "{entries:?}");
1201 let by_dir = |dir: &Path| {
1202 entries
1203 .iter()
1204 .find(|e| e.cache_dir == dir.display().to_string())
1205 .unwrap_or_else(|| panic!("no entry for {}", dir.display()))
1206 };
1207 assert!(matches!(
1208 by_dir(&active_cache).status,
1209 EntryStatus::Active(_)
1210 ));
1211 assert!(matches!(by_dir(&stale_cache).status, EntryStatus::Stale(_)));
1212 let unknown = entries
1217 .iter()
1218 .find(|e| matches!(e.status, EntryStatus::Unknown))
1219 .unwrap_or_else(|| panic!("no Unknown entry: {entries:?}"));
1220 assert!(
1221 unknown
1222 .cache_dir
1223 .contains(&unknown_path.display().to_string()),
1224 "{unknown:?}"
1225 );
1226 }
1227
1228 #[test]
1229 fn a_released_lease_is_reported_idle_from_the_catalog_not_dropped_entirely() {
1230 let home = tempfile::TempDir::new().expect("temp");
1231 let cache = home.path().join("cache");
1232 let this = std::process::id();
1233
1234 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1235 AcquireOutcome::Acquired(g) => g.release(),
1236 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1237 }
1238
1239 assert!(!in_use(home.path(), &cache));
1244 let entries = inventory_with(home.path(), |pid| pid == this);
1245 let entry = entries
1246 .iter()
1247 .find(|e| e.cache_dir == cache.display().to_string())
1248 .unwrap_or_else(|| panic!("no entry for a released cache: {entries:?}"));
1249 match &entry.status {
1250 EntryStatus::Idle(o) => assert_eq!(o.run, "r1"),
1251 other => panic!("expected Idle, got {other:?}"),
1252 }
1253 }
1254
1255 #[test]
1256 fn reacquiring_a_released_cache_reports_active_not_idle() {
1257 let home = tempfile::TempDir::new().expect("temp");
1258 let cache = home.path().join("cache");
1259 let this = std::process::id();
1260 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1261 AcquireOutcome::Acquired(g) => g.release(),
1262 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1263 }
1264 let _held = try_acquire(home.path(), &cache, &owner(this)).expect("reacquire");
1265 let entries = inventory_with(home.path(), |pid| pid == this);
1266 assert_eq!(
1267 entries.len(),
1268 1,
1269 "the catalog row must not duplicate the live lease: {entries:?}"
1270 );
1271 assert!(matches!(entries[0].status, EntryStatus::Active(_)));
1272 }
1273
1274 #[test]
1275 fn maintenance_prune_refuses_a_cache_a_live_owner_holds() {
1276 let home = tempfile::TempDir::new().expect("temp");
1277 let cache = home.path().join("cache");
1278 std::fs::create_dir_all(&cache).unwrap();
1279 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1280 let _held = try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire");
1281
1282 let result = maintenance_prune(home.path(), &cache, 1).expect("no io error");
1283 assert!(
1284 result.is_none(),
1285 "must not prune while a live owner holds it"
1286 );
1287 assert!(cache.join("big").exists(), "nothing was deleted");
1288 }
1289
1290 #[test]
1291 fn maintenance_prune_acts_once_the_cache_is_free_and_releases_after() {
1292 let home = tempfile::TempDir::new().expect("temp");
1293 let cache = home.path().join("cache");
1294 std::fs::create_dir_all(&cache).unwrap();
1295 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1296
1297 let pruned = maintenance_prune(home.path(), &cache, 1)
1298 .expect("no io error")
1299 .expect("cache was free");
1300 assert!(pruned.freed > 0);
1301 assert!(
1302 !in_use(home.path(), &cache),
1303 "the maintenance lease was released"
1304 );
1305 }
1306
1307 #[test]
1308 fn identity_drift_is_detected_once_and_then_settles() {
1309 let home = tempfile::TempDir::new().expect("temp");
1310 let cache = home.path().join("cache");
1311 let a = Identity {
1312 worktree: "/w/a".to_owned(),
1313 head: "aaaa".to_owned(),
1314 };
1315 let b = Identity {
1316 worktree: "/w/b".to_owned(),
1317 head: "bbbb".to_owned(),
1318 };
1319 assert!(
1320 needs_refresh(home.path(), &cache, &a),
1321 "nothing recorded yet"
1322 );
1323 record_identity(home.path(), &cache, &a).expect("record");
1324 assert!(
1325 !needs_refresh(home.path(), &cache, &a),
1326 "same identity, no refresh needed"
1327 );
1328 assert!(needs_refresh(home.path(), &cache, &b), "different source");
1329 record_identity(home.path(), &cache, &b).expect("record");
1330 assert!(!needs_refresh(home.path(), &cache, &b));
1331 }
1332
1333 #[test]
1334 fn invalidating_forgets_a_recorded_identity_so_the_next_check_refreshes() {
1335 let home = tempfile::TempDir::new().expect("temp");
1336 let cache = home.path().join("cache");
1337 let a = Identity {
1338 worktree: "/w/a".to_owned(),
1339 head: "aaaa".to_owned(),
1340 };
1341 record_identity(home.path(), &cache, &a).expect("record");
1342 assert!(!needs_refresh(home.path(), &cache, &a));
1343
1344 invalidate_identity(home.path(), &cache);
1349 assert!(
1350 needs_refresh(home.path(), &cache, &a),
1351 "invalidation must not be skippable by asking about the same identity again"
1352 );
1353
1354 invalidate_identity(home.path(), &home.path().join("never-recorded"));
1357 }
1358
1359 #[test]
1360 fn workspace_package_names_are_read_from_cargo_metadata_json() {
1361 let fixture = r#"{
1362 "packages": [
1363 {"name": "magi", "version": "0.1.0"},
1364 {"name": "magi-cli", "version": "0.1.0"}
1365 ],
1366 "workspace_members": []
1367 }"#;
1368 let mut names = parse_workspace_package_names(fixture);
1369 names.sort();
1370 assert_eq!(names, vec!["magi".to_owned(), "magi-cli".to_owned()]);
1371 assert_eq!(
1372 parse_workspace_package_names("not json"),
1373 Vec::<String>::new()
1374 );
1375 assert_eq!(parse_workspace_package_names("{}"), Vec::<String>::new());
1376 }
1377
1378 #[test]
1379 fn slugs_are_stable_and_filesystem_safe() {
1380 let a = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1381 let b = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1382 assert_eq!(a, b, "same input, same slug");
1383 assert!(
1384 a.chars()
1385 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
1386 "filesystem-safe: {a}"
1387 );
1388 }
1389
1390 #[test]
1391 fn busy_active_describes_the_holder() {
1392 let b = Busy::Active(owner(123));
1393 let s = b.describe();
1394 assert!(
1395 s.contains("r1") && s.contains("gate") && s.contains("123"),
1396 "{s}"
1397 );
1398 }
1399
1400 trait Pipe: Sized {
1401 fn pipe(self) -> Guard;
1402 }
1403 impl Pipe for AcquireOutcome {
1404 fn pipe(self) -> Guard {
1405 match self {
1406 AcquireOutcome::Acquired(g) => g,
1407 AcquireOutcome::Busy(b) => panic!("expected Acquired, got Busy({b:?})"),
1408 }
1409 }
1410 }
1411
1412 fn cmds(v: &[&str]) -> Vec<String> {
1413 v.iter().map(|s| (*s).to_owned()).collect()
1414 }
1415
1416 #[test]
1417 fn manifest_paths_are_read_in_every_spelling() {
1418 let got = extract_manifest_paths(&cmds(&[
1419 "cargo test --manifest-path tools/a/Cargo.toml",
1420 "cargo build --manifest-path=tools/b/Cargo.toml --release",
1421 "cargo fmt --manifest-path 'q q/Cargo.toml'",
1422 "cargo clippy --manifest-path \"d/Cargo.toml\" && cargo test --manifest-path tools/a/Cargo.toml",
1423 ]));
1424 let want: Vec<PathBuf> = [
1425 "tools/a/Cargo.toml",
1426 "tools/b/Cargo.toml",
1427 "q q/Cargo.toml",
1428 "d/Cargo.toml",
1429 ]
1430 .iter()
1431 .map(PathBuf::from)
1432 .collect();
1433 assert_eq!(got, want);
1434 assert!(extract_manifest_paths(&cmds(&["cargo test", "cd x && cargo test"])).is_empty());
1435 }
1436
1437 #[test]
1438 fn plan_targets_covers_blog_root_both_and_none() {
1439 let w = Path::new("/w");
1440 let only = |set: &'static [&'static str]| {
1441 move |p: &Path| Ok(set.iter().any(|s| p == Path::new(s)))
1442 };
1443 let m = vec![PathBuf::from("tools/postprocess/Cargo.toml")];
1444 let blog = plan_targets(w, &m, only(&["/w/tools/postprocess/Cargo.toml"])).unwrap();
1445 assert_eq!(
1446 blog,
1447 vec![Target::Manifest(PathBuf::from(
1448 "/w/tools/postprocess/Cargo.toml"
1449 ))]
1450 );
1451 let root = plan_targets(w, &[], only(&["/w/Cargo.toml"])).unwrap();
1452 assert_eq!(root, vec![Target::Root]);
1453 let both = plan_targets(
1454 w,
1455 &m,
1456 only(&["/w/Cargo.toml", "/w/tools/postprocess/Cargo.toml"]),
1457 )
1458 .unwrap();
1459 assert_eq!(both.len(), 2);
1460 assert_eq!(both[0], Target::Root);
1461 assert!(plan_targets(w, &m, only(&[])).unwrap().is_empty());
1462 assert!(plan_targets(w, &[], only(&[])).unwrap().is_empty());
1463 }
1464
1465 #[test]
1466 fn a_repo_without_a_reachable_manifest_passes_untouched() {
1467 let home = tempfile::TempDir::new().expect("temp");
1468 let wt = tempfile::TempDir::new().expect("temp");
1469 std::fs::create_dir_all(wt.path().join("sub")).unwrap();
1471 std::fs::write(wt.path().join("sub/Cargo.toml"), "").unwrap();
1472 let cache = home.path().join("cache");
1473 std::fs::create_dir_all(&cache).unwrap();
1474 std::fs::write(cache.join("artifact"), "x").unwrap();
1475 let old = Identity {
1476 worktree: "/w/old".to_owned(),
1477 head: "old".to_owned(),
1478 };
1479 record_identity(home.path(), &cache, &old).unwrap();
1480 let id = Identity::new(wt.path(), "head");
1481 ensure_fresh(home.path(), &cache, &id, &[]).expect("must not fail");
1482 assert_eq!(
1483 std::fs::read_to_string(cache.join("artifact")).unwrap(),
1484 "x"
1485 );
1486 assert!(
1487 needs_refresh(home.path(), &cache, &old),
1488 "identity was invalidated, not recorded"
1489 );
1490 assert!(needs_refresh(home.path(), &cache, &id));
1491 }
1492}