Skip to main content

walletkit_db/
lock.rs

1//! Cross-process exclusive lock.
2//!
3//! Used by [`crate::init_or_open_envelope_key`] to serialize the first-install
4//! envelope bootstrap, and acquired by consumers for operations that mix
5//! `SQLite` with filesystem state (e.g. plaintext export / import). `SQLite`
6//! handles cross-process writer serialization for ordinary mutations itself
7//! via WAL-mode file locks; this lock is not required for those.
8//!
9//! On native platforms (Unix, Windows) the lock is backed by a file via
10//! `flock` / `LockFileEx`. On WASM it is a no-op because the runtime is
11//! single-threaded (`sqlite-wasm-rs` is compiled with `SQLITE_THREADSAFE=0`)
12//! and runs in a dedicated Web Worker.
13
14use std::path::Path;
15
16use crate::error::StoreResult;
17
18// WASM: no-op lock (single-threaded worker, SQLITE_THREADSAFE=0)
19
20#[cfg(target_arch = "wasm32")]
21mod imp {
22    use super::*;
23
24    /// No-op storage lock for WASM.
25    #[derive(Debug, Clone)]
26    pub struct Lock;
27
28    /// No-op lock guard.
29    #[derive(Debug)]
30    pub struct LockGuard;
31
32    impl Lock {
33        /// Opens a no-op lock (WASM is single-threaded).
34        pub fn open(_path: &Path) -> StoreResult<Self> {
35            Ok(Self)
36        }
37
38        /// Acquires a no-op lock (always succeeds).
39        pub fn lock(&self) -> StoreResult<LockGuard> {
40            Ok(LockGuard)
41        }
42
43        /// Attempts to acquire a no-op lock (always succeeds).
44        pub fn try_lock(&self) -> StoreResult<Option<LockGuard>> {
45            Ok(Some(LockGuard))
46        }
47    }
48}
49
50// Native: file-backed exclusive lock (flock on Unix, LockFileEx on Windows)
51
52#[cfg(not(target_arch = "wasm32"))]
53mod imp {
54    use super::{Path, StoreResult};
55    use crate::error::StoreError;
56    use std::fs::{self, File, OpenOptions};
57    use std::sync::Arc;
58
59    /// File-backed cross-process exclusive lock. See the module docs for
60    /// what it's for (and what it isn't).
61    #[derive(Debug, Clone)]
62    pub struct Lock {
63        file: Arc<File>,
64    }
65
66    /// Guard that holds an exclusive lock for its lifetime.
67    #[derive(Debug)]
68    pub struct LockGuard {
69        file: Arc<File>,
70    }
71
72    impl Lock {
73        /// Opens or creates the lock file at `path`.
74        ///
75        /// # Errors
76        ///
77        /// Returns an error if the file cannot be opened or created.
78        pub fn open(path: &Path) -> StoreResult<Self> {
79            if let Some(parent) = path.parent() {
80                fs::create_dir_all(parent).map_err(|err| map_io_err(&err))?;
81            }
82            let file = OpenOptions::new()
83                .read(true)
84                .write(true)
85                .create(true)
86                .truncate(false)
87                .open(path)
88                .map_err(|err| map_io_err(&err))?;
89            Ok(Self {
90                file: Arc::new(file),
91            })
92        }
93
94        /// Acquires the exclusive lock.
95        ///
96        /// # Errors
97        ///
98        /// Returns an error if the lock cannot be acquired.
99        pub fn lock(&self) -> StoreResult<LockGuard> {
100            lock_exclusive(&self.file).map_err(|err| map_io_err(&err))?;
101            Ok(LockGuard {
102                file: Arc::clone(&self.file),
103            })
104        }
105
106        /// Attempts to acquire the exclusive lock without blocking.
107        ///
108        /// # Errors
109        ///
110        /// Returns an error if the lock attempt fails for reasons other than
111        /// the lock being held by another process.
112        pub fn try_lock(&self) -> StoreResult<Option<LockGuard>> {
113            if try_lock_exclusive(&self.file).map_err(|err| map_io_err(&err))? {
114                Ok(Some(LockGuard {
115                    file: Arc::clone(&self.file),
116                }))
117            } else {
118                Ok(None)
119            }
120        }
121    }
122
123    impl Drop for LockGuard {
124        fn drop(&mut self) {
125            let _ = unlock(&self.file);
126        }
127    }
128
129    fn map_io_err(err: &std::io::Error) -> StoreError {
130        StoreError::Lock(err.to_string())
131    }
132
133    // ── Unix flock ──────────────────────────────────────────────────────
134
135    #[cfg(unix)]
136    fn lock_exclusive(file: &File) -> std::io::Result<()> {
137        let fd = std::os::unix::io::AsRawFd::as_raw_fd(file);
138        let result = unsafe { flock(fd, LOCK_EX) };
139        if result == 0 {
140            Ok(())
141        } else {
142            Err(std::io::Error::last_os_error())
143        }
144    }
145
146    #[cfg(unix)]
147    fn try_lock_exclusive(file: &File) -> std::io::Result<bool> {
148        let fd = std::os::unix::io::AsRawFd::as_raw_fd(file);
149        let result = unsafe { flock(fd, LOCK_EX | LOCK_NB) };
150        if result == 0 {
151            Ok(true)
152        } else {
153            let err = std::io::Error::last_os_error();
154            if err.kind() == std::io::ErrorKind::WouldBlock {
155                Ok(false)
156            } else {
157                Err(err)
158            }
159        }
160    }
161
162    #[cfg(unix)]
163    fn unlock(file: &File) -> std::io::Result<()> {
164        let fd = std::os::unix::io::AsRawFd::as_raw_fd(file);
165        let result = unsafe { flock(fd, LOCK_UN) };
166        if result == 0 {
167            Ok(())
168        } else {
169            Err(std::io::Error::last_os_error())
170        }
171    }
172
173    #[cfg(unix)]
174    use std::os::raw::c_int;
175
176    #[cfg(unix)]
177    const LOCK_EX: c_int = 2;
178    #[cfg(unix)]
179    const LOCK_NB: c_int = 4;
180    #[cfg(unix)]
181    const LOCK_UN: c_int = 8;
182
183    #[cfg(unix)]
184    extern "C" {
185        fn flock(fd: c_int, operation: c_int) -> c_int;
186    }
187
188    // ── Windows LockFileEx ──────────────────────────────────────────────
189
190    #[cfg(windows)]
191    fn lock_exclusive(file: &File) -> std::io::Result<()> {
192        lock_file(file, 0)
193    }
194
195    #[cfg(windows)]
196    fn try_lock_exclusive(file: &File) -> std::io::Result<bool> {
197        match lock_file(file, LOCKFILE_FAIL_IMMEDIATELY) {
198            Ok(()) => Ok(true),
199            Err(err) => {
200                if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION) {
201                    Ok(false)
202                } else {
203                    Err(err)
204                }
205            }
206        }
207    }
208
209    #[cfg(windows)]
210    fn unlock(file: &File) -> std::io::Result<()> {
211        let handle = std::os::windows::io::AsRawHandle::as_raw_handle(file) as HANDLE;
212        let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
213        let result = unsafe { UnlockFileEx(handle, 0, 1, 0, &mut overlapped) };
214        if result != 0 {
215            Ok(())
216        } else {
217            Err(std::io::Error::last_os_error())
218        }
219    }
220
221    #[cfg(windows)]
222    fn lock_file(file: &File, flags: u32) -> std::io::Result<()> {
223        let handle = std::os::windows::io::AsRawHandle::as_raw_handle(file) as HANDLE;
224        let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
225        let result = unsafe {
226            LockFileEx(
227                handle,
228                LOCKFILE_EXCLUSIVE_LOCK | flags,
229                0,
230                1,
231                0,
232                &mut overlapped,
233            )
234        };
235        if result != 0 {
236            Ok(())
237        } else {
238            Err(std::io::Error::last_os_error())
239        }
240    }
241
242    #[cfg(windows)]
243    type HANDLE = *mut std::ffi::c_void;
244
245    #[cfg(windows)]
246    #[repr(C)]
247    struct OVERLAPPED {
248        internal: usize,
249        internal_high: usize,
250        offset: u32,
251        offset_high: u32,
252        h_event: HANDLE,
253    }
254
255    #[cfg(windows)]
256    const LOCKFILE_EXCLUSIVE_LOCK: u32 = 0x2;
257    #[cfg(windows)]
258    const LOCKFILE_FAIL_IMMEDIATELY: u32 = 0x1;
259    #[cfg(windows)]
260    const ERROR_LOCK_VIOLATION: i32 = 33;
261
262    #[cfg(windows)]
263    extern "system" {
264        fn LockFileEx(
265            h_file: HANDLE,
266            flags: u32,
267            reserved: u32,
268            bytes_to_lock_low: u32,
269            bytes_to_lock_high: u32,
270            overlapped: *mut OVERLAPPED,
271        ) -> i32;
272        fn UnlockFileEx(
273            h_file: HANDLE,
274            reserved: u32,
275            bytes_to_unlock_low: u32,
276            bytes_to_unlock_high: u32,
277            overlapped: *mut OVERLAPPED,
278        ) -> i32;
279    }
280
281    #[cfg(test)]
282    mod tests {
283        use super::Lock;
284
285        #[test]
286        fn test_lock_is_exclusive() {
287            let dir = tempfile::tempdir().expect("create temp dir");
288            let path = dir.path().join("lock.lock");
289            let lock_a = Lock::open(&path).expect("open lock");
290            let guard = lock_a.lock().expect("acquire lock");
291
292            let lock_b = Lock::open(&path).expect("open lock");
293            let blocked = lock_b.try_lock().expect("try lock");
294            assert!(blocked.is_none());
295
296            drop(guard);
297            let guard = lock_b.try_lock().expect("try lock");
298            assert!(guard.is_some());
299        }
300
301        #[test]
302        fn test_lock_serializes_across_threads() {
303            use std::sync::mpsc;
304            use std::thread;
305
306            let dir = tempfile::tempdir().expect("create temp dir");
307            let path = dir.path().join("lock.lock");
308            let lock = Lock::open(&path).expect("open lock");
309
310            let (locked_tx, locked_rx) = mpsc::channel();
311            let (release_tx, release_rx) = mpsc::channel();
312            let (released_tx, released_rx) = mpsc::channel();
313
314            let thread_a = thread::spawn(move || {
315                let guard = lock.lock().expect("lock in thread");
316                locked_tx.send(()).expect("signal locked");
317                release_rx.recv().expect("wait release");
318                drop(guard);
319                released_tx.send(()).expect("signal released");
320            });
321
322            locked_rx.recv().expect("wait locked");
323            let lock_b = Lock::open(&path).expect("open lock");
324            let blocked = lock_b.try_lock().expect("try lock");
325            assert!(blocked.is_none());
326
327            release_tx.send(()).expect("release");
328            released_rx.recv().expect("wait released");
329
330            let guard = lock_b.try_lock().expect("try lock");
331            assert!(guard.is_some());
332
333            thread_a.join().expect("thread join");
334        }
335    }
336}
337
338pub use imp::{Lock, LockGuard};