Skip to main content

magi/
incremental.rs

1//! Disposable incremental generations with creation-time, per-file provenance.
2//!
3//! Legacy incremental directories are never adopted. A generation is fresh for
4//! one compiler invocation; incomplete or changed generations consume their slot
5//! until an operator investigates. This is a bound, not authority to wipe them.
6use 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
15/// Stable resource-admission marker emitted by the private compiler wrapper.
16pub 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/// Forked subprocesses can retain a copy until exec closes it. Explicitly
23/// release our flock at the ownership boundary rather than waiting for every
24/// inherited descriptor to close. Session acquisition queries POSIX locks and
25/// then holds flock, so all these guards use the same release protocol.
26#[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        // SAFETY: the guard retains its live descriptor through unlock.
41        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
61/// Configure a managed command, preserving explicit environment overrides and
62/// Cargo's workspace wrapper chain. The wrapper sees shell-level opt-ins too.
63pub(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        let git = git_dir(worktree)?;
82        let executable = std::env::current_exe()?;
83        // A stable script name per executable also handles worker generations.
84        let hash: String = Sha256::digest(executable.as_os_str().as_encoded_bytes())
85            .iter()
86            .map(|b| format!("{b:02x}"))
87            .collect();
88        let name = format!("magi-rustc-{hash}.sh");
89        let script = git.join(name);
90        let quote = |p: &Path| format!("'{}'", p.to_string_lossy().replace('\'', "'\\''"));
91        let body = format!(
92            "#!/bin/sh\nexec {} __magi_incremental \"$@\"\n",
93            quote(&executable)
94        );
95        publish_wrapper(&script, &body, || {
96            use std::sync::atomic::{AtomicU64, Ordering};
97            static NEXT: AtomicU64 = AtomicU64::new(0);
98            git.join(format!(
99                "magi-rustc-{}-{}-{}.tmp",
100                std::process::id(),
101                NEXT.fetch_add(1, Ordering::Relaxed),
102                crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4()
103            ))
104        })?;
105        let previous = value("RUSTC_WRAPPER");
106        let previous = if previous.is_some() && previous == value("MAGI_INCREMENTAL_WRAPPER") {
107            value("MAGI_PREVIOUS_RUSTC_WRAPPER")
108        } else {
109            previous
110        };
111        command.env_remove("MAGI_PREVIOUS_RUSTC_WRAPPER");
112        if let Some(previous) = previous.filter(|v| !v.is_empty()) {
113            // Re-entering a managed command must not wrap our own wrapper.
114            if previous != script.as_os_str() {
115                command.env("MAGI_PREVIOUS_RUSTC_WRAPPER", previous);
116            }
117        }
118        command
119            .env("MAGI_INCREMENTAL_WORKTREE", worktree.canonicalize()?)
120            .env("MAGI_INCREMENTAL_WRAPPER", &script)
121            .env("RUSTC_WRAPPER", script);
122    }
123    Ok(())
124}
125
126/// Only a successfully reserved path belongs to this invocation. In particular,
127/// a failed create_new must never remove another publisher's temporary file.
128#[cfg(unix)]
129struct WrapperTemporary {
130    path: PathBuf,
131    file: File,
132}
133
134#[cfg(unix)]
135impl WrapperTemporary {
136    fn create(mut candidate: impl FnMut() -> PathBuf) -> std::io::Result<Self> {
137        for _ in 0..128 {
138            let path = candidate();
139            match OpenOptions::new().write(true).create_new(true).open(&path) {
140                Ok(file) => return Ok(Self { path, file }),
141                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
142                Err(error) => return Err(error),
143            }
144        }
145        Err(std::io::Error::new(
146            std::io::ErrorKind::AlreadyExists,
147            "could not reserve an incremental wrapper temporary file",
148        ))
149    }
150}
151
152#[cfg(unix)]
153impl Drop for WrapperTemporary {
154    fn drop(&mut self) {
155        let _ = fs::remove_file(&self.path);
156    }
157}
158
159#[cfg(unix)]
160fn publish_wrapper(script: &Path, body: &str, candidate: impl FnMut() -> PathBuf) -> Result<()> {
161    use std::io::Write;
162    use std::os::unix::fs::PermissionsExt;
163    let mut temporary = WrapperTemporary::create(candidate)?;
164    temporary.file.write_all(body.as_bytes())?;
165    temporary
166        .file
167        .set_permissions(fs::Permissions::from_mode(0o700))?;
168    // Link only a complete script, without replacing an existing path.
169    // Concurrent command preparation must never observe a partial body.
170    match fs::hard_link(&temporary.path, script) {
171        Ok(()) => Ok(()),
172        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
173            ensure!(
174                plain(script)?.is_file() && fs::read(script)? == body.as_bytes(),
175                "incremental wrapper changed"
176            );
177            Ok(())
178        }
179        Err(error) => Err(error.into()),
180    }
181}
182
183fn git_dir(worktree: &Path) -> Result<PathBuf> {
184    let output = std::process::Command::new("git")
185        .current_dir(worktree)
186        .args(["rev-parse", "--absolute-git-dir"])
187        .output()?;
188    ensure!(
189        output.status.success(),
190        "incremental provenance requires a Git worktree"
191    );
192    Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim()))
193}
194
195fn plain(path: &Path) -> Result<fs::Metadata> {
196    let metadata = fs::symlink_metadata(path)?;
197    ensure!(
198        !metadata.file_type().is_symlink() && path.canonicalize()? == path,
199        "incremental path is not canonical: {}",
200        path.display()
201    );
202    Ok(metadata)
203}
204
205#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207struct Identity {
208    bytes: u64,
209    digest: Vec<u8>,
210    #[cfg(unix)]
211    device: u64,
212    #[cfg(unix)]
213    inode: u64,
214}
215
216fn identity(path: &Path) -> Result<Identity> {
217    let metadata = plain(path)?;
218    ensure!(
219        metadata.is_file(),
220        "incremental entry is not a regular file"
221    );
222    let mut file = File::open(path)?;
223    let mut hash = Sha256::new();
224    let mut buffer = [0; 65536];
225    loop {
226        let n = file.read(&mut buffer)?;
227        if n == 0 {
228            break;
229        }
230        hash.update(&buffer[..n]);
231    }
232    #[cfg(unix)]
233    use std::os::unix::fs::MetadataExt;
234    Ok(Identity {
235        bytes: metadata.len(),
236        digest: hash.finalize().to_vec(),
237        #[cfg(unix)]
238        device: metadata.dev(),
239        #[cfg(unix)]
240        inode: metadata.ino(),
241    })
242}
243
244#[derive(Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246struct Proof {
247    version: u32,
248    worktree: PathBuf,
249    generation: PathBuf,
250    files: BTreeMap<PathBuf, Identity>,
251    directories: std::collections::BTreeSet<PathBuf>,
252}
253
254pub(crate) struct Generation {
255    path: PathBuf,
256    record: PathBuf,
257    // The session lock is outside the cache, and lives through compiler exit.
258    _lock: FileLease,
259}
260
261impl Generation {
262    pub(crate) fn create(worktree: &Path, parent: &Path) -> Result<Self> {
263        let target = worktree.join("target");
264        ensure!(
265            [
266                target.join("debug/incremental"),
267                target.join("release/incremental")
268            ]
269            .contains(&parent.to_owned()),
270            "explicit incremental requires a local managed target; shared/custom targets remain non-incremental"
271        );
272        // Cargo creates these parents before invoking rustc. Never follow an alias.
273        ensure!(
274            plain(&target)?.is_dir() && plain(parent)?.is_dir(),
275            "incremental parent is not a directory"
276        );
277        let store = git_dir(worktree)?.join(STORE);
278        fs::create_dir_all(&store)?;
279        ensure!(
280            plain(&store)?.is_dir(),
281            "invalid incremental provenance store"
282        );
283        // Hash immutable completed generations before taking the namespace
284        // lock. Shared owner guards keep these snapshots stable while other
285        // compiler admissions independently inspect the same completions.
286        let mut completed = BTreeMap::new();
287        let mut inspected = 0usize;
288        for entry in fs::read_dir(&store)? {
289            let owner = entry?.path();
290            if owner.extension().is_some_and(|x| x == "owner") {
291                inspected += 1;
292                if inspected > 1024 {
293                    break;
294                }
295                if let Ok(snapshot) = completed_charge(worktree, &owner) {
296                    completed.insert(owner, snapshot);
297                }
298            }
299        }
300        let mut options = OpenOptions::new();
301        #[cfg(unix)]
302        {
303            use std::os::unix::fs::OpenOptionsExt;
304            options.custom_flags(libc::O_NOFOLLOW);
305        }
306        let admission = FileLease(
307            options
308                .create(true)
309                .truncate(false)
310                .read(true)
311                .write(true)
312                .open(store.join("admission.lock"))?,
313        );
314        wait_for_admission(&admission, std::time::Duration::from_secs(30))?;
315        // Reset/clean can remove sealed cache data without its Git-side proof.
316        // Forget only completed, absent generations, never unsealed attempts.
317        for entry in fs::read_dir(&store)? {
318            let record = entry?.path();
319            if record.extension().is_none_or(|x| x != "json") {
320                continue;
321            }
322            let cleanup = || -> Result<()> {
323                ensure!(
324                    plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
325                    "invalid incremental proof"
326                );
327                let proof: Proof = serde_json::from_slice(&fs::read(&record)?)?;
328                validate_proof(worktree, &record, &proof)?;
329                ensure!(
330                    matches!(fs::symlink_metadata(&proof.generation), Err(e) if e.kind() == std::io::ErrorKind::NotFound),
331                    "generation still exists or is uninspectable"
332                );
333                let owner = record.with_extension("owner");
334                ensure!(
335                    plain(&owner)?.is_file() && fs::metadata(&owner)?.len() == 0,
336                    "invalid incremental owner"
337                );
338                let lock = FileLease(OpenOptions::new().read(true).write(true).open(&owner)?);
339                crate::pr_head::try_lock_file(&lock)?;
340                fs::remove_file(&record)?;
341                fs::remove_file(owner)?;
342                Ok(())
343            };
344            let _ = cleanup(); // Refusal retains both proof and its reservation.
345        }
346        // Live and unproven attempts reserve a full generation. Completed,
347        // unchanged generations consume their actual bytes, not a lifetime slot.
348        let mut charged = 0u64;
349        let mut records = 0usize;
350        for entry in fs::read_dir(&store)? {
351            let owner = entry?.path();
352            if owner.extension().is_some_and(|x| x == "owner") {
353                records += 1;
354                let bytes = completed
355                    .get(&owner)
356                    .and_then(|(bytes, guard)| snapshot_charge(&owner, *bytes, guard))
357                    .unwrap_or(GENERATION_BYTES);
358                charged = charged.saturating_add(bytes);
359            }
360        }
361        ensure!(
362            records < 1024,
363            "managed incremental provenance record limit exhausted; inspect retention report"
364        );
365        ensure!(
366            charged.saturating_add(GENERATION_BYTES) <= SLOTS as u64 * GENERATION_BYTES,
367            "managed incremental budget exhausted (512 MiB including active/unproven reservations); retain unproven caches and inspect retention report"
368        );
369        let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
370        let record = store.join(format!("{token}.json"));
371        let lock = FileLease(
372            OpenOptions::new()
373                .create_new(true)
374                .read(true)
375                .write(true)
376                .open(record.with_extension("owner"))?,
377        );
378        crate::pr_head::try_lock_file(&lock)?;
379        let path = parent.join(format!("magi-{token}"));
380        fs::create_dir(&path)?;
381        Ok(Self {
382            path,
383            record,
384            _lock: lock,
385        })
386    }
387
388    pub(crate) fn seal(&self, worktree: &Path) -> Result<()> {
389        finalized_sessions(&self.path)?;
390        let files = inventory(&self.path, true)?;
391        let bytes = files
392            .values()
393            .fold(0u64, |sum, f| sum.saturating_add(f.bytes));
394        ensure!(
395            bytes <= GENERATION_BYTES,
396            "managed incremental generation exceeds its 128 MiB budget"
397        );
398        let proof = Proof {
399            version: 1,
400            worktree: worktree.to_owned(),
401            generation: self.path.clone(),
402            files,
403            directories: directory_inventory(&self.path)?,
404        };
405        let temp = self.record.with_extension("tmp");
406        fs::write(&temp, serde_json::to_vec(&proof)?)?;
407        fs::rename(temp, &self.record)?;
408        Ok(())
409    }
410}
411
412// A successful compiler may leave an abandoned working session. Creation-time
413// ownership alone does not make arbitrary directories or lock names disposable.
414fn finalized_sessions(root: &Path) -> Result<()> {
415    for entry in fs::read_dir(root)? {
416        let crate_dir = entry?.path();
417        ensure!(
418            plain(&crate_dir)?.is_dir(),
419            "unknown incremental root entry"
420        );
421        let mut sessions = Vec::new();
422        let mut locks = Vec::new();
423        for entry in fs::read_dir(&crate_dir)? {
424            let path = entry?.path();
425            let name = path
426                .file_name()
427                .context("missing incremental name")?
428                .to_str()
429                .context("invalid incremental name")?
430                .to_owned();
431            if plain(&path)?.is_dir() {
432                ensure!(
433                    name.starts_with("s-")
434                        && !name.ends_with("-working")
435                        && name.split('-').count() == 4,
436                    "incremental session is unfinished or unknown"
437                );
438                for output in fs::read_dir(&path)? {
439                    ensure!(
440                        plain(&output?.path())?.is_file(),
441                        "unknown incremental session directory"
442                    );
443                }
444                sessions.push(name);
445            } else {
446                ensure!(
447                    name.starts_with("s-")
448                        && name.ends_with(".lock")
449                        && name.split('-').count() == 3,
450                    "unknown incremental session lock"
451                );
452                locks.push(name);
453            }
454        }
455        ensure!(
456            !sessions.is_empty() && sessions.len() == locks.len(),
457            "incremental session/lock inventory is incomplete"
458        );
459        for lock in locks {
460            let prefix = format!("{}-", lock.strip_suffix(".lock").unwrap());
461            ensure!(
462                sessions.iter().any(|name| name.starts_with(&prefix)),
463                "incremental session lock has no completed session"
464            );
465        }
466    }
467    Ok(())
468}
469
470// Contention is a short serialization wait, distinct from insufficient budget.
471fn wait_for_admission(file: &File, timeout: std::time::Duration) -> Result<()> {
472    let started = std::time::Instant::now();
473    loop {
474        match crate::pr_head::try_lock_file(file) {
475            Ok(()) => return Ok(()),
476            Err(error)
477                if matches!(
478                    error.kind(),
479                    std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
480                ) => {}
481            Err(error) => return Err(error).context("incremental generation admission lock"),
482        }
483        ensure!(
484            started.elapsed() < timeout,
485            "incremental generation admission wait timed out after {} ms",
486            timeout.as_millis()
487        );
488        std::thread::sleep(
489            std::time::Duration::from_millis(10).min(timeout.saturating_sub(started.elapsed())),
490        );
491    }
492}
493
494fn snapshot_charge(owner: &Path, bytes: u64, guard: &File) -> Option<u64> {
495    let current = plain(owner).ok()?;
496    let held = guard.metadata().ok()?;
497    #[cfg(unix)]
498    {
499        use std::os::unix::fs::MetadataExt;
500        (current.is_file()
501            && current.len() == 0
502            && current.dev() == held.dev()
503            && current.ino() == held.ino())
504        .then_some(bytes)
505    }
506    #[cfg(not(unix))]
507    {
508        let _ = (current, held, bytes);
509        None
510    }
511}
512
513fn lock_for_accounting(file: &File) -> std::io::Result<()> {
514    #[cfg(unix)]
515    {
516        use std::os::fd::AsRawFd;
517        // SAFETY: this live descriptor is retained with the completed snapshot.
518        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0 {
519            Ok(())
520        } else {
521            Err(std::io::Error::last_os_error())
522        }
523    }
524    #[cfg(not(unix))]
525    {
526        crate::pr_head::try_lock_file(file)
527    }
528}
529
530// Accounting grants no deletion authority. A shared owner guard and an exact
531// completion proof may reduce the conservative full-generation reservation.
532fn completed_charge(worktree: &Path, owner: &Path) -> Result<(u64, FileLease)> {
533    ensure!(
534        plain(owner)?.is_file() && fs::metadata(owner)?.len() == 0,
535        "invalid incremental owner"
536    );
537    let record = owner.with_extension("json");
538    // An unpublished owner may still be acquiring its initial exclusive lock.
539    // Do not briefly share-lock it before a completion proof even exists.
540    ensure!(
541        plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
542        "invalid incremental proof"
543    );
544    let lock = FileLease(OpenOptions::new().read(true).write(true).open(owner)?);
545    lock_for_accounting(&lock)?;
546    let proof: Proof = serde_json::from_slice(&fs::read(record)?)?;
547    validate_proof(worktree, &owner.with_extension("json"), &proof)?;
548    let actual = inventory(&proof.generation, false)?;
549    ensure!(
550        directory_inventory(&proof.generation)?.is_subset(&proof.directories)
551            && actual
552                .iter()
553                .all(|(p, identity)| proof.files.get(p) == Some(identity)),
554        "incremental proof changed"
555    );
556    let bytes = actual
557        .values()
558        .fold(0u64, |bytes, identity| bytes.saturating_add(identity.bytes));
559    Ok((bytes, lock))
560}
561
562fn inventory(root: &Path, recognize: bool) -> Result<BTreeMap<PathBuf, Identity>> {
563    let mut files = BTreeMap::new();
564    let mut bytes = 0u64;
565    let mut dirs = vec![root.to_owned()];
566    while let Some(dir) = dirs.pop() {
567        ensure!(plain(&dir)?.is_dir(), "invalid incremental directory");
568        for entry in fs::read_dir(&dir)? {
569            let path = entry?.path();
570            let metadata = plain(&path)?;
571            if metadata.is_dir() {
572                dirs.push(path);
573            } else {
574                ensure!(
575                    metadata.is_file(),
576                    "incremental entry is not a regular file"
577                );
578                bytes = bytes.saturating_add(metadata.len());
579                ensure!(
580                    bytes <= GENERATION_BYTES,
581                    "incremental generation exceeds its budget"
582                );
583                ensure!(files.len() < 10000, "incremental inventory too large");
584                if recognize {
585                    ensure!(
586                        recognized(&path)?,
587                        "unknown incremental output: {}",
588                        path.display()
589                    );
590                }
591                files.insert(path.strip_prefix(root)?.to_owned(), identity(&path)?);
592            }
593        }
594    }
595    Ok(files)
596}
597
598fn directory_inventory(root: &Path) -> Result<std::collections::BTreeSet<PathBuf>> {
599    let mut found = std::collections::BTreeSet::new();
600    let mut dirs = vec![root.to_owned()];
601    while let Some(dir) = dirs.pop() {
602        ensure!(plain(&dir)?.is_dir(), "invalid incremental directory");
603        for entry in fs::read_dir(dir)? {
604            let path = entry?.path();
605            if plain(&path)?.is_dir() {
606                ensure!(
607                    found.len() < 10000,
608                    "incremental directory inventory too large"
609                );
610                found.insert(path.strip_prefix(root)?.to_owned());
611                dirs.push(path);
612            }
613        }
614    }
615    Ok(found)
616}
617
618fn recognized(path: &Path) -> Result<bool> {
619    let name = path.file_name().unwrap_or_default().to_string_lossy();
620    if name.ends_with(".lock") {
621        return Ok(fs::metadata(path)?.len() == 0);
622    }
623    let mut header = [0; 8];
624    let n = File::open(path)?.read(&mut header)?;
625    if matches!(
626        name.as_ref(),
627        "dep-graph.bin" | "query-cache.bin" | "work-products.bin"
628    ) {
629        return Ok(header[..n].starts_with(b"RSIC"));
630    }
631    if path.extension().is_some_and(|e| e == "o") || name == "metadata.rmeta" {
632        return crate::retention::artifact_signature(path);
633    }
634    Ok(false)
635}
636
637/// Check both Unix lock protocols used by compilers. Query the POSIX lock
638/// without acquiring it: holding both protocols can conflict on BSD. Retain
639/// the flock guard through unlink, alongside the caller's output/Cargo locks.
640/// See https://doc.rust-lang.org/stable/nightly-rustc/rustc_data_structures/flock/.
641#[cfg(unix)]
642fn lock_session(file: &File) -> std::io::Result<()> {
643    use std::os::fd::AsRawFd;
644    // SAFETY: the descriptor is live and the initialized record covers the
645    // whole file. F_GETLK only inspects locks; it does not change them.
646    let mut lock: libc::flock = unsafe { std::mem::zeroed() };
647    lock.l_type = libc::F_WRLCK as _;
648    lock.l_whence = libc::SEEK_SET as _;
649    lock.l_start = 0;
650    lock.l_len = 0;
651    if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETLK, &mut lock) } == -1 {
652        return Err(std::io::Error::last_os_error());
653    }
654    let unlocked: libc::c_short = libc::F_UNLCK as _;
655    if lock.l_type != unlocked {
656        return Err(std::io::Error::new(
657            std::io::ErrorKind::WouldBlock,
658            "incremental session has a POSIX record lock",
659        ));
660    }
661    crate::pr_head::try_lock_file(file)
662}
663
664#[cfg(all(unix, test))]
665fn lock_posix_session(file: &File) -> std::io::Result<()> {
666    use std::os::fd::AsRawFd;
667    // SAFETY: zero initializes reserved fields; fcntl borrows the lock record
668    // only during this call and the test helper retains the live descriptor.
669    let mut lock: libc::flock = unsafe { std::mem::zeroed() };
670    lock.l_type = libc::F_WRLCK as _;
671    lock.l_whence = libc::SEEK_SET as _;
672    lock.l_start = 0;
673    lock.l_len = 0;
674    if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock) } == -1 {
675        return Err(std::io::Error::last_os_error());
676    }
677    Ok(())
678}
679
680#[cfg(not(unix))]
681fn lock_session(file: &File) -> std::io::Result<()> {
682    crate::pr_head::try_lock_file(file)
683}
684
685/// Handle the private wrapper entrypoint before normal CLI startup. Existing
686/// wrappers (including Cargo's workspace wrapper argument) stay in the chain.
687pub fn wrapper(args: Vec<OsString>) -> Result<i32> {
688    tokio::runtime::Builder::new_current_thread()
689        .enable_all()
690        .build()?
691        .block_on(wrapper_inner(args))
692}
693
694async fn wrapper_inner(args: Vec<OsString>) -> Result<i32> {
695    let worktree = PathBuf::from(
696        std::env::var_os("MAGI_INCREMENTAL_WORKTREE").context("missing incremental worktree")?,
697    );
698    let mut args = args;
699    let mut generation = None;
700    for index in 0..args.len() {
701        let text = args[index].to_string_lossy();
702        let value = if index > 0 && args[index - 1] == "-C" {
703            text.strip_prefix("incremental=").map(str::to_owned)
704        } else {
705            text.strip_prefix("-Cincremental=").map(str::to_owned)
706        };
707        if let Some(value) = value {
708            ensure!(
709                generation.is_none(),
710                "multiple incremental compiler paths are unsupported"
711            );
712            let parent = PathBuf::from(value);
713            let parent = if parent.is_absolute() {
714                parent
715            } else {
716                std::env::current_dir()?.join(parent)
717            };
718            // Cargo passes the profile incremental root; rustc creates its own
719            // crate/session subdirectories beneath our fresh generation.
720            let made = Generation::create(&worktree, &parent)?;
721            let prefix = if text.starts_with("-C") {
722                "-Cincremental="
723            } else {
724                "incremental="
725            };
726            args[index] = format!("{prefix}{}", made.path.display()).into();
727            generation = Some(made);
728        }
729    }
730    let command = if let Some(previous) =
731        std::env::var_os("MAGI_PREVIOUS_RUSTC_WRAPPER").filter(|s| !s.is_empty())
732    {
733        let mut command = std::process::Command::new(previous);
734        command.args(&args);
735        command
736    } else {
737        let mut command = std::process::Command::new(args.first().context("missing compiler")?);
738        command.args(&args[1..]);
739        command
740    };
741    let mut running =
742        crate::seat_process::Running::spawn_operation(command.into(), None, Vec::new())?;
743    let result: Result<_> = async {
744        loop {
745            if let Some(status) = running.child().try_wait()? {
746                break Ok(status);
747            }
748            if let Some(generation) = &generation
749                && crate::disk::dir_size(&generation.path) > GENERATION_BYTES
750            {
751                anyhow::bail!(
752                    "managed incremental compiler exceeded 128 MiB; retained unproven output"
753                );
754            }
755            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
756        }
757    }
758    .await;
759    // Keep the generation owner through confirmation of the entire invocation's
760    // descendants, including a non-exec wrapper's compiler and escaped tools.
761    running.finish().await;
762    let status = result?;
763    if status.success()
764        && let Some(generation) = generation
765    {
766        generation.seal(&worktree)?;
767    }
768    Ok(status.code().unwrap_or(1))
769}
770
771/// Validate independently of dep-info, with the caller holding owner, driver,
772/// output and Cargo locks. Keep all generation/session locks through unlink.
773#[derive(Default)]
774pub(crate) struct Reclaim {
775    pub(crate) files: Vec<PathBuf>,
776    _locks: Vec<FileLease>,
777    finished: Vec<(PathBuf, PathBuf, std::collections::BTreeSet<PathBuf>)>,
778}
779
780impl Reclaim {
781    pub(crate) fn finish(&self) -> Result<()> {
782        for (record, generation, allowed_dirs) in &self.finished {
783            if generation.exists() {
784                ensure!(
785                    inventory(generation, false)?.is_empty(),
786                    "incremental generation still contains data"
787                );
788                ensure!(
789                    directory_inventory(generation)?.is_subset(allowed_dirs),
790                    "unknown incremental directory appeared before removal"
791                );
792                let mut dirs = vec![generation.clone()];
793                let mut index = 0;
794                while index < dirs.len() {
795                    for entry in fs::read_dir(&dirs[index])? {
796                        let path = entry?.path();
797                        ensure!(plain(&path)?.is_dir(), "incremental generation changed");
798                        dirs.push(path);
799                    }
800                    index += 1;
801                }
802                for dir in dirs.into_iter().rev() {
803                    fs::remove_dir(dir)?;
804                }
805            }
806            let owner = record.with_extension("owner");
807            ensure!(
808                plain(&owner)?.len() == 0,
809                "incremental owner changed before removal"
810            );
811            fs::remove_file(record)?;
812            fs::remove_file(owner)?;
813        }
814        Ok(())
815    }
816}
817
818fn validate_proof(worktree: &Path, record: &Path, proof: &Proof) -> Result<()> {
819    ensure!(
820        proof.version == 1 && proof.worktree == worktree,
821        "incremental ownership changed"
822    );
823    let parent = proof
824        .generation
825        .parent()
826        .context("missing generation parent")?;
827    ensure!(
828        [
829            worktree.join("target/debug/incremental"),
830            worktree.join("target/release/incremental")
831        ]
832        .contains(&parent.to_owned()),
833        "incremental generation outside managed target"
834    );
835    ensure!(
836        proof
837            .generation
838            .file_name()
839            .is_some_and(|n| n.to_string_lossy()
840                == format!("magi-{}", record.file_stem().unwrap().to_string_lossy())),
841        "incremental generation identity changed"
842    );
843    Ok(())
844}
845
846pub(crate) fn candidates(worktree: &Path) -> Result<Reclaim> {
847    let store = git_dir(worktree)?.join(STORE);
848    if !store.exists() {
849        return Ok(Reclaim::default());
850    }
851    ensure!(
852        plain(&store)?.is_dir(),
853        "invalid incremental provenance store"
854    );
855    let mut outputs = Vec::new();
856    let mut locks = Vec::new();
857    let mut finished = Vec::new();
858    for entry in fs::read_dir(store)? {
859        let record = entry?.path();
860        if record.extension().is_none_or(|x| x != "json") {
861            continue;
862        }
863        ensure!(
864            plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
865            "invalid incremental provenance"
866        );
867        let proof: Proof = serde_json::from_slice(&fs::read(&record)?)?;
868        validate_proof(worktree, &record, &proof)?;
869        let lock_path = record.with_extension("owner");
870        let owner_metadata = plain(&lock_path)?;
871        ensure!(
872            owner_metadata.is_file() && owner_metadata.len() == 0,
873            "incremental owner is missing or changed"
874        );
875        let owner = FileLease(OpenOptions::new().read(true).write(true).open(lock_path)?);
876        crate::pr_head::try_lock_file(&owner).context("incremental compiler is live")?;
877        locks.push(owner);
878        finished.push((
879            record.clone(),
880            proof.generation.clone(),
881            proof.directories.clone(),
882        ));
883        if !proof.generation.exists() {
884            continue;
885        }
886        let actual = inventory(&proof.generation, false)?;
887        ensure!(
888            directory_inventory(&proof.generation)?.is_subset(&proof.directories),
889            "unknown incremental directory"
890        );
891        // Partial deletion can be retried. Added, replaced or modified files
892        // refuse the entire generation, even when their signatures look valid.
893        ensure!(
894            actual.iter().all(|(p, id)| proof.files.get(p) == Some(id)),
895            "incremental provenance mismatch or unknown data"
896        );
897        for relative in actual.keys() {
898            let path = proof.generation.join(relative);
899            if path.extension().is_some_and(|x| x == "lock") {
900                let file = OpenOptions::new().read(true).write(true).open(&path)?;
901                lock_session(&file).context("incremental session is live")?;
902                locks.push(FileLease(file));
903            }
904            outputs.push(path);
905        }
906    }
907    Ok(Reclaim {
908        files: outputs,
909        _locks: locks,
910        finished,
911    })
912}
913
914#[cfg(all(test, unix))]
915pub(crate) mod tests {
916    use super::*;
917
918    // A real, dependency-free Cargo crate keeps this regression below a few MiB.
919    // No production paths or inherited Cargo configuration participate.
920    fn cargo_generation() -> (tempfile::TempDir, PathBuf, Generation) {
921        let temp = tempfile::tempdir().unwrap();
922        let worktree = temp.path().canonicalize().unwrap();
923        let git = std::process::Command::new("git")
924            .arg("init")
925            .arg(&worktree)
926            .output()
927            .unwrap();
928        assert!(git.status.success());
929        let generation = generate(&worktree);
930        (temp, worktree, generation)
931    }
932
933    pub(crate) fn generate(worktree: &Path) -> Generation {
934        fs::create_dir(worktree.join("src")).unwrap();
935        fs::write(
936            worktree.join("Cargo.toml"),
937            "[package]\nname='cache-fixture'\nversion='0.1.0'\nedition='2021'\n[workspace]\n",
938        )
939        .unwrap();
940        fs::write(
941            worktree.join("src/lib.rs"),
942            "pub fn answer() -> usize { (0..100).sum() }\n",
943        )
944        .unwrap();
945        let parent = worktree.join("target/debug/incremental");
946        fs::create_dir_all(&parent).unwrap();
947        let generation = Generation::create(worktree, &parent).unwrap();
948        let mut command = std::process::Command::new("cargo");
949        command.env_clear();
950        for key in ["PATH", "HOME", "RUSTUP_HOME"] {
951            if let Some(value) = std::env::var_os(key) {
952                command.env(key, value);
953            }
954        }
955        let output = command
956            .current_dir(worktree)
957            .env("RUSTUP_TOOLCHAIN", "stable")
958            .env("CARGO_HOME", worktree.join("cargo-home"))
959            .env("CARGO_INCREMENTAL", "0")
960            .env(
961                "RUSTFLAGS",
962                format!("-C incremental={}", generation.path.display()),
963            )
964            .args(["build", "--offline", "--jobs", "1", "--target-dir"])
965            .arg(worktree.join("target"))
966            .output()
967            .unwrap();
968        assert!(
969            output.status.success(),
970            "{}",
971            String::from_utf8_lossy(&output.stderr)
972        );
973        assert!(crate::disk::dir_size(&generation.path) > 0);
974        generation.seal(worktree).unwrap();
975        generation
976    }
977
978    #[test]
979    fn admission_contention_waits_and_timeout_is_distinct_from_budget_exhaustion() {
980        use std::time::Duration;
981        let temp = tempfile::tempdir().unwrap();
982        let path = temp.path().join("admission.lock");
983        let held = FileLease(
984            OpenOptions::new()
985                .create_new(true)
986                .read(true)
987                .write(true)
988                .open(&path)
989                .unwrap(),
990        );
991        crate::pr_head::try_lock_file(&held).unwrap();
992        std::thread::scope(|scope| {
993            let (ready_tx, ready_rx) = std::sync::mpsc::channel();
994            let (done_tx, done_rx) = std::sync::mpsc::channel();
995            let path = path.clone();
996            scope.spawn(move || {
997                let waiting = FileLease(
998                    OpenOptions::new()
999                        .read(true)
1000                        .write(true)
1001                        .open(&path)
1002                        .unwrap(),
1003                );
1004                ready_tx.send(()).unwrap();
1005                let result = wait_for_admission(&waiting, Duration::from_secs(5));
1006                done_tx.send(result).unwrap();
1007            });
1008            ready_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1009            assert!(done_rx.recv_timeout(Duration::from_millis(40)).is_err());
1010            drop(held);
1011            done_rx
1012                .recv_timeout(Duration::from_secs(5))
1013                .unwrap()
1014                .unwrap();
1015        });
1016        let held = FileLease(
1017            OpenOptions::new()
1018                .read(true)
1019                .write(true)
1020                .open(&path)
1021                .unwrap(),
1022        );
1023        crate::pr_head::try_lock_file(&held).unwrap();
1024        let waiting = OpenOptions::new()
1025            .read(true)
1026            .write(true)
1027            .open(&path)
1028            .unwrap();
1029        let error = wait_for_admission(&waiting, Duration::from_millis(20))
1030            .unwrap_err()
1031            .to_string();
1032        assert!(error.contains("wait timed out"));
1033        assert!(!error.contains("budget exhausted"));
1034    }
1035
1036    #[test]
1037    fn completed_snapshots_share_owner_locks_without_blocking_parallel_admission() {
1038        let (_temp, worktree, generation) = cargo_generation();
1039        let owner = generation.record.with_extension("owner");
1040        drop(generation);
1041        let first = completed_charge(&worktree, &owner).unwrap();
1042        let second = completed_charge(&worktree, &owner).unwrap();
1043        assert!(first.0 > 0);
1044        assert_eq!(first.0, second.0);
1045        let exclusive = FileLease(
1046            OpenOptions::new()
1047                .read(true)
1048                .write(true)
1049                .open(&owner)
1050                .unwrap(),
1051        );
1052        assert!(crate::pr_head::try_lock_file(&exclusive).is_err());
1053        let parent = worktree.join("target/debug/incremental");
1054        let fresh = Generation::create(&worktree, &parent).unwrap();
1055        assert!(fresh.path.is_dir());
1056        drop(first);
1057        drop(second);
1058        assert!(crate::pr_head::try_lock_file(&exclusive).is_ok());
1059    }
1060
1061    #[test]
1062    fn ownership_release_is_not_delayed_by_descriptor_copies() {
1063        let (_temp, worktree, generation) = cargo_generation();
1064        let store = git_dir(&worktree).unwrap().join(STORE);
1065        let open = |path: &Path| {
1066            OpenOptions::new()
1067                .read(true)
1068                .write(true)
1069                .open(path)
1070                .unwrap()
1071        };
1072        let admission_path = store.join("admission.lock");
1073        let admission = FileLease(open(&admission_path));
1074        crate::pr_head::try_lock_file(&admission).unwrap();
1075        // dup and fork share the same open-file description. Keep a copy open
1076        // deterministically, without depending on another test's fork timing.
1077        let inherited_admission = admission.try_clone().unwrap();
1078        let next = FileLease(open(&admission_path));
1079        assert!(crate::pr_head::try_lock_file(&next).is_err());
1080        drop(admission);
1081        crate::pr_head::try_lock_file(&next).unwrap();
1082        drop(next);
1083        drop(inherited_admission);
1084
1085        let owner = generation.record.with_extension("owner");
1086        let inherited_generation = generation._lock.try_clone().unwrap();
1087        assert!(completed_charge(&worktree, &owner).is_err());
1088        drop(generation);
1089        let first = completed_charge(&worktree, &owner).unwrap();
1090        let second = completed_charge(&worktree, &owner).unwrap();
1091        let inherited_first = first.1.try_clone().unwrap();
1092        let inherited_second = second.1.try_clone().unwrap();
1093        let exclusive = FileLease(open(&owner));
1094        drop(first);
1095        assert!(
1096            crate::pr_head::try_lock_file(&exclusive).is_err(),
1097            "another actual accounting owner still excludes reclamation"
1098        );
1099        drop(second);
1100        crate::pr_head::try_lock_file(&exclusive).unwrap();
1101        drop(exclusive);
1102        drop((inherited_generation, inherited_first, inherited_second));
1103
1104        let reclaim = candidates(&worktree).unwrap();
1105        let inherited_reclaim: Vec<_> = reclaim
1106            ._locks
1107            .iter()
1108            .map(|lock| lock.try_clone().unwrap())
1109            .collect();
1110        assert!(candidates(&worktree).is_err());
1111        drop(reclaim);
1112        let retry = candidates(&worktree).unwrap();
1113        assert!(!retry.files.is_empty());
1114        drop(retry);
1115        drop(inherited_reclaim);
1116    }
1117
1118    #[test]
1119    fn default_disables_incremental_and_explicit_environment_is_preserved() {
1120        let temp = tempfile::tempdir().unwrap();
1121        let mut command = tokio::process::Command::new("cargo");
1122        command.env_remove("CARGO_INCREMENTAL");
1123        configure(&mut command, temp.path()).unwrap();
1124        let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1125        assert_eq!(
1126            env[std::ffi::OsStr::new("CARGO_INCREMENTAL")],
1127            Some(std::ffi::OsStr::new("0"))
1128        );
1129        command.env("CARGO_INCREMENTAL", "1");
1130        configure(&mut command, temp.path()).unwrap();
1131        let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1132        assert_eq!(
1133            env[std::ffi::OsStr::new("CARGO_INCREMENTAL")],
1134            Some(std::ffi::OsStr::new("1"))
1135        );
1136    }
1137
1138    #[test]
1139    fn wrapper_temporary_collision_preserves_the_other_publisher() {
1140        use std::io::Write;
1141        let temp = tempfile::tempdir().unwrap();
1142        let root = temp.path().canonicalize().unwrap();
1143        let occupied = root.join("occupied.tmp");
1144        let fresh = root.join("fresh.tmp");
1145        let script = root.join("wrapper.sh");
1146        let barrier = std::sync::Barrier::new(2);
1147        std::thread::scope(|scope| {
1148            scope.spawn(|| {
1149                let mut owner = WrapperTemporary::create(|| occupied.clone()).unwrap();
1150                owner.file.write_all(b"other publisher").unwrap();
1151                barrier.wait();
1152                barrier.wait();
1153                // The competing publisher must not unlink our reserved path.
1154                assert_eq!(fs::read(&occupied).unwrap(), b"other publisher");
1155            });
1156            barrier.wait();
1157            let mut attempts = 0;
1158            let result = publish_wrapper(&script, "complete wrapper", || {
1159                attempts += 1;
1160                if attempts == 1 {
1161                    occupied.clone()
1162                } else {
1163                    fresh.clone()
1164                }
1165            });
1166            // Always release the owner, even if publication regresses.
1167            barrier.wait();
1168            result.unwrap();
1169            assert_eq!(attempts, 2);
1170            assert_eq!(fs::read(&script).unwrap(), b"complete wrapper");
1171            assert!(!fresh.exists());
1172        });
1173        assert!(!occupied.exists());
1174    }
1175
1176    #[test]
1177    fn wrapper_temporary_retries_only_collisions_and_leaves_them_intact() {
1178        let temp = tempfile::tempdir().unwrap();
1179        let occupied = temp.path().join("occupied.tmp");
1180        fs::write(&occupied, b"reserved").unwrap();
1181        let mut attempts = 0;
1182        let error = WrapperTemporary::create(|| {
1183            attempts += 1;
1184            occupied.clone()
1185        })
1186        .err()
1187        .unwrap();
1188        assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
1189        assert_eq!(attempts, 128);
1190        assert_eq!(fs::read(&occupied).unwrap(), b"reserved");
1191        attempts = 0;
1192        let error = WrapperTemporary::create(|| {
1193            attempts += 1;
1194            temp.path().join("missing/temporary")
1195        })
1196        .err()
1197        .unwrap();
1198        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
1199        assert_eq!(attempts, 1);
1200    }
1201
1202    #[test]
1203    fn wrapper_publication_preserves_existing_destinations() {
1204        use std::os::unix::fs::{MetadataExt, symlink};
1205        let temp = tempfile::tempdir().unwrap();
1206        let root = temp.path().canonicalize().unwrap();
1207        let script = root.join("wrapper.sh");
1208        let temporary = root.join("temporary");
1209        let body = "#!/bin/sh\nexit 0\n";
1210        fs::write(&script, body).unwrap();
1211        let inode = fs::metadata(&script).unwrap().ino();
1212        publish_wrapper(&script, body, || temporary.clone()).unwrap();
1213        assert_eq!(fs::metadata(&script).unwrap().ino(), inode);
1214        assert!(!temporary.exists());
1215        fs::write(&script, "different").unwrap();
1216        assert!(publish_wrapper(&script, body, || temporary.clone()).is_err());
1217        assert_eq!(fs::read_to_string(&script).unwrap(), "different");
1218        assert!(!temporary.exists());
1219        fs::remove_file(&script).unwrap();
1220        fs::create_dir(&script).unwrap();
1221        assert!(publish_wrapper(&script, body, || temporary.clone()).is_err());
1222        assert!(script.is_dir());
1223        assert!(!temporary.exists());
1224        fs::remove_dir(&script).unwrap();
1225        let target = root.join("target");
1226        fs::write(&target, body).unwrap();
1227        symlink(&target, &script).unwrap();
1228        assert!(publish_wrapper(&script, body, || temporary.clone()).is_err());
1229        assert_eq!(fs::read_link(&script).unwrap(), target);
1230        assert_eq!(fs::read_to_string(&target).unwrap(), body);
1231        assert!(!temporary.exists());
1232    }
1233
1234    #[test]
1235    fn concurrent_configuration_publishes_complete_wrappers() {
1236        let temp = tempfile::tempdir().unwrap();
1237        let root = temp.path().canonicalize().unwrap();
1238        let worktree = root.join("worktree");
1239        assert!(
1240            std::process::Command::new("git")
1241                .args(["init", "--separate-git-dir"])
1242                .arg(root.join("metadata"))
1243                .arg(&worktree)
1244                .output()
1245                .unwrap()
1246                .status
1247                .success()
1248        );
1249        use std::os::unix::fs::PermissionsExt;
1250        let executable = std::env::current_exe().unwrap();
1251        let quoted = executable.to_string_lossy().replace('\'', "'\\''");
1252        let body = format!("#!/bin/sh\nexec '{quoted}' __magi_incremental \"$@\"\n");
1253        let barrier = std::sync::Barrier::new(8);
1254        let wrappers = std::thread::scope(|scope| {
1255            let handles: Vec<_> = (0..8)
1256                .map(|_| {
1257                    scope.spawn(|| {
1258                        let mut last = None;
1259                        barrier.wait();
1260                        for _ in 0..16 {
1261                            let mut command = tokio::process::Command::new("cargo");
1262                            configure(&mut command, &worktree).unwrap();
1263                            let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1264                            let wrapper =
1265                                PathBuf::from(env[std::ffi::OsStr::new("RUSTC_WRAPPER")].unwrap());
1266                            assert_eq!(fs::read_to_string(&wrapper).unwrap(), body);
1267                            assert_eq!(
1268                                fs::metadata(&wrapper).unwrap().permissions().mode() & 0o777,
1269                                0o700
1270                            );
1271                            if let Some(previous) = last.replace(wrapper.clone()) {
1272                                assert_eq!(previous, wrapper);
1273                            }
1274                        }
1275                        last.unwrap()
1276                    })
1277                })
1278                .collect();
1279            handles
1280                .into_iter()
1281                .map(|handle| handle.join().unwrap())
1282                .collect::<Vec<_>>()
1283        });
1284        assert!(wrappers.iter().all(|path| path == &wrappers[0]));
1285        assert!(
1286            fs::read_dir(git_dir(&worktree).unwrap())
1287                .unwrap()
1288                .all(|entry| {
1289                    !entry
1290                        .unwrap()
1291                        .file_name()
1292                        .to_string_lossy()
1293                        .ends_with(".tmp")
1294                })
1295        );
1296    }
1297
1298    #[test]
1299    fn sealing_refuses_unfinished_sessions_and_unknown_directories() {
1300        let (_temp, worktree, generation) = cargo_generation();
1301        let unknown = generation.path.join("operator-empty");
1302        fs::create_dir(&unknown).unwrap();
1303        assert!(generation.seal(&worktree).is_err());
1304        fs::remove_dir(unknown).unwrap();
1305        let crate_dir = fs::read_dir(&generation.path)
1306            .unwrap()
1307            .next()
1308            .unwrap()
1309            .unwrap()
1310            .path();
1311        let working = crate_dir.join("s-unfinished-cache-working");
1312        fs::create_dir(&working).unwrap();
1313        assert!(generation.seal(&worktree).is_err());
1314        assert!(working.exists());
1315        drop(generation);
1316        assert!(candidates(&worktree).is_err());
1317    }
1318
1319    #[test]
1320    fn unknown_empty_directories_added_after_inventory_are_retained() {
1321        let (_temp, worktree, generation) = cargo_generation();
1322        let path = generation.path.clone();
1323        drop(generation);
1324        let reclaim = candidates(&worktree).unwrap();
1325        for file in &reclaim.files {
1326            fs::remove_file(file).unwrap();
1327        }
1328        let unknown = path.join("operator-empty");
1329        fs::create_dir(&unknown).unwrap();
1330        assert!(reclaim.finish().is_err());
1331        assert!(unknown.is_dir());
1332        fs::remove_dir(unknown).unwrap();
1333        reclaim.finish().unwrap();
1334        assert!(!path.exists());
1335    }
1336
1337    #[test]
1338    fn fcntl_session_lock_is_respected_across_processes() {
1339        use std::io::{Read, Write};
1340        use std::process::{Command, Stdio};
1341        if let Some(path) = std::env::var_os("MAGI_TEST_SESSION_FILE") {
1342            let file = OpenOptions::new()
1343                .read(true)
1344                .write(true)
1345                .open(path)
1346                .unwrap();
1347            // Deliberately do not take flock: both compiler protocols must
1348            // exclude reclamation, even on hosts where they are independent.
1349            lock_posix_session(&file).unwrap();
1350            fs::write(
1351                std::env::var_os("MAGI_TEST_SESSION_READY").unwrap(),
1352                "ready",
1353            )
1354            .unwrap();
1355            std::io::stdin().read_exact(&mut [0]).unwrap();
1356            return;
1357        }
1358        let (_temp, worktree, generation) = cargo_generation();
1359        drop(generation);
1360        let candidates = candidates(&worktree).unwrap();
1361        let session = candidates
1362            .files
1363            .iter()
1364            .find(|p| p.extension().is_some_and(|x| x == "lock"))
1365            .unwrap()
1366            .clone();
1367        drop(candidates);
1368        let ready = worktree.join("session-ready");
1369        let mut child = Command::new(std::env::current_exe().unwrap())
1370            .args([
1371                "--exact",
1372                "incremental::tests::fcntl_session_lock_is_respected_across_processes",
1373            ])
1374            .env("MAGI_TEST_SESSION_FILE", &session)
1375            .env("MAGI_TEST_SESSION_READY", &ready)
1376            .stdin(Stdio::piped())
1377            .stdout(Stdio::null())
1378            .spawn()
1379            .unwrap();
1380        let started = std::time::Instant::now();
1381        while !ready.exists() && started.elapsed() < std::time::Duration::from_secs(10) {
1382            if child.try_wait().unwrap().is_some() {
1383                break;
1384            }
1385            std::thread::sleep(std::time::Duration::from_millis(10));
1386        }
1387        let observed = ready.exists();
1388        let blocked = observed
1389            && super::candidates(&worktree)
1390                .err()
1391                .is_some_and(|e| e.to_string().contains("session is live"));
1392        if observed {
1393            child.stdin.take().unwrap().write_all(b"x").unwrap();
1394        } else {
1395            child.kill().ok();
1396        }
1397        let status = child.wait().unwrap();
1398        assert!(
1399            observed && status.success(),
1400            "session helper did not acquire its lock"
1401        );
1402        assert!(blocked, "POSIX session lock must exclude reclamation");
1403        assert!(!super::candidates(&worktree).unwrap().files.is_empty());
1404        assert!(session.exists());
1405    }
1406
1407    #[test]
1408    fn real_cargo_generations_require_exact_provenance_and_live_owner_exclusion() {
1409        let (_temp, worktree, generation) = cargo_generation();
1410        assert!(
1411            candidates(&worktree).is_err(),
1412            "compiler owner still holds the generation"
1413        );
1414        let path = generation.path.clone();
1415        drop(generation);
1416        let reclaim = candidates(&worktree).unwrap();
1417        let session = reclaim
1418            .files
1419            .iter()
1420            .find(|p| p.extension().is_some_and(|x| x == "lock"))
1421            .unwrap()
1422            .clone();
1423        drop(reclaim);
1424        let live_session = FileLease(
1425            OpenOptions::new()
1426                .read(true)
1427                .write(true)
1428                .open(session)
1429                .unwrap(),
1430        );
1431        crate::pr_head::try_lock_file(&live_session).unwrap();
1432        assert!(
1433            candidates(&worktree)
1434                .err()
1435                .unwrap()
1436                .to_string()
1437                .contains("session is live")
1438        );
1439        drop(live_session);
1440        let unknown = path.join("operator-note");
1441        fs::write(&unknown, "keep unknown data").unwrap();
1442        assert!(candidates(&worktree).is_err());
1443        assert_eq!(fs::read_to_string(&unknown).unwrap(), "keep unknown data");
1444        fs::remove_file(&unknown).unwrap();
1445        let unknown_dir = path.join("operator-empty");
1446        fs::create_dir(&unknown_dir).unwrap();
1447        assert!(candidates(&worktree).is_err());
1448        fs::remove_dir(unknown_dir).unwrap();
1449        let reclaim = candidates(&worktree).unwrap();
1450        assert!(!reclaim.files.is_empty());
1451        assert!(
1452            candidates(&worktree).is_err(),
1453            "maintenance retains session/owner locks"
1454        );
1455        // Partial deletion can resume, but changed bytes cannot be adopted.
1456        let removed = reclaim.files[0].clone();
1457        fs::remove_file(removed).unwrap();
1458        drop(reclaim);
1459        let reclaim = candidates(&worktree).unwrap();
1460        let changed = reclaim
1461            .files
1462            .iter()
1463            .find(|p| p.extension().is_some_and(|e| e == "bin"))
1464            .unwrap()
1465            .clone();
1466        let original = fs::read(&changed).unwrap();
1467        fs::write(&changed, b"modified proof input").unwrap();
1468        drop(reclaim);
1469        assert!(candidates(&worktree).is_err());
1470        fs::write(&changed, original).unwrap();
1471        let reclaim = candidates(&worktree).unwrap();
1472        for file in &reclaim.files {
1473            fs::remove_file(file).unwrap();
1474        }
1475        reclaim.finish().unwrap();
1476        assert!(!path.exists());
1477        assert!(candidates(&worktree).unwrap().files.is_empty());
1478        assert!(worktree.join("src/lib.rs").is_file());
1479    }
1480
1481    #[test]
1482    fn externally_removed_sealed_generation_does_not_strand_admission() {
1483        let (_temp, worktree, generation) = cargo_generation();
1484        let path = generation.path.clone();
1485        drop(generation);
1486        let reclaim = candidates(&worktree).unwrap();
1487        for file in &reclaim.files {
1488            fs::remove_file(file).unwrap();
1489        }
1490        // Simulate a clean/reset removing only empty cache directories, while
1491        // leaving the completion proof in the worktree Git directory.
1492        let mut dirs = directory_inventory(&path)
1493            .unwrap()
1494            .into_iter()
1495            .collect::<Vec<_>>();
1496        dirs.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
1497        for relative in dirs {
1498            fs::remove_dir(path.join(relative)).unwrap();
1499        }
1500        fs::remove_dir(&path).unwrap();
1501        drop(reclaim);
1502        let parent = worktree.join("target/debug/incremental");
1503        let fresh = Generation::create(&worktree, &parent).unwrap();
1504        assert_eq!(
1505            fs::read_dir(git_dir(&worktree).unwrap().join(STORE))
1506                .unwrap()
1507                .filter_map(|e| e.ok())
1508                .filter(|e| e.path().extension().is_some_and(|x| x == "owner"))
1509                .count(),
1510            1
1511        );
1512        assert!(fresh.path.is_dir());
1513    }
1514
1515    #[test]
1516    fn unsealed_attempts_consume_the_bounded_generation_budget() {
1517        let temp = tempfile::tempdir().unwrap();
1518        let worktree = temp.path().canonicalize().unwrap();
1519        assert!(
1520            std::process::Command::new("git")
1521                .arg("init")
1522                .arg(&worktree)
1523                .output()
1524                .unwrap()
1525                .status
1526                .success()
1527        );
1528        let parent = worktree.join("target/debug/incremental");
1529        fs::create_dir_all(&parent).unwrap();
1530        for _ in 0..SLOTS {
1531            let generation = Generation::create(&worktree, &parent).unwrap();
1532            fs::write(generation.path.join("unknown"), "preserve").unwrap();
1533        }
1534        assert!(Generation::create(&worktree, &parent).is_err());
1535        assert!(
1536            candidates(&worktree).unwrap().files.is_empty(),
1537            "unsealed output never grants deletion authority"
1538        );
1539    }
1540
1541    #[cfg(unix)]
1542    #[test]
1543    fn generation_symlinks_and_replaced_inodes_refuse_reclamation() {
1544        let (_temp, worktree, generation) = cargo_generation();
1545        let path = generation.path.clone();
1546        drop(generation);
1547        let files = candidates(&worktree).unwrap().files;
1548        let file = files
1549            .iter()
1550            .find(|p| p.extension().is_some_and(|e| e == "bin"))
1551            .unwrap();
1552        let saved = worktree.join("retained-input");
1553        fs::rename(file, &saved).unwrap();
1554        std::os::unix::fs::symlink(&saved, file).unwrap();
1555        assert!(candidates(&worktree).is_err());
1556        fs::remove_file(file).unwrap();
1557        fs::copy(&saved, file).unwrap();
1558        assert!(
1559            candidates(&worktree).is_err(),
1560            "same bytes at a replaced inode are insufficient"
1561        );
1562        assert!(path.exists());
1563        assert!(saved.is_file());
1564    }
1565}