Skip to main content

luci/storage/
lock.rs

1//! Five-state file locking protocol for cross-process coordination.
2//!
3//! Implements a SQLite-inspired lock progression:
4//! UNLOCKED → SHARED → RESERVED → PENDING → EXCLUSIVE
5//!
6//! Uses `fcntl(2)` byte-range locks on specific offsets within the
7//! Luci file header (bytes 49–560, currently unused).
8//!
9//! See [[architecture-cross-process-locking]].
10
11use std::fs::File;
12use std::os::unix::io::AsRawFd;
13
14use crate::core::{LuciError, Result};
15
16/// Byte offsets within the Luci file header used for locking.
17/// These fall in the reserved/unused region (bytes 49–4095).
18const PENDING_BYTE: u64 = 49;
19const RESERVED_BYTE: u64 = 50;
20const SHARED_FIRST: u64 = 51;
21const SHARED_SIZE: u64 = 510;
22
23/// Lock level for the five-state protocol.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum LockLevel {
26    Unlocked = 0,
27    Shared = 1,
28    Reserved = 2,
29    Pending = 3,
30    Exclusive = 4,
31}
32
33/// File lock state machine. Wraps a raw fd and tracks the current lock level.
34///
35/// All transitions follow the legal state progression:
36/// - UNLOCKED → SHARED (only valid transition from UNLOCKED)
37/// - SHARED → RESERVED
38/// - RESERVED → EXCLUSIVE (via PENDING internally)
39/// - EXCLUSIVE/RESERVED → SHARED (downgrade)
40/// - SHARED → UNLOCKED
41pub struct FileLock {
42    fd: i32,
43    level: LockLevel,
44}
45
46impl FileLock {
47    /// Create a new lock state for the given file. Starts UNLOCKED.
48    pub fn new(file: &File) -> Self {
49        Self {
50            fd: file.as_raw_fd(),
51            level: LockLevel::Unlocked,
52        }
53    }
54
55    /// Current lock level.
56    pub fn level(&self) -> LockLevel {
57        self.level
58    }
59
60    /// Acquire SHARED lock (for readers and initial open).
61    ///
62    /// Multiple processes can hold SHARED simultaneously.
63    /// Blocks if another process holds PENDING or EXCLUSIVE.
64    pub fn lock_shared(&mut self) -> Result<()> {
65        assert_eq!(
66            self.level,
67            LockLevel::Unlocked,
68            "lock_shared requires UNLOCKED state"
69        );
70
71        // Step 1: Acquire read-lock on PENDING_BYTE (gate check).
72        // If a writer holds PENDING (write-lock on this byte), we block.
73        fcntl_lock(self.fd, libc::F_RDLCK, PENDING_BYTE, 1)?;
74
75        // Step 2: Acquire read-lock on the shared range.
76        let result = fcntl_lock(self.fd, libc::F_RDLCK, SHARED_FIRST, SHARED_SIZE);
77
78        // Step 3: Release the temporary PENDING_BYTE lock.
79        let _ = fcntl_lock(self.fd, libc::F_UNLCK, PENDING_BYTE, 1);
80
81        result?;
82        self.level = LockLevel::Shared;
83        Ok(())
84    }
85
86    /// Acquire RESERVED lock (write intent — one at a time).
87    ///
88    /// Signals that this process intends to write. Readers continue
89    /// unimpeded. Only one RESERVED lock can exist at a time.
90    ///
91    /// Retries with exponential backoff until the timeout expires,
92    /// then returns `WriterLocked`.
93    pub fn lock_reserved(&mut self, timeout: std::time::Duration) -> Result<()> {
94        assert_eq!(
95            self.level,
96            LockLevel::Shared,
97            "lock_reserved requires SHARED state"
98        );
99
100        let deadline = std::time::Instant::now() + timeout;
101        let mut backoff = std::time::Duration::from_millis(1);
102        let max_backoff = std::time::Duration::from_millis(100);
103
104        loop {
105            match fcntl_try_lock(self.fd, libc::F_WRLCK, RESERVED_BYTE, 1) {
106                Ok(()) => break,
107                Err(LuciError::WriterLocked) => {
108                    if std::time::Instant::now() >= deadline {
109                        return Err(LuciError::WriterLocked);
110                    }
111                    std::thread::sleep(backoff);
112                    backoff = (backoff * 2).min(max_backoff);
113                }
114                Err(e) => return Err(e),
115            }
116        }
117
118        self.level = LockLevel::Reserved;
119        Ok(())
120    }
121
122    /// Escalate from RESERVED to EXCLUSIVE.
123    ///
124    /// First acquires PENDING (blocks new readers), then waits for
125    /// existing readers to drain, then acquires EXCLUSIVE.
126    ///
127    /// This is a blocking operation — it waits until all existing
128    /// SHARED locks are released.
129    pub fn lock_exclusive(&mut self) -> Result<()> {
130        assert!(
131            self.level == LockLevel::Reserved || self.level == LockLevel::Pending,
132            "lock_exclusive requires RESERVED or PENDING state"
133        );
134
135        if self.level == LockLevel::Reserved {
136            // Step 1: Acquire write-lock on PENDING_BYTE.
137            // This blocks new SHARED acquisitions (their step 1 read-lock
138            // on PENDING_BYTE will conflict with our write-lock).
139            fcntl_lock(self.fd, libc::F_WRLCK, PENDING_BYTE, 1)?;
140            self.level = LockLevel::Pending;
141        }
142
143        // Step 2: Acquire write-lock on the shared range (blocking).
144        // This waits until all existing SHARED holders release their
145        // read-locks on this range.
146        fcntl_lock(self.fd, libc::F_WRLCK, SHARED_FIRST, SHARED_SIZE)?;
147        self.level = LockLevel::Exclusive;
148        Ok(())
149    }
150
151    /// Downgrade from EXCLUSIVE or RESERVED to SHARED.
152    pub fn downgrade_to_shared(&mut self) -> Result<()> {
153        assert!(
154            self.level >= LockLevel::Reserved,
155            "downgrade_to_shared requires RESERVED or higher"
156        );
157
158        // Replace write-lock with read-lock on the shared range.
159        fcntl_lock(self.fd, libc::F_RDLCK, SHARED_FIRST, SHARED_SIZE)?;
160
161        // Release PENDING_BYTE and RESERVED_BYTE (2 consecutive bytes).
162        fcntl_lock(self.fd, libc::F_UNLCK, PENDING_BYTE, 2)?;
163
164        self.level = LockLevel::Shared;
165        Ok(())
166    }
167
168    /// Release all locks (SHARED → UNLOCKED).
169    pub fn unlock(&mut self) -> Result<()> {
170        if self.level == LockLevel::Unlocked {
171            return Ok(());
172        }
173
174        // Release everything: offset 0, length 0 means "all locks".
175        // This is safe because we use a dedicated fd for locking.
176        fcntl_lock(self.fd, libc::F_UNLCK, 0, 0)?;
177        self.level = LockLevel::Unlocked;
178        Ok(())
179    }
180}
181
182impl Drop for FileLock {
183    fn drop(&mut self) {
184        let _ = self.unlock();
185    }
186}
187
188/// Blocking fcntl lock operation.
189fn fcntl_lock(fd: i32, lock_type: i16, start: u64, len: u64) -> Result<()> {
190    let fl = libc::flock {
191        l_type: lock_type,
192        l_whence: libc::SEEK_SET as i16,
193        l_start: start as i64,
194        l_len: len as i64,
195        l_pid: 0,
196    };
197    let ret = unsafe { libc::fcntl(fd, libc::F_SETLKW, &fl) };
198    if ret == -1 {
199        let err = std::io::Error::last_os_error();
200        return Err(LuciError::Io(err));
201    }
202    Ok(())
203}
204
205/// Non-blocking fcntl lock attempt. Returns `WriterLocked` on conflict.
206fn fcntl_try_lock(fd: i32, lock_type: i16, start: u64, len: u64) -> Result<()> {
207    let fl = libc::flock {
208        l_type: lock_type,
209        l_whence: libc::SEEK_SET as i16,
210        l_start: start as i64,
211        l_len: len as i64,
212        l_pid: 0,
213    };
214    let ret = unsafe { libc::fcntl(fd, libc::F_SETLK, &fl) };
215    if ret == -1 {
216        let err = std::io::Error::last_os_error();
217        if err.raw_os_error() == Some(libc::EAGAIN) || err.raw_os_error() == Some(libc::EACCES) {
218            return Err(LuciError::WriterLocked);
219        }
220        return Err(LuciError::Io(err));
221    }
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::fs::OpenOptions;
229
230    fn test_file(name: &str) -> (std::path::PathBuf, File) {
231        let path =
232            std::env::temp_dir().join(format!("luci_lock_test_{}_{name}", std::process::id()));
233        let _ = std::fs::remove_file(&path);
234        let file = OpenOptions::new()
235            .read(true)
236            .write(true)
237            .create(true)
238            .truncate(true)
239            .open(&path)
240            .unwrap();
241        // Ensure the file is large enough for lock byte ranges
242        file.set_len(4096).unwrap();
243        (path, file)
244    }
245
246    #[test]
247    fn shared_lock_roundtrip() {
248        let (path, file) = test_file("shared");
249        let mut lock = FileLock::new(&file);
250        assert_eq!(lock.level(), LockLevel::Unlocked);
251
252        lock.lock_shared().unwrap();
253        assert_eq!(lock.level(), LockLevel::Shared);
254
255        lock.unlock().unwrap();
256        assert_eq!(lock.level(), LockLevel::Unlocked);
257
258        std::fs::remove_file(path).ok();
259    }
260
261    #[test]
262    fn full_escalation_and_downgrade() {
263        let (path, file) = test_file("escalation");
264        let mut lock = FileLock::new(&file);
265
266        lock.lock_shared().unwrap();
267        lock.lock_reserved(std::time::Duration::from_secs(5))
268            .unwrap();
269        assert_eq!(lock.level(), LockLevel::Reserved);
270
271        lock.lock_exclusive().unwrap();
272        assert_eq!(lock.level(), LockLevel::Exclusive);
273
274        lock.downgrade_to_shared().unwrap();
275        assert_eq!(lock.level(), LockLevel::Shared);
276
277        lock.unlock().unwrap();
278        std::fs::remove_file(path).ok();
279    }
280
281    #[test]
282    fn same_process_fds_share_locks() {
283        // fcntl locks are per-(process, inode), so two fds in the
284        // same process share lock state. This is expected — within-process
285        // serialization is handled by the Mutex, not file locks.
286        let (path, file1) = test_file("same_process");
287        let file2 = OpenOptions::new()
288            .read(true)
289            .write(true)
290            .open(&path)
291            .unwrap();
292
293        let mut lock1 = FileLock::new(&file1);
294        let mut lock2 = FileLock::new(&file2);
295
296        lock1.lock_shared().unwrap();
297        lock1
298            .lock_reserved(std::time::Duration::from_secs(5))
299            .unwrap();
300
301        // Same process: second fd succeeds (shared lock state)
302        lock2.lock_shared().unwrap();
303        lock2
304            .lock_reserved(std::time::Duration::from_secs(5))
305            .unwrap();
306
307        lock1.downgrade_to_shared().unwrap();
308        lock1.unlock().unwrap();
309        lock2.downgrade_to_shared().unwrap();
310        lock2.unlock().unwrap();
311        std::fs::remove_file(path).ok();
312    }
313
314    #[test]
315    fn drop_releases_locks() {
316        let (path, file1) = test_file("drop_release");
317        let file2 = OpenOptions::new()
318            .read(true)
319            .write(true)
320            .open(&path)
321            .unwrap();
322
323        {
324            let mut lock1 = FileLock::new(&file1);
325            lock1.lock_shared().unwrap();
326            lock1
327                .lock_reserved(std::time::Duration::from_secs(5))
328                .unwrap();
329            // lock1 dropped here — should release all locks
330        }
331
332        // Second lock should now succeed
333        let mut lock2 = FileLock::new(&file2);
334        lock2.lock_shared().unwrap();
335        lock2
336            .lock_reserved(std::time::Duration::from_secs(5))
337            .unwrap();
338        lock2.downgrade_to_shared().unwrap();
339        lock2.unlock().unwrap();
340        std::fs::remove_file(path).ok();
341    }
342}