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] ";
520
521#[derive(Debug)]
523pub struct WaitTimeout(pub String);
524
525impl std::fmt::Display for WaitTimeout {
526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 f.write_str(&self.0)
528 }
529}
530
531impl std::error::Error for WaitTimeout {}
532
533pub async fn wait_for(
542 home: &Path,
543 cache_dir: &Path,
544 owner: &Owner,
545 budget: Duration,
546 poll: Duration,
547) -> Result<Guard> {
548 match wait_for_unless(home, cache_dir, owner, budget, poll, &|| false).await? {
549 Some(g) => Ok(g),
550 None => bail!("the wait for the build cache was cancelled"),
551 }
552}
553
554pub async fn wait_for_unless(
559 home: &Path,
560 cache_dir: &Path,
561 owner: &Owner,
562 budget: Duration,
563 poll: Duration,
564 cancel: &(dyn Fn() -> bool + Sync),
565) -> Result<Option<Guard>> {
566 let start = std::time::Instant::now();
567 let retry = crate::lock_file::Retry::new(Some(start + budget));
570 loop {
571 let Some(acquired) = try_acquire_async(home, cache_dir, owner, retry, cancel).await? else {
572 return Ok(None);
573 };
574 match acquired {
575 AcquireOutcome::Acquired(g) => return Ok(Some(g)),
576 AcquireOutcome::Busy(busy) => {
577 if cancel() {
578 return Ok(None);
579 }
580 let elapsed = start.elapsed();
581 if elapsed >= budget {
582 return Err(WaitTimeout(format!(
583 "timed out after {}s waiting for the build cache at {} ({})",
584 budget.as_secs(),
585 cache_dir.display(),
586 busy.describe()
587 ))
588 .into());
589 }
590 tokio::time::sleep(poll.min(budget - elapsed)).await;
591 }
592 }
593 }
594}
595
596#[derive(Debug, Clone)]
602pub struct Entry {
603 pub cache_dir: String,
605 pub status: EntryStatus,
607}
608
609#[derive(Debug, Clone)]
611pub enum EntryStatus {
612 Active(Owner),
614 Stale(Owner),
616 Unknown,
618 Idle(Owner),
624 Outputs(Owner),
627}
628
629#[must_use]
637pub fn inventory(home: &Path) -> Vec<Entry> {
638 let mut entries = inventory_with(home, proc::pid_alive);
639 for entry in &mut entries {
640 if let EntryStatus::Idle(owner) | EntryStatus::Stale(owner) = &entry.status
641 && crate::retention::OutputLease::cache(home, Path::new(&entry.cache_dir), true)
642 .is_err()
643 {
644 entry.status = EntryStatus::Outputs(owner.clone());
645 }
646 }
647 entries
648}
649
650fn inventory_with<F: Fn(u32) -> bool + Copy>(home: &Path, alive: F) -> Vec<Entry> {
653 let dir = leases_dir(home);
654 let Ok(rd) = std::fs::read_dir(&dir) else {
655 return Vec::new();
656 };
657 let mut out = Vec::new();
658 for entry in rd.flatten() {
659 let path = entry.path();
660 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
661 continue;
662 };
663 if !name.ends_with(".json")
664 || name.ends_with(".identity.json")
665 || name.ends_with(".catalog.json")
666 {
667 continue;
668 }
669 let status = match classify_with(&path, alive) {
670 Status::Free => continue,
671 Status::Active(o) => EntryStatus::Active(o),
672 Status::Stale(o) => EntryStatus::Stale(o),
673 Status::Unknown => EntryStatus::Unknown,
674 };
675 let cache_dir = peek_cache_dir(&path)
681 .unwrap_or_else(|| format!("(unreadable lease file: {})", path.display()));
682 out.push(Entry { cache_dir, status });
683 }
684
685 if let Ok(rd) = std::fs::read_dir(&dir) {
691 for entry in rd.flatten() {
692 let path = entry.path();
693 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
694 continue;
695 };
696 let Some(stem) = name.strip_suffix(".catalog.json") else {
697 continue;
698 };
699 if dir.join(format!("{stem}.json")).exists() {
700 continue;
701 }
702 if let Some(record) = read_catalog(&path) {
703 out.push(Entry {
704 cache_dir: record.cache_dir,
705 status: EntryStatus::Idle(record.last_owner),
706 });
707 }
708 }
709 }
710
711 out.sort_by(|a, b| a.cache_dir.cmp(&b.cache_dir));
712 out
713}
714
715pub fn maintenance_prune(
724 home: &Path,
725 cache_dir: &Path,
726 limit: u64,
727) -> Result<Option<crate::disk::Prune>> {
728 let owner = Owner {
729 run: "maintenance".to_owned(),
730 node: "prune".to_owned(),
731 seat: "janitor".to_owned(),
732 pid: std::process::id(),
733 worktree: String::new(),
734 head: String::new(),
735 };
736 match try_acquire_maintenance(home, cache_dir, &owner)? {
737 AcquireOutcome::Busy(_) => Ok(None),
738 AcquireOutcome::Acquired(guard) => {
739 let _cargo = crate::retention::cargo_locks(cache_dir)?;
740 let result = crate::disk::prune_dir(cache_dir, limit)?;
741 guard.release();
742 Ok(Some(result))
743 }
744 }
745}
746
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct Identity {
752 pub worktree: String,
754 pub head: String,
756}
757
758impl Identity {
759 #[must_use]
761 pub fn new(worktree: &Path, head: &str) -> Identity {
762 Identity {
763 worktree: worktree.display().to_string(),
764 head: head.to_owned(),
765 }
766 }
767}
768
769#[must_use]
774pub fn needs_refresh(home: &Path, cache_dir: &Path, current: &Identity) -> bool {
775 let path = identity_path(home, cache_dir);
776 let Ok(body) = std::fs::read_to_string(path) else {
777 return true;
778 };
779 match serde_json::from_str::<Identity>(&body) {
780 Ok(recorded) => &recorded != current,
781 Err(_) => true,
782 }
783}
784
785pub fn record_identity(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
787 let path = identity_path(home, cache_dir);
788 if let Some(parent) = path.parent() {
789 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
790 }
791 let body = serde_json::to_string_pretty(identity).context("serialize cache identity")?;
792 let tmp = path.with_extension("json.tmp");
793 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
794 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
795 Ok(())
796}
797
798pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
812 let _ = std::fs::remove_file(identity_path(home, cache_dir));
813}
814
815#[must_use]
821pub fn parse_workspace_package_names(metadata_json: &str) -> Vec<String> {
822 let Ok(value) = serde_json::from_str::<serde_json::Value>(metadata_json) else {
823 return Vec::new();
824 };
825 value
826 .get("packages")
827 .and_then(|p| p.as_array())
828 .map(|packages| {
829 packages
830 .iter()
831 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
832 .map(str::to_owned)
833 .collect()
834 })
835 .unwrap_or_default()
836}
837
838#[must_use]
842pub fn extract_manifest_paths(commands: &[String]) -> Vec<PathBuf> {
843 const KEY: &str = "--manifest-path";
844 let mut out: Vec<PathBuf> = Vec::new();
845 for command in commands {
846 let mut rest = command.as_str();
847 while let Some(at) = rest.find(KEY) {
848 rest = &rest[at + KEY.len()..];
849 let value = if let Some(r) = rest.strip_prefix('=') {
850 r
851 } else if rest.starts_with(char::is_whitespace) {
852 rest.trim_start()
853 } else {
854 continue;
855 };
856 let value = if let Some(s) = value.strip_prefix('\'') {
857 s.split('\'').next().unwrap_or("")
858 } else if let Some(s) = value.strip_prefix('"') {
859 s.split('"').next().unwrap_or("")
860 } else {
861 let end = value.find(char::is_whitespace).unwrap_or(value.len());
862 &value[..end]
863 };
864 if !value.is_empty() {
865 let path = PathBuf::from(value);
866 if !out.contains(&path) {
867 out.push(path);
868 }
869 }
870 }
871 }
872 out
873}
874
875#[derive(Debug, Clone, PartialEq, Eq)]
877pub enum Target {
878 Root,
880 Manifest(PathBuf),
882}
883
884fn plan_targets(
889 worktree: &Path,
890 manifests: &[PathBuf],
891 exists: impl Fn(&Path) -> Result<bool>,
892) -> Result<Vec<Target>> {
893 let mut targets = Vec::new();
894 if exists(&worktree.join("Cargo.toml"))? {
895 targets.push(Target::Root);
896 }
897 for m in manifests {
898 let full = if m.is_absolute() {
899 m.clone()
900 } else {
901 worktree.join(m)
902 };
903 if exists(&full)? {
904 targets.push(Target::Manifest(full));
905 } else {
906 tracing::warn!(
907 manifest = %m.display(),
908 "build cache: --manifest-path from a verify command does not exist; skipped"
909 );
910 }
911 }
912 Ok(targets)
913}
914
915fn file_exists(path: &Path) -> Result<bool> {
918 match std::fs::metadata(path) {
919 Ok(m) => Ok(m.is_file()),
920 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
921 Err(e) => Err(e).with_context(|| format!("stat {}", path.display())),
922 }
923}
924
925fn refresh_stale_packages(
933 worktree: &Path,
934 cache_dir: &Path,
935 manifests: &[PathBuf],
936) -> Result<Option<Vec<String>>> {
937 let targets = plan_targets(worktree, manifests, file_exists)?;
938 if targets.is_empty() {
939 return Ok(None);
940 }
941 let mut all = Vec::new();
942 let mut failed = Vec::new();
943 for target in &targets {
944 let manifest_arg = match target {
945 Target::Root => None,
946 Target::Manifest(m) => Some(m),
947 };
948 let mut cmd = std::process::Command::new("cargo");
949 cmd.args(["metadata", "--no-deps", "--format-version", "1"]);
950 if let Some(m) = manifest_arg {
951 cmd.arg("--manifest-path").arg(m);
952 }
953 let meta = cmd
954 .current_dir(worktree)
955 .quiet()
956 .output()
957 .context("run `cargo metadata`")?;
958 if !meta.status.success() {
959 bail!(
960 "cargo metadata failed: {}",
961 String::from_utf8_lossy(&meta.stderr)
962 );
963 }
964 let names = parse_workspace_package_names(&String::from_utf8_lossy(&meta.stdout));
965 for name in &names {
966 let mut clean = std::process::Command::new("cargo");
967 clean.arg("clean").arg("-p").arg(name);
968 if let Some(m) = manifest_arg {
969 clean.arg("--manifest-path").arg(m);
970 }
971 let out = clean
972 .arg("--target-dir")
973 .arg(cache_dir)
974 .current_dir(worktree)
975 .quiet()
976 .output()
977 .with_context(|| format!("cargo clean -p {name}"))?;
978 if !out.status.success() {
979 failed.push(format!(
988 "{name}: {}",
989 String::from_utf8_lossy(&out.stderr).trim()
990 ));
991 }
992 }
993 for n in names {
994 if !all.contains(&n) {
995 all.push(n);
996 }
997 }
998 }
999 if !failed.is_empty() {
1000 bail!(
1001 "cargo clean -p failed for {} package(s): {}",
1002 failed.len(),
1003 failed.join("; ")
1004 );
1005 }
1006 Ok(Some(all))
1007}
1008
1009const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
1011# This file is a cache directory tag created by cargo.\n\
1012# For information about cache directory tags see https://bford.info/cachedir/\n";
1013
1014fn restore_cachedir_tag(cache_dir: &Path) -> Result<bool> {
1023 use std::io::Write as _;
1024 if !cache_dir.is_dir() || !cache_dir.join(".rustc_info.json").is_file() {
1025 return Ok(false);
1026 }
1027 let tag = cache_dir.join("CACHEDIR.TAG");
1028 match std::fs::OpenOptions::new()
1029 .write(true)
1030 .create_new(true)
1031 .open(&tag)
1032 {
1033 Ok(mut f) => {
1034 f.write_all(CACHEDIR_TAG.as_bytes())
1035 .with_context(|| format!("write {}", tag.display()))?;
1036 Ok(true)
1037 }
1038 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1039 Err(e) => Err(e).with_context(|| format!("create {}", tag.display())),
1040 }
1041}
1042
1043pub fn ensure_fresh(
1054 home: &Path,
1055 cache_dir: &Path,
1056 identity: &Identity,
1057 manifests: &[PathBuf],
1058) -> Result<()> {
1059 match restore_cachedir_tag(cache_dir) {
1060 Ok(true) => tracing::info!(
1061 cache = %cache_dir.display(),
1062 "build cache: restored a missing CACHEDIR.TAG"
1063 ),
1064 Ok(false) => {}
1065 Err(e) => tracing::warn!(error = %e, "build cache: could not restore CACHEDIR.TAG"),
1067 }
1068 if needs_refresh(home, cache_dir, identity) {
1069 let worktree = PathBuf::from(&identity.worktree);
1070 match refresh_stale_packages(&worktree, cache_dir, manifests)? {
1071 Some(cleaned) => tracing::info!(
1072 ?cleaned,
1073 cache = %cache_dir.display(),
1074 "build cache: source identity changed; cleaned the workspace's own packages before reuse"
1075 ),
1076 None => {
1077 tracing::warn!(
1082 cache = %cache_dir.display(),
1083 "build cache: no Cargo manifest found for the freshness check; skipping the package clean"
1084 );
1085 invalidate_identity(home, cache_dir);
1086 return Ok(());
1087 }
1088 }
1089 }
1090 record_identity(home, cache_dir, identity)
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 fn owner(pid: u32) -> Owner {
1098 Owner {
1099 run: "r1".to_owned(),
1100 node: "gate".to_owned(),
1101 seat: "gate".to_owned(),
1102 pid,
1103 worktree: "/w".to_owned(),
1104 head: "deadbeef".to_owned(),
1105 }
1106 }
1107
1108 #[test]
1109 fn a_missing_cachedir_tag_is_restored_only_for_a_cargo_target() {
1110 let t = tempfile::TempDir::new().expect("temp");
1111 let cache = t.path().join("cache");
1112 assert!(!restore_cachedir_tag(&cache).expect("missing dir"));
1113 assert!(!cache.exists(), "a missing directory is left for cargo");
1114
1115 std::fs::create_dir_all(&cache).expect("mkdir");
1116 assert!(!restore_cachedir_tag(&cache).expect("no rustc info"));
1117 assert!(!cache.join("CACHEDIR.TAG").exists());
1118
1119 std::fs::write(cache.join(".rustc_info.json"), "{}").expect("info");
1120 assert!(restore_cachedir_tag(&cache).expect("restore"));
1121 let body = std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag");
1122 assert_eq!(
1123 body.lines().next(),
1124 Some("Signature: 8a477f597d28d172789f06886806bc55")
1125 );
1126
1127 std::fs::write(cache.join("CACHEDIR.TAG"), "custom").expect("custom");
1128 assert!(!restore_cachedir_tag(&cache).expect("existing"));
1129 assert_eq!(
1130 std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag"),
1131 "custom"
1132 );
1133 }
1134
1135 #[test]
1136 fn an_uncontended_lease_is_acquired_and_freed_on_release() {
1137 let home = tempfile::TempDir::new().expect("temp");
1138 let cache = home.path().join("cache");
1139 let this = std::process::id();
1140 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1141 AcquireOutcome::Acquired(g) => {
1142 assert!(in_use(home.path(), &cache), "held while the guard lives");
1143 g.release();
1144 }
1145 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1146 }
1147 assert!(!in_use(home.path(), &cache), "freed after release");
1148 }
1149
1150 #[test]
1151 fn a_lease_held_by_a_live_pid_is_reported_active_and_refuses_a_second_acquire() {
1152 let home = tempfile::TempDir::new().expect("temp");
1153 let cache = home.path().join("cache");
1154 let this = std::process::id();
1155 let _first =
1159 try_acquire(home.path(), &cache, &owner(this)).expect("first acquire succeeds");
1160 let mut second_owner = owner(this);
1161 second_owner.run = "r2".to_owned();
1162 match try_acquire(home.path(), &cache, &second_owner).expect("no io error") {
1163 AcquireOutcome::Busy(Busy::Active(held_by)) => assert_eq!(held_by.run, "r1"),
1164 other => panic!("expected Busy::Active, got a different outcome: {other:?}"),
1165 }
1166 assert!(in_use(home.path(), &cache));
1167 }
1168
1169 impl std::fmt::Debug for AcquireOutcome {
1170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1171 match self {
1172 AcquireOutcome::Acquired(_) => write!(f, "Acquired"),
1173 AcquireOutcome::Busy(b) => write!(f, "Busy({b:?})"),
1174 }
1175 }
1176 }
1177
1178 #[test]
1179 fn a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer() {
1180 let home = tempfile::TempDir::new().expect("temp");
1181 let cache = home.path().join("cache");
1182 let dead_owner = owner(999_999);
1188 let path = lease_path(home.path(), &cache);
1189 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1190 write_new(
1191 &path,
1192 &cache,
1193 &dead_owner,
1194 crate::lock_file::Retry::new(None),
1195 )
1196 .expect("seed a stale lease");
1197 assert_eq!(
1198 classify_with(&path, |_| false),
1199 Status::Stale(dead_owner.clone())
1200 );
1201
1202 match try_acquire_with(home.path(), &cache, &owner(std::process::id()), |_| false)
1203 .expect("acquire")
1204 {
1205 AcquireOutcome::Acquired(_) => {}
1206 other => panic!("stale lease should have been reclaimed: {other:?}"),
1207 }
1208 }
1209
1210 #[test]
1211 fn an_unreadable_lease_is_unknown_and_never_reclaimed() {
1212 let home = tempfile::TempDir::new().expect("temp");
1213 let cache = home.path().join("cache");
1214 let path = lease_path(home.path(), &cache);
1215 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1216 std::fs::write(&path, b"not json").unwrap();
1217 assert_eq!(classify(&path), Status::Unknown);
1218 assert!(in_use(home.path(), &cache), "unknown counts as in use");
1219 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("no io error") {
1220 AcquireOutcome::Busy(Busy::Unknown) => {}
1221 other => panic!("expected Busy::Unknown, got {other:?}"),
1222 }
1223 }
1224
1225 #[tokio::test]
1226 async fn waiting_for_a_busy_lease_times_out_within_its_own_budget() {
1227 let home = tempfile::TempDir::new().expect("temp");
1228 let cache = home.path().join("cache");
1229 let _held = try_acquire(home.path(), &cache, &owner(std::process::id()))
1230 .expect("acquire")
1231 .pipe();
1232 let mut waiter = owner(std::process::id());
1233 waiter.run = "r2".to_owned();
1234 let started = std::time::Instant::now();
1235 let err = wait_for(
1236 home.path(),
1237 &cache,
1238 &waiter,
1239 Duration::from_millis(150),
1240 Duration::from_millis(20),
1241 )
1242 .await
1243 .expect_err("still held, must time out");
1244 assert!(started.elapsed() < Duration::from_secs(2), "bounded wait");
1245 assert!(
1246 err.to_string().contains("r1"),
1247 "names the current holder: {err}"
1248 );
1249 }
1250
1251 #[tokio::test]
1252 async fn a_wait_succeeds_as_soon_as_the_lease_is_released() {
1253 let home = tempfile::TempDir::new().expect("temp");
1254 let cache = home.path().join("cache");
1255 let guard =
1256 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire") {
1257 AcquireOutcome::Acquired(g) => g,
1258 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1259 };
1260 let home_path = home.path().to_path_buf();
1261 let cache_path = cache.clone();
1262 let mut waiter = owner(std::process::id());
1263 waiter.run = "r2".to_owned();
1264 let wait = tokio::spawn(async move {
1265 wait_for(
1266 &home_path,
1267 &cache_path,
1268 &waiter,
1269 Duration::from_secs(5),
1270 Duration::from_millis(10),
1271 )
1272 .await
1273 });
1274 tokio::time::sleep(Duration::from_millis(50)).await;
1275 guard.release();
1276 let acquired = wait.await.expect("task").expect("acquire after release");
1277 acquired.release();
1278 }
1279
1280 #[test]
1281 fn inventory_reports_active_stale_and_unknown_but_not_free() {
1282 let home = tempfile::TempDir::new().expect("temp");
1283 let active_cache = home.path().join("active");
1284 let stale_cache = home.path().join("stale");
1285 let unknown_cache = home.path().join("unknown");
1286
1287 let _held =
1288 try_acquire(home.path(), &active_cache, &owner(std::process::id())).expect("acquire");
1289 let stale_path = lease_path(home.path(), &stale_cache);
1290 std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1291 write_new(
1292 &stale_path,
1293 &stale_cache,
1294 &owner(999_999),
1295 crate::lock_file::Retry::new(None),
1296 )
1297 .unwrap();
1298 let unknown_path = lease_path(home.path(), &unknown_cache);
1299 std::fs::write(&unknown_path, b"garbage").unwrap();
1300
1301 let entries = inventory_with(home.path(), |pid| pid == std::process::id());
1306 assert_eq!(entries.len(), 3, "{entries:?}");
1307 let by_dir = |dir: &Path| {
1308 entries
1309 .iter()
1310 .find(|e| e.cache_dir == dir.display().to_string())
1311 .unwrap_or_else(|| panic!("no entry for {}", dir.display()))
1312 };
1313 assert!(matches!(
1314 by_dir(&active_cache).status,
1315 EntryStatus::Active(_)
1316 ));
1317 assert!(matches!(by_dir(&stale_cache).status, EntryStatus::Stale(_)));
1318 let unknown = entries
1323 .iter()
1324 .find(|e| matches!(e.status, EntryStatus::Unknown))
1325 .unwrap_or_else(|| panic!("no Unknown entry: {entries:?}"));
1326 assert!(
1327 unknown
1328 .cache_dir
1329 .contains(&unknown_path.display().to_string()),
1330 "{unknown:?}"
1331 );
1332 }
1333
1334 #[test]
1335 fn a_released_lease_is_reported_idle_from_the_catalog_not_dropped_entirely() {
1336 let home = tempfile::TempDir::new().expect("temp");
1337 let cache = home.path().join("cache");
1338 let this = std::process::id();
1339
1340 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1341 AcquireOutcome::Acquired(g) => g.release(),
1342 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1343 }
1344
1345 assert!(!in_use(home.path(), &cache));
1350 let entries = inventory_with(home.path(), |pid| pid == this);
1351 let entry = entries
1352 .iter()
1353 .find(|e| e.cache_dir == cache.display().to_string())
1354 .unwrap_or_else(|| panic!("no entry for a released cache: {entries:?}"));
1355 match &entry.status {
1356 EntryStatus::Idle(o) => assert_eq!(o.run, "r1"),
1357 other => panic!("expected Idle, got {other:?}"),
1358 }
1359 }
1360
1361 #[test]
1362 fn reacquiring_a_released_cache_reports_active_not_idle() {
1363 let home = tempfile::TempDir::new().expect("temp");
1364 let cache = home.path().join("cache");
1365 let this = std::process::id();
1366 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1367 AcquireOutcome::Acquired(g) => g.release(),
1368 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1369 }
1370 let _held = try_acquire(home.path(), &cache, &owner(this)).expect("reacquire");
1371 let entries = inventory_with(home.path(), |pid| pid == this);
1372 assert_eq!(
1373 entries.len(),
1374 1,
1375 "the catalog row must not duplicate the live lease: {entries:?}"
1376 );
1377 assert!(matches!(entries[0].status, EntryStatus::Active(_)));
1378 }
1379
1380 #[test]
1381 fn maintenance_prune_refuses_a_cache_a_live_owner_holds() {
1382 let home = tempfile::TempDir::new().expect("temp");
1383 let cache = home.path().join("cache");
1384 std::fs::create_dir_all(&cache).unwrap();
1385 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1386 let _held = try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire");
1387
1388 let result = maintenance_prune(home.path(), &cache, 1).expect("no io error");
1389 assert!(
1390 result.is_none(),
1391 "must not prune while a live owner holds it"
1392 );
1393 assert!(cache.join("big").exists(), "nothing was deleted");
1394 }
1395
1396 #[test]
1397 fn maintenance_prune_acts_once_the_cache_is_free_and_releases_after() {
1398 let home = tempfile::TempDir::new().expect("temp");
1399 let cache = home.path().join("cache");
1400 std::fs::create_dir_all(&cache).unwrap();
1401 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1402
1403 let pruned = maintenance_prune(home.path(), &cache, 1)
1404 .expect("no io error")
1405 .expect("cache was free");
1406 assert!(pruned.freed > 0);
1407 assert!(
1408 !in_use(home.path(), &cache),
1409 "the maintenance lease was released"
1410 );
1411 }
1412
1413 #[test]
1414 fn identity_drift_is_detected_once_and_then_settles() {
1415 let home = tempfile::TempDir::new().expect("temp");
1416 let cache = home.path().join("cache");
1417 let a = Identity {
1418 worktree: "/w/a".to_owned(),
1419 head: "aaaa".to_owned(),
1420 };
1421 let b = Identity {
1422 worktree: "/w/b".to_owned(),
1423 head: "bbbb".to_owned(),
1424 };
1425 assert!(
1426 needs_refresh(home.path(), &cache, &a),
1427 "nothing recorded yet"
1428 );
1429 record_identity(home.path(), &cache, &a).expect("record");
1430 assert!(
1431 !needs_refresh(home.path(), &cache, &a),
1432 "same identity, no refresh needed"
1433 );
1434 assert!(needs_refresh(home.path(), &cache, &b), "different source");
1435 record_identity(home.path(), &cache, &b).expect("record");
1436 assert!(!needs_refresh(home.path(), &cache, &b));
1437 }
1438
1439 #[test]
1440 fn invalidating_forgets_a_recorded_identity_so_the_next_check_refreshes() {
1441 let home = tempfile::TempDir::new().expect("temp");
1442 let cache = home.path().join("cache");
1443 let a = Identity {
1444 worktree: "/w/a".to_owned(),
1445 head: "aaaa".to_owned(),
1446 };
1447 record_identity(home.path(), &cache, &a).expect("record");
1448 assert!(!needs_refresh(home.path(), &cache, &a));
1449
1450 invalidate_identity(home.path(), &cache);
1455 assert!(
1456 needs_refresh(home.path(), &cache, &a),
1457 "invalidation must not be skippable by asking about the same identity again"
1458 );
1459
1460 invalidate_identity(home.path(), &home.path().join("never-recorded"));
1463 }
1464
1465 #[test]
1466 fn workspace_package_names_are_read_from_cargo_metadata_json() {
1467 let fixture = r#"{
1468 "packages": [
1469 {"name": "magi", "version": "0.1.0"},
1470 {"name": "magi-cli", "version": "0.1.0"}
1471 ],
1472 "workspace_members": []
1473 }"#;
1474 let mut names = parse_workspace_package_names(fixture);
1475 names.sort();
1476 assert_eq!(names, vec!["magi".to_owned(), "magi-cli".to_owned()]);
1477 assert_eq!(
1478 parse_workspace_package_names("not json"),
1479 Vec::<String>::new()
1480 );
1481 assert_eq!(parse_workspace_package_names("{}"), Vec::<String>::new());
1482 }
1483
1484 #[test]
1485 fn slugs_are_stable_and_filesystem_safe() {
1486 let a = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1487 let b = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1488 assert_eq!(a, b, "same input, same slug");
1489 assert!(
1490 a.chars()
1491 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
1492 "filesystem-safe: {a}"
1493 );
1494 }
1495
1496 #[test]
1497 fn busy_active_describes_the_holder() {
1498 let b = Busy::Active(owner(123));
1499 let s = b.describe();
1500 assert!(
1501 s.contains("r1") && s.contains("gate") && s.contains("123"),
1502 "{s}"
1503 );
1504 }
1505
1506 trait Pipe: Sized {
1507 fn pipe(self) -> Guard;
1508 }
1509 impl Pipe for AcquireOutcome {
1510 fn pipe(self) -> Guard {
1511 match self {
1512 AcquireOutcome::Acquired(g) => g,
1513 AcquireOutcome::Busy(b) => panic!("expected Acquired, got Busy({b:?})"),
1514 }
1515 }
1516 }
1517
1518 fn cmds(v: &[&str]) -> Vec<String> {
1519 v.iter().map(|s| (*s).to_owned()).collect()
1520 }
1521
1522 #[test]
1523 fn manifest_paths_are_read_in_every_spelling() {
1524 let got = extract_manifest_paths(&cmds(&[
1525 "cargo test --manifest-path tools/a/Cargo.toml",
1526 "cargo build --manifest-path=tools/b/Cargo.toml --release",
1527 "cargo fmt --manifest-path 'q q/Cargo.toml'",
1528 "cargo clippy --manifest-path \"d/Cargo.toml\" && cargo test --manifest-path tools/a/Cargo.toml",
1529 ]));
1530 let want: Vec<PathBuf> = [
1531 "tools/a/Cargo.toml",
1532 "tools/b/Cargo.toml",
1533 "q q/Cargo.toml",
1534 "d/Cargo.toml",
1535 ]
1536 .iter()
1537 .map(PathBuf::from)
1538 .collect();
1539 assert_eq!(got, want);
1540 assert!(extract_manifest_paths(&cmds(&["cargo test", "cd x && cargo test"])).is_empty());
1541 }
1542
1543 #[test]
1544 fn plan_targets_covers_blog_root_both_and_none() {
1545 let w = Path::new("/w");
1546 let only = |set: &'static [&'static str]| {
1547 move |p: &Path| Ok(set.iter().any(|s| p == Path::new(s)))
1548 };
1549 let m = vec![PathBuf::from("tools/postprocess/Cargo.toml")];
1550 let blog = plan_targets(w, &m, only(&["/w/tools/postprocess/Cargo.toml"])).unwrap();
1551 assert_eq!(
1552 blog,
1553 vec![Target::Manifest(PathBuf::from(
1554 "/w/tools/postprocess/Cargo.toml"
1555 ))]
1556 );
1557 let root = plan_targets(w, &[], only(&["/w/Cargo.toml"])).unwrap();
1558 assert_eq!(root, vec![Target::Root]);
1559 let both = plan_targets(
1560 w,
1561 &m,
1562 only(&["/w/Cargo.toml", "/w/tools/postprocess/Cargo.toml"]),
1563 )
1564 .unwrap();
1565 assert_eq!(both.len(), 2);
1566 assert_eq!(both[0], Target::Root);
1567 assert!(plan_targets(w, &m, only(&[])).unwrap().is_empty());
1568 assert!(plan_targets(w, &[], only(&[])).unwrap().is_empty());
1569 }
1570
1571 #[test]
1572 fn a_repo_without_a_reachable_manifest_passes_untouched() {
1573 let home = tempfile::TempDir::new().expect("temp");
1574 let wt = tempfile::TempDir::new().expect("temp");
1575 std::fs::create_dir_all(wt.path().join("sub")).unwrap();
1577 std::fs::write(wt.path().join("sub/Cargo.toml"), "").unwrap();
1578 let cache = home.path().join("cache");
1579 std::fs::create_dir_all(&cache).unwrap();
1580 std::fs::write(cache.join("artifact"), "x").unwrap();
1581 let old = Identity {
1582 worktree: "/w/old".to_owned(),
1583 head: "old".to_owned(),
1584 };
1585 record_identity(home.path(), &cache, &old).unwrap();
1586 let id = Identity::new(wt.path(), "head");
1587 ensure_fresh(home.path(), &cache, &id, &[]).expect("must not fail");
1588 assert_eq!(
1589 std::fs::read_to_string(cache.join("artifact")).unwrap(),
1590 "x"
1591 );
1592 assert!(
1593 needs_refresh(home.path(), &cache, &old),
1594 "identity was invalidated, not recorded"
1595 );
1596 assert!(needs_refresh(home.path(), &cache, &id));
1597 }
1598}