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    pub fn mode(&self) -> LockMode {
85        self.mode
86    }
87
88    pub fn downgrade_to_shared(&mut self) -> Result<()> {
89        if self.mode == LockMode::None {
90            return Err(MemvidError::Lock(
91                "cannot downgrade an unlocked file handle".to_string(),
92            ));
93        }
94        if self.mode == LockMode::Shared {
95            return Ok(());
96        }
97        self.file
98            .unlock()
99            .map_err(|err| MemvidError::Lock(err.to_string()))?;
100        Self::lock_with_retry(&self.file, LockMode::Shared)?;
101        self.mode = LockMode::Shared;
102        Ok(())
103    }
104
105    pub fn upgrade_to_exclusive(&mut self) -> Result<()> {
106        if self.mode == LockMode::None {
107            return Err(MemvidError::Lock(
108                "cannot upgrade an unlocked file handle".to_string(),
109            ));
110        }
111        if self.mode == LockMode::Exclusive {
112            return Ok(());
113        }
114        self.file
115            .unlock()
116            .map_err(|err| MemvidError::Lock(err.to_string()))?;
117        Self::lock_with_retry(&self.file, LockMode::Exclusive)?;
118        self.mode = LockMode::Exclusive;
119        Ok(())
120    }
121
122    pub(crate) fn acquire_with_mode(file: &File, mode: LockMode) -> Result<Self> {
123        let clone = file.try_clone()?;
124        Self::lock_with_retry(&clone, mode)?;
125        Ok(Self { file: clone, mode })
126    }
127
128    fn lock_with_retry(file: &File, mode: LockMode) -> Result<()> {
129        const MAX_ATTEMPTS: u32 = 200; // ~10 seconds with 50ms backoff
130        const BACKOFF: Duration = Duration::from_millis(50);
131        let mut attempts = 0;
132        loop {
133            let result = match mode {
134                LockMode::None => return Ok(()),
135                LockMode::Exclusive => file.try_lock_exclusive(),
136                LockMode::Shared => FileExt::try_lock_shared(file),
137            };
138            match result {
139                Ok(()) => return Ok(()),
140                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
141                Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
142                    if attempts >= MAX_ATTEMPTS {
143                        return Err(MemvidError::Lock(
144                            "exclusive access unavailable; file is in use by another process"
145                                .to_string(),
146                        ));
147                    }
148                    attempts += 1;
149                    thread::sleep(BACKOFF);
150                    continue;
151                }
152                Err(err) => return Err(MemvidError::Lock(err.to_string())),
153            }
154        }
155    }
156}
157
158impl Drop for FileLock {
159    fn drop(&mut self) {
160        if self.mode != LockMode::None {
161            let _ = self.file.unlock();
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use std::io::Write;
170    use tempfile::NamedTempFile;
171
172    #[test]
173    #[cfg(not(target_os = "windows"))] // Windows has different file locking semantics
174    fn acquiring_lock_blocks_second_writer() {
175        let temp = NamedTempFile::new().expect("temp file");
176        let path = temp.path();
177        writeln!(&mut temp.as_file().try_clone().unwrap(), "seed").unwrap();
178
179        let file = OpenOptions::new()
180            .read(true)
181            .write(true)
182            .open(path)
183            .expect("open file");
184        let guard = FileLock::acquire(&file, path).expect("first lock succeeds");
185
186        let second = FileLock::try_acquire(&file, path).expect("second lock attempt");
187        assert!(second.is_none(), "lock should already be held");
188
189        drop(guard);
190        let third = FileLock::try_acquire(&file, path).expect("third lock attempt");
191        assert!(third.is_some(), "lock released after drop");
192    }
193}