Skip to main content

memvid_core/
lock.rs

1use std::fs::{File, OpenOptions};
2use std::path::Path;
3use std::thread;
4use std::time::Duration;
5
6use fs2::FileExt;
7
8use crate::error::{MemvidError, Result};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum LockMode {
12    None,
13    Shared,
14    Exclusive,
15}
16
17/// File lock guard that can hold either a shared or exclusive OS lock.
18pub struct FileLock {
19    file: File,
20    mode: LockMode,
21}
22
23impl FileLock {
24    /// Opens a file at `path` with read/write permissions and acquires an exclusive lock.
25    pub fn open_and_lock(path: &Path) -> Result<(File, Self)> {
26        let file = OpenOptions::new().read(true).write(true).open(path)?;
27        let guard = Self::acquire_with_mode(&file, LockMode::Exclusive)?;
28        Ok((file, guard))
29    }
30
31    /// Opens a file at `path` with read/write permissions and acquires a shared lock.
32    pub fn open_read_only(path: &Path) -> Result<(File, Self)> {
33        let file = OpenOptions::new().read(true).write(true).open(path)?;
34        let guard = Self::acquire_with_mode(&file, LockMode::Shared)?;
35        Ok((file, guard))
36    }
37
38    /// Returns a non-locking guard for callers that only require a stable clone handle.
39    pub fn unlocked(file: &File) -> Result<Self> {
40        Ok(Self {
41            file: file.try_clone()?,
42            mode: LockMode::None,
43        })
44    }
45
46    /// Clones the provided file handle and locks it exclusively.
47    pub fn acquire(file: &File, _path: &Path) -> Result<Self> {
48        Self::acquire_with_mode(file, LockMode::Exclusive)
49    }
50
51    /// Attempts a non-blocking exclusive lock, returning None if already locked.
52    pub fn try_acquire(_file: &File, path: &Path) -> Result<Option<Self>> {
53        let clone = OpenOptions::new().read(true).write(true).open(path)?;
54        loop {
55            match clone.try_lock_exclusive() {
56                Ok(()) => {
57                    return Ok(Some(Self {
58                        file: clone,
59                        mode: LockMode::Exclusive,
60                    }));
61                }
62                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
63                Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => return Ok(None),
64                Err(err) => return Err(MemvidError::Lock(err.to_string())),
65            }
66        }
67    }
68
69    /// Releases the underlying OS file lock.
70    pub fn unlock(&mut self) -> Result<()> {
71        if self.mode == LockMode::None {
72            return Ok(());
73        }
74        self.file
75            .unlock()
76            .map_err(|err| MemvidError::Lock(err.to_string()))
77    }
78
79    /// Exposes a clone of the locked handle for buffered operations.
80    pub fn clone_handle(&self) -> Result<File> {
81        Ok(self.file.try_clone()?)
82    }
83
84    #[must_use]
85    pub fn mode(&self) -> LockMode {
86        self.mode
87    }
88
89    pub fn downgrade_to_shared(&mut self) -> Result<()> {
90        if self.mode == LockMode::None {
91            return Err(MemvidError::Lock(
92                "cannot downgrade an unlocked file handle".to_string(),
93            ));
94        }
95        if self.mode == LockMode::Shared {
96            return Ok(());
97        }
98        self.file
99            .unlock()
100            .map_err(|err| MemvidError::Lock(err.to_string()))?;
101        Self::lock_with_retry(&self.file, LockMode::Shared)?;
102        self.mode = LockMode::Shared;
103        Ok(())
104    }
105
106    pub fn upgrade_to_exclusive(&mut self) -> Result<()> {
107        if self.mode == LockMode::None {
108            return Err(MemvidError::Lock(
109                "cannot upgrade an unlocked file handle".to_string(),
110            ));
111        }
112        if self.mode == LockMode::Exclusive {
113            return Ok(());
114        }
115        self.file
116            .unlock()
117            .map_err(|err| MemvidError::Lock(err.to_string()))?;
118        Self::lock_with_retry(&self.file, LockMode::Exclusive)?;
119        self.mode = LockMode::Exclusive;
120        Ok(())
121    }
122
123    pub(crate) fn acquire_with_mode(file: &File, mode: LockMode) -> Result<Self> {
124        let clone = file.try_clone()?;
125        Self::lock_with_retry(&clone, mode)?;
126        Ok(Self { file: clone, mode })
127    }
128
129    fn lock_with_retry(file: &File, mode: LockMode) -> Result<()> {
130        const MAX_ATTEMPTS: u32 = 200; // ~10 seconds with 50ms backoff
131        const BACKOFF: Duration = Duration::from_millis(50);
132        let mut attempts = 0;
133        loop {
134            let result = match mode {
135                LockMode::None => return Ok(()),
136                LockMode::Exclusive => file.try_lock_exclusive(),
137                LockMode::Shared => FileExt::try_lock_shared(file),
138            };
139            match result {
140                Ok(()) => return Ok(()),
141                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
142                Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
143                    if attempts >= MAX_ATTEMPTS {
144                        return Err(MemvidError::Lock(
145                            "exclusive access unavailable; file is in use by another process"
146                                .to_string(),
147                        ));
148                    }
149                    attempts += 1;
150                    thread::sleep(BACKOFF);
151                    continue;
152                }
153                Err(err) => return Err(MemvidError::Lock(err.to_string())),
154            }
155        }
156    }
157}
158
159impl Drop for FileLock {
160    fn drop(&mut self) {
161        if self.mode != LockMode::None {
162            let _ = self.file.unlock();
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::io::Write;
171    use tempfile::NamedTempFile;
172
173    #[test]
174    #[cfg(not(target_os = "windows"))] // Windows has different file locking semantics
175    fn acquiring_lock_blocks_second_writer() {
176        let temp = NamedTempFile::new().expect("temp file");
177        let path = temp.path();
178        writeln!(&mut temp.as_file().try_clone().unwrap(), "seed").unwrap();
179
180        let file = OpenOptions::new()
181            .read(true)
182            .write(true)
183            .open(path)
184            .expect("open file");
185        let guard = FileLock::acquire(&file, path).expect("first lock succeeds");
186
187        let second = FileLock::try_acquire(&file, path).expect("second lock attempt");
188        assert!(second.is_none(), "lock should already be held");
189
190        drop(guard);
191        let third = FileLock::try_acquire(&file, path).expect("third lock attempt");
192        assert!(third.is_some(), "lock released after drop");
193    }
194}