Skip to main content

wowlab_fs/
lock.rs

1//! Process-scoped advisory file locks.
2
3use crate::path::{Path, PathBuf};
4
5/// An exclusive advisory lock released when dropped or when the process exits.
6///
7/// The lock file remains on disk.
8/// Acquisition therefore cannot race with deletion and inode replacement.
9#[derive(Debug)]
10pub struct Lock {
11    _file: std::fs::File,
12    path: PathBuf,
13}
14
15impl Lock {
16    /// Attempt to acquire an exclusive advisory lock without waiting.
17    ///
18    /// The lock is tied to the open file handle.
19    /// A crashed process releases the operating-system lock automatically.
20    /// Timestamp-based stale-lock recovery is neither required nor safe.
21    ///
22    /// # Errors
23    ///
24    /// Returns [`LockError::is_busy`] when another process owns the lock.
25    /// Opening and locking failures are returned with path context.
26    pub fn try_acquire(path: &Path) -> Result<Self, LockError> {
27        let file = std::fs::OpenOptions::new()
28            .read(true)
29            .write(true)
30            .create(true)
31            .truncate(false)
32            .open(path)
33            .map_err(|source| LockError::io(path, source))?;
34
35        match fs4::FileExt::try_lock(&file) {
36            Ok(()) => Ok(Self {
37                _file: file,
38                path: path.to_path_buf(),
39            }),
40            Err(fs4::TryLockError::WouldBlock) => Err(LockError::busy(path)),
41            Err(fs4::TryLockError::Error(source)) => Err(LockError::io(path, source)),
42        }
43    }
44
45    /// Return the persistent lock-file path.
46    #[must_use]
47    pub fn path(&self) -> &Path {
48        &self.path
49    }
50}
51
52/// Failure to acquire an exclusive file lock.
53#[derive(Debug, thiserror::Error)]
54#[error(transparent)]
55pub struct LockError(LockErrorKind);
56
57#[derive(Debug, thiserror::Error)]
58enum LockErrorKind {
59    #[error("another process holds the filesystem lock {path}")]
60    Busy { path: PathBuf },
61    #[error("failed to acquire lock {path}: {source}")]
62    Io {
63        path: PathBuf,
64        #[source]
65        source: std::io::Error,
66    },
67}
68
69impl LockError {
70    fn busy(path: &Path) -> Self {
71        Self(LockErrorKind::Busy {
72            path: path.to_path_buf(),
73        })
74    }
75
76    fn io(path: &Path, source: std::io::Error) -> Self {
77        Self(LockErrorKind::Io {
78            path: path.to_path_buf(),
79            source,
80        })
81    }
82
83    /// Report whether another process currently owns the lock.
84    #[must_use]
85    pub const fn is_busy(&self) -> bool {
86        matches!(self.0, LockErrorKind::Busy { .. })
87    }
88
89    /// Return the persistent lock-file path.
90    #[must_use]
91    pub fn path(&self) -> &Path {
92        match &self.0 {
93            LockErrorKind::Busy { path } | LockErrorKind::Io { path, .. } => path,
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use googletest::prelude::*;
101
102    use super::Lock;
103    use crate::temporary::Directory;
104
105    #[gtest]
106    fn lock_is_exclusive_and_released_with_its_guard() -> Result<()> {
107        let directory = Directory::new().or_fail()?;
108        let path = directory.path().join("operation.lock");
109        let first = Lock::try_acquire(&path).or_fail()?;
110
111        verify_true!(Lock::try_acquire(&path).unwrap_err().is_busy())?;
112
113        drop(first);
114
115        verify_that!(Lock::try_acquire(&path).or_fail()?.path(), eq(&*path))
116    }
117}