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 async fn wait_for(
425 home: &Path,
426 cache_dir: &Path,
427 owner: &Owner,
428 budget: Duration,
429 poll: Duration,
430) -> Result<Guard> {
431 let start = std::time::Instant::now();
432 loop {
433 match try_acquire(home, cache_dir, owner)? {
434 AcquireOutcome::Acquired(g) => return Ok(g),
435 AcquireOutcome::Busy(busy) => {
436 let elapsed = start.elapsed();
437 if elapsed >= budget {
438 bail!(
439 "timed out after {}s waiting for the build cache at {} ({})",
440 budget.as_secs(),
441 cache_dir.display(),
442 busy.describe()
443 );
444 }
445 tokio::time::sleep(poll.min(budget - elapsed)).await;
446 }
447 }
448 }
449}
450
451#[derive(Debug, Clone)]
457pub struct Entry {
458 pub cache_dir: String,
460 pub status: EntryStatus,
462}
463
464#[derive(Debug, Clone)]
466pub enum EntryStatus {
467 Active(Owner),
469 Stale(Owner),
471 Unknown,
473 Idle(Owner),
479}
480
481#[must_use]
489pub fn inventory(home: &Path) -> Vec<Entry> {
490 inventory_with(home, proc::pid_alive)
491}
492
493fn inventory_with<F: Fn(u32) -> bool + Copy>(home: &Path, alive: F) -> Vec<Entry> {
496 let dir = leases_dir(home);
497 let Ok(rd) = std::fs::read_dir(&dir) else {
498 return Vec::new();
499 };
500 let mut out = Vec::new();
501 for entry in rd.flatten() {
502 let path = entry.path();
503 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
504 continue;
505 };
506 if !name.ends_with(".json")
507 || name.ends_with(".identity.json")
508 || name.ends_with(".catalog.json")
509 {
510 continue;
511 }
512 let status = match classify_with(&path, alive) {
513 Status::Free => continue,
514 Status::Active(o) => EntryStatus::Active(o),
515 Status::Stale(o) => EntryStatus::Stale(o),
516 Status::Unknown => EntryStatus::Unknown,
517 };
518 let cache_dir = peek_cache_dir(&path)
524 .unwrap_or_else(|| format!("(unreadable lease file: {})", path.display()));
525 out.push(Entry { cache_dir, status });
526 }
527
528 if let Ok(rd) = std::fs::read_dir(&dir) {
534 for entry in rd.flatten() {
535 let path = entry.path();
536 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
537 continue;
538 };
539 let Some(stem) = name.strip_suffix(".catalog.json") else {
540 continue;
541 };
542 if dir.join(format!("{stem}.json")).exists() {
543 continue;
544 }
545 if let Some(record) = read_catalog(&path) {
546 out.push(Entry {
547 cache_dir: record.cache_dir,
548 status: EntryStatus::Idle(record.last_owner),
549 });
550 }
551 }
552 }
553
554 out.sort_by(|a, b| a.cache_dir.cmp(&b.cache_dir));
555 out
556}
557
558pub fn maintenance_prune(
567 home: &Path,
568 cache_dir: &Path,
569 limit: u64,
570) -> Result<Option<crate::disk::Prune>> {
571 let owner = Owner {
572 run: "maintenance".to_owned(),
573 node: "prune".to_owned(),
574 seat: "janitor".to_owned(),
575 pid: std::process::id(),
576 worktree: String::new(),
577 head: String::new(),
578 };
579 match try_acquire(home, cache_dir, &owner)? {
580 AcquireOutcome::Busy(_) => Ok(None),
581 AcquireOutcome::Acquired(guard) => {
582 let result = crate::disk::prune_dir(cache_dir, limit)?;
583 guard.release();
584 Ok(Some(result))
585 }
586 }
587}
588
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
593pub struct Identity {
594 pub worktree: String,
596 pub head: String,
598}
599
600impl Identity {
601 #[must_use]
603 pub fn new(worktree: &Path, head: &str) -> Identity {
604 Identity {
605 worktree: worktree.display().to_string(),
606 head: head.to_owned(),
607 }
608 }
609}
610
611#[must_use]
616pub fn needs_refresh(home: &Path, cache_dir: &Path, current: &Identity) -> bool {
617 let path = identity_path(home, cache_dir);
618 let Ok(body) = std::fs::read_to_string(path) else {
619 return true;
620 };
621 match serde_json::from_str::<Identity>(&body) {
622 Ok(recorded) => &recorded != current,
623 Err(_) => true,
624 }
625}
626
627pub fn record_identity(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
629 let path = identity_path(home, cache_dir);
630 if let Some(parent) = path.parent() {
631 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
632 }
633 let body = serde_json::to_string_pretty(identity).context("serialize cache identity")?;
634 let tmp = path.with_extension("json.tmp");
635 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
636 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
637 Ok(())
638}
639
640pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
654 let _ = std::fs::remove_file(identity_path(home, cache_dir));
655}
656
657#[must_use]
663pub fn parse_workspace_package_names(metadata_json: &str) -> Vec<String> {
664 let Ok(value) = serde_json::from_str::<serde_json::Value>(metadata_json) else {
665 return Vec::new();
666 };
667 value
668 .get("packages")
669 .and_then(|p| p.as_array())
670 .map(|packages| {
671 packages
672 .iter()
673 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
674 .map(str::to_owned)
675 .collect()
676 })
677 .unwrap_or_default()
678}
679
680fn refresh_stale_packages(worktree: &Path, cache_dir: &Path) -> Result<Vec<String>> {
687 let meta = std::process::Command::new("cargo")
688 .args(["metadata", "--no-deps", "--format-version", "1"])
689 .current_dir(worktree)
690 .quiet()
691 .output()
692 .context("run `cargo metadata`")?;
693 if !meta.status.success() {
694 bail!(
695 "cargo metadata failed: {}",
696 String::from_utf8_lossy(&meta.stderr)
697 );
698 }
699 let names = parse_workspace_package_names(&String::from_utf8_lossy(&meta.stdout));
700 let mut failed = Vec::new();
701 for name in &names {
702 let out = std::process::Command::new("cargo")
703 .arg("clean")
704 .arg("-p")
705 .arg(name)
706 .arg("--target-dir")
707 .arg(cache_dir)
708 .current_dir(worktree)
709 .quiet()
710 .output()
711 .with_context(|| format!("cargo clean -p {name}"))?;
712 if !out.status.success() {
713 failed.push(format!(
722 "{name}: {}",
723 String::from_utf8_lossy(&out.stderr).trim()
724 ));
725 }
726 }
727 if !failed.is_empty() {
728 bail!(
729 "cargo clean -p failed for {} package(s): {}",
730 failed.len(),
731 failed.join("; ")
732 );
733 }
734 Ok(names)
735}
736
737const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
739# This file is a cache directory tag created by cargo.\n\
740# For information about cache directory tags see https://bford.info/cachedir/\n";
741
742fn restore_cachedir_tag(cache_dir: &Path) -> Result<bool> {
751 use std::io::Write as _;
752 if !cache_dir.is_dir() || !cache_dir.join(".rustc_info.json").is_file() {
753 return Ok(false);
754 }
755 let tag = cache_dir.join("CACHEDIR.TAG");
756 match std::fs::OpenOptions::new()
757 .write(true)
758 .create_new(true)
759 .open(&tag)
760 {
761 Ok(mut f) => {
762 f.write_all(CACHEDIR_TAG.as_bytes())
763 .with_context(|| format!("write {}", tag.display()))?;
764 Ok(true)
765 }
766 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
767 Err(e) => Err(e).with_context(|| format!("create {}", tag.display())),
768 }
769}
770
771pub fn ensure_fresh(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
782 match restore_cachedir_tag(cache_dir) {
783 Ok(true) => tracing::info!(
784 cache = %cache_dir.display(),
785 "build cache: restored a missing CACHEDIR.TAG"
786 ),
787 Ok(false) => {}
788 Err(e) => tracing::warn!(error = %e, "build cache: could not restore CACHEDIR.TAG"),
790 }
791 if needs_refresh(home, cache_dir, identity) {
792 let cleaned = refresh_stale_packages(&PathBuf::from(&identity.worktree), cache_dir)?;
793 tracing::info!(
794 ?cleaned,
795 cache = %cache_dir.display(),
796 "build cache: source identity changed; cleaned the workspace's own packages before reuse"
797 );
798 }
799 record_identity(home, cache_dir, identity)
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 fn owner(pid: u32) -> Owner {
807 Owner {
808 run: "r1".to_owned(),
809 node: "gate".to_owned(),
810 seat: "gate".to_owned(),
811 pid,
812 worktree: "/w".to_owned(),
813 head: "deadbeef".to_owned(),
814 }
815 }
816
817 #[test]
818 fn a_missing_cachedir_tag_is_restored_only_for_a_cargo_target() {
819 let t = tempfile::TempDir::new().expect("temp");
820 let cache = t.path().join("cache");
821 assert!(!restore_cachedir_tag(&cache).expect("missing dir"));
822 assert!(!cache.exists(), "a missing directory is left for cargo");
823
824 std::fs::create_dir_all(&cache).expect("mkdir");
825 assert!(!restore_cachedir_tag(&cache).expect("no rustc info"));
826 assert!(!cache.join("CACHEDIR.TAG").exists());
827
828 std::fs::write(cache.join(".rustc_info.json"), "{}").expect("info");
829 assert!(restore_cachedir_tag(&cache).expect("restore"));
830 let body = std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag");
831 assert_eq!(
832 body.lines().next(),
833 Some("Signature: 8a477f597d28d172789f06886806bc55")
834 );
835
836 std::fs::write(cache.join("CACHEDIR.TAG"), "custom").expect("custom");
837 assert!(!restore_cachedir_tag(&cache).expect("existing"));
838 assert_eq!(
839 std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag"),
840 "custom"
841 );
842 }
843
844 #[test]
845 fn an_uncontended_lease_is_acquired_and_freed_on_release() {
846 let home = tempfile::TempDir::new().expect("temp");
847 let cache = home.path().join("cache");
848 let this = std::process::id();
849 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
850 AcquireOutcome::Acquired(g) => {
851 assert!(in_use(home.path(), &cache), "held while the guard lives");
852 g.release();
853 }
854 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
855 }
856 assert!(!in_use(home.path(), &cache), "freed after release");
857 }
858
859 #[test]
860 fn a_lease_held_by_a_live_pid_is_reported_active_and_refuses_a_second_acquire() {
861 let home = tempfile::TempDir::new().expect("temp");
862 let cache = home.path().join("cache");
863 let this = std::process::id();
864 let _first =
868 try_acquire(home.path(), &cache, &owner(this)).expect("first acquire succeeds");
869 let mut second_owner = owner(this);
870 second_owner.run = "r2".to_owned();
871 match try_acquire(home.path(), &cache, &second_owner).expect("no io error") {
872 AcquireOutcome::Busy(Busy::Active(held_by)) => assert_eq!(held_by.run, "r1"),
873 other => panic!("expected Busy::Active, got a different outcome: {other:?}"),
874 }
875 assert!(in_use(home.path(), &cache));
876 }
877
878 impl std::fmt::Debug for AcquireOutcome {
879 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
880 match self {
881 AcquireOutcome::Acquired(_) => write!(f, "Acquired"),
882 AcquireOutcome::Busy(b) => write!(f, "Busy({b:?})"),
883 }
884 }
885 }
886
887 #[test]
888 fn a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer() {
889 let home = tempfile::TempDir::new().expect("temp");
890 let cache = home.path().join("cache");
891 let dead_owner = owner(999_999);
897 let path = lease_path(home.path(), &cache);
898 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
899 write_new(&path, &cache, &dead_owner).expect("seed a stale lease");
900 assert_eq!(
901 classify_with(&path, |_| false),
902 Status::Stale(dead_owner.clone())
903 );
904
905 match try_acquire_with(home.path(), &cache, &owner(std::process::id()), |_| false)
906 .expect("acquire")
907 {
908 AcquireOutcome::Acquired(_) => {}
909 other => panic!("stale lease should have been reclaimed: {other:?}"),
910 }
911 }
912
913 #[test]
914 fn an_unreadable_lease_is_unknown_and_never_reclaimed() {
915 let home = tempfile::TempDir::new().expect("temp");
916 let cache = home.path().join("cache");
917 let path = lease_path(home.path(), &cache);
918 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
919 std::fs::write(&path, b"not json").unwrap();
920 assert_eq!(classify(&path), Status::Unknown);
921 assert!(in_use(home.path(), &cache), "unknown counts as in use");
922 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("no io error") {
923 AcquireOutcome::Busy(Busy::Unknown) => {}
924 other => panic!("expected Busy::Unknown, got {other:?}"),
925 }
926 }
927
928 #[tokio::test]
929 async fn waiting_for_a_busy_lease_times_out_within_its_own_budget() {
930 let home = tempfile::TempDir::new().expect("temp");
931 let cache = home.path().join("cache");
932 let _held = try_acquire(home.path(), &cache, &owner(std::process::id()))
933 .expect("acquire")
934 .pipe();
935 let mut waiter = owner(std::process::id());
936 waiter.run = "r2".to_owned();
937 let started = std::time::Instant::now();
938 let err = wait_for(
939 home.path(),
940 &cache,
941 &waiter,
942 Duration::from_millis(150),
943 Duration::from_millis(20),
944 )
945 .await
946 .expect_err("still held, must time out");
947 assert!(started.elapsed() < Duration::from_secs(2), "bounded wait");
948 assert!(
949 err.to_string().contains("r1"),
950 "names the current holder: {err}"
951 );
952 }
953
954 #[tokio::test]
955 async fn a_wait_succeeds_as_soon_as_the_lease_is_released() {
956 let home = tempfile::TempDir::new().expect("temp");
957 let cache = home.path().join("cache");
958 let guard =
959 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire") {
960 AcquireOutcome::Acquired(g) => g,
961 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
962 };
963 let home_path = home.path().to_path_buf();
964 let cache_path = cache.clone();
965 let mut waiter = owner(std::process::id());
966 waiter.run = "r2".to_owned();
967 let wait = tokio::spawn(async move {
968 wait_for(
969 &home_path,
970 &cache_path,
971 &waiter,
972 Duration::from_secs(5),
973 Duration::from_millis(10),
974 )
975 .await
976 });
977 tokio::time::sleep(Duration::from_millis(50)).await;
978 guard.release();
979 let acquired = wait.await.expect("task").expect("acquire after release");
980 acquired.release();
981 }
982
983 #[test]
984 fn inventory_reports_active_stale_and_unknown_but_not_free() {
985 let home = tempfile::TempDir::new().expect("temp");
986 let active_cache = home.path().join("active");
987 let stale_cache = home.path().join("stale");
988 let unknown_cache = home.path().join("unknown");
989
990 let _held =
991 try_acquire(home.path(), &active_cache, &owner(std::process::id())).expect("acquire");
992 let stale_path = lease_path(home.path(), &stale_cache);
993 std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
994 write_new(&stale_path, &stale_cache, &owner(999_999)).unwrap();
995 let unknown_path = lease_path(home.path(), &unknown_cache);
996 std::fs::write(&unknown_path, b"garbage").unwrap();
997
998 let entries = inventory_with(home.path(), |pid| pid == std::process::id());
1003 assert_eq!(entries.len(), 3, "{entries:?}");
1004 let by_dir = |dir: &Path| {
1005 entries
1006 .iter()
1007 .find(|e| e.cache_dir == dir.display().to_string())
1008 .unwrap_or_else(|| panic!("no entry for {}", dir.display()))
1009 };
1010 assert!(matches!(
1011 by_dir(&active_cache).status,
1012 EntryStatus::Active(_)
1013 ));
1014 assert!(matches!(by_dir(&stale_cache).status, EntryStatus::Stale(_)));
1015 let unknown = entries
1020 .iter()
1021 .find(|e| matches!(e.status, EntryStatus::Unknown))
1022 .unwrap_or_else(|| panic!("no Unknown entry: {entries:?}"));
1023 assert!(
1024 unknown
1025 .cache_dir
1026 .contains(&unknown_path.display().to_string()),
1027 "{unknown:?}"
1028 );
1029 }
1030
1031 #[test]
1032 fn a_released_lease_is_reported_idle_from_the_catalog_not_dropped_entirely() {
1033 let home = tempfile::TempDir::new().expect("temp");
1034 let cache = home.path().join("cache");
1035 let this = std::process::id();
1036
1037 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1038 AcquireOutcome::Acquired(g) => g.release(),
1039 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1040 }
1041
1042 assert!(!in_use(home.path(), &cache));
1047 let entries = inventory_with(home.path(), |pid| pid == this);
1048 let entry = entries
1049 .iter()
1050 .find(|e| e.cache_dir == cache.display().to_string())
1051 .unwrap_or_else(|| panic!("no entry for a released cache: {entries:?}"));
1052 match &entry.status {
1053 EntryStatus::Idle(o) => assert_eq!(o.run, "r1"),
1054 other => panic!("expected Idle, got {other:?}"),
1055 }
1056 }
1057
1058 #[test]
1059 fn reacquiring_a_released_cache_reports_active_not_idle() {
1060 let home = tempfile::TempDir::new().expect("temp");
1061 let cache = home.path().join("cache");
1062 let this = std::process::id();
1063 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1064 AcquireOutcome::Acquired(g) => g.release(),
1065 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1066 }
1067 let _held = try_acquire(home.path(), &cache, &owner(this)).expect("reacquire");
1068 let entries = inventory_with(home.path(), |pid| pid == this);
1069 assert_eq!(
1070 entries.len(),
1071 1,
1072 "the catalog row must not duplicate the live lease: {entries:?}"
1073 );
1074 assert!(matches!(entries[0].status, EntryStatus::Active(_)));
1075 }
1076
1077 #[test]
1078 fn maintenance_prune_refuses_a_cache_a_live_owner_holds() {
1079 let home = tempfile::TempDir::new().expect("temp");
1080 let cache = home.path().join("cache");
1081 std::fs::create_dir_all(&cache).unwrap();
1082 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1083 let _held = try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire");
1084
1085 let result = maintenance_prune(home.path(), &cache, 1).expect("no io error");
1086 assert!(
1087 result.is_none(),
1088 "must not prune while a live owner holds it"
1089 );
1090 assert!(cache.join("big").exists(), "nothing was deleted");
1091 }
1092
1093 #[test]
1094 fn maintenance_prune_acts_once_the_cache_is_free_and_releases_after() {
1095 let home = tempfile::TempDir::new().expect("temp");
1096 let cache = home.path().join("cache");
1097 std::fs::create_dir_all(&cache).unwrap();
1098 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1099
1100 let pruned = maintenance_prune(home.path(), &cache, 1)
1101 .expect("no io error")
1102 .expect("cache was free");
1103 assert!(pruned.freed > 0);
1104 assert!(
1105 !in_use(home.path(), &cache),
1106 "the maintenance lease was released"
1107 );
1108 }
1109
1110 #[test]
1111 fn identity_drift_is_detected_once_and_then_settles() {
1112 let home = tempfile::TempDir::new().expect("temp");
1113 let cache = home.path().join("cache");
1114 let a = Identity {
1115 worktree: "/w/a".to_owned(),
1116 head: "aaaa".to_owned(),
1117 };
1118 let b = Identity {
1119 worktree: "/w/b".to_owned(),
1120 head: "bbbb".to_owned(),
1121 };
1122 assert!(
1123 needs_refresh(home.path(), &cache, &a),
1124 "nothing recorded yet"
1125 );
1126 record_identity(home.path(), &cache, &a).expect("record");
1127 assert!(
1128 !needs_refresh(home.path(), &cache, &a),
1129 "same identity, no refresh needed"
1130 );
1131 assert!(needs_refresh(home.path(), &cache, &b), "different source");
1132 record_identity(home.path(), &cache, &b).expect("record");
1133 assert!(!needs_refresh(home.path(), &cache, &b));
1134 }
1135
1136 #[test]
1137 fn invalidating_forgets_a_recorded_identity_so_the_next_check_refreshes() {
1138 let home = tempfile::TempDir::new().expect("temp");
1139 let cache = home.path().join("cache");
1140 let a = Identity {
1141 worktree: "/w/a".to_owned(),
1142 head: "aaaa".to_owned(),
1143 };
1144 record_identity(home.path(), &cache, &a).expect("record");
1145 assert!(!needs_refresh(home.path(), &cache, &a));
1146
1147 invalidate_identity(home.path(), &cache);
1152 assert!(
1153 needs_refresh(home.path(), &cache, &a),
1154 "invalidation must not be skippable by asking about the same identity again"
1155 );
1156
1157 invalidate_identity(home.path(), &home.path().join("never-recorded"));
1160 }
1161
1162 #[test]
1163 fn workspace_package_names_are_read_from_cargo_metadata_json() {
1164 let fixture = r#"{
1165 "packages": [
1166 {"name": "magi", "version": "0.1.0"},
1167 {"name": "magi-cli", "version": "0.1.0"}
1168 ],
1169 "workspace_members": []
1170 }"#;
1171 let mut names = parse_workspace_package_names(fixture);
1172 names.sort();
1173 assert_eq!(names, vec!["magi".to_owned(), "magi-cli".to_owned()]);
1174 assert_eq!(
1175 parse_workspace_package_names("not json"),
1176 Vec::<String>::new()
1177 );
1178 assert_eq!(parse_workspace_package_names("{}"), Vec::<String>::new());
1179 }
1180
1181 #[test]
1182 fn slugs_are_stable_and_filesystem_safe() {
1183 let a = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1184 let b = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1185 assert_eq!(a, b, "same input, same slug");
1186 assert!(
1187 a.chars()
1188 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
1189 "filesystem-safe: {a}"
1190 );
1191 }
1192
1193 #[test]
1194 fn busy_active_describes_the_holder() {
1195 let b = Busy::Active(owner(123));
1196 let s = b.describe();
1197 assert!(
1198 s.contains("r1") && s.contains("gate") && s.contains("123"),
1199 "{s}"
1200 );
1201 }
1202
1203 trait Pipe: Sized {
1204 fn pipe(self) -> Guard;
1205 }
1206 impl Pipe for AcquireOutcome {
1207 fn pipe(self) -> Guard {
1208 match self {
1209 AcquireOutcome::Acquired(g) => g,
1210 AcquireOutcome::Busy(b) => panic!("expected Acquired, got Busy({b:?})"),
1211 }
1212 }
1213 }
1214}