cargo/util/flock.rs
1//! File-locking support.
2//!
3//! This module defines the [`Filesystem`] type which is an abstraction over a
4//! filesystem, ensuring that access to the filesystem is only done through
5//! coordinated locks.
6//!
7//! The [`FileLock`] type represents a locked file, and provides access to the
8//! file.
9
10use std::fs::TryLockError;
11use std::fs::{File, OpenOptions};
12use std::io;
13use std::io::{Read, Seek, SeekFrom, Write};
14use std::path::{Display, Path, PathBuf};
15
16use crate::util::GlobalContext;
17use crate::util::errors::CargoResult;
18use crate::util::style;
19use anyhow::Context as _;
20use cargo_util::paths;
21
22/// A locked file.
23///
24/// This provides access to file while holding a lock on the file. This type
25/// implements the [`Read`], [`Write`], and [`Seek`] traits to provide access
26/// to the underlying file.
27///
28/// Locks are either shared (multiple processes can access the file) or
29/// exclusive (only one process can access the file).
30///
31/// This type is created via methods on the [`Filesystem`] type.
32///
33/// When this value is dropped, the lock will be released.
34#[derive(Debug)]
35pub struct FileLock {
36 f: Option<File>,
37 path: PathBuf,
38}
39
40impl FileLock {
41 /// Returns the underlying file handle of this lock.
42 pub fn file(&self) -> &File {
43 self.f.as_ref().unwrap()
44 }
45
46 /// Returns the underlying path that this lock points to.
47 ///
48 /// Note that special care must be taken to ensure that the path is not
49 /// referenced outside the lifetime of this lock.
50 pub fn path(&self) -> &Path {
51 &self.path
52 }
53
54 /// Returns the parent path containing this file
55 pub fn parent(&self) -> &Path {
56 self.path.parent().unwrap()
57 }
58
59 /// Removes all sibling files to this locked file.
60 ///
61 /// This can be useful if a directory is locked with a sentinel file but it
62 /// needs to be cleared out as it may be corrupt.
63 pub fn remove_siblings(&self) -> CargoResult<()> {
64 let path = self.path();
65 for entry in path.parent().unwrap().read_dir()? {
66 let entry = entry?;
67 if Some(&entry.file_name()[..]) == path.file_name() {
68 continue;
69 }
70 let kind = entry.file_type()?;
71 if kind.is_dir() {
72 paths::remove_dir_all(entry.path())?;
73 } else {
74 paths::remove_file(entry.path())?;
75 }
76 }
77 Ok(())
78 }
79
80 /// Renames the file and updates the internal path.
81 ///
82 /// This method performs a filesystem rename operation using [`std::fs::rename`]
83 /// while keeping the FileLock's internal path synchronized with the actual
84 /// file location.
85 ///
86 /// ## Difference from `std::fs::rename`
87 ///
88 /// - `std::fs::rename(old, new)` only moves the file on the filesystem
89 /// - `FileLock::rename(new)` moves the file AND updates `self.path` to point to the new location
90 pub fn rename<P: AsRef<Path>>(&mut self, new_path: P) -> CargoResult<()> {
91 let new_path = new_path.as_ref();
92 std::fs::rename(&self.path, new_path).with_context(|| {
93 format!(
94 "failed to rename {} to {}",
95 self.path.display(),
96 new_path.display()
97 )
98 })?;
99 self.path = new_path.to_path_buf();
100 Ok(())
101 }
102}
103
104impl Read for FileLock {
105 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
106 self.file().read(buf)
107 }
108}
109
110impl Seek for FileLock {
111 fn seek(&mut self, to: SeekFrom) -> io::Result<u64> {
112 self.file().seek(to)
113 }
114}
115
116impl Write for FileLock {
117 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
118 self.file().write(buf)
119 }
120
121 fn flush(&mut self) -> io::Result<()> {
122 self.file().flush()
123 }
124}
125
126impl Drop for FileLock {
127 fn drop(&mut self) {
128 if let Some(f) = self.f.take() {
129 if let Err(e) = f.unlock() {
130 tracing::warn!("failed to release lock: {e:?}");
131 }
132 }
133 }
134}
135
136/// A "filesystem" is intended to be a globally shared, hence locked, resource
137/// in Cargo.
138///
139/// The `Path` of a filesystem cannot be learned unless it's done in a locked
140/// fashion, and otherwise functions on this structure are prepared to handle
141/// concurrent invocations across multiple instances of Cargo.
142///
143/// The methods on `Filesystem` that open files return a [`FileLock`] which
144/// holds the lock, and that type provides methods for accessing the
145/// underlying file.
146///
147/// If the blocking methods (like [`Filesystem::open_ro_shared`]) detect that
148/// they will block, then they will display a message to the user letting them
149/// know it is blocked. There are non-blocking variants starting with the
150/// `try_` prefix like [`Filesystem::try_open_ro_shared_create`].
151///
152/// The behavior of locks acquired by the `Filesystem` depend on the operating
153/// system. On unix-like system, they are advisory using [`flock`], and thus
154/// not enforced against processes which do not try to acquire the lock. On
155/// Windows, they are mandatory using [`LockFileEx`], enforced against all
156/// processes.
157///
158/// This **does not** guarantee that a lock is acquired. In some cases, for
159/// example on filesystems that don't support locking, it will return a
160/// [`FileLock`] even though the filesystem lock was not acquired. This is
161/// intended to provide a graceful fallback instead of refusing to work.
162/// Usually there aren't multiple processes accessing the same resource. In
163/// that case, it is the user's responsibility to not run concurrent
164/// processes.
165///
166/// [`flock`]: https://linux.die.net/man/2/flock
167/// [`LockFileEx`]: https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct Filesystem {
170 root: PathBuf,
171}
172
173impl Filesystem {
174 /// Creates a new filesystem to be rooted at the given path.
175 pub fn new(path: PathBuf) -> Filesystem {
176 Filesystem { root: path }
177 }
178
179 /// Like `Path::join`, creates a new filesystem rooted at this filesystem
180 /// joined with the given path.
181 pub fn join<T: AsRef<Path>>(&self, other: T) -> Filesystem {
182 Filesystem::new(self.root.join(other))
183 }
184
185 /// Like `Path::push`, pushes a new path component onto this filesystem.
186 pub fn push<T: AsRef<Path>>(&mut self, other: T) {
187 self.root.push(other);
188 }
189
190 /// Consumes this filesystem and returns the underlying `PathBuf`.
191 ///
192 /// Note that this is a relatively dangerous operation and should be used
193 /// with great caution!.
194 pub fn into_path_unlocked(self) -> PathBuf {
195 self.root
196 }
197
198 /// Returns the underlying `Path`.
199 ///
200 /// Note that this is a relatively dangerous operation and should be used
201 /// with great caution!.
202 pub fn as_path_unlocked(&self) -> &Path {
203 &self.root
204 }
205
206 /// Creates the directory pointed to by this filesystem.
207 ///
208 /// Handles errors where other Cargo processes are also attempting to
209 /// concurrently create this directory.
210 pub fn create_dir(&self) -> CargoResult<()> {
211 paths::create_dir_all(&self.root)
212 }
213
214 /// Returns an adaptor that can be used to print the path of this
215 /// filesystem.
216 pub fn display(&self) -> Display<'_> {
217 self.root.display()
218 }
219
220 /// Opens read-write exclusive access to a file, returning the locked
221 /// version of a file.
222 ///
223 /// This function will create a file at `path` if it doesn't already exist
224 /// (including intermediate directories), and then it will acquire an
225 /// exclusive lock on `path`. If the process must block waiting for the
226 /// lock, the `msg` is printed to [`GlobalContext`].
227 ///
228 /// The returned file can be accessed to look at the path and also has
229 /// read/write access to the underlying file.
230 pub fn open_rw_exclusive_create<P>(
231 &self,
232 path: P,
233 gctx: &GlobalContext,
234 msg: &str,
235 ) -> CargoResult<FileLock>
236 where
237 P: AsRef<Path>,
238 {
239 let mut opts = OpenOptions::new();
240 opts.read(true).write(true).create(true);
241 let (path, f) = self.open(path.as_ref(), &opts, true)?;
242 acquire(gctx, msg, &path, &|| f.try_lock(), &|| f.lock())?;
243 Ok(FileLock { f: Some(f), path })
244 }
245
246 /// A non-blocking version of [`Filesystem::open_rw_exclusive_create`].
247 ///
248 /// Returns `None` if the operation would block due to another process
249 /// holding the lock.
250 pub fn try_open_rw_exclusive_create<P: AsRef<Path>>(
251 &self,
252 path: P,
253 ) -> CargoResult<Option<FileLock>> {
254 let mut opts = OpenOptions::new();
255 opts.read(true).write(true).create(true);
256 let (path, f) = self.open(path.as_ref(), &opts, true)?;
257 if try_acquire(&path, &|| f.try_lock())? {
258 Ok(Some(FileLock { f: Some(f), path }))
259 } else {
260 Ok(None)
261 }
262 }
263
264 /// Opens read-only shared access to a file, returning the locked version of a file.
265 ///
266 /// This function will fail if `path` doesn't already exist, but if it does
267 /// then it will acquire a shared lock on `path`. If the process must block
268 /// waiting for the lock, the `msg` is printed to [`GlobalContext`].
269 ///
270 /// The returned file can be accessed to look at the path and also has read
271 /// access to the underlying file. Any writes to the file will return an
272 /// error.
273 pub fn open_ro_shared<P>(
274 &self,
275 path: P,
276 gctx: &GlobalContext,
277 msg: &str,
278 ) -> CargoResult<FileLock>
279 where
280 P: AsRef<Path>,
281 {
282 let (path, f) = self.open(path.as_ref(), &OpenOptions::new().read(true), false)?;
283 acquire(gctx, msg, &path, &|| f.try_lock_shared(), &|| {
284 f.lock_shared()
285 })?;
286 Ok(FileLock { f: Some(f), path })
287 }
288
289 /// Opens read-only shared access to a file, returning the locked version of a file.
290 ///
291 /// Compared to [`Filesystem::open_ro_shared`], this will create the file
292 /// (and any directories in the parent) if the file does not already
293 /// exist.
294 pub fn open_ro_shared_create<P: AsRef<Path>>(
295 &self,
296 path: P,
297 gctx: &GlobalContext,
298 msg: &str,
299 ) -> CargoResult<FileLock> {
300 let mut opts = OpenOptions::new();
301 opts.read(true).write(true).create(true);
302 let (path, f) = self.open(path.as_ref(), &opts, true)?;
303 acquire(gctx, msg, &path, &|| f.try_lock_shared(), &|| {
304 f.lock_shared()
305 })?;
306 Ok(FileLock { f: Some(f), path })
307 }
308
309 /// A non-blocking version of [`Filesystem::open_ro_shared_create`].
310 ///
311 /// Returns `None` if the operation would block due to another process
312 /// holding the lock.
313 pub fn try_open_ro_shared_create<P: AsRef<Path>>(
314 &self,
315 path: P,
316 ) -> CargoResult<Option<FileLock>> {
317 let mut opts = OpenOptions::new();
318 opts.read(true).write(true).create(true);
319 let (path, f) = self.open(path.as_ref(), &opts, true)?;
320 if try_acquire(&path, &|| f.try_lock_shared())? {
321 Ok(Some(FileLock { f: Some(f), path }))
322 } else {
323 Ok(None)
324 }
325 }
326
327 fn open(&self, path: &Path, opts: &OpenOptions, create: bool) -> CargoResult<(PathBuf, File)> {
328 let path = self.root.join(path);
329 let f = opts
330 .open(&path)
331 .or_else(|e| {
332 // If we were requested to create this file, and there was a
333 // NotFound error, then that was likely due to missing
334 // intermediate directories. Try creating them and try again.
335 if e.kind() == io::ErrorKind::NotFound && create {
336 paths::create_dir_all(path.parent().unwrap())?;
337 Ok(opts.open(&path)?)
338 } else {
339 Err(anyhow::Error::from(e))
340 }
341 })
342 .with_context(|| format!("failed to open: {}", path.display()))?;
343 Ok((path, f))
344 }
345}
346
347impl PartialEq<Path> for Filesystem {
348 fn eq(&self, other: &Path) -> bool {
349 self.root == other
350 }
351}
352
353impl PartialEq<Filesystem> for Path {
354 fn eq(&self, other: &Filesystem) -> bool {
355 self == other.root
356 }
357}
358
359fn try_acquire(path: &Path, lock_try: &dyn Fn() -> Result<(), TryLockError>) -> CargoResult<bool> {
360 // File locking on Unix is currently implemented via `flock`, which is known
361 // to be broken on NFS. We could in theory just ignore errors that happen on
362 // NFS, but apparently the failure mode [1] for `flock` on NFS is **blocking
363 // forever**, even if the "non-blocking" flag is passed!
364 //
365 // As a result, we just skip all file locks entirely on NFS mounts. That
366 // should avoid calling any `flock` functions at all, and it wouldn't work
367 // there anyway.
368 //
369 // [1]: https://github.com/rust-lang/cargo/issues/2615
370 if is_on_nfs_mount(path) {
371 tracing::debug!("{path:?} appears to be an NFS mount, not trying to lock");
372 return Ok(true);
373 }
374
375 match lock_try() {
376 Ok(()) => Ok(true),
377
378 // In addition to ignoring NFS which is commonly not working we also
379 // just ignore locking on filesystems that look like they don't
380 // implement file locking.
381 Err(TryLockError::Error(e)) if error_unsupported(&e) => Ok(true),
382
383 Err(TryLockError::Error(e)) => {
384 let e = anyhow::Error::from(e);
385 let cx = format!("failed to lock file: {}", path.display());
386 Err(e.context(cx))
387 }
388
389 Err(TryLockError::WouldBlock) => Ok(false),
390 }
391}
392
393/// Acquires a lock on a file in a "nice" manner.
394///
395/// Almost all long-running blocking actions in Cargo have a status message
396/// associated with them as we're not sure how long they'll take. Whenever a
397/// conflicted file lock happens, this is the case (we're not sure when the lock
398/// will be released).
399///
400/// This function will acquire the lock on a `path`, printing out a nice message
401/// to the console if we have to wait for it. It will first attempt to use `try`
402/// to acquire a lock on the crate, and in the case of contention it will emit a
403/// status message based on `msg` to [`GlobalContext`]'s shell, and then use `block` to
404/// block waiting to acquire a lock.
405///
406/// Returns an error if the lock could not be acquired or if any error other
407/// than a contention error happens.
408fn acquire(
409 gctx: &GlobalContext,
410 msg: &str,
411 path: &Path,
412 lock_try: &dyn Fn() -> Result<(), TryLockError>,
413 lock_block: &dyn Fn() -> io::Result<()>,
414) -> CargoResult<()> {
415 // Ensure `shell` is not already in use,
416 // regardless of whether we hit contention or not
417 gctx.debug_assert_shell_not_borrowed();
418 if try_acquire(path, lock_try)? {
419 return Ok(());
420 }
421 let msg = format!("waiting for file lock on {}", msg);
422 gctx.shell()
423 .status_with_color("Blocking", &msg, &style::NOTE)?;
424
425 lock_block().with_context(|| format!("failed to lock file: {}", path.display()))?;
426 Ok(())
427}
428
429#[cfg(all(target_os = "linux", not(target_env = "musl")))]
430fn is_on_nfs_mount(path: &Path) -> bool {
431 use std::ffi::CString;
432 use std::mem;
433 use std::os::unix::prelude::*;
434
435 let Ok(path) = CString::new(path.as_os_str().as_bytes()) else {
436 return false;
437 };
438
439 unsafe {
440 let mut buf: libc::statfs = mem::zeroed();
441 let r = libc::statfs(path.as_ptr(), &mut buf);
442
443 r == 0 && buf.f_type as u32 == libc::NFS_SUPER_MAGIC as u32
444 }
445}
446
447#[cfg(any(not(target_os = "linux"), target_env = "musl"))]
448fn is_on_nfs_mount(_path: &Path) -> bool {
449 false
450}
451
452#[cfg(unix)]
453fn error_unsupported(err: &std::io::Error) -> bool {
454 match err.raw_os_error() {
455 // Unfortunately, depending on the target, these may or may not be the same.
456 // For targets in which they are the same, the duplicate pattern causes a warning.
457 #[allow(unreachable_patterns)]
458 Some(libc::ENOTSUP | libc::EOPNOTSUPP) => true,
459 Some(libc::ENOSYS) => true,
460 _ => err.kind() == std::io::ErrorKind::Unsupported,
461 }
462}
463
464#[cfg(windows)]
465fn error_unsupported(err: &std::io::Error) -> bool {
466 use windows_sys::Win32::Foundation::ERROR_INVALID_FUNCTION;
467 match err.raw_os_error() {
468 Some(code) if code == ERROR_INVALID_FUNCTION as i32 => true,
469 _ => err.kind() == std::io::ErrorKind::Unsupported,
470 }
471}