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