1use anyhow::{Context, Result, ensure};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10use std::ffi::OsString;
11use std::fs::{self, File, OpenOptions};
12use std::io::Read;
13use std::path::{Path, PathBuf};
14
15pub const BLOCKED: &str = "incremental cache admission blocked";
17
18const SLOTS: usize = 4;
19const GENERATION_BYTES: u64 = 128 * 1024 * 1024;
20const STORE: &str = "magi-incremental";
21
22#[derive(Debug)]
27struct FileLease(File);
28
29impl std::ops::Deref for FileLease {
30 type Target = File;
31 fn deref(&self) -> &Self::Target {
32 &self.0
33 }
34}
35
36#[cfg(unix)]
37impl Drop for FileLease {
38 fn drop(&mut self) {
39 use std::os::fd::AsRawFd;
40 unsafe {
42 libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
43 }
44 }
45}
46
47pub(crate) fn default_environment(command: &mut tokio::process::Command) {
48 let explicit = command
49 .as_std()
50 .get_envs()
51 .find(|(key, _)| *key == "CARGO_INCREMENTAL");
52 let supplied = match explicit {
53 Some((_, value)) => value.is_some(),
54 None => std::env::var_os("CARGO_INCREMENTAL").is_some(),
55 };
56 if !supplied {
57 command.env("CARGO_INCREMENTAL", "0");
58 }
59}
60
61pub(crate) fn configure(command: &mut tokio::process::Command, worktree: &Path) -> Result<()> {
64 default_environment(command);
65 #[cfg(not(unix))]
66 let _ = worktree;
67 #[cfg(unix)]
68 let env: BTreeMap<_, _> = command
69 .as_std()
70 .get_envs()
71 .map(|(k, v)| (k.to_owned(), v.map(ToOwned::to_owned)))
72 .collect();
73 #[cfg(unix)]
74 let value = |key: &str| {
75 env.get(std::ffi::OsStr::new(key))
76 .cloned()
77 .unwrap_or_else(|| std::env::var_os(key))
78 };
79 #[cfg(unix)]
80 if worktree.join(".git").is_file() {
81 use std::os::unix::fs::PermissionsExt;
82 let git = git_dir(worktree)?;
83 let executable = std::env::current_exe()?;
84 let hash: String = Sha256::digest(executable.as_os_str().as_encoded_bytes())
86 .iter()
87 .map(|b| format!("{b:02x}"))
88 .collect();
89 let name = format!("magi-rustc-{hash}.sh");
90 let script = git.join(name);
91 let quote = |p: &Path| format!("'{}'", p.to_string_lossy().replace('\'', "'\\''"));
92 let body = format!(
93 "#!/bin/sh\nexec {} __magi_incremental \"$@\"\n",
94 quote(&executable)
95 );
96 let temporary = git.join(format!(
97 "magi-rustc-{}.tmp",
98 crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4()
99 ));
100 let publish = || -> Result<()> {
101 use std::io::Write;
102 let mut file = OpenOptions::new()
103 .write(true)
104 .create_new(true)
105 .open(&temporary)?;
106 file.write_all(body.as_bytes())?;
107 file.set_permissions(fs::Permissions::from_mode(0o700))?;
108 match fs::hard_link(&temporary, &script) {
111 Ok(()) => Ok(()),
112 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
113 ensure!(
114 plain(&script)?.is_file() && fs::read(&script)? == body.as_bytes(),
115 "incremental wrapper changed"
116 );
117 Ok(())
118 }
119 Err(e) => Err(e.into()),
120 }
121 };
122 let published = publish();
123 let _ = fs::remove_file(&temporary);
124 published?;
125 let previous = value("RUSTC_WRAPPER");
126 let previous = if previous.is_some() && previous == value("MAGI_INCREMENTAL_WRAPPER") {
127 value("MAGI_PREVIOUS_RUSTC_WRAPPER")
128 } else {
129 previous
130 };
131 command.env_remove("MAGI_PREVIOUS_RUSTC_WRAPPER");
132 if let Some(previous) = previous.filter(|v| !v.is_empty()) {
133 if previous != script.as_os_str() {
135 command.env("MAGI_PREVIOUS_RUSTC_WRAPPER", previous);
136 }
137 }
138 command
139 .env("MAGI_INCREMENTAL_WORKTREE", worktree.canonicalize()?)
140 .env("MAGI_INCREMENTAL_WRAPPER", &script)
141 .env("RUSTC_WRAPPER", script);
142 }
143 Ok(())
144}
145
146fn git_dir(worktree: &Path) -> Result<PathBuf> {
147 let output = std::process::Command::new("git")
148 .current_dir(worktree)
149 .args(["rev-parse", "--absolute-git-dir"])
150 .output()?;
151 ensure!(
152 output.status.success(),
153 "incremental provenance requires a Git worktree"
154 );
155 Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim()))
156}
157
158fn plain(path: &Path) -> Result<fs::Metadata> {
159 let metadata = fs::symlink_metadata(path)?;
160 ensure!(
161 !metadata.file_type().is_symlink() && path.canonicalize()? == path,
162 "incremental path is not canonical: {}",
163 path.display()
164 );
165 Ok(metadata)
166}
167
168#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170struct Identity {
171 bytes: u64,
172 digest: Vec<u8>,
173 #[cfg(unix)]
174 device: u64,
175 #[cfg(unix)]
176 inode: u64,
177}
178
179fn identity(path: &Path) -> Result<Identity> {
180 let metadata = plain(path)?;
181 ensure!(
182 metadata.is_file(),
183 "incremental entry is not a regular file"
184 );
185 let mut file = File::open(path)?;
186 let mut hash = Sha256::new();
187 let mut buffer = [0; 65536];
188 loop {
189 let n = file.read(&mut buffer)?;
190 if n == 0 {
191 break;
192 }
193 hash.update(&buffer[..n]);
194 }
195 #[cfg(unix)]
196 use std::os::unix::fs::MetadataExt;
197 Ok(Identity {
198 bytes: metadata.len(),
199 digest: hash.finalize().to_vec(),
200 #[cfg(unix)]
201 device: metadata.dev(),
202 #[cfg(unix)]
203 inode: metadata.ino(),
204 })
205}
206
207#[derive(Serialize, Deserialize)]
208#[serde(deny_unknown_fields)]
209struct Proof {
210 version: u32,
211 worktree: PathBuf,
212 generation: PathBuf,
213 files: BTreeMap<PathBuf, Identity>,
214 directories: std::collections::BTreeSet<PathBuf>,
215}
216
217pub(crate) struct Generation {
218 path: PathBuf,
219 record: PathBuf,
220 _lock: FileLease,
222}
223
224impl Generation {
225 pub(crate) fn create(worktree: &Path, parent: &Path) -> Result<Self> {
226 let target = worktree.join("target");
227 ensure!(
228 [
229 target.join("debug/incremental"),
230 target.join("release/incremental")
231 ]
232 .contains(&parent.to_owned()),
233 "explicit incremental requires a local managed target; shared/custom targets remain non-incremental"
234 );
235 ensure!(
237 plain(&target)?.is_dir() && plain(parent)?.is_dir(),
238 "incremental parent is not a directory"
239 );
240 let store = git_dir(worktree)?.join(STORE);
241 fs::create_dir_all(&store)?;
242 ensure!(
243 plain(&store)?.is_dir(),
244 "invalid incremental provenance store"
245 );
246 let mut completed = BTreeMap::new();
250 let mut inspected = 0usize;
251 for entry in fs::read_dir(&store)? {
252 let owner = entry?.path();
253 if owner.extension().is_some_and(|x| x == "owner") {
254 inspected += 1;
255 if inspected > 1024 {
256 break;
257 }
258 if let Ok(snapshot) = completed_charge(worktree, &owner) {
259 completed.insert(owner, snapshot);
260 }
261 }
262 }
263 let mut options = OpenOptions::new();
264 #[cfg(unix)]
265 {
266 use std::os::unix::fs::OpenOptionsExt;
267 options.custom_flags(libc::O_NOFOLLOW);
268 }
269 let admission = FileLease(
270 options
271 .create(true)
272 .truncate(false)
273 .read(true)
274 .write(true)
275 .open(store.join("admission.lock"))?,
276 );
277 wait_for_admission(&admission, std::time::Duration::from_secs(30))?;
278 for entry in fs::read_dir(&store)? {
281 let record = entry?.path();
282 if record.extension().is_none_or(|x| x != "json") {
283 continue;
284 }
285 let cleanup = || -> Result<()> {
286 ensure!(
287 plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
288 "invalid incremental proof"
289 );
290 let proof: Proof = serde_json::from_slice(&fs::read(&record)?)?;
291 validate_proof(worktree, &record, &proof)?;
292 ensure!(
293 matches!(fs::symlink_metadata(&proof.generation), Err(e) if e.kind() == std::io::ErrorKind::NotFound),
294 "generation still exists or is uninspectable"
295 );
296 let owner = record.with_extension("owner");
297 ensure!(
298 plain(&owner)?.is_file() && fs::metadata(&owner)?.len() == 0,
299 "invalid incremental owner"
300 );
301 let lock = FileLease(OpenOptions::new().read(true).write(true).open(&owner)?);
302 crate::pr_head::try_lock_file(&lock)?;
303 fs::remove_file(&record)?;
304 fs::remove_file(owner)?;
305 Ok(())
306 };
307 let _ = cleanup(); }
309 let mut charged = 0u64;
312 let mut records = 0usize;
313 for entry in fs::read_dir(&store)? {
314 let owner = entry?.path();
315 if owner.extension().is_some_and(|x| x == "owner") {
316 records += 1;
317 let bytes = completed
318 .get(&owner)
319 .and_then(|(bytes, guard)| snapshot_charge(&owner, *bytes, guard))
320 .unwrap_or(GENERATION_BYTES);
321 charged = charged.saturating_add(bytes);
322 }
323 }
324 ensure!(
325 records < 1024,
326 "managed incremental provenance record limit exhausted; inspect retention report"
327 );
328 ensure!(
329 charged.saturating_add(GENERATION_BYTES) <= SLOTS as u64 * GENERATION_BYTES,
330 "managed incremental budget exhausted (512 MiB including active/unproven reservations); retain unproven caches and inspect retention report"
331 );
332 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
333 let record = store.join(format!("{token}.json"));
334 let lock = FileLease(
335 OpenOptions::new()
336 .create_new(true)
337 .read(true)
338 .write(true)
339 .open(record.with_extension("owner"))?,
340 );
341 crate::pr_head::try_lock_file(&lock)?;
342 let path = parent.join(format!("magi-{token}"));
343 fs::create_dir(&path)?;
344 Ok(Self {
345 path,
346 record,
347 _lock: lock,
348 })
349 }
350
351 pub(crate) fn seal(&self, worktree: &Path) -> Result<()> {
352 finalized_sessions(&self.path)?;
353 let files = inventory(&self.path, true)?;
354 let bytes = files
355 .values()
356 .fold(0u64, |sum, f| sum.saturating_add(f.bytes));
357 ensure!(
358 bytes <= GENERATION_BYTES,
359 "managed incremental generation exceeds its 128 MiB budget"
360 );
361 let proof = Proof {
362 version: 1,
363 worktree: worktree.to_owned(),
364 generation: self.path.clone(),
365 files,
366 directories: directory_inventory(&self.path)?,
367 };
368 let temp = self.record.with_extension("tmp");
369 fs::write(&temp, serde_json::to_vec(&proof)?)?;
370 fs::rename(temp, &self.record)?;
371 Ok(())
372 }
373}
374
375fn finalized_sessions(root: &Path) -> Result<()> {
378 for entry in fs::read_dir(root)? {
379 let crate_dir = entry?.path();
380 ensure!(
381 plain(&crate_dir)?.is_dir(),
382 "unknown incremental root entry"
383 );
384 let mut sessions = Vec::new();
385 let mut locks = Vec::new();
386 for entry in fs::read_dir(&crate_dir)? {
387 let path = entry?.path();
388 let name = path
389 .file_name()
390 .context("missing incremental name")?
391 .to_str()
392 .context("invalid incremental name")?
393 .to_owned();
394 if plain(&path)?.is_dir() {
395 ensure!(
396 name.starts_with("s-")
397 && !name.ends_with("-working")
398 && name.split('-').count() == 4,
399 "incremental session is unfinished or unknown"
400 );
401 for output in fs::read_dir(&path)? {
402 ensure!(
403 plain(&output?.path())?.is_file(),
404 "unknown incremental session directory"
405 );
406 }
407 sessions.push(name);
408 } else {
409 ensure!(
410 name.starts_with("s-")
411 && name.ends_with(".lock")
412 && name.split('-').count() == 3,
413 "unknown incremental session lock"
414 );
415 locks.push(name);
416 }
417 }
418 ensure!(
419 !sessions.is_empty() && sessions.len() == locks.len(),
420 "incremental session/lock inventory is incomplete"
421 );
422 for lock in locks {
423 let prefix = format!("{}-", lock.strip_suffix(".lock").unwrap());
424 ensure!(
425 sessions.iter().any(|name| name.starts_with(&prefix)),
426 "incremental session lock has no completed session"
427 );
428 }
429 }
430 Ok(())
431}
432
433fn wait_for_admission(file: &File, timeout: std::time::Duration) -> Result<()> {
435 let started = std::time::Instant::now();
436 loop {
437 match crate::pr_head::try_lock_file(file) {
438 Ok(()) => return Ok(()),
439 Err(error)
440 if matches!(
441 error.kind(),
442 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
443 ) => {}
444 Err(error) => return Err(error).context("incremental generation admission lock"),
445 }
446 ensure!(
447 started.elapsed() < timeout,
448 "incremental generation admission wait timed out after {} ms",
449 timeout.as_millis()
450 );
451 std::thread::sleep(
452 std::time::Duration::from_millis(10).min(timeout.saturating_sub(started.elapsed())),
453 );
454 }
455}
456
457fn snapshot_charge(owner: &Path, bytes: u64, guard: &File) -> Option<u64> {
458 let current = plain(owner).ok()?;
459 let held = guard.metadata().ok()?;
460 #[cfg(unix)]
461 {
462 use std::os::unix::fs::MetadataExt;
463 (current.is_file()
464 && current.len() == 0
465 && current.dev() == held.dev()
466 && current.ino() == held.ino())
467 .then_some(bytes)
468 }
469 #[cfg(not(unix))]
470 {
471 let _ = (current, held, bytes);
472 None
473 }
474}
475
476fn lock_for_accounting(file: &File) -> std::io::Result<()> {
477 #[cfg(unix)]
478 {
479 use std::os::fd::AsRawFd;
480 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0 {
482 Ok(())
483 } else {
484 Err(std::io::Error::last_os_error())
485 }
486 }
487 #[cfg(not(unix))]
488 {
489 crate::pr_head::try_lock_file(file)
490 }
491}
492
493fn completed_charge(worktree: &Path, owner: &Path) -> Result<(u64, FileLease)> {
496 ensure!(
497 plain(owner)?.is_file() && fs::metadata(owner)?.len() == 0,
498 "invalid incremental owner"
499 );
500 let record = owner.with_extension("json");
501 ensure!(
504 plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
505 "invalid incremental proof"
506 );
507 let lock = FileLease(OpenOptions::new().read(true).write(true).open(owner)?);
508 lock_for_accounting(&lock)?;
509 let proof: Proof = serde_json::from_slice(&fs::read(record)?)?;
510 validate_proof(worktree, &owner.with_extension("json"), &proof)?;
511 let actual = inventory(&proof.generation, false)?;
512 ensure!(
513 directory_inventory(&proof.generation)?.is_subset(&proof.directories)
514 && actual
515 .iter()
516 .all(|(p, identity)| proof.files.get(p) == Some(identity)),
517 "incremental proof changed"
518 );
519 let bytes = actual
520 .values()
521 .fold(0u64, |bytes, identity| bytes.saturating_add(identity.bytes));
522 Ok((bytes, lock))
523}
524
525fn inventory(root: &Path, recognize: bool) -> Result<BTreeMap<PathBuf, Identity>> {
526 let mut files = BTreeMap::new();
527 let mut bytes = 0u64;
528 let mut dirs = vec![root.to_owned()];
529 while let Some(dir) = dirs.pop() {
530 ensure!(plain(&dir)?.is_dir(), "invalid incremental directory");
531 for entry in fs::read_dir(&dir)? {
532 let path = entry?.path();
533 let metadata = plain(&path)?;
534 if metadata.is_dir() {
535 dirs.push(path);
536 } else {
537 ensure!(
538 metadata.is_file(),
539 "incremental entry is not a regular file"
540 );
541 bytes = bytes.saturating_add(metadata.len());
542 ensure!(
543 bytes <= GENERATION_BYTES,
544 "incremental generation exceeds its budget"
545 );
546 ensure!(files.len() < 10000, "incremental inventory too large");
547 if recognize {
548 ensure!(
549 recognized(&path)?,
550 "unknown incremental output: {}",
551 path.display()
552 );
553 }
554 files.insert(path.strip_prefix(root)?.to_owned(), identity(&path)?);
555 }
556 }
557 }
558 Ok(files)
559}
560
561fn directory_inventory(root: &Path) -> Result<std::collections::BTreeSet<PathBuf>> {
562 let mut found = std::collections::BTreeSet::new();
563 let mut dirs = vec![root.to_owned()];
564 while let Some(dir) = dirs.pop() {
565 ensure!(plain(&dir)?.is_dir(), "invalid incremental directory");
566 for entry in fs::read_dir(dir)? {
567 let path = entry?.path();
568 if plain(&path)?.is_dir() {
569 ensure!(
570 found.len() < 10000,
571 "incremental directory inventory too large"
572 );
573 found.insert(path.strip_prefix(root)?.to_owned());
574 dirs.push(path);
575 }
576 }
577 }
578 Ok(found)
579}
580
581fn recognized(path: &Path) -> Result<bool> {
582 let name = path.file_name().unwrap_or_default().to_string_lossy();
583 if name.ends_with(".lock") {
584 return Ok(fs::metadata(path)?.len() == 0);
585 }
586 let mut header = [0; 8];
587 let n = File::open(path)?.read(&mut header)?;
588 if matches!(
589 name.as_ref(),
590 "dep-graph.bin" | "query-cache.bin" | "work-products.bin"
591 ) {
592 return Ok(header[..n].starts_with(b"RSIC"));
593 }
594 if path.extension().is_some_and(|e| e == "o") || name == "metadata.rmeta" {
595 return crate::retention::artifact_signature(path);
596 }
597 Ok(false)
598}
599
600#[cfg(unix)]
605fn lock_session(file: &File) -> std::io::Result<()> {
606 use std::os::fd::AsRawFd;
607 let mut lock: libc::flock = unsafe { std::mem::zeroed() };
610 lock.l_type = libc::F_WRLCK as _;
611 lock.l_whence = libc::SEEK_SET as _;
612 lock.l_start = 0;
613 lock.l_len = 0;
614 if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETLK, &mut lock) } == -1 {
615 return Err(std::io::Error::last_os_error());
616 }
617 let unlocked: libc::c_short = libc::F_UNLCK as _;
618 if lock.l_type != unlocked {
619 return Err(std::io::Error::new(
620 std::io::ErrorKind::WouldBlock,
621 "incremental session has a POSIX record lock",
622 ));
623 }
624 crate::pr_head::try_lock_file(file)
625}
626
627#[cfg(all(unix, test))]
628fn lock_posix_session(file: &File) -> std::io::Result<()> {
629 use std::os::fd::AsRawFd;
630 let mut lock: libc::flock = unsafe { std::mem::zeroed() };
633 lock.l_type = libc::F_WRLCK as _;
634 lock.l_whence = libc::SEEK_SET as _;
635 lock.l_start = 0;
636 lock.l_len = 0;
637 if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock) } == -1 {
638 return Err(std::io::Error::last_os_error());
639 }
640 Ok(())
641}
642
643#[cfg(not(unix))]
644fn lock_session(file: &File) -> std::io::Result<()> {
645 crate::pr_head::try_lock_file(file)
646}
647
648pub fn wrapper(args: Vec<OsString>) -> Result<i32> {
651 tokio::runtime::Builder::new_current_thread()
652 .enable_all()
653 .build()?
654 .block_on(wrapper_inner(args))
655}
656
657async fn wrapper_inner(args: Vec<OsString>) -> Result<i32> {
658 let worktree = PathBuf::from(
659 std::env::var_os("MAGI_INCREMENTAL_WORKTREE").context("missing incremental worktree")?,
660 );
661 let mut args = args;
662 let mut generation = None;
663 for index in 0..args.len() {
664 let text = args[index].to_string_lossy();
665 let value = if index > 0 && args[index - 1] == "-C" {
666 text.strip_prefix("incremental=").map(str::to_owned)
667 } else {
668 text.strip_prefix("-Cincremental=").map(str::to_owned)
669 };
670 if let Some(value) = value {
671 ensure!(
672 generation.is_none(),
673 "multiple incremental compiler paths are unsupported"
674 );
675 let parent = PathBuf::from(value);
676 let parent = if parent.is_absolute() {
677 parent
678 } else {
679 std::env::current_dir()?.join(parent)
680 };
681 let made = Generation::create(&worktree, &parent)?;
684 let prefix = if text.starts_with("-C") {
685 "-Cincremental="
686 } else {
687 "incremental="
688 };
689 args[index] = format!("{prefix}{}", made.path.display()).into();
690 generation = Some(made);
691 }
692 }
693 let command = if let Some(previous) =
694 std::env::var_os("MAGI_PREVIOUS_RUSTC_WRAPPER").filter(|s| !s.is_empty())
695 {
696 let mut command = std::process::Command::new(previous);
697 command.args(&args);
698 command
699 } else {
700 let mut command = std::process::Command::new(args.first().context("missing compiler")?);
701 command.args(&args[1..]);
702 command
703 };
704 let mut running =
705 crate::seat_process::Running::spawn_operation(command.into(), None, Vec::new())?;
706 let result: Result<_> = async {
707 loop {
708 if let Some(status) = running.child().try_wait()? {
709 break Ok(status);
710 }
711 if let Some(generation) = &generation
712 && crate::disk::dir_size(&generation.path) > GENERATION_BYTES
713 {
714 anyhow::bail!(
715 "managed incremental compiler exceeded 128 MiB; retained unproven output"
716 );
717 }
718 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
719 }
720 }
721 .await;
722 running.finish().await;
725 let status = result?;
726 if status.success()
727 && let Some(generation) = generation
728 {
729 generation.seal(&worktree)?;
730 }
731 Ok(status.code().unwrap_or(1))
732}
733
734#[derive(Default)]
737pub(crate) struct Reclaim {
738 pub(crate) files: Vec<PathBuf>,
739 _locks: Vec<FileLease>,
740 finished: Vec<(PathBuf, PathBuf, std::collections::BTreeSet<PathBuf>)>,
741}
742
743impl Reclaim {
744 pub(crate) fn finish(&self) -> Result<()> {
745 for (record, generation, allowed_dirs) in &self.finished {
746 if generation.exists() {
747 ensure!(
748 inventory(generation, false)?.is_empty(),
749 "incremental generation still contains data"
750 );
751 ensure!(
752 directory_inventory(generation)?.is_subset(allowed_dirs),
753 "unknown incremental directory appeared before removal"
754 );
755 let mut dirs = vec![generation.clone()];
756 let mut index = 0;
757 while index < dirs.len() {
758 for entry in fs::read_dir(&dirs[index])? {
759 let path = entry?.path();
760 ensure!(plain(&path)?.is_dir(), "incremental generation changed");
761 dirs.push(path);
762 }
763 index += 1;
764 }
765 for dir in dirs.into_iter().rev() {
766 fs::remove_dir(dir)?;
767 }
768 }
769 let owner = record.with_extension("owner");
770 ensure!(
771 plain(&owner)?.len() == 0,
772 "incremental owner changed before removal"
773 );
774 fs::remove_file(record)?;
775 fs::remove_file(owner)?;
776 }
777 Ok(())
778 }
779}
780
781fn validate_proof(worktree: &Path, record: &Path, proof: &Proof) -> Result<()> {
782 ensure!(
783 proof.version == 1 && proof.worktree == worktree,
784 "incremental ownership changed"
785 );
786 let parent = proof
787 .generation
788 .parent()
789 .context("missing generation parent")?;
790 ensure!(
791 [
792 worktree.join("target/debug/incremental"),
793 worktree.join("target/release/incremental")
794 ]
795 .contains(&parent.to_owned()),
796 "incremental generation outside managed target"
797 );
798 ensure!(
799 proof
800 .generation
801 .file_name()
802 .is_some_and(|n| n.to_string_lossy()
803 == format!("magi-{}", record.file_stem().unwrap().to_string_lossy())),
804 "incremental generation identity changed"
805 );
806 Ok(())
807}
808
809pub(crate) fn candidates(worktree: &Path) -> Result<Reclaim> {
810 let store = git_dir(worktree)?.join(STORE);
811 if !store.exists() {
812 return Ok(Reclaim::default());
813 }
814 ensure!(
815 plain(&store)?.is_dir(),
816 "invalid incremental provenance store"
817 );
818 let mut outputs = Vec::new();
819 let mut locks = Vec::new();
820 let mut finished = Vec::new();
821 for entry in fs::read_dir(store)? {
822 let record = entry?.path();
823 if record.extension().is_none_or(|x| x != "json") {
824 continue;
825 }
826 ensure!(
827 plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
828 "invalid incremental provenance"
829 );
830 let proof: Proof = serde_json::from_slice(&fs::read(&record)?)?;
831 validate_proof(worktree, &record, &proof)?;
832 let lock_path = record.with_extension("owner");
833 let owner_metadata = plain(&lock_path)?;
834 ensure!(
835 owner_metadata.is_file() && owner_metadata.len() == 0,
836 "incremental owner is missing or changed"
837 );
838 let owner = FileLease(OpenOptions::new().read(true).write(true).open(lock_path)?);
839 crate::pr_head::try_lock_file(&owner).context("incremental compiler is live")?;
840 locks.push(owner);
841 finished.push((
842 record.clone(),
843 proof.generation.clone(),
844 proof.directories.clone(),
845 ));
846 if !proof.generation.exists() {
847 continue;
848 }
849 let actual = inventory(&proof.generation, false)?;
850 ensure!(
851 directory_inventory(&proof.generation)?.is_subset(&proof.directories),
852 "unknown incremental directory"
853 );
854 ensure!(
857 actual.iter().all(|(p, id)| proof.files.get(p) == Some(id)),
858 "incremental provenance mismatch or unknown data"
859 );
860 for relative in actual.keys() {
861 let path = proof.generation.join(relative);
862 if path.extension().is_some_and(|x| x == "lock") {
863 let file = OpenOptions::new().read(true).write(true).open(&path)?;
864 lock_session(&file).context("incremental session is live")?;
865 locks.push(FileLease(file));
866 }
867 outputs.push(path);
868 }
869 }
870 Ok(Reclaim {
871 files: outputs,
872 _locks: locks,
873 finished,
874 })
875}
876
877#[cfg(all(test, unix))]
878pub(crate) mod tests {
879 use super::*;
880
881 fn cargo_generation() -> (tempfile::TempDir, PathBuf, Generation) {
884 let temp = tempfile::tempdir().unwrap();
885 let worktree = temp.path().canonicalize().unwrap();
886 let git = std::process::Command::new("git")
887 .arg("init")
888 .arg(&worktree)
889 .output()
890 .unwrap();
891 assert!(git.status.success());
892 let generation = generate(&worktree);
893 (temp, worktree, generation)
894 }
895
896 pub(crate) fn generate(worktree: &Path) -> Generation {
897 fs::create_dir(worktree.join("src")).unwrap();
898 fs::write(
899 worktree.join("Cargo.toml"),
900 "[package]\nname='cache-fixture'\nversion='0.1.0'\nedition='2021'\n[workspace]\n",
901 )
902 .unwrap();
903 fs::write(
904 worktree.join("src/lib.rs"),
905 "pub fn answer() -> usize { (0..100).sum() }\n",
906 )
907 .unwrap();
908 let parent = worktree.join("target/debug/incremental");
909 fs::create_dir_all(&parent).unwrap();
910 let generation = Generation::create(worktree, &parent).unwrap();
911 let mut command = std::process::Command::new("cargo");
912 command.env_clear();
913 for key in ["PATH", "HOME", "RUSTUP_HOME"] {
914 if let Some(value) = std::env::var_os(key) {
915 command.env(key, value);
916 }
917 }
918 let output = command
919 .current_dir(worktree)
920 .env("RUSTUP_TOOLCHAIN", "stable")
921 .env("CARGO_HOME", worktree.join("cargo-home"))
922 .env("CARGO_INCREMENTAL", "0")
923 .env(
924 "RUSTFLAGS",
925 format!("-C incremental={}", generation.path.display()),
926 )
927 .args(["build", "--offline", "--jobs", "1", "--target-dir"])
928 .arg(worktree.join("target"))
929 .output()
930 .unwrap();
931 assert!(
932 output.status.success(),
933 "{}",
934 String::from_utf8_lossy(&output.stderr)
935 );
936 assert!(crate::disk::dir_size(&generation.path) > 0);
937 generation.seal(worktree).unwrap();
938 generation
939 }
940
941 #[test]
942 fn admission_contention_waits_and_timeout_is_distinct_from_budget_exhaustion() {
943 use std::time::Duration;
944 let temp = tempfile::tempdir().unwrap();
945 let path = temp.path().join("admission.lock");
946 let held = FileLease(
947 OpenOptions::new()
948 .create_new(true)
949 .read(true)
950 .write(true)
951 .open(&path)
952 .unwrap(),
953 );
954 crate::pr_head::try_lock_file(&held).unwrap();
955 std::thread::scope(|scope| {
956 let (ready_tx, ready_rx) = std::sync::mpsc::channel();
957 let (done_tx, done_rx) = std::sync::mpsc::channel();
958 let path = path.clone();
959 scope.spawn(move || {
960 let waiting = FileLease(
961 OpenOptions::new()
962 .read(true)
963 .write(true)
964 .open(&path)
965 .unwrap(),
966 );
967 ready_tx.send(()).unwrap();
968 let result = wait_for_admission(&waiting, Duration::from_secs(5));
969 done_tx.send(result).unwrap();
970 });
971 ready_rx.recv_timeout(Duration::from_secs(1)).unwrap();
972 assert!(done_rx.recv_timeout(Duration::from_millis(40)).is_err());
973 drop(held);
974 done_rx
975 .recv_timeout(Duration::from_secs(5))
976 .unwrap()
977 .unwrap();
978 });
979 let held = FileLease(
980 OpenOptions::new()
981 .read(true)
982 .write(true)
983 .open(&path)
984 .unwrap(),
985 );
986 crate::pr_head::try_lock_file(&held).unwrap();
987 let waiting = OpenOptions::new()
988 .read(true)
989 .write(true)
990 .open(&path)
991 .unwrap();
992 let error = wait_for_admission(&waiting, Duration::from_millis(20))
993 .unwrap_err()
994 .to_string();
995 assert!(error.contains("wait timed out"));
996 assert!(!error.contains("budget exhausted"));
997 }
998
999 #[test]
1000 fn completed_snapshots_share_owner_locks_without_blocking_parallel_admission() {
1001 let (_temp, worktree, generation) = cargo_generation();
1002 let owner = generation.record.with_extension("owner");
1003 drop(generation);
1004 let first = completed_charge(&worktree, &owner).unwrap();
1005 let second = completed_charge(&worktree, &owner).unwrap();
1006 assert!(first.0 > 0);
1007 assert_eq!(first.0, second.0);
1008 let exclusive = FileLease(
1009 OpenOptions::new()
1010 .read(true)
1011 .write(true)
1012 .open(&owner)
1013 .unwrap(),
1014 );
1015 assert!(crate::pr_head::try_lock_file(&exclusive).is_err());
1016 let parent = worktree.join("target/debug/incremental");
1017 let fresh = Generation::create(&worktree, &parent).unwrap();
1018 assert!(fresh.path.is_dir());
1019 drop(first);
1020 drop(second);
1021 assert!(crate::pr_head::try_lock_file(&exclusive).is_ok());
1022 }
1023
1024 #[test]
1025 fn ownership_release_is_not_delayed_by_descriptor_copies() {
1026 let (_temp, worktree, generation) = cargo_generation();
1027 let store = git_dir(&worktree).unwrap().join(STORE);
1028 let open = |path: &Path| {
1029 OpenOptions::new()
1030 .read(true)
1031 .write(true)
1032 .open(path)
1033 .unwrap()
1034 };
1035 let admission_path = store.join("admission.lock");
1036 let admission = FileLease(open(&admission_path));
1037 crate::pr_head::try_lock_file(&admission).unwrap();
1038 let inherited_admission = admission.try_clone().unwrap();
1041 let next = FileLease(open(&admission_path));
1042 assert!(crate::pr_head::try_lock_file(&next).is_err());
1043 drop(admission);
1044 crate::pr_head::try_lock_file(&next).unwrap();
1045 drop(next);
1046 drop(inherited_admission);
1047
1048 let owner = generation.record.with_extension("owner");
1049 let inherited_generation = generation._lock.try_clone().unwrap();
1050 assert!(completed_charge(&worktree, &owner).is_err());
1051 drop(generation);
1052 let first = completed_charge(&worktree, &owner).unwrap();
1053 let second = completed_charge(&worktree, &owner).unwrap();
1054 let inherited_first = first.1.try_clone().unwrap();
1055 let inherited_second = second.1.try_clone().unwrap();
1056 let exclusive = FileLease(open(&owner));
1057 drop(first);
1058 assert!(
1059 crate::pr_head::try_lock_file(&exclusive).is_err(),
1060 "another actual accounting owner still excludes reclamation"
1061 );
1062 drop(second);
1063 crate::pr_head::try_lock_file(&exclusive).unwrap();
1064 drop(exclusive);
1065 drop((inherited_generation, inherited_first, inherited_second));
1066
1067 let reclaim = candidates(&worktree).unwrap();
1068 let inherited_reclaim: Vec<_> = reclaim
1069 ._locks
1070 .iter()
1071 .map(|lock| lock.try_clone().unwrap())
1072 .collect();
1073 assert!(candidates(&worktree).is_err());
1074 drop(reclaim);
1075 let retry = candidates(&worktree).unwrap();
1076 assert!(!retry.files.is_empty());
1077 drop(retry);
1078 drop(inherited_reclaim);
1079 }
1080
1081 #[test]
1082 fn default_disables_incremental_and_explicit_environment_is_preserved() {
1083 let temp = tempfile::tempdir().unwrap();
1084 let mut command = tokio::process::Command::new("cargo");
1085 command.env_remove("CARGO_INCREMENTAL");
1086 configure(&mut command, temp.path()).unwrap();
1087 let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1088 assert_eq!(
1089 env[std::ffi::OsStr::new("CARGO_INCREMENTAL")],
1090 Some(std::ffi::OsStr::new("0"))
1091 );
1092 command.env("CARGO_INCREMENTAL", "1");
1093 configure(&mut command, temp.path()).unwrap();
1094 let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1095 assert_eq!(
1096 env[std::ffi::OsStr::new("CARGO_INCREMENTAL")],
1097 Some(std::ffi::OsStr::new("1"))
1098 );
1099 }
1100
1101 #[test]
1102 fn concurrent_configuration_publishes_complete_wrappers() {
1103 let temp = tempfile::tempdir().unwrap();
1104 let root = temp.path().canonicalize().unwrap();
1105 let worktree = root.join("worktree");
1106 assert!(
1107 std::process::Command::new("git")
1108 .args(["init", "--separate-git-dir"])
1109 .arg(root.join("metadata"))
1110 .arg(&worktree)
1111 .output()
1112 .unwrap()
1113 .status
1114 .success()
1115 );
1116 std::thread::scope(|scope| {
1117 let handles: Vec<_> = (0..8)
1118 .map(|_| {
1119 scope.spawn(|| {
1120 let mut command = tokio::process::Command::new("cargo");
1121 configure(&mut command, &worktree).unwrap();
1122 let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1123 let wrapper =
1124 PathBuf::from(env[std::ffi::OsStr::new("RUSTC_WRAPPER")].unwrap());
1125 assert!(fs::read_to_string(wrapper).unwrap().ends_with("\"$@\"\n"));
1126 })
1127 })
1128 .collect();
1129 for handle in handles {
1130 handle.join().unwrap();
1131 }
1132 });
1133 }
1134
1135 #[test]
1136 fn sealing_refuses_unfinished_sessions_and_unknown_directories() {
1137 let (_temp, worktree, generation) = cargo_generation();
1138 let unknown = generation.path.join("operator-empty");
1139 fs::create_dir(&unknown).unwrap();
1140 assert!(generation.seal(&worktree).is_err());
1141 fs::remove_dir(unknown).unwrap();
1142 let crate_dir = fs::read_dir(&generation.path)
1143 .unwrap()
1144 .next()
1145 .unwrap()
1146 .unwrap()
1147 .path();
1148 let working = crate_dir.join("s-unfinished-cache-working");
1149 fs::create_dir(&working).unwrap();
1150 assert!(generation.seal(&worktree).is_err());
1151 assert!(working.exists());
1152 drop(generation);
1153 assert!(candidates(&worktree).is_err());
1154 }
1155
1156 #[test]
1157 fn unknown_empty_directories_added_after_inventory_are_retained() {
1158 let (_temp, worktree, generation) = cargo_generation();
1159 let path = generation.path.clone();
1160 drop(generation);
1161 let reclaim = candidates(&worktree).unwrap();
1162 for file in &reclaim.files {
1163 fs::remove_file(file).unwrap();
1164 }
1165 let unknown = path.join("operator-empty");
1166 fs::create_dir(&unknown).unwrap();
1167 assert!(reclaim.finish().is_err());
1168 assert!(unknown.is_dir());
1169 fs::remove_dir(unknown).unwrap();
1170 reclaim.finish().unwrap();
1171 assert!(!path.exists());
1172 }
1173
1174 #[test]
1175 fn fcntl_session_lock_is_respected_across_processes() {
1176 use std::io::{Read, Write};
1177 use std::process::{Command, Stdio};
1178 if let Some(path) = std::env::var_os("MAGI_TEST_SESSION_FILE") {
1179 let file = OpenOptions::new()
1180 .read(true)
1181 .write(true)
1182 .open(path)
1183 .unwrap();
1184 lock_posix_session(&file).unwrap();
1187 fs::write(
1188 std::env::var_os("MAGI_TEST_SESSION_READY").unwrap(),
1189 "ready",
1190 )
1191 .unwrap();
1192 std::io::stdin().read_exact(&mut [0]).unwrap();
1193 return;
1194 }
1195 let (_temp, worktree, generation) = cargo_generation();
1196 drop(generation);
1197 let candidates = candidates(&worktree).unwrap();
1198 let session = candidates
1199 .files
1200 .iter()
1201 .find(|p| p.extension().is_some_and(|x| x == "lock"))
1202 .unwrap()
1203 .clone();
1204 drop(candidates);
1205 let ready = worktree.join("session-ready");
1206 let mut child = Command::new(std::env::current_exe().unwrap())
1207 .args([
1208 "--exact",
1209 "incremental::tests::fcntl_session_lock_is_respected_across_processes",
1210 ])
1211 .env("MAGI_TEST_SESSION_FILE", &session)
1212 .env("MAGI_TEST_SESSION_READY", &ready)
1213 .stdin(Stdio::piped())
1214 .stdout(Stdio::null())
1215 .spawn()
1216 .unwrap();
1217 let started = std::time::Instant::now();
1218 while !ready.exists() && started.elapsed() < std::time::Duration::from_secs(10) {
1219 if child.try_wait().unwrap().is_some() {
1220 break;
1221 }
1222 std::thread::sleep(std::time::Duration::from_millis(10));
1223 }
1224 let observed = ready.exists();
1225 let blocked = observed
1226 && super::candidates(&worktree)
1227 .err()
1228 .is_some_and(|e| e.to_string().contains("session is live"));
1229 if observed {
1230 child.stdin.take().unwrap().write_all(b"x").unwrap();
1231 } else {
1232 child.kill().ok();
1233 }
1234 let status = child.wait().unwrap();
1235 assert!(
1236 observed && status.success(),
1237 "session helper did not acquire its lock"
1238 );
1239 assert!(blocked, "POSIX session lock must exclude reclamation");
1240 assert!(!super::candidates(&worktree).unwrap().files.is_empty());
1241 assert!(session.exists());
1242 }
1243
1244 #[test]
1245 fn real_cargo_generations_require_exact_provenance_and_live_owner_exclusion() {
1246 let (_temp, worktree, generation) = cargo_generation();
1247 assert!(
1248 candidates(&worktree).is_err(),
1249 "compiler owner still holds the generation"
1250 );
1251 let path = generation.path.clone();
1252 drop(generation);
1253 let reclaim = candidates(&worktree).unwrap();
1254 let session = reclaim
1255 .files
1256 .iter()
1257 .find(|p| p.extension().is_some_and(|x| x == "lock"))
1258 .unwrap()
1259 .clone();
1260 drop(reclaim);
1261 let live_session = FileLease(
1262 OpenOptions::new()
1263 .read(true)
1264 .write(true)
1265 .open(session)
1266 .unwrap(),
1267 );
1268 crate::pr_head::try_lock_file(&live_session).unwrap();
1269 assert!(
1270 candidates(&worktree)
1271 .err()
1272 .unwrap()
1273 .to_string()
1274 .contains("session is live")
1275 );
1276 drop(live_session);
1277 let unknown = path.join("operator-note");
1278 fs::write(&unknown, "keep unknown data").unwrap();
1279 assert!(candidates(&worktree).is_err());
1280 assert_eq!(fs::read_to_string(&unknown).unwrap(), "keep unknown data");
1281 fs::remove_file(&unknown).unwrap();
1282 let unknown_dir = path.join("operator-empty");
1283 fs::create_dir(&unknown_dir).unwrap();
1284 assert!(candidates(&worktree).is_err());
1285 fs::remove_dir(unknown_dir).unwrap();
1286 let reclaim = candidates(&worktree).unwrap();
1287 assert!(!reclaim.files.is_empty());
1288 assert!(
1289 candidates(&worktree).is_err(),
1290 "maintenance retains session/owner locks"
1291 );
1292 let removed = reclaim.files[0].clone();
1294 fs::remove_file(removed).unwrap();
1295 drop(reclaim);
1296 let reclaim = candidates(&worktree).unwrap();
1297 let changed = reclaim
1298 .files
1299 .iter()
1300 .find(|p| p.extension().is_some_and(|e| e == "bin"))
1301 .unwrap()
1302 .clone();
1303 let original = fs::read(&changed).unwrap();
1304 fs::write(&changed, b"modified proof input").unwrap();
1305 drop(reclaim);
1306 assert!(candidates(&worktree).is_err());
1307 fs::write(&changed, original).unwrap();
1308 let reclaim = candidates(&worktree).unwrap();
1309 for file in &reclaim.files {
1310 fs::remove_file(file).unwrap();
1311 }
1312 reclaim.finish().unwrap();
1313 assert!(!path.exists());
1314 assert!(candidates(&worktree).unwrap().files.is_empty());
1315 assert!(worktree.join("src/lib.rs").is_file());
1316 }
1317
1318 #[test]
1319 fn externally_removed_sealed_generation_does_not_strand_admission() {
1320 let (_temp, worktree, generation) = cargo_generation();
1321 let path = generation.path.clone();
1322 drop(generation);
1323 let reclaim = candidates(&worktree).unwrap();
1324 for file in &reclaim.files {
1325 fs::remove_file(file).unwrap();
1326 }
1327 let mut dirs = directory_inventory(&path)
1330 .unwrap()
1331 .into_iter()
1332 .collect::<Vec<_>>();
1333 dirs.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
1334 for relative in dirs {
1335 fs::remove_dir(path.join(relative)).unwrap();
1336 }
1337 fs::remove_dir(&path).unwrap();
1338 drop(reclaim);
1339 let parent = worktree.join("target/debug/incremental");
1340 let fresh = Generation::create(&worktree, &parent).unwrap();
1341 assert_eq!(
1342 fs::read_dir(git_dir(&worktree).unwrap().join(STORE))
1343 .unwrap()
1344 .filter_map(|e| e.ok())
1345 .filter(|e| e.path().extension().is_some_and(|x| x == "owner"))
1346 .count(),
1347 1
1348 );
1349 assert!(fresh.path.is_dir());
1350 }
1351
1352 #[test]
1353 fn unsealed_attempts_consume_the_bounded_generation_budget() {
1354 let temp = tempfile::tempdir().unwrap();
1355 let worktree = temp.path().canonicalize().unwrap();
1356 assert!(
1357 std::process::Command::new("git")
1358 .arg("init")
1359 .arg(&worktree)
1360 .output()
1361 .unwrap()
1362 .status
1363 .success()
1364 );
1365 let parent = worktree.join("target/debug/incremental");
1366 fs::create_dir_all(&parent).unwrap();
1367 for _ in 0..SLOTS {
1368 let generation = Generation::create(&worktree, &parent).unwrap();
1369 fs::write(generation.path.join("unknown"), "preserve").unwrap();
1370 }
1371 assert!(Generation::create(&worktree, &parent).is_err());
1372 assert!(
1373 candidates(&worktree).unwrap().files.is_empty(),
1374 "unsealed output never grants deletion authority"
1375 );
1376 }
1377
1378 #[cfg(unix)]
1379 #[test]
1380 fn generation_symlinks_and_replaced_inodes_refuse_reclamation() {
1381 let (_temp, worktree, generation) = cargo_generation();
1382 let path = generation.path.clone();
1383 drop(generation);
1384 let files = candidates(&worktree).unwrap().files;
1385 let file = files
1386 .iter()
1387 .find(|p| p.extension().is_some_and(|e| e == "bin"))
1388 .unwrap();
1389 let saved = worktree.join("retained-input");
1390 fs::rename(file, &saved).unwrap();
1391 std::os::unix::fs::symlink(&saved, file).unwrap();
1392 assert!(candidates(&worktree).is_err());
1393 fs::remove_file(file).unwrap();
1394 fs::copy(&saved, file).unwrap();
1395 assert!(
1396 candidates(&worktree).is_err(),
1397 "same bytes at a replaced inode are insufficient"
1398 );
1399 assert!(path.exists());
1400 assert!(saved.is_file());
1401 }
1402}