wdl_modules/project/
lockfile.rs1use 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#[derive(Debug)]
28pub struct LockedLockfile {
29 path: PathBuf,
31 file: File,
33}
34
35impl LockedLockfile {
36 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 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 pub fn current(&self) -> Result<Option<Lockfile>, ProjectError> {
83 parse(&self.file, &self.path)
84 }
85
86 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
114fn 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
140fn 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 const LOCKFILE: &[u8] = br#"{"version":1,"dependencies":{}}"#;
175
176 type Result = std::result::Result<(), Box<dyn std::error::Error>>;
178
179 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 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 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}