Skip to main content

wdl_modules/project/
lockfile.rs

1//! Advisory locking for `module-lock.json`.
2//!
3//! `module-lock.json` carries the lock that coordinates its readers and
4//! writers. [`LockedLockfile::read`] opens an existing file under a shared lock
5//! and never creates it. [`LockedLockfile::acquire`] opens or creates the file
6//! under an exclusive lock so the caller can inspect its current contents and
7//! decide whether to replace them. A replacement is written in place through
8//! the held handle rather than through a rename, because a rename would install
9//! a new inode and leave the lock guarding the old one.
10
11use std::fs::File;
12use std::fs::OpenOptions;
13use std::fs::TryLockError;
14use std::io::Read as _;
15use std::io::Seek as _;
16use std::io::Write as _;
17use std::path::Path;
18use std::path::PathBuf;
19
20use super::ProjectError;
21use crate::Lockfile;
22
23/// A `module-lock.json` held under its exclusive advisory lock.
24///
25/// The lock is released when this value is dropped, or when
26/// [`Self::write`] consumes it.
27#[derive(Debug)]
28pub struct LockedLockfile {
29    /// The locked lockfile path.
30    path: PathBuf,
31    /// Open handle that holds the lock and receives the write.
32    file: File,
33}
34
35impl LockedLockfile {
36    /// Reads and parses the `module-lock.json` at `path` under a shared
37    /// advisory lock.
38    ///
39    /// Returns `Ok(None)` when the file is absent or empty. The file is never
40    /// created, so reading cannot make a project appear to have a lockfile.
41    pub fn read(path: &Path) -> Result<Option<Lockfile>, ProjectError> {
42        let file = match File::open(path) {
43            Ok(file) => file,
44            Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
45            Err(source) => {
46                return Err(ProjectError::Io {
47                    path: path.to_path_buf(),
48                    source,
49                });
50            }
51        };
52        wait_for_lock(path, || file.try_lock_shared(), || file.lock_shared())?;
53        parse(&file, path)
54    }
55
56    /// Acquires the exclusive advisory lock on the `module-lock.json` at
57    /// `path`, creating the file when it is absent.
58    ///
59    /// This blocks until any other holder releases the lock.
60    pub fn acquire(path: &Path) -> Result<Self, ProjectError> {
61        let file = OpenOptions::new()
62            .create(true)
63            .read(true)
64            .write(true)
65            .truncate(false)
66            .open(path)
67            .map_err(|source| ProjectError::Io {
68                path: path.to_path_buf(),
69                source,
70            })?;
71        wait_for_lock(path, || file.try_lock(), || file.lock())?;
72        Ok(Self {
73            path: path.to_path_buf(),
74            file,
75        })
76    }
77
78    /// Returns the lockfile as it stands on disk under the held lock.
79    ///
80    /// This is `None` when the file is empty, which is how a lockfile created
81    /// solely to be locked reads.
82    pub fn current(&self) -> Result<Option<Lockfile>, ProjectError> {
83        parse(&self.file, &self.path)
84    }
85
86    /// Replaces the lockfile contents with `lockfile` and releases the lock.
87    ///
88    /// The lockfile is serialized into memory first, so a serialization failure
89    /// never reaches the file.
90    pub fn write(self, lockfile: &Lockfile) -> Result<(), ProjectError> {
91        let mut bytes = Vec::new();
92        lockfile
93            .write(&mut bytes)
94            .map_err(|source| ProjectError::Io {
95                path: self.path.clone(),
96                source,
97            })?;
98        let mut file = &self.file;
99        file.rewind().map_err(|source| ProjectError::Io {
100            path: self.path.clone(),
101            source,
102        })?;
103        file.set_len(0).map_err(|source| ProjectError::Io {
104            path: self.path.clone(),
105            source,
106        })?;
107        file.write_all(&bytes).map_err(|source| ProjectError::Io {
108            path: self.path.clone(),
109            source,
110        })
111    }
112}
113
114/// Takes a lock, logging once when the lock is contended.
115fn wait_for_lock(
116    path: &Path,
117    try_lock: impl FnOnce() -> Result<(), TryLockError>,
118    lock: impl FnOnce() -> std::io::Result<()>,
119) -> Result<(), ProjectError> {
120    match try_lock() {
121        Ok(()) => Ok(()),
122        Err(TryLockError::WouldBlock) => {
123            #[cfg(feature = "git-resolver")]
124            tracing::info!(
125                lockfile = %path.display(),
126                "waiting to acquire the module lockfile lock"
127            );
128            lock().map_err(|source| ProjectError::Io {
129                path: path.to_path_buf(),
130                source,
131            })
132        }
133        Err(TryLockError::Error(source)) => Err(ProjectError::Io {
134            path: path.to_path_buf(),
135            source,
136        }),
137    }
138}
139
140/// Reads and parses a locked lockfile handle from its start.
141fn parse(file: &File, path: &Path) -> Result<Option<Lockfile>, ProjectError> {
142    let mut handle = file;
143    handle.rewind().map_err(|source| ProjectError::Io {
144        path: path.to_path_buf(),
145        source,
146    })?;
147    let mut bytes = Vec::new();
148    handle
149        .read_to_end(&mut bytes)
150        .map_err(|source| ProjectError::Io {
151            path: path.to_path_buf(),
152            source,
153        })?;
154    if bytes.is_empty() {
155        return Ok(None);
156    }
157    Lockfile::parse(&bytes)
158        .map(Some)
159        .map_err(|source| ProjectError::Lockfile {
160            path: path.to_path_buf(),
161            source,
162        })
163}
164
165#[cfg(test)]
166mod tests {
167    use std::path::Path;
168    use std::sync::mpsc;
169    use std::time::Duration;
170
171    use super::*;
172
173    /// A minimal valid `module-lock.json`.
174    const LOCKFILE: &[u8] = br#"{"version":1,"dependencies":{}}"#;
175
176    /// Any error a test can propagate.
177    type Result = std::result::Result<(), Box<dyn std::error::Error>>;
178
179    /// Returns the lockfile path inside `root`.
180    fn lockfile_path(root: &Path) -> std::path::PathBuf {
181        root.join(crate::LOCKFILE_FILENAME)
182    }
183
184    #[test]
185    fn read_reports_an_absent_lockfile_as_none() -> Result {
186        let directory = tempfile::tempdir()?;
187        let path = lockfile_path(directory.path());
188
189        assert!(LockedLockfile::read(&path)?.is_none());
190        assert!(
191            !path.exists(),
192            "reading must never create `module-lock.json`"
193        );
194        Ok(())
195    }
196
197    #[test]
198    fn read_parses_a_present_lockfile() -> Result {
199        let directory = tempfile::tempdir()?;
200        let path = lockfile_path(directory.path());
201        std::fs::write(&path, LOCKFILE)?;
202
203        assert_eq!(
204            LockedLockfile::read(&path)?.map(|lockfile| lockfile.version),
205            Some(crate::lockfile::LOCKFILE_VERSION)
206        );
207        Ok(())
208    }
209
210    #[test]
211    fn read_reports_an_empty_lockfile_as_none() -> Result {
212        let directory = tempfile::tempdir()?;
213        let path = lockfile_path(directory.path());
214        std::fs::write(&path, b"")?;
215
216        assert!(LockedLockfile::read(&path)?.is_none());
217        Ok(())
218    }
219
220    #[cfg(unix)]
221    #[test]
222    fn write_keeps_the_locked_inode() -> Result {
223        use std::os::unix::fs::MetadataExt as _;
224
225        let directory = tempfile::tempdir()?;
226        let path = lockfile_path(directory.path());
227        std::fs::write(&path, LOCKFILE)?;
228        let before = std::fs::metadata(&path)?.ino();
229
230        LockedLockfile::acquire(&path)?.write(&Lockfile::default())?;
231
232        assert_eq!(
233            std::fs::metadata(&path)?.ino(),
234            before,
235            "writing through the held handle must not replace the inode"
236        );
237        Ok(())
238    }
239
240    #[test]
241    fn write_replaces_longer_previous_contents() -> Result {
242        let directory = tempfile::tempdir()?;
243        let path = lockfile_path(directory.path());
244        std::fs::write(&path, [LOCKFILE, b"                    "].concat())?;
245
246        LockedLockfile::acquire(&path)?.write(&Lockfile::default())?;
247
248        assert_eq!(
249            LockedLockfile::read(&path)?.map(|lockfile| lockfile.version),
250            Some(crate::lockfile::LOCKFILE_VERSION)
251        );
252        Ok(())
253    }
254
255    #[test]
256    fn acquire_serializes_concurrent_writers() -> Result {
257        let directory = tempfile::tempdir()?;
258        let path = lockfile_path(directory.path());
259        let first = LockedLockfile::acquire(&path)?;
260        let (sender, receiver) = mpsc::channel();
261        let thread = std::thread::spawn({
262            let path = path.clone();
263            move || {
264                // SAFETY: the receiver lives until this thread is joined, so
265                // the channel cannot be disconnected before the send.
266                sender.send(LockedLockfile::acquire(&path).is_ok()).unwrap();
267            }
268        });
269
270        assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err());
271        drop(first);
272        assert!(receiver.recv_timeout(Duration::from_secs(5))?);
273        // SAFETY: the spawned closure only sends on a channel, so it cannot
274        // panic and the join cannot observe a panicked thread.
275        thread.join().unwrap();
276        Ok(())
277    }
278
279    #[test]
280    fn current_sees_what_is_on_disk_under_the_lock() -> Result {
281        let directory = tempfile::tempdir()?;
282        let path = lockfile_path(directory.path());
283        std::fs::write(&path, LOCKFILE)?;
284
285        let guard = LockedLockfile::acquire(&path)?;
286
287        assert_eq!(
288            guard.current()?.map(|lockfile| lockfile.version),
289            Some(crate::lockfile::LOCKFILE_VERSION)
290        );
291        Ok(())
292    }
293}