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}