Skip to main content

link_cli/storage/
lock.rs

1//! Advisory file locking for multi-process access to a links database.
2//!
3//! Uses the standard library's [`std::fs::File`] advisory locks (`flock`
4//! on Unix, `LockFileEx` on Windows). Locks are taken on a dedicated
5//! sidecar `*.lock` file rather than on the database itself, so that the
6//! lock survives operations that rewrite or remap the database file.
7//!
8//! Advisory locks are held per *open file description*, which means two
9//! [`FileLock`] values inside a single process contend exactly the same
10//! way two separate processes do.
11
12use std::fs::{File, OpenOptions, TryLockError};
13use std::path::{Path, PathBuf};
14
15use crate::error::LinkError;
16
17/// Requested sharing mode for a [`FileLock`].
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LockMode {
20    /// Multiple readers may hold the lock at the same time.
21    Shared,
22    /// Only one holder at a time; excludes all shared holders.
23    Exclusive,
24}
25
26/// Conventional sidecar lock filename for a links database.
27pub fn lock_file_path<P: AsRef<Path>>(database_filename: P) -> PathBuf {
28    let path = database_filename.as_ref();
29    let mut name = path
30        .file_name()
31        .map(|n| n.to_os_string())
32        .unwrap_or_default();
33    name.push(".lock");
34    match path.parent() {
35        Some(parent) if !parent.as_os_str().is_empty() => parent.join(name),
36        _ => PathBuf::from(name),
37    }
38}
39
40/// RAII guard around an advisory lock on a sidecar lock file.
41///
42/// The lock is released when the guard is dropped (and, as a backstop,
43/// by the operating system when the process exits — so a crashed writer
44/// never leaves a database permanently locked).
45#[derive(Debug)]
46pub struct FileLock {
47    file: File,
48    path: PathBuf,
49    mode: LockMode,
50}
51
52impl FileLock {
53    /// Acquires the lock, blocking until it becomes available.
54    pub fn acquire<P: AsRef<Path>>(lock_path: P, mode: LockMode) -> Result<Self, LinkError> {
55        let (file, path) = Self::open(lock_path)?;
56        let result = match mode {
57            LockMode::Shared => file.lock_shared(),
58            LockMode::Exclusive => file.lock(),
59        };
60        result.map_err(|error| {
61            LinkError::Lock(format!("failed to lock {}: {error}", path.display()))
62        })?;
63        Ok(Self { file, path, mode })
64    }
65
66    /// Tries to acquire the lock, returning `Ok(None)` when another
67    /// holder currently owns a conflicting lock.
68    pub fn try_acquire<P: AsRef<Path>>(
69        lock_path: P,
70        mode: LockMode,
71    ) -> Result<Option<Self>, LinkError> {
72        let (file, path) = Self::open(lock_path)?;
73        let result = match mode {
74            LockMode::Shared => file.try_lock_shared(),
75            LockMode::Exclusive => file.try_lock(),
76        };
77        match result {
78            Ok(()) => Ok(Some(Self { file, path, mode })),
79            Err(TryLockError::WouldBlock) => Ok(None),
80            Err(TryLockError::Error(error)) => Err(LinkError::Lock(format!(
81                "failed to lock {}: {error}",
82                path.display()
83            ))),
84        }
85    }
86
87    /// The sidecar lock file this guard holds.
88    pub fn path(&self) -> &Path {
89        &self.path
90    }
91
92    /// The sharing mode this guard was acquired with.
93    pub fn mode(&self) -> LockMode {
94        self.mode
95    }
96
97    fn open<P: AsRef<Path>>(lock_path: P) -> Result<(File, PathBuf), LinkError> {
98        let path = lock_path.as_ref().to_path_buf();
99        if let Some(parent) = path.parent() {
100            if !parent.as_os_str().is_empty() && !parent.exists() {
101                std::fs::create_dir_all(parent)?;
102            }
103        }
104        let file = OpenOptions::new()
105            .create(true)
106            .truncate(false)
107            .read(true)
108            .write(true)
109            .open(&path)?;
110        Ok((file, path))
111    }
112}
113
114impl Drop for FileLock {
115    fn drop(&mut self) {
116        let _ = self.file.unlock();
117    }
118}