Skip to main content

npm_utils/
cache.rs

1//! Skip-if-unchanged cache helpers: content-hash markers, a cross-process build
2//! lock, and directory utilities.
3
4use std::fs::{self, create_dir_all};
5use std::io::{Read, Write};
6use std::path::Path;
7use std::time::Duration;
8
9/// Run `f` while holding an exclusive cross-process lock on `lock_path`.
10///
11/// A build script may be invoked concurrently for multiple compile units of the
12/// same crate (e.g. the host-profile build-dep unit and the target-profile unit
13/// of a `links` crate). This serializes a download/extract block via an
14/// atomic-create lock file so concurrent invocations don't race on shared
15/// writes; the second waiter typically observes a fresh marker and skips its own
16/// work. A lock held longer than 120 s (e.g. a crashed previous holder) is
17/// treated as stale, removed, and the wait continues.
18pub fn with_lock<F: FnOnce() -> R, R>(lock_path: &Path) -> impl FnOnce(F) -> R {
19    let lock_path = lock_path.to_path_buf();
20    move |f: F| -> R {
21        if let Some(parent) = lock_path.parent() {
22            let _ = create_dir_all(parent);
23        }
24        loop {
25            match fs::OpenOptions::new()
26                .write(true)
27                .create_new(true)
28                .open(&lock_path)
29            {
30                Ok(mut file) => {
31                    let _ = writeln!(file, "{}", std::process::id());
32                    drop(file);
33                    let result = f();
34                    let _ = fs::remove_file(&lock_path);
35                    return result;
36                }
37                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
38                    if lock_is_stale(&lock_path) {
39                        crate::warn::warn(&format!(
40                            "lock at {} looks stale (file older than {}s) — \
41                             removing and continuing",
42                            lock_path.display(),
43                            STALE_LOCK.as_secs()
44                        ));
45                        let _ = fs::remove_file(&lock_path);
46                        continue;
47                    }
48                    std::thread::sleep(Duration::from_millis(200));
49                }
50                Err(e) => panic!(
51                    "npm-utils: failed to acquire lock at {}: {}",
52                    lock_path.display(),
53                    e
54                ),
55            }
56        }
57    }
58}
59
60/// A lock whose file is older than this is treated as abandoned by a crashed holder and reclaimed.
61/// Judged by the lock file's real age (its mtime) — not any single waiter's elapsed wait — so all
62/// waiters agree, and a long-but-live install isn't preempted at the old 2-minute mark.
63///
64/// Heuristic, not airtight: a holder genuinely running longer than this could still be preempted
65/// (a true fix needs PID-liveness or a heartbeat). Ten minutes clears any realistic install.
66const STALE_LOCK: Duration = Duration::from_secs(600);
67
68/// Whether the lock file's age exceeds [`STALE_LOCK`]. A missing/unreadable timestamp, or a clock
69/// skew (mtime in the future → `elapsed()` errors), reads as *not* stale: keep waiting rather than
70/// risk yanking a lock a live holder still owns.
71fn lock_is_stale(lock_path: &Path) -> bool {
72    fs::metadata(lock_path)
73        .and_then(|m| m.modified())
74        .ok()
75        .and_then(|t| t.elapsed().ok())
76        .is_some_and(|age| age > STALE_LOCK)
77}
78
79/// Whether a directory exists and contains at least one entry.
80pub fn dir_has_content(dir: &Path) -> bool {
81    if !dir.exists() {
82        return false;
83    }
84    match std::fs::read_dir(dir) {
85        Ok(mut entries) => entries.next().is_some(),
86        Err(_) => false,
87    }
88}
89
90/// Compute a fast, position-weighted hash of a file's contents.
91///
92/// Not cryptographically secure — sufficient for cache invalidation (detecting
93/// that an input changed), not for integrity verification.
94pub fn file_hash(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
95    let mut file = fs::File::open(path)?;
96    let mut contents = Vec::new();
97    file.read_to_end(&mut contents)?;
98
99    let mut hash: u64 = 0;
100    for (i, byte) in contents.iter().enumerate() {
101        hash = hash.wrapping_add((*byte as u64).wrapping_mul((i as u64).wrapping_add(1)));
102    }
103    Ok(format!("{:016x}", hash))
104}
105
106/// Whether a marker file exists and its contents equal `expected_hash`.
107pub fn marker_matches(marker_path: &Path, expected_hash: &str) -> bool {
108    match fs::read_to_string(marker_path) {
109        Ok(content) => content.trim() == expected_hash,
110        Err(_) => false,
111    }
112}
113
114/// Write `hash` to a marker file.
115pub fn write_marker(
116    marker_path: &Path,
117    hash: &str,
118) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
119    let mut file = fs::File::create(marker_path)?;
120    file.write_all(hash.as_bytes())?;
121    Ok(())
122}
123
124/// Remove and recreate a directory.
125///
126/// Retries on `ENOTEMPTY` — observed under CI overlay/tmpfs filesystems where
127/// the final `rmdir` races with leftover dentries even after all children are
128/// gone. Linux returns 39, macOS/BSD return 66 — match both.
129pub fn clear_directory(dir: &Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
130    if dir.exists() {
131        let mut delay_ms: u64 = 50;
132        let mut attempts = 0;
133        loop {
134            match fs::remove_dir_all(dir) {
135                Ok(()) => break,
136                Err(e) if is_not_empty_error(&e) && attempts < 5 => {
137                    attempts += 1;
138                    std::thread::sleep(Duration::from_millis(delay_ms));
139                    delay_ms *= 2;
140                }
141                Err(e) => return Err(Box::new(e)),
142            }
143        }
144    }
145    create_dir_all(dir)?;
146    Ok(())
147}
148
149fn is_not_empty_error(e: &std::io::Error) -> bool {
150    matches!(e.raw_os_error(), Some(39) | Some(66))
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use tempfile::tempdir;
157
158    #[test]
159    fn hash_changes_with_content_and_markers_round_trip() {
160        let tmp = tempdir().unwrap();
161        let f = tmp.path().join("input");
162        fs::write(&f, b"alpha").unwrap();
163        let h1 = file_hash(&f).unwrap();
164        fs::write(&f, b"alphb").unwrap();
165        let h2 = file_hash(&f).unwrap();
166        assert_ne!(h1, h2);
167
168        let marker = tmp.path().join(".marker");
169        assert!(!marker_matches(&marker, &h2));
170        write_marker(&marker, &h2).unwrap();
171        assert!(marker_matches(&marker, &h2));
172        assert!(!marker_matches(&marker, &h1));
173    }
174
175    #[test]
176    fn clear_directory_empties_and_recreates() {
177        let tmp = tempdir().unwrap();
178        let d = tmp.path().join("d");
179        fs::create_dir_all(d.join("nested")).unwrap();
180        fs::write(d.join("nested/file"), b"x").unwrap();
181        assert!(dir_has_content(&d));
182        clear_directory(&d).unwrap();
183        assert!(d.exists());
184        assert!(!dir_has_content(&d));
185    }
186
187    #[test]
188    fn with_lock_runs_the_closure_and_releases() {
189        let tmp = tempdir().unwrap();
190        let lock = tmp.path().join(".lock");
191        let out = with_lock(&lock)(|| 42);
192        assert_eq!(out, 42);
193        assert!(!lock.exists(), "lock should be released");
194    }
195
196    #[test]
197    fn fresh_and_missing_locks_are_not_stale() {
198        let tmp = tempdir().unwrap();
199        let lock = tmp.path().join(".lock");
200        fs::write(&lock, "123\n").unwrap();
201        assert!(
202            !lock_is_stale(&lock),
203            "a just-created lock must not be considered stale"
204        );
205        assert!(
206            !lock_is_stale(&tmp.path().join("absent")),
207            "a missing lock reads as not stale (keep waiting)"
208        );
209    }
210}