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(
340 path: &Path,
341 cache_dir: &Path,
342 owner: &Owner,
343 retry: crate::lock_file::Retry,
344) -> std::io::Result<()> {
345 write_lease(retry.create(path)?, cache_dir, owner)
346}
347
348fn write_lease(mut f: std::fs::File, cache_dir: &Path, owner: &Owner) -> std::io::Result<()> {
349 use std::io::Write as _;
350 let lease = LeaseFile {
351 cache_dir: cache_dir.display().to_string(),
352 owner: owner.clone(),
353 acquired_at: Timestamp::now(),
354 };
355 let body = serde_json::to_string_pretty(&lease).unwrap_or_default();
356 f.write_all(body.as_bytes())?;
357 Ok(())
358}
359
360pub enum AcquireOutcome {
362 Acquired(Guard),
364 Busy(Busy),
366}
367
368pub fn try_acquire(home: &Path, cache_dir: &Path, owner: &Owner) -> Result<AcquireOutcome> {
372 let Ok(_outputs) = crate::retention::OutputLease::cache(home, cache_dir, true) else {
373 return Ok(AcquireOutcome::Busy(Busy::Outputs));
374 };
375 try_acquire_with(home, cache_dir, owner, proc::pid_alive)
376}
377
378pub fn try_acquire_maintenance(
381 home: &Path,
382 cache_dir: &Path,
383 owner: &Owner,
384) -> Result<AcquireOutcome> {
385 let Ok(outputs) = crate::retention::OutputLease::cache(home, cache_dir, true) else {
386 return Ok(AcquireOutcome::Busy(Busy::Outputs));
387 };
388 match try_acquire_with(home, cache_dir, owner, proc::pid_alive)? {
389 AcquireOutcome::Acquired(mut guard) => {
390 guard.outputs = Some(outputs);
391 Ok(AcquireOutcome::Acquired(guard))
392 }
393 busy => Ok(busy),
394 }
395}
396
397fn try_acquire_with<F: Fn(u32) -> bool + Copy>(
400 home: &Path,
401 cache_dir: &Path,
402 owner: &Owner,
403 alive: F,
404) -> Result<AcquireOutcome> {
405 try_acquire_before(
406 home,
407 cache_dir,
408 owner,
409 alive,
410 crate::lock_file::Retry::new(None),
411 )
412}
413
414fn try_acquire_before<F: Fn(u32) -> bool + Copy>(
415 home: &Path,
416 cache_dir: &Path,
417 owner: &Owner,
418 alive: F,
419 retry: crate::lock_file::Retry,
420) -> Result<AcquireOutcome> {
421 let dir = leases_dir(home);
422 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
423 let path = dir.join(format!("{}.json", slug(cache_dir)));
424
425 for _ in 0..2 {
431 let result = write_new(&path, cache_dir, owner, retry);
432 if let Some(outcome) = acquire_result(home, &path, cache_dir, owner, alive, result)? {
433 return Ok(outcome);
434 }
435 }
436 Ok(AcquireOutcome::Busy(Busy::Contended))
437}
438
439fn acquire_result(
441 home: &Path,
442 path: &Path,
443 cache_dir: &Path,
444 owner: &Owner,
445 alive: impl Fn(u32) -> bool + Copy,
446 result: std::io::Result<()>,
447) -> Result<Option<AcquireOutcome>> {
448 match result {
449 Ok(()) => {
450 record_catalog(home, cache_dir, owner);
451 return Ok(Some(AcquireOutcome::Acquired(Guard::new(path.to_owned()))));
452 }
453 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
454 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
455 }
456 match classify_with(path, alive) {
457 Status::Free => {}
458 Status::Stale(_) => {
459 let _ = std::fs::remove_file(path);
460 }
461 Status::Active(o) => return Ok(Some(AcquireOutcome::Busy(Busy::Active(o)))),
462 Status::Unknown => return Ok(Some(AcquireOutcome::Busy(Busy::Unknown))),
463 }
464 Ok(None)
465}
466
467async fn try_acquire_async(
468 home: &Path,
469 cache_dir: &Path,
470 owner: &Owner,
471 retry: crate::lock_file::Retry,
472 cancel: &(dyn Fn() -> bool + Sync),
473) -> Result<Option<AcquireOutcome>> {
474 let Ok(_outputs) = crate::retention::OutputLease::cache(home, cache_dir, true) else {
475 return Ok(Some(AcquireOutcome::Busy(Busy::Outputs)));
476 };
477 let dir = leases_dir(home);
478 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
479 let path = dir.join(format!("{}.json", slug(cache_dir)));
480 for _ in 0..2 {
481 let opened = retry
482 .run_async_unless(
483 || {
484 std::fs::OpenOptions::new()
485 .write(true)
486 .create_new(true)
487 .open(&path)
488 },
489 cancel,
490 )
491 .await;
492 let result = match opened {
493 Ok(Some(file)) => write_lease(file, cache_dir, owner),
494 Ok(None) => return Ok(None),
495 Err(error) => Err(error),
496 };
497 if let Some(outcome) =
498 acquire_result(home, &path, cache_dir, owner, proc::pid_alive, result)?
499 {
500 return Ok(Some(outcome));
501 }
502 }
503 Ok(Some(AcquireOutcome::Busy(Busy::Contended)))
504}
505
506#[must_use]
511pub fn in_use(home: &Path, cache_dir: &Path) -> bool {
512 let path = lease_path(home, cache_dir);
513 matches!(classify(&path), Status::Active(_) | Status::Unknown)
514 || crate::retention::OutputLease::cache(home, cache_dir, true).is_err()
515}
516
517pub const RESOURCE_HOLD: &str = "[resource] ";
521
522pub async fn wait_for(
531 home: &Path,
532 cache_dir: &Path,
533 owner: &Owner,
534 budget: Duration,
535 poll: Duration,
536) -> Result<Guard> {
537 match wait_for_unless(home, cache_dir, owner, budget, poll, &|| false).await? {
538 Some(g) => Ok(g),
539 None => bail!("the wait for the build cache was cancelled"),
540 }
541}
542
543pub async fn wait_for_unless(
548 home: &Path,
549 cache_dir: &Path,
550 owner: &Owner,
551 budget: Duration,
552 poll: Duration,
553 cancel: &(dyn Fn() -> bool + Sync),
554) -> Result<Option<Guard>> {
555 let start = std::time::Instant::now();
556 let retry = crate::lock_file::Retry::new(Some(start + budget));
559 loop {
560 let Some(acquired) = try_acquire_async(home, cache_dir, owner, retry, cancel).await? else {
561 return Ok(None);
562 };
563 match acquired {
564 AcquireOutcome::Acquired(g) => return Ok(Some(g)),
565 AcquireOutcome::Busy(busy) => {
566 if cancel() {
567 return Ok(None);
568 }
569 let elapsed = start.elapsed();
570 if elapsed >= budget {
571 bail!(
572 "timed out after {}s waiting for the build cache at {} ({})",
573 budget.as_secs(),
574 cache_dir.display(),
575 busy.describe()
576 );
577 }
578 tokio::time::sleep(poll.min(budget - elapsed)).await;
579 }
580 }
581 }
582}
583
584#[derive(Debug, Clone)]
590pub struct Entry {
591 pub cache_dir: String,
593 pub status: EntryStatus,
595}
596
597#[derive(Debug, Clone)]
599pub enum EntryStatus {
600 Active(Owner),
602 Stale(Owner),
604 Unknown,
606 Idle(Owner),
612 Outputs(Owner),
615}
616
617#[must_use]
625pub fn inventory(home: &Path) -> Vec<Entry> {
626 let mut entries = inventory_with(home, proc::pid_alive);
627 for entry in &mut entries {
628 if let EntryStatus::Idle(owner) | EntryStatus::Stale(owner) = &entry.status
629 && crate::retention::OutputLease::cache(home, Path::new(&entry.cache_dir), true)
630 .is_err()
631 {
632 entry.status = EntryStatus::Outputs(owner.clone());
633 }
634 }
635 entries
636}
637
638fn inventory_with<F: Fn(u32) -> bool + Copy>(home: &Path, alive: F) -> Vec<Entry> {
641 let dir = leases_dir(home);
642 let Ok(rd) = std::fs::read_dir(&dir) else {
643 return Vec::new();
644 };
645 let mut out = Vec::new();
646 for entry in rd.flatten() {
647 let path = entry.path();
648 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
649 continue;
650 };
651 if !name.ends_with(".json")
652 || name.ends_with(".identity.json")
653 || name.ends_with(".catalog.json")
654 {
655 continue;
656 }
657 let status = match classify_with(&path, alive) {
658 Status::Free => continue,
659 Status::Active(o) => EntryStatus::Active(o),
660 Status::Stale(o) => EntryStatus::Stale(o),
661 Status::Unknown => EntryStatus::Unknown,
662 };
663 let cache_dir = peek_cache_dir(&path)
669 .unwrap_or_else(|| format!("(unreadable lease file: {})", path.display()));
670 out.push(Entry { cache_dir, status });
671 }
672
673 if let Ok(rd) = std::fs::read_dir(&dir) {
679 for entry in rd.flatten() {
680 let path = entry.path();
681 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
682 continue;
683 };
684 let Some(stem) = name.strip_suffix(".catalog.json") else {
685 continue;
686 };
687 if dir.join(format!("{stem}.json")).exists() {
688 continue;
689 }
690 if let Some(record) = read_catalog(&path) {
691 out.push(Entry {
692 cache_dir: record.cache_dir,
693 status: EntryStatus::Idle(record.last_owner),
694 });
695 }
696 }
697 }
698
699 out.sort_by(|a, b| a.cache_dir.cmp(&b.cache_dir));
700 out
701}
702
703pub fn maintenance_prune(
712 home: &Path,
713 cache_dir: &Path,
714 limit: u64,
715) -> Result<Option<crate::disk::Prune>> {
716 let owner = Owner {
717 run: "maintenance".to_owned(),
718 node: "prune".to_owned(),
719 seat: "janitor".to_owned(),
720 pid: std::process::id(),
721 worktree: String::new(),
722 head: String::new(),
723 };
724 match try_acquire_maintenance(home, cache_dir, &owner)? {
725 AcquireOutcome::Busy(_) => Ok(None),
726 AcquireOutcome::Acquired(guard) => {
727 let _cargo = crate::retention::cargo_locks(cache_dir)?;
728 let result = crate::disk::prune_dir(cache_dir, limit)?;
729 guard.release();
730 Ok(Some(result))
731 }
732 }
733}
734
735#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
739pub struct Identity {
740 pub worktree: String,
742 pub head: String,
744}
745
746impl Identity {
747 #[must_use]
749 pub fn new(worktree: &Path, head: &str) -> Identity {
750 Identity {
751 worktree: worktree.display().to_string(),
752 head: head.to_owned(),
753 }
754 }
755}
756
757#[must_use]
762pub fn needs_refresh(home: &Path, cache_dir: &Path, current: &Identity) -> bool {
763 let path = identity_path(home, cache_dir);
764 let Ok(body) = std::fs::read_to_string(path) else {
765 return true;
766 };
767 match serde_json::from_str::<Identity>(&body) {
768 Ok(recorded) => &recorded != current,
769 Err(_) => true,
770 }
771}
772
773pub fn record_identity(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
775 let path = identity_path(home, cache_dir);
776 if let Some(parent) = path.parent() {
777 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
778 }
779 let body = serde_json::to_string_pretty(identity).context("serialize cache identity")?;
780 let tmp = path.with_extension("json.tmp");
781 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
782 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
783 Ok(())
784}
785
786pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
800 let _ = std::fs::remove_file(identity_path(home, cache_dir));
801}
802
803#[must_use]
809pub fn parse_workspace_package_names(metadata_json: &str) -> Vec<String> {
810 let Ok(value) = serde_json::from_str::<serde_json::Value>(metadata_json) else {
811 return Vec::new();
812 };
813 value
814 .get("packages")
815 .and_then(|p| p.as_array())
816 .map(|packages| {
817 packages
818 .iter()
819 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
820 .map(str::to_owned)
821 .collect()
822 })
823 .unwrap_or_default()
824}
825
826#[must_use]
830pub fn extract_manifest_paths(commands: &[String]) -> Vec<PathBuf> {
831 const KEY: &str = "--manifest-path";
832 let mut out: Vec<PathBuf> = Vec::new();
833 for command in commands {
834 let mut rest = command.as_str();
835 while let Some(at) = rest.find(KEY) {
836 rest = &rest[at + KEY.len()..];
837 let value = if let Some(r) = rest.strip_prefix('=') {
838 r
839 } else if rest.starts_with(char::is_whitespace) {
840 rest.trim_start()
841 } else {
842 continue;
843 };
844 let value = if let Some(s) = value.strip_prefix('\'') {
845 s.split('\'').next().unwrap_or("")
846 } else if let Some(s) = value.strip_prefix('"') {
847 s.split('"').next().unwrap_or("")
848 } else {
849 let end = value.find(char::is_whitespace).unwrap_or(value.len());
850 &value[..end]
851 };
852 if !value.is_empty() {
853 let path = PathBuf::from(value);
854 if !out.contains(&path) {
855 out.push(path);
856 }
857 }
858 }
859 }
860 out
861}
862
863#[derive(Debug, Clone, PartialEq, Eq)]
865pub enum Target {
866 Root,
868 Manifest(PathBuf),
870}
871
872fn plan_targets(
877 worktree: &Path,
878 manifests: &[PathBuf],
879 exists: impl Fn(&Path) -> Result<bool>,
880) -> Result<Vec<Target>> {
881 let mut targets = Vec::new();
882 if exists(&worktree.join("Cargo.toml"))? {
883 targets.push(Target::Root);
884 }
885 for m in manifests {
886 let full = if m.is_absolute() {
887 m.clone()
888 } else {
889 worktree.join(m)
890 };
891 if exists(&full)? {
892 targets.push(Target::Manifest(full));
893 } else {
894 tracing::warn!(
895 manifest = %m.display(),
896 "build cache: --manifest-path from a verify command does not exist; skipped"
897 );
898 }
899 }
900 Ok(targets)
901}
902
903fn file_exists(path: &Path) -> Result<bool> {
906 match std::fs::metadata(path) {
907 Ok(m) => Ok(m.is_file()),
908 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
909 Err(e) => Err(e).with_context(|| format!("stat {}", path.display())),
910 }
911}
912
913fn refresh_stale_packages(
921 worktree: &Path,
922 cache_dir: &Path,
923 manifests: &[PathBuf],
924) -> Result<Option<Vec<String>>> {
925 let targets = plan_targets(worktree, manifests, file_exists)?;
926 if targets.is_empty() {
927 return Ok(None);
928 }
929 let mut all = Vec::new();
930 let mut failed = Vec::new();
931 for target in &targets {
932 let manifest_arg = match target {
933 Target::Root => None,
934 Target::Manifest(m) => Some(m),
935 };
936 let mut cmd = std::process::Command::new("cargo");
937 cmd.args(["metadata", "--no-deps", "--format-version", "1"]);
938 if let Some(m) = manifest_arg {
939 cmd.arg("--manifest-path").arg(m);
940 }
941 let meta = cmd
942 .current_dir(worktree)
943 .quiet()
944 .output()
945 .context("run `cargo metadata`")?;
946 if !meta.status.success() {
947 bail!(
948 "cargo metadata failed: {}",
949 String::from_utf8_lossy(&meta.stderr)
950 );
951 }
952 let names = parse_workspace_package_names(&String::from_utf8_lossy(&meta.stdout));
953 for name in &names {
954 let mut clean = std::process::Command::new("cargo");
955 clean.arg("clean").arg("-p").arg(name);
956 if let Some(m) = manifest_arg {
957 clean.arg("--manifest-path").arg(m);
958 }
959 let out = clean
960 .arg("--target-dir")
961 .arg(cache_dir)
962 .current_dir(worktree)
963 .quiet()
964 .output()
965 .with_context(|| format!("cargo clean -p {name}"))?;
966 if !out.status.success() {
967 failed.push(format!(
976 "{name}: {}",
977 String::from_utf8_lossy(&out.stderr).trim()
978 ));
979 }
980 }
981 for n in names {
982 if !all.contains(&n) {
983 all.push(n);
984 }
985 }
986 }
987 if !failed.is_empty() {
988 bail!(
989 "cargo clean -p failed for {} package(s): {}",
990 failed.len(),
991 failed.join("; ")
992 );
993 }
994 Ok(Some(all))
995}
996
997const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
999# This file is a cache directory tag created by cargo.\n\
1000# For information about cache directory tags see https://bford.info/cachedir/\n";
1001
1002fn restore_cachedir_tag(cache_dir: &Path) -> Result<bool> {
1011 use std::io::Write as _;
1012 if !cache_dir.is_dir() || !cache_dir.join(".rustc_info.json").is_file() {
1013 return Ok(false);
1014 }
1015 let tag = cache_dir.join("CACHEDIR.TAG");
1016 match std::fs::OpenOptions::new()
1017 .write(true)
1018 .create_new(true)
1019 .open(&tag)
1020 {
1021 Ok(mut f) => {
1022 f.write_all(CACHEDIR_TAG.as_bytes())
1023 .with_context(|| format!("write {}", tag.display()))?;
1024 Ok(true)
1025 }
1026 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1027 Err(e) => Err(e).with_context(|| format!("create {}", tag.display())),
1028 }
1029}
1030
1031pub fn ensure_fresh(
1042 home: &Path,
1043 cache_dir: &Path,
1044 identity: &Identity,
1045 manifests: &[PathBuf],
1046) -> Result<()> {
1047 match restore_cachedir_tag(cache_dir) {
1048 Ok(true) => tracing::info!(
1049 cache = %cache_dir.display(),
1050 "build cache: restored a missing CACHEDIR.TAG"
1051 ),
1052 Ok(false) => {}
1053 Err(e) => tracing::warn!(error = %e, "build cache: could not restore CACHEDIR.TAG"),
1055 }
1056 if needs_refresh(home, cache_dir, identity) {
1057 let worktree = PathBuf::from(&identity.worktree);
1058 match refresh_stale_packages(&worktree, cache_dir, manifests)? {
1059 Some(cleaned) => tracing::info!(
1060 ?cleaned,
1061 cache = %cache_dir.display(),
1062 "build cache: source identity changed; cleaned the workspace's own packages before reuse"
1063 ),
1064 None => {
1065 tracing::warn!(
1070 cache = %cache_dir.display(),
1071 "build cache: no Cargo manifest found for the freshness check; skipping the package clean"
1072 );
1073 invalidate_identity(home, cache_dir);
1074 return Ok(());
1075 }
1076 }
1077 }
1078 record_identity(home, cache_dir, identity)
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084
1085 fn owner(pid: u32) -> Owner {
1086 Owner {
1087 run: "r1".to_owned(),
1088 node: "gate".to_owned(),
1089 seat: "gate".to_owned(),
1090 pid,
1091 worktree: "/w".to_owned(),
1092 head: "deadbeef".to_owned(),
1093 }
1094 }
1095
1096 #[test]
1097 fn a_missing_cachedir_tag_is_restored_only_for_a_cargo_target() {
1098 let t = tempfile::TempDir::new().expect("temp");
1099 let cache = t.path().join("cache");
1100 assert!(!restore_cachedir_tag(&cache).expect("missing dir"));
1101 assert!(!cache.exists(), "a missing directory is left for cargo");
1102
1103 std::fs::create_dir_all(&cache).expect("mkdir");
1104 assert!(!restore_cachedir_tag(&cache).expect("no rustc info"));
1105 assert!(!cache.join("CACHEDIR.TAG").exists());
1106
1107 std::fs::write(cache.join(".rustc_info.json"), "{}").expect("info");
1108 assert!(restore_cachedir_tag(&cache).expect("restore"));
1109 let body = std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag");
1110 assert_eq!(
1111 body.lines().next(),
1112 Some("Signature: 8a477f597d28d172789f06886806bc55")
1113 );
1114
1115 std::fs::write(cache.join("CACHEDIR.TAG"), "custom").expect("custom");
1116 assert!(!restore_cachedir_tag(&cache).expect("existing"));
1117 assert_eq!(
1118 std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag"),
1119 "custom"
1120 );
1121 }
1122
1123 #[test]
1124 fn an_uncontended_lease_is_acquired_and_freed_on_release() {
1125 let home = tempfile::TempDir::new().expect("temp");
1126 let cache = home.path().join("cache");
1127 let this = std::process::id();
1128 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1129 AcquireOutcome::Acquired(g) => {
1130 assert!(in_use(home.path(), &cache), "held while the guard lives");
1131 g.release();
1132 }
1133 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1134 }
1135 assert!(!in_use(home.path(), &cache), "freed after release");
1136 }
1137
1138 #[test]
1139 fn a_lease_held_by_a_live_pid_is_reported_active_and_refuses_a_second_acquire() {
1140 let home = tempfile::TempDir::new().expect("temp");
1141 let cache = home.path().join("cache");
1142 let this = std::process::id();
1143 let _first =
1147 try_acquire(home.path(), &cache, &owner(this)).expect("first acquire succeeds");
1148 let mut second_owner = owner(this);
1149 second_owner.run = "r2".to_owned();
1150 match try_acquire(home.path(), &cache, &second_owner).expect("no io error") {
1151 AcquireOutcome::Busy(Busy::Active(held_by)) => assert_eq!(held_by.run, "r1"),
1152 other => panic!("expected Busy::Active, got a different outcome: {other:?}"),
1153 }
1154 assert!(in_use(home.path(), &cache));
1155 }
1156
1157 impl std::fmt::Debug for AcquireOutcome {
1158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1159 match self {
1160 AcquireOutcome::Acquired(_) => write!(f, "Acquired"),
1161 AcquireOutcome::Busy(b) => write!(f, "Busy({b:?})"),
1162 }
1163 }
1164 }
1165
1166 #[test]
1167 fn a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer() {
1168 let home = tempfile::TempDir::new().expect("temp");
1169 let cache = home.path().join("cache");
1170 let dead_owner = owner(999_999);
1176 let path = lease_path(home.path(), &cache);
1177 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1178 write_new(
1179 &path,
1180 &cache,
1181 &dead_owner,
1182 crate::lock_file::Retry::new(None),
1183 )
1184 .expect("seed a stale lease");
1185 assert_eq!(
1186 classify_with(&path, |_| false),
1187 Status::Stale(dead_owner.clone())
1188 );
1189
1190 match try_acquire_with(home.path(), &cache, &owner(std::process::id()), |_| false)
1191 .expect("acquire")
1192 {
1193 AcquireOutcome::Acquired(_) => {}
1194 other => panic!("stale lease should have been reclaimed: {other:?}"),
1195 }
1196 }
1197
1198 #[test]
1199 fn an_unreadable_lease_is_unknown_and_never_reclaimed() {
1200 let home = tempfile::TempDir::new().expect("temp");
1201 let cache = home.path().join("cache");
1202 let path = lease_path(home.path(), &cache);
1203 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1204 std::fs::write(&path, b"not json").unwrap();
1205 assert_eq!(classify(&path), Status::Unknown);
1206 assert!(in_use(home.path(), &cache), "unknown counts as in use");
1207 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("no io error") {
1208 AcquireOutcome::Busy(Busy::Unknown) => {}
1209 other => panic!("expected Busy::Unknown, got {other:?}"),
1210 }
1211 }
1212
1213 #[tokio::test]
1214 async fn waiting_for_a_busy_lease_times_out_within_its_own_budget() {
1215 let home = tempfile::TempDir::new().expect("temp");
1216 let cache = home.path().join("cache");
1217 let _held = try_acquire(home.path(), &cache, &owner(std::process::id()))
1218 .expect("acquire")
1219 .pipe();
1220 let mut waiter = owner(std::process::id());
1221 waiter.run = "r2".to_owned();
1222 let started = std::time::Instant::now();
1223 let err = wait_for(
1224 home.path(),
1225 &cache,
1226 &waiter,
1227 Duration::from_millis(150),
1228 Duration::from_millis(20),
1229 )
1230 .await
1231 .expect_err("still held, must time out");
1232 assert!(started.elapsed() < Duration::from_secs(2), "bounded wait");
1233 assert!(
1234 err.to_string().contains("r1"),
1235 "names the current holder: {err}"
1236 );
1237 }
1238
1239 #[tokio::test]
1240 async fn a_wait_succeeds_as_soon_as_the_lease_is_released() {
1241 let home = tempfile::TempDir::new().expect("temp");
1242 let cache = home.path().join("cache");
1243 let guard =
1244 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire") {
1245 AcquireOutcome::Acquired(g) => g,
1246 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1247 };
1248 let home_path = home.path().to_path_buf();
1249 let cache_path = cache.clone();
1250 let mut waiter = owner(std::process::id());
1251 waiter.run = "r2".to_owned();
1252 let wait = tokio::spawn(async move {
1253 wait_for(
1254 &home_path,
1255 &cache_path,
1256 &waiter,
1257 Duration::from_secs(5),
1258 Duration::from_millis(10),
1259 )
1260 .await
1261 });
1262 tokio::time::sleep(Duration::from_millis(50)).await;
1263 guard.release();
1264 let acquired = wait.await.expect("task").expect("acquire after release");
1265 acquired.release();
1266 }
1267
1268 #[test]
1269 fn inventory_reports_active_stale_and_unknown_but_not_free() {
1270 let home = tempfile::TempDir::new().expect("temp");
1271 let active_cache = home.path().join("active");
1272 let stale_cache = home.path().join("stale");
1273 let unknown_cache = home.path().join("unknown");
1274
1275 let _held =
1276 try_acquire(home.path(), &active_cache, &owner(std::process::id())).expect("acquire");
1277 let stale_path = lease_path(home.path(), &stale_cache);
1278 std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1279 write_new(
1280 &stale_path,
1281 &stale_cache,
1282 &owner(999_999),
1283 crate::lock_file::Retry::new(None),
1284 )
1285 .unwrap();
1286 let unknown_path = lease_path(home.path(), &unknown_cache);
1287 std::fs::write(&unknown_path, b"garbage").unwrap();
1288
1289 let entries = inventory_with(home.path(), |pid| pid == std::process::id());
1294 assert_eq!(entries.len(), 3, "{entries:?}");
1295 let by_dir = |dir: &Path| {
1296 entries
1297 .iter()
1298 .find(|e| e.cache_dir == dir.display().to_string())
1299 .unwrap_or_else(|| panic!("no entry for {}", dir.display()))
1300 };
1301 assert!(matches!(
1302 by_dir(&active_cache).status,
1303 EntryStatus::Active(_)
1304 ));
1305 assert!(matches!(by_dir(&stale_cache).status, EntryStatus::Stale(_)));
1306 let unknown = entries
1311 .iter()
1312 .find(|e| matches!(e.status, EntryStatus::Unknown))
1313 .unwrap_or_else(|| panic!("no Unknown entry: {entries:?}"));
1314 assert!(
1315 unknown
1316 .cache_dir
1317 .contains(&unknown_path.display().to_string()),
1318 "{unknown:?}"
1319 );
1320 }
1321
1322 #[test]
1323 fn a_released_lease_is_reported_idle_from_the_catalog_not_dropped_entirely() {
1324 let home = tempfile::TempDir::new().expect("temp");
1325 let cache = home.path().join("cache");
1326 let this = std::process::id();
1327
1328 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1329 AcquireOutcome::Acquired(g) => g.release(),
1330 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1331 }
1332
1333 assert!(!in_use(home.path(), &cache));
1338 let entries = inventory_with(home.path(), |pid| pid == this);
1339 let entry = entries
1340 .iter()
1341 .find(|e| e.cache_dir == cache.display().to_string())
1342 .unwrap_or_else(|| panic!("no entry for a released cache: {entries:?}"));
1343 match &entry.status {
1344 EntryStatus::Idle(o) => assert_eq!(o.run, "r1"),
1345 other => panic!("expected Idle, got {other:?}"),
1346 }
1347 }
1348
1349 #[test]
1350 fn reacquiring_a_released_cache_reports_active_not_idle() {
1351 let home = tempfile::TempDir::new().expect("temp");
1352 let cache = home.path().join("cache");
1353 let this = std::process::id();
1354 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1355 AcquireOutcome::Acquired(g) => g.release(),
1356 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1357 }
1358 let _held = try_acquire(home.path(), &cache, &owner(this)).expect("reacquire");
1359 let entries = inventory_with(home.path(), |pid| pid == this);
1360 assert_eq!(
1361 entries.len(),
1362 1,
1363 "the catalog row must not duplicate the live lease: {entries:?}"
1364 );
1365 assert!(matches!(entries[0].status, EntryStatus::Active(_)));
1366 }
1367
1368 #[test]
1369 fn maintenance_prune_refuses_a_cache_a_live_owner_holds() {
1370 let home = tempfile::TempDir::new().expect("temp");
1371 let cache = home.path().join("cache");
1372 std::fs::create_dir_all(&cache).unwrap();
1373 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1374 let _held = try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire");
1375
1376 let result = maintenance_prune(home.path(), &cache, 1).expect("no io error");
1377 assert!(
1378 result.is_none(),
1379 "must not prune while a live owner holds it"
1380 );
1381 assert!(cache.join("big").exists(), "nothing was deleted");
1382 }
1383
1384 #[test]
1385 fn maintenance_prune_acts_once_the_cache_is_free_and_releases_after() {
1386 let home = tempfile::TempDir::new().expect("temp");
1387 let cache = home.path().join("cache");
1388 std::fs::create_dir_all(&cache).unwrap();
1389 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1390
1391 let pruned = maintenance_prune(home.path(), &cache, 1)
1392 .expect("no io error")
1393 .expect("cache was free");
1394 assert!(pruned.freed > 0);
1395 assert!(
1396 !in_use(home.path(), &cache),
1397 "the maintenance lease was released"
1398 );
1399 }
1400
1401 #[test]
1402 fn identity_drift_is_detected_once_and_then_settles() {
1403 let home = tempfile::TempDir::new().expect("temp");
1404 let cache = home.path().join("cache");
1405 let a = Identity {
1406 worktree: "/w/a".to_owned(),
1407 head: "aaaa".to_owned(),
1408 };
1409 let b = Identity {
1410 worktree: "/w/b".to_owned(),
1411 head: "bbbb".to_owned(),
1412 };
1413 assert!(
1414 needs_refresh(home.path(), &cache, &a),
1415 "nothing recorded yet"
1416 );
1417 record_identity(home.path(), &cache, &a).expect("record");
1418 assert!(
1419 !needs_refresh(home.path(), &cache, &a),
1420 "same identity, no refresh needed"
1421 );
1422 assert!(needs_refresh(home.path(), &cache, &b), "different source");
1423 record_identity(home.path(), &cache, &b).expect("record");
1424 assert!(!needs_refresh(home.path(), &cache, &b));
1425 }
1426
1427 #[test]
1428 fn invalidating_forgets_a_recorded_identity_so_the_next_check_refreshes() {
1429 let home = tempfile::TempDir::new().expect("temp");
1430 let cache = home.path().join("cache");
1431 let a = Identity {
1432 worktree: "/w/a".to_owned(),
1433 head: "aaaa".to_owned(),
1434 };
1435 record_identity(home.path(), &cache, &a).expect("record");
1436 assert!(!needs_refresh(home.path(), &cache, &a));
1437
1438 invalidate_identity(home.path(), &cache);
1443 assert!(
1444 needs_refresh(home.path(), &cache, &a),
1445 "invalidation must not be skippable by asking about the same identity again"
1446 );
1447
1448 invalidate_identity(home.path(), &home.path().join("never-recorded"));
1451 }
1452
1453 #[test]
1454 fn workspace_package_names_are_read_from_cargo_metadata_json() {
1455 let fixture = r#"{
1456 "packages": [
1457 {"name": "magi", "version": "0.1.0"},
1458 {"name": "magi-cli", "version": "0.1.0"}
1459 ],
1460 "workspace_members": []
1461 }"#;
1462 let mut names = parse_workspace_package_names(fixture);
1463 names.sort();
1464 assert_eq!(names, vec!["magi".to_owned(), "magi-cli".to_owned()]);
1465 assert_eq!(
1466 parse_workspace_package_names("not json"),
1467 Vec::<String>::new()
1468 );
1469 assert_eq!(parse_workspace_package_names("{}"), Vec::<String>::new());
1470 }
1471
1472 #[test]
1473 fn slugs_are_stable_and_filesystem_safe() {
1474 let a = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1475 let b = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1476 assert_eq!(a, b, "same input, same slug");
1477 assert!(
1478 a.chars()
1479 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
1480 "filesystem-safe: {a}"
1481 );
1482 }
1483
1484 #[test]
1485 fn busy_active_describes_the_holder() {
1486 let b = Busy::Active(owner(123));
1487 let s = b.describe();
1488 assert!(
1489 s.contains("r1") && s.contains("gate") && s.contains("123"),
1490 "{s}"
1491 );
1492 }
1493
1494 trait Pipe: Sized {
1495 fn pipe(self) -> Guard;
1496 }
1497 impl Pipe for AcquireOutcome {
1498 fn pipe(self) -> Guard {
1499 match self {
1500 AcquireOutcome::Acquired(g) => g,
1501 AcquireOutcome::Busy(b) => panic!("expected Acquired, got Busy({b:?})"),
1502 }
1503 }
1504 }
1505
1506 fn cmds(v: &[&str]) -> Vec<String> {
1507 v.iter().map(|s| (*s).to_owned()).collect()
1508 }
1509
1510 #[test]
1511 fn manifest_paths_are_read_in_every_spelling() {
1512 let got = extract_manifest_paths(&cmds(&[
1513 "cargo test --manifest-path tools/a/Cargo.toml",
1514 "cargo build --manifest-path=tools/b/Cargo.toml --release",
1515 "cargo fmt --manifest-path 'q q/Cargo.toml'",
1516 "cargo clippy --manifest-path \"d/Cargo.toml\" && cargo test --manifest-path tools/a/Cargo.toml",
1517 ]));
1518 let want: Vec<PathBuf> = [
1519 "tools/a/Cargo.toml",
1520 "tools/b/Cargo.toml",
1521 "q q/Cargo.toml",
1522 "d/Cargo.toml",
1523 ]
1524 .iter()
1525 .map(PathBuf::from)
1526 .collect();
1527 assert_eq!(got, want);
1528 assert!(extract_manifest_paths(&cmds(&["cargo test", "cd x && cargo test"])).is_empty());
1529 }
1530
1531 #[test]
1532 fn plan_targets_covers_blog_root_both_and_none() {
1533 let w = Path::new("/w");
1534 let only = |set: &'static [&'static str]| {
1535 move |p: &Path| Ok(set.iter().any(|s| p == Path::new(s)))
1536 };
1537 let m = vec![PathBuf::from("tools/postprocess/Cargo.toml")];
1538 let blog = plan_targets(w, &m, only(&["/w/tools/postprocess/Cargo.toml"])).unwrap();
1539 assert_eq!(
1540 blog,
1541 vec![Target::Manifest(PathBuf::from(
1542 "/w/tools/postprocess/Cargo.toml"
1543 ))]
1544 );
1545 let root = plan_targets(w, &[], only(&["/w/Cargo.toml"])).unwrap();
1546 assert_eq!(root, vec![Target::Root]);
1547 let both = plan_targets(
1548 w,
1549 &m,
1550 only(&["/w/Cargo.toml", "/w/tools/postprocess/Cargo.toml"]),
1551 )
1552 .unwrap();
1553 assert_eq!(both.len(), 2);
1554 assert_eq!(both[0], Target::Root);
1555 assert!(plan_targets(w, &m, only(&[])).unwrap().is_empty());
1556 assert!(plan_targets(w, &[], only(&[])).unwrap().is_empty());
1557 }
1558
1559 #[test]
1560 fn a_repo_without_a_reachable_manifest_passes_untouched() {
1561 let home = tempfile::TempDir::new().expect("temp");
1562 let wt = tempfile::TempDir::new().expect("temp");
1563 std::fs::create_dir_all(wt.path().join("sub")).unwrap();
1565 std::fs::write(wt.path().join("sub/Cargo.toml"), "").unwrap();
1566 let cache = home.path().join("cache");
1567 std::fs::create_dir_all(&cache).unwrap();
1568 std::fs::write(cache.join("artifact"), "x").unwrap();
1569 let old = Identity {
1570 worktree: "/w/old".to_owned(),
1571 head: "old".to_owned(),
1572 };
1573 record_identity(home.path(), &cache, &old).unwrap();
1574 let id = Identity::new(wt.path(), "head");
1575 ensure_fresh(home.path(), &cache, &id, &[]).expect("must not fail");
1576 assert_eq!(
1577 std::fs::read_to_string(cache.join("artifact")).unwrap(),
1578 "x"
1579 );
1580 assert!(
1581 needs_refresh(home.path(), &cache, &old),
1582 "identity was invalidated, not recorded"
1583 );
1584 assert!(needs_refresh(home.path(), &cache, &id));
1585 }
1586}