1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Owner {
59 pub run: String,
61 pub node: String,
63 pub seat: String,
66 pub pid: u32,
70 pub worktree: String,
73 pub head: String,
75}
76
77impl Owner {
78 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
94struct LeaseFile {
95 cache_dir: String,
99 owner: Owner,
100 acquired_at: Timestamp,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Status {
106 Free,
108 Active(Owner),
110 Stale(Owner),
112 Unknown,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Busy {
120 Active(Owner),
122 Unknown,
124 Contended,
126}
127
128impl Busy {
129 #[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#[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 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
183fn leases_dir(home: &Path) -> PathBuf {
187 home.join("cache-leases")
188}
189
190fn 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
207fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
249struct CatalogRecord {
250 cache_dir: String,
251 last_owner: Owner,
252 last_used_at: Timestamp,
253}
254
255fn 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
276fn 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
285fn 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
292fn 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
305fn classify(path: &Path) -> Status {
307 classify_with(path, proc::pid_alive)
308}
309
310fn 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
333fn 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
353pub enum AcquireOutcome {
355 Acquired(Guard),
357 Busy(Busy),
359}
360
361pub 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
368fn 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 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 => {} 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#[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
416pub const RESOURCE_HOLD: &str = "[resource] ";
420
421pub 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
442pub 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#[derive(Debug, Clone)]
483pub struct Entry {
484 pub cache_dir: String,
486 pub status: EntryStatus,
488}
489
490#[derive(Debug, Clone)]
492pub enum EntryStatus {
493 Active(Owner),
495 Stale(Owner),
497 Unknown,
499 Idle(Owner),
505}
506
507#[must_use]
515pub fn inventory(home: &Path) -> Vec<Entry> {
516 inventory_with(home, proc::pid_alive)
517}
518
519fn 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 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 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
584pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619pub struct Identity {
620 pub worktree: String,
622 pub head: String,
624}
625
626impl Identity {
627 #[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#[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
653pub 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
666pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
680 let _ = std::fs::remove_file(identity_path(home, cache_dir));
681}
682
683#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
745pub enum Target {
746 Root,
748 Manifest(PathBuf),
750}
751
752fn 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
783fn 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
793fn 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 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
877const 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
882fn 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
911pub 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 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 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 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 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 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 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 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 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 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 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}