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, Instant};
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        let start = Instant::now();
25        let max_wait = Duration::from_secs(120);
26        loop {
27            match fs::OpenOptions::new()
28                .write(true)
29                .create_new(true)
30                .open(&lock_path)
31            {
32                Ok(mut file) => {
33                    let _ = writeln!(file, "{}", std::process::id());
34                    drop(file);
35                    let result = f();
36                    let _ = fs::remove_file(&lock_path);
37                    return result;
38                }
39                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
40                    if start.elapsed() > max_wait {
41                        eprintln!(
42                            "npm-utils: lock at {} held for {}s — assuming stale and continuing",
43                            lock_path.display(),
44                            start.elapsed().as_secs()
45                        );
46                        let _ = fs::remove_file(&lock_path);
47                        continue;
48                    }
49                    std::thread::sleep(Duration::from_millis(200));
50                }
51                Err(e) => panic!(
52                    "npm-utils: failed to acquire lock at {}: {}",
53                    lock_path.display(),
54                    e
55                ),
56            }
57        }
58    }
59}
60
61/// Whether a directory exists and contains at least one entry.
62pub fn dir_has_content(dir: &Path) -> bool {
63    if !dir.exists() {
64        return false;
65    }
66    match std::fs::read_dir(dir) {
67        Ok(mut entries) => entries.next().is_some(),
68        Err(_) => false,
69    }
70}
71
72/// Compute a fast, position-weighted hash of a file's contents.
73///
74/// Not cryptographically secure — sufficient for cache invalidation (detecting
75/// that an input changed), not for integrity verification.
76pub fn file_hash(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
77    let mut file = fs::File::open(path)?;
78    let mut contents = Vec::new();
79    file.read_to_end(&mut contents)?;
80
81    let mut hash: u64 = 0;
82    for (i, byte) in contents.iter().enumerate() {
83        hash = hash.wrapping_add((*byte as u64).wrapping_mul((i as u64).wrapping_add(1)));
84    }
85    Ok(format!("{:016x}", hash))
86}
87
88/// Whether a marker file exists and its contents equal `expected_hash`.
89pub fn marker_matches(marker_path: &Path, expected_hash: &str) -> bool {
90    match fs::read_to_string(marker_path) {
91        Ok(content) => content.trim() == expected_hash,
92        Err(_) => false,
93    }
94}
95
96/// Write `hash` to a marker file.
97pub fn write_marker(marker_path: &Path, hash: &str) -> Result<(), Box<dyn std::error::Error>> {
98    let mut file = fs::File::create(marker_path)?;
99    file.write_all(hash.as_bytes())?;
100    Ok(())
101}
102
103/// Remove and recreate a directory.
104///
105/// Retries on `ENOTEMPTY` — observed under CI overlay/tmpfs filesystems where
106/// the final `rmdir` races with leftover dentries even after all children are
107/// gone. Linux returns 39, macOS/BSD return 66 — match both.
108pub fn clear_directory(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
109    if dir.exists() {
110        let mut delay_ms: u64 = 50;
111        let mut attempts = 0;
112        loop {
113            match fs::remove_dir_all(dir) {
114                Ok(()) => break,
115                Err(e) if is_not_empty_error(&e) && attempts < 5 => {
116                    attempts += 1;
117                    std::thread::sleep(Duration::from_millis(delay_ms));
118                    delay_ms *= 2;
119                }
120                Err(e) => return Err(Box::new(e)),
121            }
122        }
123    }
124    create_dir_all(dir)?;
125    Ok(())
126}
127
128fn is_not_empty_error(e: &std::io::Error) -> bool {
129    matches!(e.raw_os_error(), Some(39) | Some(66))
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use tempfile::tempdir;
136
137    #[test]
138    fn hash_changes_with_content_and_markers_round_trip() {
139        let tmp = tempdir().unwrap();
140        let f = tmp.path().join("input");
141        fs::write(&f, b"alpha").unwrap();
142        let h1 = file_hash(&f).unwrap();
143        fs::write(&f, b"alphb").unwrap();
144        let h2 = file_hash(&f).unwrap();
145        assert_ne!(h1, h2);
146
147        let marker = tmp.path().join(".marker");
148        assert!(!marker_matches(&marker, &h2));
149        write_marker(&marker, &h2).unwrap();
150        assert!(marker_matches(&marker, &h2));
151        assert!(!marker_matches(&marker, &h1));
152    }
153
154    #[test]
155    fn clear_directory_empties_and_recreates() {
156        let tmp = tempdir().unwrap();
157        let d = tmp.path().join("d");
158        fs::create_dir_all(d.join("nested")).unwrap();
159        fs::write(d.join("nested/file"), b"x").unwrap();
160        assert!(dir_has_content(&d));
161        clear_directory(&d).unwrap();
162        assert!(d.exists());
163        assert!(!dir_has_content(&d));
164    }
165
166    #[test]
167    fn with_lock_runs_the_closure_and_releases() {
168        let tmp = tempdir().unwrap();
169        let lock = tmp.path().join(".lock");
170        let out = with_lock(&lock)(|| 42);
171        assert_eq!(out, 42);
172        assert!(!lock.exists(), "lock should be released");
173    }
174}