tame_index/utils/
flock.rs

1//! Provides facilities for file locks on unix and windows
2
3use crate::{Error, Path, PathBuf};
4use std::{fs, time::Duration};
5
6#[cfg_attr(unix, path = "flock/unix.rs")]
7#[cfg_attr(windows, path = "flock/windows.rs")]
8mod sys;
9
10/// An error pertaining to a failed file lock
11#[derive(Debug, thiserror::Error)]
12#[error("failed to obtain lock file '{path}'")]
13pub struct FileLockError {
14    /// The path of the file lock
15    pub path: PathBuf,
16    /// The underlying failure reason
17    pub source: LockError,
18}
19
20/// Errors that can occur when attempting to acquire a [`FileLock`]
21#[derive(Debug, thiserror::Error)]
22pub enum LockError {
23    /// An I/O error occurred attempting to open the lock file
24    #[error(transparent)]
25    Open(std::io::Error),
26    /// Exclusive locks cannot be take on read-only file systems
27    #[error("attempted to take an exclusive lock on a read-only path")]
28    Readonly,
29    /// Failed to create parents directories to lock file
30    #[error("failed to create parent directories for lock path")]
31    CreateDir(std::io::Error),
32    /// Locking is not supported if the lock file is on an NFS, though note this
33    /// is a bit more nuanced as `NFSv4` _does_ support file locking, but is out
34    /// of scope, at least for now
35    #[error("NFS do not support locking")]
36    Nfs,
37    /// This could happen on eg. _extremely_ old and outdated OSes or some filesystems
38    /// and is only present for completeness
39    #[error("locking is not supported on the filesystem and/or in the kernel")]
40    NotSupported,
41    /// An I/O error occurred attempting to un/lock the file
42    #[error("failed to acquire or release file lock")]
43    Lock(std::io::Error),
44    /// The lock could not be acquired within the caller provided timeout
45    #[error("failed to acquire lock within the specified duration")]
46    TimedOut,
47    /// The lock is currently held by another
48    #[error("the lock is currently held by another")]
49    Contested,
50}
51
52/// Provides options for creating a [`FileLock`]
53pub struct LockOptions<'pb> {
54    path: std::borrow::Cow<'pb, Path>,
55    exclusive: bool,
56    shared_fallback: bool,
57}
58
59impl<'pb> LockOptions<'pb> {
60    /// Creates a new [`Self`] for the specified path
61    #[inline]
62    pub fn new(path: &'pb Path) -> Self {
63        Self {
64            path: path.into(),
65            exclusive: false,
66            shared_fallback: false,
67        }
68    }
69
70    /// Creates a new [`Self`] for locking cargo's global package lock
71    ///
72    /// If specified, the path is used as the root, otherwise it is rooted at
73    /// the path determined by `$CARGO_HOME`
74    #[inline]
75    pub fn cargo_package_lock(root: Option<PathBuf>) -> Result<Self, Error> {
76        let mut path = if let Some(root) = root {
77            root
78        } else {
79            crate::utils::cargo_home()?
80        };
81        path.push(".package-cache");
82
83        Ok(Self {
84            path: path.into(),
85            exclusive: true,
86            shared_fallback: false,
87        })
88    }
89
90    /// Will attempt to acquire a shared lock rather than an exclusive one
91    #[inline]
92    pub fn shared(mut self) -> Self {
93        self.exclusive = false;
94        self
95    }
96
97    /// Will attempt to acquire an exclusive lock, which can optionally fallback
98    /// to a shared lock if the lock file is for a read only filesystem
99    #[inline]
100    pub fn exclusive(mut self, shared_fallback: bool) -> Self {
101        self.exclusive = true;
102        self.shared_fallback = shared_fallback;
103        self
104    }
105
106    /// Attempts to acquire a lock, but fails immediately if the lock is currently
107    /// held
108    #[inline]
109    pub fn try_lock(&self) -> Result<FileLock, Error> {
110        self.open_and_lock(Option::<fn(&Path) -> Option<Duration>>::None)
111    }
112
113    /// Attempts to acquire a lock, waiting if the lock is currently held.
114    ///
115    /// Unlike [`Self::try_lock`], if the lock is currently held, the specified
116    /// callback is called to inform the caller that a wait is about to
117    /// be performed, then waits for the amount of time specified by the return
118    /// of the callback, or infinitely in the case of `None`.
119    #[inline]
120    pub fn lock(&self, wait: impl Fn(&Path) -> Option<Duration>) -> Result<FileLock, Error> {
121        self.open_and_lock(Some(wait))
122    }
123
124    fn open(&self, opts: &fs::OpenOptions) -> Result<fs::File, FileLockError> {
125        opts.open(self.path.as_std_path()).or_else(|err| {
126            if err.kind() == std::io::ErrorKind::NotFound && self.exclusive {
127                fs::create_dir_all(self.path.parent().unwrap()).map_err(|e| FileLockError {
128                    path: self.path.parent().unwrap().to_owned(),
129                    source: LockError::CreateDir(e),
130                })?;
131                self.open(opts)
132            } else {
133                // Note we just use the 30 EROFS constant here, which won't work on WASI, Haiku, or some other
134                // niche targets, but none of them are intended targets for this crate, but can be fixed later
135                // if someone actually uses them
136                let source = if err.kind() == std::io::ErrorKind::PermissionDenied
137                    || cfg!(unix) && err.raw_os_error() == Some(30 /* EROFS */)
138                {
139                    LockError::Readonly
140                } else {
141                    LockError::Open(err)
142                };
143
144                Err(FileLockError {
145                    path: self.path.as_ref().to_owned(),
146                    source,
147                })
148            }
149        })
150    }
151
152    fn open_and_lock(
153        &self,
154        wait: Option<impl Fn(&Path) -> Option<Duration>>,
155    ) -> Result<FileLock, Error> {
156        let (state, file) = if self.exclusive {
157            match self.open(&sys::open_opts(true)) {
158                Ok(file) => (LockState::Exclusive, file),
159                Err(err) => {
160                    // If the user requested it, check if the error is due to a read only error,
161                    // and if so, fallback to a shared lock instead of an exclusive lock, just
162                    // as cargo does
163                    //
164                    // https://github.com/rust-lang/cargo/blob/0b6cc3c75f1813df857fb54421edf7f8fee548e3/src/cargo/util/config/mod.rs#L1907-L1935
165                    if self.shared_fallback && matches!(err.source, LockError::Readonly) {
166                        (LockState::Shared, self.open(&sys::open_opts(false))?)
167                    } else {
168                        return Err(err.into());
169                    }
170                }
171            }
172        } else {
173            (LockState::Shared, self.open(&sys::open_opts(false))?)
174        };
175
176        self.do_lock(state, &file, wait)
177            .map_err(|source| FileLockError {
178                path: self.path.as_ref().to_owned(),
179                source,
180            })?;
181
182        Ok(FileLock {
183            file: Some(file),
184            state,
185        })
186    }
187
188    fn do_lock(
189        &self,
190        state: LockState,
191        file: &fs::File,
192        wait: Option<impl Fn(&Path) -> Option<std::time::Duration>>,
193    ) -> Result<(), LockError> {
194        #[cfg(all(target_os = "linux", not(target_env = "musl")))]
195        fn is_on_nfs_mount(path: &crate::Path) -> bool {
196            use std::os::unix::prelude::*;
197
198            let path = match std::ffi::CString::new(path.as_os_str().as_bytes()) {
199                Ok(path) => path,
200                Err(_) => return false,
201            };
202
203            #[allow(unsafe_code)]
204            unsafe {
205                let mut buf: libc::statfs = std::mem::zeroed();
206                let r = libc::statfs(path.as_ptr(), &mut buf);
207
208                r == 0 && buf.f_type as u32 == libc::NFS_SUPER_MAGIC as u32
209            }
210        }
211
212        #[cfg(any(not(target_os = "linux"), target_env = "musl"))]
213        fn is_on_nfs_mount(_path: &crate::Path) -> bool {
214            false
215        }
216
217        // File locking on Unix is currently implemented via `flock`, which is known
218        // to be broken on NFS. We could in theory just ignore errors that happen on
219        // NFS, but apparently the failure mode [1] for `flock` on NFS is **blocking
220        // forever**, even if the "non-blocking" flag is passed!
221        //
222        // As a result, we just skip all file locks entirely on NFS mounts. That
223        // should avoid calling any `flock` functions at all, and it wouldn't work
224        // there anyway.
225        //
226        // [1]: https://github.com/rust-lang/cargo/issues/2615
227        if is_on_nfs_mount(&self.path) {
228            return Err(LockError::Nfs);
229        }
230
231        match sys::try_lock(file, state) {
232            Ok(()) => return Ok(()),
233
234            // In addition to ignoring NFS which is commonly not working we also
235            // just ignore locking on filesystems that look like they don't
236            // implement file locking.
237            Err(e) if sys::is_unsupported(&e) => return Err(LockError::NotSupported),
238
239            Err(e) => {
240                if !sys::is_contended(&e) {
241                    return Err(LockError::Lock(e));
242                }
243            }
244        }
245
246        // Signal to the caller that we are about to enter a blocking operation
247        // and whether they want to assign a timeout to it
248        if let Some(wait) = wait {
249            let timeout = wait(&self.path);
250
251            sys::lock(file, state, timeout).map_err(|e| {
252                if sys::is_timed_out(&e) {
253                    LockError::TimedOut
254                } else {
255                    LockError::Lock(e)
256                }
257            })
258        } else {
259            Err(LockError::Contested)
260        }
261    }
262}
263
264#[derive(PartialEq, Copy, Clone, Debug)]
265enum LockState {
266    Exclusive,
267    Shared,
268    Unlocked,
269}
270
271/// A currently held file lock.
272///
273/// The lock is released when this is dropped, or the program exits for any reason,
274/// including `SIGKILL` or power loss
275pub struct FileLock {
276    file: Option<std::fs::File>,
277    state: LockState,
278}
279
280impl FileLock {
281    /// Creates a [`Self`] in an unlocked state.
282    ///
283    /// This allows for easy testing or use in situations where you don't care
284    /// about file locking, or have other ways to ensure something is uncontested
285    pub fn unlocked() -> Self {
286        Self {
287            file: None,
288            state: LockState::Unlocked,
289        }
290    }
291}
292
293impl Drop for FileLock {
294    fn drop(&mut self) {
295        if self.state != LockState::Unlocked {
296            if let Some(f) = self.file.take() {
297                let _ = sys::unlock(&f);
298            }
299        }
300    }
301}