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/// A symlink at `dir` is unlinked, never followed: `exists()` resolves through links, so a
127/// *dangling* link would otherwise survive the wipe and `create_dir_all` would then create the
128/// link's target directory — outside the tree the caller means to confine extraction to.
129///
130/// Retries on `ENOTEMPTY` — observed under CI overlay/tmpfs filesystems where
131/// the final `rmdir` races with leftover dentries even after all children are
132/// gone. Linux returns 39, macOS/BSD return 66 — match both.
133pub fn clear_directory(dir: &Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
134    if fs::symlink_metadata(dir)
135        .map(|meta| meta.file_type().is_symlink())
136        .unwrap_or(false)
137    {
138        fs::remove_file(dir)?;
139    }
140    if dir.exists() {
141        let mut delay_ms: u64 = 50;
142        let mut attempts = 0;
143        loop {
144            match fs::remove_dir_all(dir) {
145                Ok(()) => break,
146                Err(e) if is_not_empty_error(&e) && attempts < 5 => {
147                    attempts += 1;
148                    std::thread::sleep(Duration::from_millis(delay_ms));
149                    delay_ms *= 2;
150                }
151                Err(e) => return Err(Box::new(e)),
152            }
153        }
154    }
155    create_dir_all(dir)?;
156    Ok(())
157}
158
159fn is_not_empty_error(e: &std::io::Error) -> bool {
160    matches!(e.raw_os_error(), Some(39) | Some(66))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use tempfile::tempdir;
167
168    #[test]
169    fn hash_changes_with_content_and_markers_round_trip() {
170        let tmp = tempdir().unwrap();
171        let f = tmp.path().join("input");
172        fs::write(&f, b"alpha").unwrap();
173        let h1 = file_hash(&f).unwrap();
174        fs::write(&f, b"alphb").unwrap();
175        let h2 = file_hash(&f).unwrap();
176        assert_ne!(h1, h2);
177
178        let marker = tmp.path().join(".marker");
179        assert!(!marker_matches(&marker, &h2));
180        write_marker(&marker, &h2).unwrap();
181        assert!(marker_matches(&marker, &h2));
182        assert!(!marker_matches(&marker, &h1));
183    }
184
185    #[test]
186    fn clear_directory_empties_and_recreates() {
187        let tmp = tempdir().unwrap();
188        let d = tmp.path().join("d");
189        fs::create_dir_all(d.join("nested")).unwrap();
190        fs::write(d.join("nested/file"), b"x").unwrap();
191        assert!(dir_has_content(&d));
192        clear_directory(&d).unwrap();
193        assert!(d.exists());
194        assert!(!dir_has_content(&d));
195    }
196
197    #[cfg(unix)]
198    #[test]
199    fn clear_directory_unlinks_a_dangling_symlink_instead_of_following_it() {
200        use std::os::unix::fs::symlink;
201        let tmp = tempdir().unwrap();
202        // A link whose target does not exist: `exists()` is false through it, so without the
203        // symlink check the wipe is skipped and `create_dir_all` creates the *target*.
204        let target = tmp.path().join("outside");
205        let link = tmp.path().join("dest");
206        symlink(&target, &link).unwrap();
207
208        clear_directory(&link).unwrap();
209        assert!(
210            !target.exists(),
211            "the link's target directory must not be created"
212        );
213        assert!(link.is_dir() && !link.symlink_metadata().unwrap().file_type().is_symlink());
214    }
215
216    #[cfg(unix)]
217    #[test]
218    fn clear_directory_replaces_a_live_symlink_with_a_real_dir() {
219        use std::os::unix::fs::symlink;
220        let tmp = tempdir().unwrap();
221        let target = tmp.path().join("real");
222        fs::create_dir_all(&target).unwrap();
223        fs::write(target.join("keep"), b"x").unwrap();
224        let link = tmp.path().join("dest");
225        symlink(&target, &link).unwrap();
226
227        clear_directory(&link).unwrap();
228        assert!(link.is_dir() && !link.symlink_metadata().unwrap().file_type().is_symlink());
229        assert!(
230            target.join("keep").exists(),
231            "the link's target is left untouched"
232        );
233    }
234
235    #[test]
236    fn with_lock_runs_the_closure_and_releases() {
237        let tmp = tempdir().unwrap();
238        let lock = tmp.path().join(".lock");
239        let out = with_lock(&lock)(|| 42);
240        assert_eq!(out, 42);
241        assert!(!lock.exists(), "lock should be released");
242    }
243
244    #[test]
245    fn fresh_and_missing_locks_are_not_stale() {
246        let tmp = tempdir().unwrap();
247        let lock = tmp.path().join(".lock");
248        fs::write(&lock, "123\n").unwrap();
249        assert!(
250            !lock_is_stale(&lock),
251            "a just-created lock must not be considered stale"
252        );
253        assert!(
254            !lock_is_stale(&tmp.path().join("absent")),
255            "a missing lock reads as not stale (keep waiting)"
256        );
257    }
258}