Skip to main content

fslock_guard/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3// @@ begin lint list maintained by maint/add_warning @@
4#![allow(renamed_and_removed_lints)] // @@REMOVE_WHEN(ci_arti_stable)
5#![allow(unknown_lints)] // @@REMOVE_WHEN(ci_arti_nightly)
6#![warn(missing_docs)]
7#![warn(noop_method_call)]
8#![warn(unreachable_pub)]
9#![warn(clippy::all)]
10#![deny(clippy::await_holding_lock)]
11#![deny(clippy::cargo_common_metadata)]
12#![deny(clippy::cast_lossless)]
13#![deny(clippy::checked_conversions)]
14#![allow(clippy::cognitive_complexity)] // See arti#2556
15#![deny(clippy::debug_assert_with_mut_call)]
16#![deny(clippy::exhaustive_enums)]
17#![deny(clippy::exhaustive_structs)]
18#![deny(clippy::expl_impl_clone_on_copy)]
19#![deny(clippy::fallible_impl_from)]
20#![deny(clippy::implicit_clone)]
21#![deny(clippy::large_stack_arrays)]
22#![warn(clippy::manual_ok_or)]
23#![deny(clippy::missing_docs_in_private_items)]
24#![warn(clippy::needless_borrow)]
25#![warn(clippy::needless_pass_by_value)]
26#![warn(clippy::option_option)]
27#![deny(clippy::print_stderr)]
28#![deny(clippy::print_stdout)]
29#![warn(clippy::rc_buffer)]
30#![deny(clippy::ref_option_ref)]
31#![warn(clippy::semicolon_if_nothing_returned)]
32#![warn(clippy::trait_duplication_in_bounds)]
33#![deny(clippy::unchecked_time_subtraction)]
34#![deny(clippy::unnecessary_wraps)]
35#![warn(clippy::unseparated_literal_suffix)]
36#![deny(clippy::unwrap_used)]
37#![deny(clippy::mod_module_files)]
38#![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness
39#![allow(clippy::uninlined_format_args)]
40#![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945
41#![allow(clippy::result_large_err)] // temporary workaround for arti#587
42#![allow(clippy::needless_raw_string_hashes)] // complained-about code is fine, often best
43#![allow(clippy::needless_lifetimes)] // See arti#1765
44#![allow(mismatched_lifetime_syntaxes)] // temporary workaround for arti#2060
45#![allow(clippy::collapsible_if)] // See arti#2342
46#![deny(clippy::unused_async)]
47#![deny(clippy::string_slice)] // See arti#2571
48//! <!-- @@ end lint list maintained by maint/add_warning @@ -->
49
50use std::{fs, path::Path};
51
52/// A lock-file for which we hold the lock.
53///
54/// So long as this object exists, we hold the lock on this file.
55/// When it is dropped, we will release the lock.
56///
57/// # Semantics
58///
59///  * Only one `LockFileGuard` can exist at one time
60///    for any particular `path`.
61///  * This applies across all tasks and threads in all programs;
62///    other acquisitions of the lock in the same process are prevented.
63///  * This applies across even separate machines, if `path` is on a shared filesystem.
64///
65/// # Restrictions
66///
67///  * **`path` must only be deleted (or renamed) via the APIs in this module**
68///  * This restriction applies to all programs on the computer,
69///    so for example automatic file cleaning with `find` and `rm` is forbidden.
70///  * Cross-filesystem locking is broken on Linux before 2.6.12.
71#[derive(Debug)]
72pub struct LockFileGuard {
73    /// A [`File`](fs::File) with its exclusive lock held.
74    ///
75    /// This `File` instance will remain locked for as long as this
76    /// LockFileGuard exists.
77    locked_file: fs::File,
78}
79
80impl LockFileGuard {
81    /// Try to open `path` with options suitable for using it as a lockfile,
82    /// creating it as necessary.
83    fn open<P>(path: P) -> Result<fs::File, std::io::Error>
84    where
85        P: AsRef<Path>,
86    {
87        fs::OpenOptions::new()
88            .read(true)
89            .write(true)
90            .create(true)
91            .truncate(false)
92            .open(&path)
93    }
94
95    /// Try to construct a new [`LockFileGuard`] representing a lock we hold on
96    /// the file `path`.
97    ///
98    /// Blocks until we can get the lock.
99    pub fn lock<P>(path: P) -> Result<Self, std::io::Error>
100    where
101        P: AsRef<Path>,
102    {
103        let path = path.as_ref();
104        loop {
105            let file = Self::open(path)?;
106            do_lock(&file)?;
107
108            if os::lockfile_has_path(&file, path)? {
109                return Ok(Self { locked_file: file });
110            }
111        }
112    }
113
114    /// Try to construct a new [`LockFileGuard`] representing a lock we hold on
115    /// the file `path`.
116    ///
117    /// Does not block; returns Ok(None) if somebody else holds the lock.
118    pub fn try_lock<P>(path: P) -> Result<Option<Self>, std::io::Error>
119    where
120        P: AsRef<Path>,
121    {
122        let path = path.as_ref();
123        let file = Self::open(path)?;
124        match do_try_lock(&file) {
125            Ok(()) => {
126                if os::lockfile_has_path(&file, path)? {
127                    Ok(Some(Self { locked_file: file }))
128                } else {
129                    Ok(None)
130                }
131            }
132            Err(fs::TryLockError::WouldBlock) => Ok(None),
133            Err(fs::TryLockError::Error(e)) => Err(e),
134        }
135    }
136
137    /// Try to delete the lock file that we hold.
138    ///
139    /// The provided `path` must be the same as was passed to `lock`.
140    pub fn delete_lock_file<P>(self, path: P) -> Result<(), std::io::Error>
141    where
142        P: AsRef<Path>,
143    {
144        let path = path.as_ref();
145        if os::lockfile_has_path(&self.locked_file, path)? {
146            std::fs::remove_file(path)
147        } else {
148            Err(std::io::Error::other(MismatchedPathError {}))
149        }
150    }
151}
152
153impl Drop for LockFileGuard {
154    // We pro-actively unlock the file rather than relying on drop of the File closing it.
155    //
156    // This is necessary on Unix because otherwise the following scenario is possible:
157    //   0. The process has multiple threads
158    //   1. Thread A executes fork (eg as part of spawn), and the child gets a copy of the fd,
159    //   2. Thread B drops the `LockFileGuard` and calls close() on its copy of the fd
160    //   3. Thread B tries to re-acquire the same lock with try_lock and fails
161    //   4. Thread A closes the fd (via exec, or otherwise)
162    // We want to prevent the error in step 3, which arises from a race which is possible
163    // due to us violating the expected semantics of a guard (namely, that the lock is
164    // synchronously released when the guard is dropped).
165    #[allow(clippy::unnecessary_lazy_evaluations)] // we want to write the discarded error type
166    fn drop(&mut self) {
167        self.locked_file
168            .unlock()
169            // Ignore errors from unlock.  There shouldn't be any, but if there are we
170            // don't have anything sensible we could do with them.
171            .unwrap_or_else(|_: std::io::Error| ());
172    }
173}
174
175/// Try to lock `f`, blocking if need be.
176///
177/// On non-android, this just calls [`fs::File::lock`].
178#[cfg(not(target_os = "android"))]
179fn do_lock(f: &fs::File) -> std::io::Result<()> {
180    f.lock()
181}
182
183/// Try to lock `f`, without blocking.
184///
185/// On non-android, this just calls [`fs::File::try_lock`].
186#[cfg(not(target_os = "android"))]
187fn do_try_lock(f: &fs::File) -> Result<(), std::fs::TryLockError> {
188    f.try_lock()
189}
190
191/// Try to lock `f`, blocking if need be.
192///
193/// On android, we need to use flock manually, since Rust (as of May 2026)
194/// always returns "not implemented" for `lock()` and `try_lock()`.
195///
196/// See <https://github.com/rust-lang/rust/issues/148325>.
197/// Apparently,
198/// although there are filesystems (specifically FUSE filesystems)
199/// where flock won't work, it will correctly report ENOSYS
200/// on those filesystems.
201//
202// TODO MSRV ????: we can remove this once Rust supports file locking on Android
203// at our MSRV.  As of May 2026, https://github.com/rust-lang/rust/pull/157038/
204// seems like the likeliest MR for that, but it has not been merged.
205#[cfg(target_os = "android")]
206fn do_lock(f: &fs::File) -> std::io::Result<()> {
207    use std::os::fd::AsRawFd;
208
209    let fd = f.as_raw_fd();
210    // SAFETY: Since `f` is a file, it has a valid fd.
211    let success = unsafe { libc::flock(fd, libc::LOCK_EX) } == 0;
212
213    if success {
214        Ok(())
215    } else {
216        Err(std::io::Error::last_os_error())
217    }
218}
219
220/// Try to lock `f`, without blocking.
221///
222/// On android, we need to use flock manually, since Rust (as of May 2026)
223/// always returns "not implemented" for `lock()` and `try_lock()`.
224///
225/// See <https://github.com/rust-lang/rust/issues/148325>.
226/// Apparently,
227/// although there are filesystems (specifically FUSE filesystems)
228/// where flock won't work, it will correctly report ENOSYS
229/// on those filesystems.
230//
231// TODO MSRV ????: See 'TODO MSRV' on do_lock above.
232#[cfg(target_os = "android")]
233fn do_try_lock(f: &fs::File) -> Result<(), std::fs::TryLockError> {
234    use std::os::fd::AsRawFd;
235
236    let fd = f.as_raw_fd();
237    // SAFETY: Since `f` is a file, it has a valid fd.
238    let success = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0;
239
240    if success {
241        Ok(())
242    } else {
243        let err = std::io::Error::last_os_error();
244        if err.kind() == std::io::ErrorKind::WouldBlock {
245            Err(std::fs::TryLockError::WouldBlock)
246        } else {
247            Err(std::fs::TryLockError::Error(err))
248        }
249    }
250}
251
252/// An error that we return when the path given to `delete_lock_file` does not
253/// match the file we have.
254///
255/// Since we wrap this in an `io::Error`, it doesn't need to be public or fancy.
256#[derive(thiserror::Error, Debug, Clone)]
257#[error("Called delete_lock_file with a mismatched path.")]
258struct MismatchedPathError {}
259
260/// Platform module for locking protocol on Unix.
261///
262/// ### Locking protocol on Unix
263///
264/// The lock is held by an open-file iff:
265///
266///  * that open-file holds an `flock` `LOCK_EX` lock; and
267///  * the directory entry for `path` refers to the same file as the open-file
268///
269/// `path` may only refer to a plain file, or `ENOENT`.
270/// If `path` refers to a file,
271/// only the lockholder may cause it to no longer refer to that file.
272///
273/// In principle the open-file might be shared with subprocesses.
274/// Even a naive program can safely and correctly inherit and hold the lock,
275/// since the lockholder only needs to not close an fd.
276/// However uncontrolled leaking of the fd into other processes is undesirable,
277/// as it might cause delays or even deadlocks, if those processes' inheritors live too long.
278/// In our Rust implementation we don't support sharing the held lock
279/// with subprocesses or different process images (ie across exec);
280/// we use `O_CLOEXEC`.
281///
282/// #### Locking algorithm
283///
284///  1. open the file with `O_CREAT|O_RDWR`
285///  2. `flock LOCK_EX`
286///  3. `fstat` the open-file and `lstat` the path
287///  4. If the inode and device numbers don't match,
288///     close the fd and go back to the start.
289///  5. Now we hold the lock.
290///
291/// Proof sketch:
292///
293/// If we get to point 5, we see that at point 3, we had the lock.
294/// No-one else could cause the conditions to become false
295/// in the meantime:
296/// no-one else ~~can~~ may make `path` refer to a different file
297/// since they don't hold the lock.
298/// And, no-one else can `flock` it since the kernel prevents
299/// a conflicting lock.
300/// So at step 5 we must still hold the lock.
301///
302/// #### Unlocking algorithm
303///
304///  1. Close the fd.
305///  2. Now we no longer hold the lock and others can acquire it.
306///
307/// This drops the open-file and
308/// leaves the lock available for another caller.
309///
310/// #### Deletion algorithm
311///
312///  0. The lock must already be held
313///  1. `unlink` the file
314///  2. close the fd
315///  3. Now we no longer hold the lock and others can acquire it.
316///
317/// Step 1 atomically falsifies the lock-holding condition.
318/// We are allowed to perform it because we hold the lock.
319///
320/// Concurrent lockers might open the old file,
321/// which we are about to delete.
322/// They will acquire their `flock` (locking step 2)
323/// after we close (deletion step 2)
324/// and then see that they have a stale file.
325#[cfg(unix)]
326mod os {
327    use std::{fs::File, os::unix::fs::MetadataExt as _, path::Path};
328
329    /// Return true if `lf` currently exists with the given `path`, and false otherwise.
330    pub(crate) fn lockfile_has_path(lf: &File, path: &Path) -> std::io::Result<bool> {
331        let m1 = std::fs::metadata(path)?;
332        let m2 = lf.metadata()?;
333
334        Ok(m1.ino() == m2.ino() && m1.dev() == m2.dev())
335    }
336}
337
338/// Platform module for locking protocol on Windows.
339///
340/// The argument for correctness on Windows proceeds as for Unix, but with a
341/// higher degree of uncertainty, since we are not sufficient Windows experts to
342/// determine if our assumptions hold.
343///
344/// Here we assume as follows:
345/// * When `File::open` calls `CreateFileW`, it gets a `HANDLE` to an open file.
346///   As we use them, the `HANDLE` behaves
347///   similarly to the "fd" in the Unix argument above,
348///   and the open file behaves similarly to the "open-file".
349///   * We assume that any differences that exist in their behavior do not
350///     affect our correctness above.
351/// * When `File::lock` calls `LockFileEx`, and it completes successfully,
352///   we now have a lock on the file.
353///   Only one lock can exist on a file at a time.
354/// * When we compare members of `handle.metadata()` and `path.metadata()`,
355///   the comparison will return equal if ~~and only if~~
356///   the two files are truly the same.
357///   * We rely on the property that a file cannot change its file_index while it is
358///     open.
359/// * Deleting the lock file will actually work, since `File::open` opened it with
360///   FILE_SHARE_DELETE.  (This is the default according to the documentation
361///   for `OpenOptionsExt::share_mode`.)
362/// * When we delete the lock file, possibly-asynchronous ("deferred") deletion
363///   definitely won't mean that the OS kernel violates our rule that no-one but the lockholder
364///   is allowed to delete the file.
365/// * The above is true even if someone with read
366///   access to the file - eg the human user - opens it without the FILE_SHARE options.
367/// * The same is true even if there is a virus scanner.
368/// * The same is true even on a remote filesystem.
369/// * If someone with read access to the file - eg the human user - opens it for reading
370///   without FILE_SHARE options, the algorithm will still work and not fail
371///   with a file sharing violation io error.
372///   (Or, every program the user might use to randomly peer at files in arti's
373///   state directory, including the equivalents of `grep -R` and backup programs,
374///   will use suitable FILE_SHARE options.)
375///   (If this assumption is false, the consequence is not data loss;
376///   rather, arti would fall over.  So that would be tolerable if we don't
377///   know how to do better, or if doing better is hard.)
378#[cfg(windows)]
379mod os {
380    use std::{fs::File, mem::MaybeUninit, os::windows::io::AsRawHandle, path::Path};
381    use windows_sys::Win32::{
382        Foundation::HANDLE,
383        Storage::FileSystem::{FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx},
384    };
385
386    /// Use `GetFileInformationByHandleEx` to return a FILE_ID_INFO data for `f`.
387    ///
388    /// `GetFileInformationByHandleEx` is supported in Vista and later, so it
389    /// should be fine here.  Unlike GetFileInformationByHandle, it gives
390    /// 128-bit identifiers which are supposedly even more unique.
391    fn get_id_info(f: &File) -> std::io::Result<FILE_ID_INFO> {
392        let handle = f.as_raw_handle() as HANDLE;
393        let mut info: MaybeUninit<FILE_ID_INFO> = MaybeUninit::uninit();
394        let buffersize: u32 = std::mem::size_of::<FILE_ID_INFO>()
395            .try_into()
396            .expect("sizeof(FILE_ID_INFO) is ridiculously large");
397
398        let info = unsafe {
399            // SAFETY: Since `size` is the size of info, this will not write to
400            // uninitialized memory.
401            let rv = GetFileInformationByHandleEx(
402                handle,
403                FileIdInfo,
404                info.as_mut_ptr() as _,
405                buffersize,
406            );
407
408            if rv == 0 {
409                return Err(std::io::Error::last_os_error());
410            }
411
412            // SAFETY: since rv was nonzero, this value is initialized.
413            info.assume_init()
414        };
415        Ok(info)
416    }
417
418    /// Return true if `lf` currently exists with the given `path`, and false otherwise.
419    pub(crate) fn lockfile_has_path(lf: &File, path: &Path) -> std::io::Result<bool> {
420        let f2 = File::open(path)?;
421
422        // Note: we would like to just use the MetadataExt methods for index and
423        // volume serial number, but they are currently available only on
424        // nightly: https://github.com/rust-lang/rust/issues/63010
425        //
426        // If they stabilize at our MSRV, _and_ the file ID is expanded to the
427        // 128-bit version, we can use them here instead.
428
429        let i1 = get_id_info(lf)?;
430        let i2 = get_id_info(&f2)?;
431
432        // This comparison is about the best we can do on Windows,
433        // though there are caveats.
434        //
435        // See Raymond Chen's writeup at
436        //   https://devblogs.microsoft.com/oldnewthing/20220128-00/?p=106201
437        // and also see BurntSushi's caveats at
438        //   https://github.com/BurntSushi/same-file/blob/master/src/win.rs
439        Ok(i1.VolumeSerialNumber == i2.VolumeSerialNumber
440            && i1.FileId.Identifier == i2.FileId.Identifier)
441    }
442}
443
444/// Non-windows, non-unix implementation for lockfile_has_path.
445///
446/// For now, this implementation always reports an error.
447/// It exists so that we can build (but not run) on wasm.
448#[cfg(all(not(windows), not(unix)))]
449mod os {
450    use std::path::Path;
451
452    /// Return true if `lf` currently exists with the given `path`, and false otherwise.
453    pub(crate) fn lockfile_has_path(_lf: &std::fs::File, _path: &Path) -> std::io::Result<bool> {
454        Err(std::io::Error::other(
455            "fslock-guard does not support this operating system".to_string(),
456        ))
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    // @@ begin test lint list maintained by maint/add_warning @@
463    #![allow(clippy::bool_assert_comparison)]
464    #![allow(clippy::clone_on_copy)]
465    #![allow(clippy::dbg_macro)]
466    #![allow(clippy::mixed_attributes_style)]
467    #![allow(clippy::print_stderr)]
468    #![allow(clippy::print_stdout)]
469    #![allow(clippy::single_char_pattern)]
470    #![allow(clippy::unwrap_used)]
471    #![allow(clippy::unchecked_time_subtraction)]
472    #![allow(clippy::useless_vec)]
473    #![allow(clippy::needless_pass_by_value)]
474    #![allow(clippy::string_slice)] // See arti#2571
475    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
476
477    use crate::LockFileGuard;
478    use std::sync::Arc;
479    use std::thread;
480    use test_temp_dir::test_temp_dir;
481
482    #[test]
483    fn keep_lock_file_after_drop() {
484        test_temp_dir!().used_by(|dir| {
485            let file = dir.join("file");
486            let flock_guard = LockFileGuard::lock(&file).unwrap();
487            assert!(file.try_exists().unwrap());
488            drop(flock_guard);
489            assert!(file.try_exists().unwrap());
490        });
491    }
492
493    #[test]
494    fn delete_lock_file_if_requested() {
495        test_temp_dir!().used_by(|dir| {
496            let file = dir.join("file");
497            let flock_guard = LockFileGuard::lock(&file).unwrap();
498            assert!(file.try_exists().unwrap());
499            assert!(flock_guard.delete_lock_file(&file).is_ok());
500            assert!(!file.try_exists().unwrap());
501        });
502    }
503
504    #[test]
505    fn tight_loop() {
506        let tmp = Arc::new(test_temp_dir!());
507
508        // We make several threads in case there are any cross-thread interactions
509        // that we're not aware of.  There shouldn't be.
510        let threads = (0..10)
511            .map(|i| {
512                let tmp = tmp.clone();
513                thread::spawn(move || {
514                    tmp.used_by(|dir| {
515                        let file = dir.join(format!("{i}"));
516                        for _ in 0..1000 {
517                            // Test that the lock is immediately re-requirable after drop.
518                            let lock: LockFileGuard =
519                                LockFileGuard::try_lock(&file).unwrap().unwrap();
520                            drop(lock);
521                        }
522                    });
523                })
524            })
525            .collect::<Vec<_>>();
526
527        for t in threads {
528            t.join().unwrap_or_else(|e| std::panic::resume_unwind(e));
529        }
530    }
531
532    #[test]
533    #[cfg(unix)]
534    fn fork_leak_fds() {
535        use std::ffi::c_int;
536
537        let tmp = test_temp_dir!();
538
539        tmp.used_by(|tmp| {
540            let file = tmp.join("lock");
541            let lock = LockFileGuard::lock(&file).unwrap();
542
543            let child = unsafe {
544                // It would be nicer to do this with std's Command, but
545                // we'd have to use the unsafe pre-exec hook for synchronisation
546                // and anyway that runs after Command's impl has closed "unwanted" fds.
547                match libc::fork() {
548                    -1 => panic!("fork failed"),
549                    0 => {
550                        libc::usleep(10_000);
551                        libc::_exit(0);
552                    }
553                    child => child,
554                }
555            };
556
557            drop(lock);
558            let _lock: LockFileGuard = LockFileGuard::try_lock(&file).unwrap().unwrap();
559
560            unsafe {
561                let mut status: c_int = 0;
562                let got = libc::waitpid(child, (&mut status) as *mut _, 0);
563                assert_eq!(got, child);
564                assert_eq!(status, 0, "{status}");
565            }
566        });
567    }
568}