Skip to main content

sochdb_storage/
lock.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! Advisory File Locking for Database Exclusivity
19//!
20//! This module implements cross-platform advisory file locking to enforce
21//! single-writer database exclusivity at the filesystem level.
22//!
23//! ## Problem
24//!
25//! Process-local synchronization primitives (`Mutex`, `RwLock`, `AtomicU64`)
26//! provide zero protection against concurrent multi-process access. When
27//! multiple OS processes open the same database files, data corruption occurs:
28//!
29//! 1. Process A appends entry at offset X, increments local sequence to N
30//! 2. Process B appends entry at offset X+∆, has independent sequence M≠N
31//! 3. Recovery sees inconsistent sequences → data loss
32//!
33//! ## Solution
34//!
35//! Use POSIX advisory locks (`flock`/`fcntl`) to enforce:
36//! - Single-process exclusive access to database files
37//! - Fail-fast behavior for concurrent access attempts
38//! - Automatic lock release on process crash
39//!
40//! ## Platform Support
41//!
42//! - **Unix/Linux/macOS**: Uses `flock()` system call
43//! - **Windows**: Uses `LockFileEx()` with `LOCKFILE_EXCLUSIVE_LOCK`
44//!
45//! ## Usage
46//!
47//! ```rust,ignore
48//! use sochdb_storage::lock::DatabaseLock;
49//!
50//! // Acquire exclusive lock (fails fast if already locked)
51//! let lock = DatabaseLock::acquire("/path/to/db")?;
52//!
53//! // Lock held for lifetime of `lock` variable
54//! // ... database operations ...
55//!
56//! // Lock automatically released on drop
57//! drop(lock);
58//! ```
59
60use std::fs::{File, OpenOptions};
61use std::io::{Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{Duration, Instant};
64
65use sochdb_core::SochDBError;
66
67// =============================================================================
68// Error Types
69// =============================================================================
70
71/// Errors specific to database locking operations
72#[derive(Debug)]
73pub enum LockError {
74    /// Database is locked by another process
75    DatabaseLocked {
76        /// PID of the process holding the lock (if known)
77        holder_pid: Option<u32>,
78        /// Path to the lock file
79        lock_path: PathBuf,
80    },
81    /// Lock acquisition timed out
82    Timeout {
83        /// How long we waited
84        elapsed: Duration,
85        /// The configured timeout
86        timeout: Duration,
87    },
88    /// Stale lock detected (holder process no longer exists)
89    StaleLock {
90        /// PID that was recorded in the lock file
91        stale_pid: u32,
92    },
93    /// I/O error during lock operations
94    Io(std::io::Error),
95}
96
97impl std::fmt::Display for LockError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            LockError::DatabaseLocked { holder_pid, lock_path } => {
101                if let Some(pid) = holder_pid {
102                    write!(f, "Database is locked by process {} (lock file: {})", 
103                           pid, lock_path.display())
104                } else {
105                    write!(f, "Database is locked (lock file: {})", lock_path.display())
106                }
107            }
108            LockError::Timeout { elapsed, timeout } => {
109                write!(f, "Lock acquisition timed out after {:?} (timeout: {:?})", 
110                       elapsed, timeout)
111            }
112            LockError::StaleLock { stale_pid } => {
113                write!(f, "Stale lock detected from crashed process {}", stale_pid)
114            }
115            LockError::Io(e) => write!(f, "Lock I/O error: {}", e),
116        }
117    }
118}
119
120impl std::error::Error for LockError {
121    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
122        match self {
123            LockError::Io(e) => Some(e),
124            _ => None,
125        }
126    }
127}
128
129impl From<std::io::Error> for LockError {
130    fn from(e: std::io::Error) -> Self {
131        LockError::Io(e)
132    }
133}
134
135impl From<LockError> for SochDBError {
136    fn from(e: LockError) -> Self {
137        match e {
138            LockError::DatabaseLocked { holder_pid, lock_path } => {
139                SochDBError::LockError(format!(
140                    "Database locked by PID {:?} (lock: {})", 
141                    holder_pid, lock_path.display()
142                ))
143            }
144            LockError::Timeout { elapsed, timeout } => {
145                SochDBError::LockError(format!(
146                    "Lock timeout after {:?} (max: {:?})", elapsed, timeout
147                ))
148            }
149            LockError::StaleLock { stale_pid } => {
150                SochDBError::LockError(format!(
151                    "Stale lock from crashed process {}", stale_pid
152                ))
153            }
154            LockError::Io(e) => SochDBError::Io(e),
155        }
156    }
157}
158
159// =============================================================================
160// Lock Configuration
161// =============================================================================
162
163/// Configuration for database lock behavior
164#[derive(Debug, Clone)]
165pub struct LockConfig {
166    /// Timeout for lock acquisition (None = fail immediately)
167    pub timeout: Option<Duration>,
168    /// Interval between lock retry attempts
169    pub retry_interval: Duration,
170    /// Whether to detect and recover from stale locks
171    pub detect_stale_locks: bool,
172    /// Lock file name (relative to database directory)
173    pub lock_file_name: String,
174}
175
176impl Default for LockConfig {
177    fn default() -> Self {
178        Self {
179            timeout: Some(Duration::from_secs(5)),
180            retry_interval: Duration::from_millis(100),
181            detect_stale_locks: true,
182            lock_file_name: ".lock".to_string(),
183        }
184    }
185}
186
187impl LockConfig {
188    /// Create config with no timeout (fail immediately if locked)
189    pub fn no_wait() -> Self {
190        Self {
191            timeout: None,
192            ..Default::default()
193        }
194    }
195
196    /// Create config with specific timeout
197    pub fn with_timeout(timeout: Duration) -> Self {
198        Self {
199            timeout: Some(timeout),
200            ..Default::default()
201        }
202    }
203}
204
205// =============================================================================
206// Database Lock
207// =============================================================================
208
209/// Exclusive advisory lock on a database directory
210///
211/// This lock ensures single-process access to a SochDB database.
212/// The lock is automatically released when this struct is dropped.
213///
214/// ## Implementation
215///
216/// Uses POSIX `flock()` on Unix systems and `LockFileEx()` on Windows.
217/// The lock file also contains the PID of the lock holder for debugging
218/// and stale lock detection.
219///
220/// ## Safety
221///
222/// Advisory locks are cooperative - they only work if all processes
223/// attempting to access the database use this locking mechanism.
224pub struct DatabaseLock {
225    /// Open file handle (keeps the lock active)
226    lock_file: File,
227    /// Path to the lock file
228    path: PathBuf,
229    /// Our PID (for diagnostics)
230    our_pid: u32,
231}
232
233impl DatabaseLock {
234    /// Acquire exclusive lock on a database directory
235    ///
236    /// # Arguments
237    ///
238    /// * `db_path` - Path to the database directory
239    ///
240    /// # Returns
241    ///
242    /// Returns `Ok(DatabaseLock)` if lock acquired successfully.
243    /// Returns `Err(LockError::DatabaseLocked)` if another process holds the lock.
244    ///
245    /// # Example
246    ///
247    /// ```rust,ignore
248    /// let lock = DatabaseLock::acquire("/path/to/db")?;
249    /// // Lock is held until `lock` is dropped
250    /// ```
251    pub fn acquire<P: AsRef<Path>>(db_path: P) -> std::result::Result<Self, LockError> {
252        Self::acquire_with_config(db_path, &LockConfig::no_wait())
253    }
254
255    /// Acquire exclusive lock with timeout
256    ///
257    /// Will retry lock acquisition until timeout expires.
258    ///
259    /// # Arguments
260    ///
261    /// * `db_path` - Path to the database directory
262    /// * `timeout` - Maximum time to wait for lock
263    pub fn acquire_with_timeout<P: AsRef<Path>>(
264        db_path: P, 
265        timeout: Duration
266    ) -> std::result::Result<Self, LockError> {
267        Self::acquire_with_config(db_path, &LockConfig::with_timeout(timeout))
268    }
269
270    /// Acquire exclusive lock with full configuration
271    pub fn acquire_with_config<P: AsRef<Path>>(
272        db_path: P,
273        config: &LockConfig,
274    ) -> std::result::Result<Self, LockError> {
275        let db_path = db_path.as_ref();
276        let lock_path = db_path.join(&config.lock_file_name);
277
278        // Ensure database directory exists
279        if !db_path.exists() {
280            std::fs::create_dir_all(db_path)?;
281        }
282
283        let deadline = config.timeout.map(|t| Instant::now() + t);
284        let our_pid = std::process::id();
285
286        loop {
287            // Try to open/create lock file
288            let file = OpenOptions::new()
289                .create(true)
290                .read(true)
291                .write(true)
292                .open(&lock_path)?;
293
294            // Attempt to acquire exclusive lock
295            match Self::try_flock(&file, false) {
296                Ok(()) => {
297                    // Lock acquired! Write our PID
298                    Self::write_pid(&file, our_pid)?;
299                    
300                    return Ok(Self {
301                        lock_file: file,
302                        path: lock_path,
303                        our_pid,
304                    });
305                }
306                Err(LockError::DatabaseLocked { .. }) => {
307                    // Lock is held by another process
308                    
309                    // Check for stale lock
310                    let mut should_retry = false;
311                    if config.detect_stale_locks {
312                        if let Some(holder_pid) = Self::read_pid(&file) {
313                            if !Self::process_exists(holder_pid) {
314                                // Process is dead - try to take over
315                                // We need to close and reopen to clear state
316                                drop(file);
317                                
318                                // Force remove the lock file
319                                if std::fs::remove_file(&lock_path).is_ok() {
320                                    should_retry = true;
321                                }
322                            }
323                        }
324                    }
325                    
326                    if should_retry {
327                        continue; // Retry acquisition
328                    }
329
330                    // Check timeout
331                    if let Some(deadline) = deadline {
332                        if Instant::now() >= deadline {
333                            return Err(LockError::Timeout {
334                                elapsed: config.timeout.unwrap_or_default(),
335                                timeout: config.timeout.unwrap_or_default(),
336                            });
337                        }
338                        
339                        // Wait and retry
340                        std::thread::sleep(config.retry_interval);
341                        continue;
342                    } else {
343                        // No timeout - fail immediately
344                        // Note: file may have been dropped above, so we can't read PID
345                        return Err(LockError::DatabaseLocked { 
346                            holder_pid: None, 
347                            lock_path 
348                        });
349                    }
350                }
351                Err(e) => return Err(e),
352            }
353        }
354    }
355
356    /// Get path to the lock file
357    pub fn path(&self) -> &Path {
358        &self.path
359    }
360
361    /// Get PID of lock holder (us)
362    pub fn pid(&self) -> u32 {
363        self.our_pid
364    }
365
366    /// Check if a given PID is holding the lock on a database
367    ///
368    /// Useful for diagnostics without attempting to acquire.
369    pub fn get_lock_holder<P: AsRef<Path>>(db_path: P) -> Option<u32> {
370        let lock_path = db_path.as_ref().join(".lock");
371        let file = File::open(&lock_path).ok()?;
372        Self::read_pid(&file)
373    }
374
375    /// Write PID to lock file
376    fn write_pid(file: &File, pid: u32) -> std::result::Result<(), LockError> {
377        use std::io::Seek;
378        let mut file = file;
379        file.seek(std::io::SeekFrom::Start(0))?;
380        file.set_len(0)?;
381        writeln!(file, "{}", pid)?;
382        file.sync_all()?;
383        Ok(())
384    }
385
386    /// Read PID from lock file
387    fn read_pid(file: &File) -> Option<u32> {
388        use std::io::Seek;
389        let mut file = file;
390        let _ = file.seek(std::io::SeekFrom::Start(0));
391        let mut contents = String::new();
392        file.read_to_string(&mut contents).ok()?;
393        contents.trim().parse().ok()
394    }
395
396    /// Check if a process exists
397    #[cfg(unix)]
398    fn process_exists(pid: u32) -> bool {
399        // kill(pid, 0) checks if process exists without sending a signal
400        // Returns 0 if process exists, -1 with ESRCH if not
401        let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
402        if result == 0 {
403            true
404        } else {
405            // Check if error is ESRCH (no such process)
406            let errno = std::io::Error::last_os_error().raw_os_error();
407            errno != Some(libc::ESRCH)
408        }
409    }
410
411    #[cfg(windows)]
412    fn process_exists(pid: u32) -> bool {
413        unsafe {
414            let handle = windows_sys::Win32::System::Threading::OpenProcess(
415                windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION,
416                0,
417                pid,
418            );
419            if handle == 0 || handle == -1 {
420                false
421            } else {
422                windows_sys::Win32::Foundation::CloseHandle(handle);
423                true
424            }
425        }
426    }
427
428    #[cfg(not(any(unix, windows)))]
429    fn process_exists(_pid: u32) -> bool {
430        // On unknown platforms, assume process exists to be safe
431        true
432    }
433
434    /// Try to acquire flock on file
435    #[cfg(unix)]
436    fn try_flock(file: &File, blocking: bool) -> std::result::Result<(), LockError> {
437        use std::os::unix::io::AsRawFd;
438        
439        let fd = file.as_raw_fd();
440        let operation = if blocking {
441            libc::LOCK_EX
442        } else {
443            libc::LOCK_EX | libc::LOCK_NB
444        };
445
446        let result = unsafe { libc::flock(fd, operation) };
447        
448        if result == 0 {
449            Ok(())
450        } else {
451            let err = std::io::Error::last_os_error();
452            if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
453                Err(LockError::DatabaseLocked {
454                    holder_pid: None,
455                    lock_path: PathBuf::new(),
456                })
457            } else {
458                Err(LockError::Io(err))
459            }
460        }
461    }
462
463    #[cfg(windows)]
464    fn try_flock(file: &File, blocking: bool) -> std::result::Result<(), LockError> {
465        use std::os::windows::io::AsRawHandle;
466        
467        let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
468        
469        let flags = windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK
470            | if blocking { 0 } else { windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY };
471        
472        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
473        
474        let result = unsafe {
475            windows_sys::Win32::Storage::FileSystem::LockFileEx(
476                handle,
477                flags,
478                0,
479                1,
480                0,
481                &mut overlapped,
482            )
483        };
484        
485        if result != 0 {
486            Ok(())
487        } else {
488            let err = std::io::Error::last_os_error();
489            if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) {
490                Err(LockError::DatabaseLocked {
491                    holder_pid: None,
492                    lock_path: PathBuf::new(),
493                })
494            } else {
495                Err(LockError::Io(err))
496            }
497        }
498    }
499
500    #[cfg(not(any(unix, windows)))]
501    fn try_flock(_file: &File, _blocking: bool) -> std::result::Result<(), LockError> {
502        // On unsupported platforms, assume success (no locking)
503        // This is unsafe but allows compilation
504        Ok(())
505    }
506
507    /// Release the lock (called automatically on drop)
508    #[cfg(unix)]
509    fn release(&self) {
510        use std::os::unix::io::AsRawFd;
511        let fd = self.lock_file.as_raw_fd();
512        unsafe { libc::flock(fd, libc::LOCK_UN) };
513    }
514
515    #[cfg(windows)]
516    fn release(&self) {
517        use std::os::windows::io::AsRawHandle;
518        let handle = self.lock_file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
519        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
520        unsafe {
521            windows_sys::Win32::Storage::FileSystem::UnlockFileEx(
522                handle,
523                0,
524                1,
525                0,
526                &mut overlapped,
527            );
528        }
529    }
530
531    #[cfg(not(any(unix, windows)))]
532    fn release(&self) {
533        // No-op on unsupported platforms
534    }
535}
536
537impl Drop for DatabaseLock {
538    fn drop(&mut self) {
539        self.release();
540        // Lock file is removed when the last handle is closed
541        // We explicitly remove it for cleaner state
542        let _ = std::fs::remove_file(&self.path);
543    }
544}
545
546// =============================================================================
547// Reader-Writer Lock Protocol (Task 3)
548// =============================================================================
549
550/// Shared-Exclusive lock state stored in lock file header
551///
552/// Format (16 bytes):
553/// ```text
554/// ┌────────────┬────────────────┬──────────────────┬─────────┐
555/// │ reader_cnt │ writer_intent  │ writer_active    │ padding │
556/// │ (4 bytes)  │ (4 bytes)      │ (4 bytes)        │ (4 B)   │
557/// └────────────┴────────────────┴──────────────────┴─────────┘
558/// ```
559#[repr(C)]
560#[derive(Debug, Clone, Copy, Default)]
561pub struct RwLockState {
562    /// Number of active readers
563    pub reader_count: u32,
564    /// Writer waiting to acquire (prevents reader starvation)
565    pub writer_intent: u32,
566    /// Writer currently active
567    pub writer_active: u32,
568    /// Reserved for future use
569    pub _padding: u32,
570}
571
572/// Connection mode for database access
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
574pub enum ConnectionMode {
575    /// Read-only access (acquires shared lock)
576    ReadOnly,
577    /// Read-write access (acquires exclusive lock)
578    ReadWrite,
579}
580
581/// Reader-Writer database lock for concurrent read access
582///
583/// Implements a shared-exclusive lock protocol:
584/// - Multiple concurrent readers allowed
585/// - Single exclusive writer
586/// - Writer intent prevents reader starvation
587pub struct RwDatabaseLock {
588    /// Open file handle
589    lock_file: File,
590    /// Path to lock file
591    path: PathBuf,
592    /// Our connection mode
593    mode: ConnectionMode,
594    /// Our PID
595    our_pid: u32,
596}
597
598impl RwDatabaseLock {
599    /// Acquire a shared (read-only) lock
600    ///
601    /// Multiple processes can hold shared locks simultaneously.
602    /// Blocks if a writer is active or waiting.
603    pub fn acquire_shared<P: AsRef<Path>>(db_path: P) -> std::result::Result<Self, LockError> {
604        Self::acquire_with_mode(db_path, ConnectionMode::ReadOnly, &LockConfig::default())
605    }
606
607    /// Acquire an exclusive (read-write) lock
608    ///
609    /// Only one process can hold an exclusive lock.
610    /// Blocks if any readers or another writer is active.
611    pub fn acquire_exclusive<P: AsRef<Path>>(db_path: P) -> std::result::Result<Self, LockError> {
612        Self::acquire_with_mode(db_path, ConnectionMode::ReadWrite, &LockConfig::default())
613    }
614
615    /// Acquire lock with specified mode and configuration
616    pub fn acquire_with_mode<P: AsRef<Path>>(
617        db_path: P,
618        mode: ConnectionMode,
619        config: &LockConfig,
620    ) -> std::result::Result<Self, LockError> {
621        let db_path = db_path.as_ref();
622        let lock_path = db_path.join(&config.lock_file_name);
623        
624        if !db_path.exists() {
625            std::fs::create_dir_all(db_path)?;
626        }
627
628        let file = OpenOptions::new()
629            .create(true)
630            .read(true)
631            .write(true)
632            .open(&lock_path)?;
633
634        let our_pid = std::process::id();
635        let deadline = config.timeout.map(|t| Instant::now() + t);
636
637        loop {
638            match mode {
639                ConnectionMode::ReadOnly => {
640                    // Acquire shared lock
641                    if Self::try_shared_lock(&file)? {
642                        return Ok(Self {
643                            lock_file: file,
644                            path: lock_path,
645                            mode,
646                            our_pid,
647                        });
648                    }
649                }
650                ConnectionMode::ReadWrite => {
651                    // Acquire exclusive lock
652                    if Self::try_exclusive_lock(&file)? {
653                        return Ok(Self {
654                            lock_file: file,
655                            path: lock_path,
656                            mode,
657                            our_pid,
658                        });
659                    }
660                }
661            }
662
663            // Check timeout
664            if let Some(deadline) = deadline {
665                if Instant::now() >= deadline {
666                    return Err(LockError::Timeout {
667                        elapsed: config.timeout.unwrap_or_default(),
668                        timeout: config.timeout.unwrap_or_default(),
669                    });
670                }
671                std::thread::sleep(config.retry_interval);
672            } else {
673                return Err(LockError::DatabaseLocked {
674                    holder_pid: None,
675                    lock_path,
676                });
677            }
678        }
679    }
680
681    /// Get connection mode
682    pub fn mode(&self) -> ConnectionMode {
683        self.mode
684    }
685
686    /// Check if this is a read-only connection
687    pub fn is_readonly(&self) -> bool {
688        self.mode == ConnectionMode::ReadOnly
689    }
690
691    #[cfg(unix)]
692    fn try_shared_lock(file: &File) -> std::result::Result<bool, LockError> {
693        use std::os::unix::io::AsRawFd;
694        let fd = file.as_raw_fd();
695        let result = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
696        if result == 0 {
697            Ok(true)
698        } else {
699            let err = std::io::Error::last_os_error();
700            if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
701                Ok(false)
702            } else {
703                Err(LockError::Io(err))
704            }
705        }
706    }
707
708    #[cfg(unix)]
709    fn try_exclusive_lock(file: &File) -> std::result::Result<bool, LockError> {
710        use std::os::unix::io::AsRawFd;
711        let fd = file.as_raw_fd();
712        let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
713        if result == 0 {
714            Ok(true)
715        } else {
716            let err = std::io::Error::last_os_error();
717            if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
718                Ok(false)
719            } else {
720                Err(LockError::Io(err))
721            }
722        }
723    }
724
725    #[cfg(windows)]
726    fn try_shared_lock(file: &File) -> std::result::Result<bool, LockError> {
727        use std::os::windows::io::AsRawHandle;
728        let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
729        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
730        
731        let result = unsafe {
732            windows_sys::Win32::Storage::FileSystem::LockFileEx(
733                handle,
734                windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY,
735                0, 1, 0,
736                &mut overlapped,
737            )
738        };
739        
740        if result != 0 {
741            Ok(true)
742        } else {
743            let err = std::io::Error::last_os_error();
744            if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) {
745                Ok(false)
746            } else {
747                Err(LockError::Io(err))
748            }
749        }
750    }
751
752    #[cfg(windows)]
753    fn try_exclusive_lock(file: &File) -> std::result::Result<bool, LockError> {
754        use std::os::windows::io::AsRawHandle;
755        let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
756        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
757        
758        let result = unsafe {
759            windows_sys::Win32::Storage::FileSystem::LockFileEx(
760                handle,
761                windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK 
762                    | windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY,
763                0, 1, 0,
764                &mut overlapped,
765            )
766        };
767        
768        if result != 0 {
769            Ok(true)
770        } else {
771            let err = std::io::Error::last_os_error();
772            if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) {
773                Ok(false)
774            } else {
775                Err(LockError::Io(err))
776            }
777        }
778    }
779
780    #[cfg(not(any(unix, windows)))]
781    fn try_shared_lock(_file: &File) -> std::result::Result<bool, LockError> {
782        Ok(true)
783    }
784
785    #[cfg(not(any(unix, windows)))]
786    fn try_exclusive_lock(_file: &File) -> std::result::Result<bool, LockError> {
787        Ok(true)
788    }
789
790    #[cfg(unix)]
791    fn release(&self) {
792        use std::os::unix::io::AsRawFd;
793        let fd = self.lock_file.as_raw_fd();
794        unsafe { libc::flock(fd, libc::LOCK_UN) };
795    }
796
797    #[cfg(windows)]
798    fn release(&self) {
799        use std::os::windows::io::AsRawHandle;
800        let handle = self.lock_file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
801        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
802        unsafe {
803            windows_sys::Win32::Storage::FileSystem::UnlockFileEx(handle, 0, 1, 0, &mut overlapped);
804        }
805    }
806
807    #[cfg(not(any(unix, windows)))]
808    fn release(&self) {}
809}
810
811impl Drop for RwDatabaseLock {
812    fn drop(&mut self) {
813        self.release();
814    }
815}
816
817// =============================================================================
818// Tests
819// =============================================================================
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824    use std::thread;
825    use tempfile::TempDir;
826
827    #[test]
828    fn test_exclusive_lock_basic() {
829        let dir = TempDir::new().unwrap();
830        let db_path = dir.path();
831
832        // First lock should succeed
833        let lock1 = DatabaseLock::acquire(db_path);
834        assert!(lock1.is_ok());
835
836        // Second lock should fail immediately
837        let lock2 = DatabaseLock::acquire(db_path);
838        assert!(matches!(lock2, Err(LockError::DatabaseLocked { .. })));
839
840        // After releasing first lock, second should succeed
841        drop(lock1);
842        let lock3 = DatabaseLock::acquire(db_path);
843        assert!(lock3.is_ok());
844    }
845
846    #[test]
847    fn test_lock_with_timeout() {
848        let dir = TempDir::new().unwrap();
849        let db_path = dir.path().to_path_buf();
850
851        // Acquire lock
852        let _lock = DatabaseLock::acquire(&db_path).unwrap();
853
854        // Try with short timeout - should fail
855        let start = Instant::now();
856        let result = DatabaseLock::acquire_with_timeout(&db_path, Duration::from_millis(100));
857        let elapsed = start.elapsed();
858
859        assert!(matches!(result, Err(LockError::Timeout { .. })));
860        assert!(elapsed >= Duration::from_millis(100));
861        assert!(elapsed < Duration::from_millis(500)); // Shouldn't be too long
862    }
863
864    #[test]
865    fn test_lock_pid_recorded() {
866        let dir = TempDir::new().unwrap();
867        let db_path = dir.path();
868
869        let lock = DatabaseLock::acquire(db_path).unwrap();
870        let our_pid = std::process::id();
871        
872        assert_eq!(lock.pid(), our_pid);
873        
874        // Check we can read the holder
875        let holder = DatabaseLock::get_lock_holder(db_path);
876        assert_eq!(holder, Some(our_pid));
877    }
878
879    #[test]
880    fn test_shared_lock_multiple_readers() {
881        let dir = TempDir::new().unwrap();
882        let db_path = dir.path();
883
884        // Multiple shared locks should succeed
885        let lock1 = RwDatabaseLock::acquire_shared(db_path);
886        let lock2 = RwDatabaseLock::acquire_shared(db_path);
887
888        assert!(lock1.is_ok());
889        assert!(lock2.is_ok());
890    }
891
892    #[test]
893    fn test_exclusive_blocks_shared() {
894        let dir = TempDir::new().unwrap();
895        let db_path = dir.path();
896
897        // Exclusive lock first
898        let _exclusive = RwDatabaseLock::acquire_exclusive(db_path).unwrap();
899
900        // Shared lock should fail immediately with no timeout
901        let shared = RwDatabaseLock::acquire_with_mode(
902            db_path,
903            ConnectionMode::ReadOnly,
904            &LockConfig::no_wait(),
905        );
906        
907        assert!(matches!(shared, Err(LockError::DatabaseLocked { .. })));
908    }
909}