Skip to main content

torrust_tracker_deployer_lib/infrastructure/persistence/filesystem/
file_lock.rs

1//! File Locking Mechanism with Process ID Tracking
2//!
3//! This module provides a portable file locking mechanism that works across Unix and Windows
4//! by creating lock files containing the process ID of the lock holder. This approach enables:
5//! - Detection and automatic cleanup of stale locks from crashed processes
6//! - Debugging by showing which process holds a lock
7//! - Timeout-based lock acquisition
8//!
9//! # Design
10//!
11//! The locking mechanism uses separate `.lock` files rather than OS-level file locks because:
12//! - Process ID tracking: Can identify and clean up stale locks
13//! - Portability: Works consistently on Unix and Windows
14//! - Debuggability: Lock holder can be identified by reading the lock file
15//!
16//! # Usage
17//!
18//! ```rust,no_run
19//! use std::path::Path;
20//! use std::time::Duration;
21//! use torrust_tracker_deployer_lib::infrastructure::persistence::filesystem::file_lock::FileLock;
22//!
23//! let state_file = Path::new("./data/test-env/state.json");
24//! let lock = FileLock::acquire(state_file, Duration::from_secs(5))?;
25//!
26//! // Perform file operations...
27//! // Lock is automatically released when dropped
28//! # Ok::<(), Box<dyn std::error::Error>>(())
29//! ```
30
31use std::fs;
32use std::path::{Path, PathBuf};
33use std::time::{Duration, Instant};
34use thiserror::Error;
35use tracing;
36
37use super::process_id::ProcessId;
38
39/// Interval in milliseconds between lock acquisition retry attempts
40const LOCK_RETRY_INTERVAL_MS: u64 = 100;
41
42/// Duration to sleep between lock acquisition retry attempts
43const LOCK_RETRY_SLEEP: Duration = Duration::from_millis(LOCK_RETRY_INTERVAL_MS);
44
45/// File locking mechanism with process ID tracking
46///
47/// Provides exclusive access to files by creating lock files that contain
48/// the process ID of the lock holder. This prevents race conditions when multiple
49/// processes attempt to access the same file concurrently.
50///
51/// # Lock Files
52///
53/// Lock files are named `{file}.lock` and contain the process ID as text.
54/// Example: `./data/my-env/state.json.lock` contains "12345"
55///
56/// # Stale Lock Detection
57///
58/// If a process crashes while holding a lock, the lock file remains but the
59/// process is dead. This implementation detects stale locks by checking if
60/// the process ID in the lock file is still running, then automatically cleans
61/// up and retries.
62///
63/// # RAII Pattern
64///
65/// The lock is automatically released when the `FileLock` is dropped, ensuring
66/// cleanup even if an error occurs during file operations.
67#[derive(Debug)]
68pub struct FileLock {
69    lock_file_path: PathBuf,
70    acquired: bool,
71}
72
73impl FileLock {
74    /// Attempt to acquire a lock for the given file path
75    ///
76    /// Creates a lock file at `{file_path}.lock` containing the current process ID.
77    /// If the lock file already exists, checks if the holding process is still alive.
78    /// If the process is dead, removes the stale lock and retries.
79    ///
80    /// # Arguments
81    ///
82    /// * `file_path` - Path to the file to lock (the actual file, not the lock file)
83    /// * `timeout` - Maximum time to wait for lock acquisition
84    ///
85    /// # Returns
86    ///
87    /// Returns `FileLock` on successful acquisition, which will automatically release
88    /// the lock when dropped.
89    ///
90    /// # Errors
91    ///
92    /// Returns error if:
93    /// - Another process holds the lock and timeout expires (`AcquisitionTimeout`)
94    /// - Lock file cannot be created due to permissions (`CreateFailed`)
95    /// - I/O error occurs during lock operations
96    ///
97    /// # Examples
98    ///
99    /// ```rust,no_run
100    /// use std::path::Path;
101    /// use std::time::Duration;
102    /// use torrust_tracker_deployer_lib::infrastructure::persistence::filesystem::file_lock::FileLock;
103    ///
104    /// let file_path = Path::new("./data/test/state.json");
105    /// let timeout = Duration::from_secs(10);
106    ///
107    /// match FileLock::acquire(file_path, timeout) {
108    ///     Ok(lock) => {
109    ///         // Perform operations on the file
110    ///         // Lock automatically released when lock goes out of scope
111    ///     }
112    ///     Err(e) => eprintln!("Failed to acquire lock: {}", e),
113    /// }
114    /// # Ok::<(), Box<dyn std::error::Error>>(())
115    /// ```
116    #[tracing::instrument(
117        name = "file_lock_acquire",
118        skip(file_path),
119        fields(
120            file = %file_path.display(),
121            timeout_ms = timeout.as_millis(),
122            pid = %ProcessId::current(),
123        )
124    )]
125    pub fn acquire(file_path: &Path, timeout: Duration) -> Result<Self, FileLockError> {
126        tracing::debug!("Attempting to acquire lock");
127
128        let lock_file_path = Self::lock_file_path(file_path);
129        let current_pid = ProcessId::current();
130        let retry_strategy = LockRetryStrategy::new(timeout);
131
132        tracing::trace!(
133            lock_file = %lock_file_path.display(),
134            "Lock file path determined"
135        );
136
137        let mut attempt = 0;
138        loop {
139            attempt += 1;
140            tracing::trace!(attempt, "Lock acquisition attempt");
141
142            match Self::try_acquire_once(&lock_file_path, current_pid) {
143                AcquireAttemptResult::Success => {
144                    tracing::debug!(attempts = attempt, "Lock acquired successfully");
145                    return Ok(Self {
146                        lock_file_path,
147                        acquired: true,
148                    });
149                }
150                AcquireAttemptResult::StaleProcess(pid) => {
151                    tracing::warn!(
152                        stale_pid = %pid,
153                        attempt,
154                        "Detected stale lock, cleaning up"
155                    );
156                    // Stale lock detected, clean it up and retry immediately
157                    drop(fs::remove_file(&lock_file_path));
158                    // Continue to next retry attempt
159                }
160                AcquireAttemptResult::TransientError => {
161                    tracing::trace!(
162                        attempt,
163                        "Transient error during lock acquisition (likely race condition), retrying"
164                    );
165                    // Transient errors (like empty lock files) should be retried
166                    // Wait a short time before retrying
167                    LockRetryStrategy::wait();
168                }
169                AcquireAttemptResult::HeldByLiveProcess(pid) => {
170                    tracing::trace!(
171                        holder_pid = %pid,
172                        attempt,
173                        elapsed_ms = retry_strategy.start.elapsed().as_millis(),
174                        "Lock held by live process"
175                    );
176
177                    // Process is alive, check if we've timed out
178                    if retry_strategy.is_expired() {
179                        tracing::warn!(
180                            holder_pid = %pid,
181                            attempts = attempt,
182                            timeout_ms = timeout.as_millis(),
183                            "Lock acquisition timeout"
184                        );
185                        return Err(FileLockError::AcquisitionTimeout {
186                            path: lock_file_path,
187                            holder_pid: Some(pid),
188                            timeout,
189                        });
190                    }
191                    // Wait before retrying
192                    LockRetryStrategy::wait();
193                }
194                AcquireAttemptResult::Error(e) => {
195                    tracing::warn!(
196                        error = %e,
197                        attempt,
198                        "Error during lock acquisition"
199                    );
200                    return Err(e);
201                }
202            }
203        }
204    }
205
206    /// Release the lock by removing the lock file
207    ///
208    /// This is called automatically when the `FileLock` is dropped, but can
209    /// also be called explicitly for better error handling.
210    ///
211    /// # Errors
212    ///
213    /// Returns error if the lock file cannot be removed due to I/O issues.
214    ///
215    /// # Examples
216    ///
217    /// ```rust,no_run
218    /// use std::path::Path;
219    /// use std::time::Duration;
220    /// use torrust_tracker_deployer_lib::infrastructure::persistence::filesystem::file_lock::FileLock;
221    ///
222    /// let lock = FileLock::acquire(Path::new("test.json"), Duration::from_secs(5))?;
223    /// // ... perform operations ...
224    /// lock.release()?; // Explicit release with error handling
225    /// # Ok::<(), Box<dyn std::error::Error>>(())
226    /// ```
227    #[tracing::instrument(
228        name = "file_lock_release",
229        skip(self),
230        fields(lock_file = %self.lock_file_path.display())
231    )]
232    pub fn release(mut self) -> Result<(), FileLockError> {
233        tracing::debug!("Releasing lock");
234
235        if self.acquired {
236            fs::remove_file(&self.lock_file_path).map_err(|source| {
237                tracing::warn!(error = %source, "Failed to remove lock file");
238                FileLockError::ReleaseFailed {
239                    path: self.lock_file_path.clone(),
240                    source,
241                }
242            })?;
243            self.acquired = false;
244            tracing::debug!("Lock released successfully");
245        } else {
246            tracing::trace!("Lock was not acquired, nothing to release");
247        }
248        Ok(())
249    }
250
251    /// Get the lock file path for a given file path
252    ///
253    /// Appends `.lock` to the file path. For example:
254    /// - `state.json` → `state.json.lock`
255    /// - `data/env/state.json` → `data/env/state.json.lock`
256    fn lock_file_path(file_path: &Path) -> PathBuf {
257        let mut lock_path = file_path.to_path_buf();
258        let current_extension = lock_path.extension().and_then(|e| e.to_str()).unwrap_or("");
259        let new_extension = if current_extension.is_empty() {
260            "lock".to_string()
261        } else {
262            format!("{current_extension}.lock")
263        };
264        lock_path.set_extension(new_extension);
265        lock_path
266    }
267
268    /// Try to acquire the lock once
269    ///
270    /// Returns the result of a single acquisition attempt, classifying the outcome
271    /// to help the retry logic make decisions
272    fn try_acquire_once(lock_path: &Path, current_pid: ProcessId) -> AcquireAttemptResult {
273        match Self::try_create_lock(lock_path, current_pid) {
274            Ok(()) => AcquireAttemptResult::Success,
275            Err(FileLockError::LockHeldByProcess { pid }) => {
276                if pid.is_alive() {
277                    AcquireAttemptResult::HeldByLiveProcess(pid)
278                } else {
279                    AcquireAttemptResult::StaleProcess(pid)
280                }
281            }
282            Err(FileLockError::InvalidLockFile { ref content, .. }) if content.is_empty() => {
283                // Empty lock file indicates a race condition during write
284                // Treat as transient and retry
285                AcquireAttemptResult::TransientError
286            }
287            Err(e) => AcquireAttemptResult::Error(e),
288        }
289    }
290
291    /// Try to create lock file atomically with current process ID
292    ///
293    /// Uses `create_new` flag to ensure atomic creation - the operation fails
294    /// if the file already exists, preventing race conditions.
295    #[tracing::instrument(
296        name = "file_lock_try_create",
297        skip(lock_path),
298        fields(lock_file = %lock_path.display(), pid = %pid)
299    )]
300    fn try_create_lock(lock_path: &Path, pid: ProcessId) -> Result<(), FileLockError> {
301        use std::fs::OpenOptions;
302        use std::io::Write;
303
304        tracing::trace!("Attempting to create lock file");
305
306        // Try to create the file exclusively (fails if exists)
307        match OpenOptions::new()
308            .write(true)
309            .create_new(true)
310            .open(lock_path)
311        {
312            Ok(mut file) => {
313                tracing::trace!("Lock file created, writing PID");
314                // Write our PID to the lock file
315                write!(file, "{pid}").map_err(|source| {
316                    tracing::warn!(error = %source, "Failed to write PID to lock file");
317                    FileLockError::CreateFailed {
318                        path: lock_path.to_path_buf(),
319                        source,
320                    }
321                })?;
322                // Flush to ensure PID is written to disk before other processes can read
323                file.flush().map_err(|source| {
324                    tracing::warn!(error = %source, "Failed to flush PID to lock file");
325                    FileLockError::CreateFailed {
326                        path: lock_path.to_path_buf(),
327                        source,
328                    }
329                })?;
330                tracing::debug!("Lock file created successfully");
331                Ok(())
332            }
333            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
334                tracing::trace!("Lock file already exists, reading holder PID");
335                // Lock file exists, read the holder PID
336                let content = fs::read_to_string(lock_path).map_err(|source| {
337                    tracing::warn!(error = %source, "Failed to read lock file");
338                    FileLockError::ReadFailed {
339                        path: lock_path.to_path_buf(),
340                        source,
341                    }
342                })?;
343
344                let holder_pid = content.trim().parse::<ProcessId>().map_err(|_| {
345                    tracing::warn!(content = %content, "Invalid PID content in lock file");
346                    FileLockError::InvalidLockFile {
347                        path: lock_path.to_path_buf(),
348                        content,
349                    }
350                })?;
351
352                tracing::trace!(holder_pid = %holder_pid, "Lock held by process");
353                Err(FileLockError::LockHeldByProcess { pid: holder_pid })
354            }
355            Err(source) => {
356                tracing::warn!(error = %source, "Failed to create lock file");
357                Err(FileLockError::CreateFailed {
358                    path: lock_path.to_path_buf(),
359                    source,
360                })
361            }
362        }
363    }
364
365    /// Get the current state of lock acquisition (test helper)
366    ///
367    /// This method checks the lock state without actually acquiring the lock or waiting.
368    /// It's primarily for testing to verify lock states in specific scenarios.
369    ///
370    /// # Note
371    ///
372    /// If the lock is available (Acquired state), this method briefly creates and
373    /// then immediately removes the lock file to verify availability.
374    #[cfg(test)]
375    #[must_use]
376    pub fn check_lock_state(file_path: &Path) -> LockAcquisitionState {
377        let lock_path = Self::lock_file_path(file_path);
378        let current_pid = ProcessId::current();
379
380        match Self::try_create_lock(&lock_path, current_pid) {
381            Ok(()) => {
382                // Clean up the lock file we just created for testing
383                drop(fs::remove_file(&lock_path));
384                LockAcquisitionState::Acquired
385            }
386            Err(FileLockError::LockHeldByProcess { pid }) => {
387                if pid.is_alive() {
388                    LockAcquisitionState::Blocked(pid)
389                } else {
390                    LockAcquisitionState::FoundStaleLock(pid)
391                }
392            }
393            Err(_) => LockAcquisitionState::Attempting,
394        }
395    }
396}
397
398impl Drop for FileLock {
399    /// Automatically release the lock when the `FileLock` is dropped
400    ///
401    /// This ensures cleanup even if an error occurs during file operations.
402    /// Errors during cleanup are logged but otherwise ignored as this is best-effort cleanup.
403    fn drop(&mut self) {
404        if self.acquired {
405            // Best effort cleanup, log errors for observability
406            if let Err(e) = fs::remove_file(&self.lock_file_path) {
407                tracing::warn!(
408                    lock_file = %self.lock_file_path.display(),
409                    error = %e,
410                    "Failed to remove lock file during drop"
411                );
412            } else {
413                tracing::trace!(
414                    lock_file = %self.lock_file_path.display(),
415                    "Lock file removed successfully during drop"
416                );
417            }
418            self.acquired = false;
419        }
420    }
421}
422
423// --- Lock Acquisition Helper Types ---
424
425/// Represents the result of attempting to acquire a lock
426///
427/// This internal enum helps separate different failure modes during lock acquisition
428enum AcquireAttemptResult {
429    /// Lock was successfully acquired
430    Success,
431    /// Lock is held by a dead process (stale lock)
432    StaleProcess(ProcessId),
433    /// Lock is held by a live process
434    HeldByLiveProcess(ProcessId),
435    /// Transient error that should be retried (e.g., empty lock file during write race)
436    TransientError,
437    /// I/O or other error occurred
438    Error(FileLockError),
439}
440
441/// Represents the state of lock acquisition process
442///
443/// This enum makes the lock acquisition state machine explicit and testable.
444/// It's used primarily in tests to verify lock states without actually
445/// acquiring locks or waiting for timeouts.
446#[cfg(test)]
447#[derive(Debug, PartialEq, Eq)]
448pub enum LockAcquisitionState {
449    /// Lock acquisition is being attempted
450    Attempting,
451    /// Found a lock held by a dead process (stale lock)
452    FoundStaleLock(ProcessId),
453    /// Lock is held by a live process
454    Blocked(ProcessId),
455    /// Lock was successfully acquired (or would be acquired)
456    Acquired,
457}
458
459/// Manages retry logic for lock acquisition
460///
461/// Encapsulates timeout tracking and retry timing to keep the acquire logic clean
462struct LockRetryStrategy {
463    start: Instant,
464    timeout: Duration,
465}
466
467impl LockRetryStrategy {
468    /// Create a new retry strategy with the given timeout
469    fn new(timeout: Duration) -> Self {
470        Self {
471            start: Instant::now(),
472            timeout,
473        }
474    }
475
476    /// Check if the timeout has expired
477    fn is_expired(&self) -> bool {
478        self.start.elapsed() >= self.timeout
479    }
480
481    /// Sleep before the next retry attempt
482    fn wait() {
483        std::thread::sleep(LOCK_RETRY_SLEEP);
484    }
485}
486
487// --- Error Types ---
488
489/// Errors related to file locking operations
490#[derive(Debug, Error)]
491pub enum FileLockError {
492    /// Lock is held by another process
493    ///
494    /// This is an internal error used during lock acquisition retries.
495    /// Users typically see `AcquisitionTimeout` instead.
496    #[error("Lock held by process {pid}")]
497    LockHeldByProcess { pid: ProcessId },
498
499    /// Failed to acquire lock within timeout period
500    ///
501    /// This typically means another process is holding the lock.
502    /// Use `.help()` for detailed troubleshooting steps.
503    #[error(
504        "Failed to acquire lock for '{path}' within {timeout:?} (held by process {holder_pid:?})
505Tip: Use 'ps -p {holder_pid:?}' to check if process is running"
506    )]
507    AcquisitionTimeout {
508        path: PathBuf,
509        holder_pid: Option<ProcessId>,
510        timeout: Duration,
511    },
512
513    /// Failed to create lock file
514    ///
515    /// This usually indicates permission issues or file system problems.
516    /// Use `.help()` for detailed troubleshooting steps.
517    #[error(
518        "Failed to create lock file at '{path}': {source}
519Tip: Check directory permissions and disk space"
520    )]
521    CreateFailed {
522        path: PathBuf,
523        #[source]
524        source: std::io::Error,
525    },
526
527    /// Failed to read lock file content
528    ///
529    /// This may indicate file system corruption or permission changes.
530    /// Use `.help()` for detailed troubleshooting steps.
531    #[error(
532        "Failed to read lock file at '{path}': {source}
533Tip: Check file permissions and file system status"
534    )]
535    ReadFailed {
536        path: PathBuf,
537        #[source]
538        source: std::io::Error,
539    },
540
541    /// Lock file contains invalid content
542    ///
543    /// Expected a process ID but found something else.
544    /// Use `.help()` for detailed troubleshooting steps.
545    #[error(
546        "Invalid lock file content at '{path}': expected PID, found '{content}'
547Tip: Remove the invalid lock file and let the system recreate it"
548    )]
549    InvalidLockFile { path: PathBuf, content: String },
550
551    /// Failed to release lock file during cleanup
552    ///
553    /// This is usually not critical but the lock file may persist.
554    /// Use `.help()` for detailed troubleshooting steps.
555    #[error(
556        "Failed to release lock file at '{path}': {source}
557Tip: The lock file may need manual cleanup"
558    )]
559    ReleaseFailed {
560        path: PathBuf,
561        #[source]
562        source: std::io::Error,
563    },
564}
565
566impl FileLockError {
567    /// Get detailed troubleshooting guidance for this error
568    ///
569    /// This method provides comprehensive troubleshooting steps that can be
570    /// displayed to users when they need more help resolving the error.
571    ///
572    /// # Example
573    ///
574    /// ```rust,no_run
575    /// use std::path::Path;
576    /// use std::time::Duration;
577    /// use torrust_tracker_deployer_lib::infrastructure::persistence::filesystem::file_lock::FileLock;
578    ///
579    /// if let Err(e) = FileLock::acquire(Path::new("test.json"), Duration::from_secs(5)) {
580    ///     eprintln!("Error: {e}");
581    ///     eprintln!("\nTroubleshooting:\n{}", e.help());
582    /// }
583    /// # Ok::<(), ()>(())
584    /// ```
585    #[must_use]
586    #[allow(clippy::too_many_lines)]
587    pub fn help(&self) -> &'static str {
588        match self {
589            Self::AcquisitionTimeout { .. } => {
590                "Lock Acquisition Timeout - Detailed Troubleshooting:
591
5921. Check if the holder process is still running:
593   Unix/Linux/macOS: ps -p <pid>
594   Windows: tasklist /FI \"PID eq <pid>\"
595
5962. If the process is running and should release the lock:
597   - Wait for the process to complete its operation
598   - Or increase the timeout duration in your configuration
599
6003. If the process is stuck or hung:
601   - Try graceful termination: kill <pid>  (Unix) or taskkill /PID <pid> (Windows)
602   - Force terminate if needed: kill -9 <pid>  (Unix) or taskkill /F /PID <pid> (Windows)
603
6044. If the process doesn't exist (stale lock):
605   - This should be handled automatically by the lock system
606   - If you see this error repeatedly, it indicates a bug
607   - Please report at: https://github.com/torrust/torrust-tracker-deployer/issues
608
609For more information, see the documentation on file locking."
610            }
611
612            Self::CreateFailed { .. } => {
613                "Lock Creation Failed - Detailed Troubleshooting:
614
6151. Check directory permissions:
616   Unix: ls -la <directory>
617   Windows: icacls <directory>
618   - Ensure write access: chmod u+w <directory>  (Unix)
619
6202. Verify parent directory exists:
621   - Create if needed: mkdir -p <directory>  (Unix/Linux/macOS)
622   - Create if needed: mkdir <directory>  (Windows)
623
6243. Check available disk space:
625   Unix: df -h
626   Windows: wmic logicaldisk get size,freespace,caption
627   - Free up space or use a different location if disk is full
628
6294. Check for file system issues:
630   - Run file system checks if problems persist
631   - Try using a different directory
632   - Check system logs for file system errors
633
634If the problem persists, report it with system details."
635            }
636
637            Self::ReadFailed { .. } => {
638                "Lock File Read Failed - Detailed Troubleshooting:
639
640This error may indicate:
6411. File system corruption
6422. Permission changes after lock creation
6433. Concurrent file deletion by another process
644
645Troubleshooting steps:
6461. Check if the lock file still exists:
647   Unix: ls -la <path>.lock
648   Windows: dir <path>.lock
649
6502. Check file permissions:
651   Unix: stat <path>.lock
652   Windows: icacls <path>.lock
653
6543. Check file system status:
655   Unix: df -h && dmesg | tail
656   Windows: chkdsk
657
6584. If the error persists:
659   - The lock file may be corrupted
660   - You can manually remove it: rm <path>.lock  (Unix) or del <path>.lock  (Windows)
661   - Let the system recreate it on next lock acquisition
662
663Report persistent issues with full error context."
664            }
665
666            Self::InvalidLockFile { .. } => {
667                "Invalid Lock File Content - Detailed Troubleshooting:
668
669The lock file should contain only a process ID (numeric value).
670This error indicates the file contains invalid content.
671
672Common causes:
6731. Manual modification of lock file (not recommended)
6742. File system corruption
6753. Lock file created by incompatible software
6764. Encoding issues
677
678Resolution steps:
6791. Remove the invalid lock file:
680   Unix: rm <path>.lock
681   Windows: del <path>.lock
682
6832. Let the system recreate it properly on next lock acquisition
684
6853. Ensure no external tools or scripts are modifying .lock files
686
6874. If using shared storage (NFS, CIFS, etc.):
688   - Check for file system compatibility issues
689   - Verify proper file locking support
690
691Prevention:
692- Never manually edit .lock files
693- Ensure proper file system support for atomic operations
694- Use appropriate locking mechanisms for shared storage
695
696Report if this error occurs without manual intervention."
697            }
698
699            Self::ReleaseFailed { .. } => {
700                "Lock Release Failed - Detailed Troubleshooting:
701
702This is a cleanup error that occurs when removing the lock file.
703It typically doesn't affect functionality, but the lock file may persist.
704
705Common causes:
7061. File was already deleted (race condition with another process)
7072. Permissions changed after lock creation
7083. File system issue during cleanup
7094. File is open by another process
710
711Steps to resolve:
7121. Check if the lock file still exists:
713   Unix: ls -la <path>.lock
714   Windows: dir <path>.lock
715
7162. If it exists and causes issues, manually remove it:
717   Unix: rm <path>.lock
718   Windows: del <path>.lock
719
7203. Verify no other processes have the file open:
721   Unix: lsof <path>.lock
722   Windows: handle.exe <path>.lock  (requires Sysinternals)
723
724Impact:
725- This error usually doesn't affect the current operation
726- The lock was already released from the application perspective
727- Stale lock files will be cleaned up on next acquisition
728
729Only report if this error occurs frequently or causes operational issues."
730            }
731
732            Self::LockHeldByProcess { .. } => {
733                "This is an internal error used during lock acquisition.
734If you see this error directly, it may indicate a logic error in the application.
735Please report it with full context."
736            }
737        }
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    //! # File Lock Test Suite
744    //!
745    //! Comprehensive tests for the file locking mechanism, organized into logical modules.
746    //!
747    //! ## 📋 Test Organization
748    //!
749    //! - **`basic_operations`**: Core lock acquisition and release functionality
750    //! - **`concurrency`**: Multi-threaded scenarios and concurrent lock handling
751    //! - **`stale_lock_handling`**: Detection and cleanup of stale locks from dead processes
752    //! - **`timeout_behavior`**: Retry logic and timeout handling
753    //! - **`error_handling`**: Error message validation and source chain preservation
754    //! - **`lock_file_path_generation`**: Lock file path generation and validation
755    //!
756    //! ## 🛠️ Test Helpers
757    //!
758    //! - **`TestLockScenario`**: Builder pattern for configuring test scenarios
759    //! - **`assert_lock_file_contains_current_pid`**: Verify lock file exists with correct PID
760    //! - **`assert_lock_file_absent`**: Verify lock file doesn't exist
761
762    use super::*;
763    use rstest::rstest;
764    use std::error::Error;
765    use std::fs;
766    use std::thread;
767    use tempfile::TempDir;
768
769    /// PID value that is highly unlikely to be a running process
770    /// Used in tests to simulate stale locks from dead processes
771    const FAKE_DEAD_PROCESS_PID: u32 = 999_999;
772
773    /// Test helper to verify that a lock file exists and contains the current process ID
774    fn assert_lock_file_contains_current_pid(file_path: &Path) {
775        assert_lock_file_exists(file_path);
776        assert_lock_file_contains_pid(file_path, ProcessId::current());
777    }
778
779    /// Test helper to verify that a lock file does not exist
780    fn assert_lock_file_absent(file_path: &Path) {
781        let lock_file_path = FileLock::lock_file_path(file_path);
782        assert!(
783            !lock_file_path.exists(),
784            "Lock file should not exist at {lock_file_path:?}"
785        );
786    }
787
788    /// Test helper to verify that a lock file exists (without checking content)
789    fn assert_lock_file_exists(file_path: &Path) {
790        let lock_file_path = FileLock::lock_file_path(file_path);
791        assert!(
792            lock_file_path.exists(),
793            "Lock file should exist at {lock_file_path:?}"
794        );
795    }
796
797    /// Test helper to verify that a lock file contains a specific PID
798    fn assert_lock_file_contains_pid(file_path: &Path, expected_pid: ProcessId) {
799        let lock_file_path = FileLock::lock_file_path(file_path);
800        let pid_content =
801            fs::read_to_string(&lock_file_path).expect("Should be able to read lock file");
802        assert_eq!(
803            pid_content.trim(),
804            expected_pid.to_string(),
805            "Lock file should contain PID {expected_pid}"
806        );
807    }
808
809    /// Test helper to verify that lock acquisition failed with a timeout error
810    fn assert_timeout_error(result: Result<FileLock, FileLockError>) {
811        assert!(result.is_err(), "Expected timeout error");
812        match result.unwrap_err() {
813            FileLockError::AcquisitionTimeout { .. } => {}
814            other => panic!("Expected AcquisitionTimeout, got: {other:?}"),
815        }
816    }
817
818    /// Test helper to verify timeout error and check the holder PID
819    fn assert_timeout_error_with_holder(
820        result: Result<FileLock, FileLockError>,
821        expected_holder: ProcessId,
822    ) {
823        assert!(result.is_err(), "Expected timeout error");
824        match result.unwrap_err() {
825            FileLockError::AcquisitionTimeout { holder_pid, .. } => {
826                assert_eq!(
827                    holder_pid,
828                    Some(expected_holder),
829                    "Expected holder PID {expected_holder}"
830                );
831            }
832            other => panic!("Expected AcquisitionTimeout, got: {other:?}"),
833        }
834    }
835
836    /// Test helper to verify invalid lock file error with expected content
837    fn assert_invalid_lock_file_error(
838        result: Result<FileLock, FileLockError>,
839        expected_content: &str,
840    ) {
841        assert!(result.is_err(), "Expected invalid lock file error");
842        match result.unwrap_err() {
843            FileLockError::InvalidLockFile { content, .. } => {
844                assert_eq!(
845                    content, expected_content,
846                    "Expected invalid content '{expected_content}'"
847                );
848            }
849            other => panic!("Expected InvalidLockFile, got: {other:?}"),
850        }
851    }
852
853    // ========================================================================
854    // Test Builder - Builder pattern for test configuration
855    // ========================================================================
856
857    /// Builder for creating test lock scenarios with configurable parameters
858    struct TestLockScenario {
859        temp_dir: TempDir,
860        file_name: String,
861        timeout: Duration,
862    }
863
864    impl TestLockScenario {
865        /// Create a new test scenario with default values
866        fn new() -> Self {
867            Self {
868                temp_dir: TempDir::new().expect("Failed to create temporary directory for test"),
869                file_name: "test.json".to_string(),
870                timeout: Duration::from_secs(1),
871            }
872        }
873
874        /// Set a custom file name for the lock file
875        fn with_file_name(mut self, name: &str) -> Self {
876            self.file_name = name.to_string();
877            self
878        }
879
880        /// Set a custom timeout duration
881        fn with_timeout(mut self, timeout: Duration) -> Self {
882            self.timeout = timeout;
883            self
884        }
885
886        /// Get the path to the file that will be locked
887        fn file_path(&self) -> PathBuf {
888            self.temp_dir.path().join(&self.file_name)
889        }
890
891        /// Get the path to the lock file
892        fn lock_file_path(&self) -> PathBuf {
893            FileLock::lock_file_path(&self.file_path())
894        }
895
896        /// Attempt to acquire a lock with the configured parameters
897        fn acquire_lock(&self) -> Result<FileLock, FileLockError> {
898            FileLock::acquire(&self.file_path(), self.timeout)
899        }
900
901        /// Create scenario with short timeout for failure tests (200ms)
902        fn for_timeout_test() -> Self {
903            Self::new().with_timeout(Duration::from_millis(200))
904        }
905
906        /// Create scenario with long timeout for success tests (5 seconds)
907        fn for_success_test() -> Self {
908            Self::new().with_timeout(Duration::from_secs(5))
909        }
910
911        /// Create a stale lock file with a dead process PID
912        fn with_stale_lock(&self, fake_pid: u32) -> Result<(), std::io::Error> {
913            fs::write(self.lock_file_path(), fake_pid.to_string())
914        }
915
916        /// Create a lock file with invalid content for error testing
917        fn with_invalid_lock(&self, content: &str) -> Result<(), std::io::Error> {
918            fs::write(self.lock_file_path(), content)
919        }
920    }
921
922    // ========================================================================
923    // Basic Operations - Core lock acquisition and release functionality
924    // ========================================================================
925
926    mod basic_operations {
927        use super::*;
928
929        #[test]
930        fn it_should_successfully_acquire_lock() {
931            // Arrange
932            let scenario = TestLockScenario::new();
933
934            // Act
935            let lock = scenario.acquire_lock();
936
937            // Assert
938            assert!(lock.is_ok());
939            let lock = lock.expect("Failed to acquire lock for basic operations test");
940            assert!(lock.acquired);
941
942            // Verify lock file exists and contains our PID
943            assert_lock_file_contains_current_pid(&scenario.file_path());
944        }
945
946        #[test]
947        fn it_should_release_lock_explicitly() {
948            // Arrange
949            let scenario = TestLockScenario::new().with_file_name("explicit_release.json");
950
951            // Act: Acquire and explicitly release
952            let lock = scenario
953                .acquire_lock()
954                .expect("Failed to acquire lock for explicit release test");
955            assert!(scenario.lock_file_path().exists());
956
957            let release_result = lock.release();
958
959            // Assert
960            assert!(release_result.is_ok());
961            assert!(!scenario.lock_file_path().exists());
962
963            // Verify we can acquire again
964            let lock2 = scenario.acquire_lock();
965            assert!(lock2.is_ok());
966        }
967
968        #[test]
969        fn it_should_release_lock_on_drop() {
970            // Arrange
971            let scenario = TestLockScenario::new().with_file_name("drop_release.json");
972
973            // Act: Acquire lock in inner scope
974            {
975                let _lock = scenario
976                    .acquire_lock()
977                    .expect("Failed to acquire lock for drop release test");
978                assert!(scenario.lock_file_path().exists());
979            } // Lock dropped here
980
981            // Assert: Lock file should be removed
982            assert_lock_file_absent(&scenario.file_path());
983
984            // Verify we can acquire again
985            let lock2 = scenario.acquire_lock();
986            assert!(lock2.is_ok());
987        }
988
989        #[test]
990        fn it_should_allow_sequential_locks_by_same_process() {
991            // Arrange
992            let scenario = TestLockScenario::new().with_file_name("sequential.json");
993
994            // Act & Assert: Acquire, release, acquire again
995            let lock1 = scenario
996                .acquire_lock()
997                .expect("Failed to acquire first lock for sequential test");
998            drop(lock1); // Release
999
1000            let lock2 = scenario.acquire_lock();
1001            assert!(lock2.is_ok());
1002        }
1003    }
1004
1005    // ========================================================================
1006    // Concurrency - Tests for concurrent lock acquisition scenarios
1007    // ========================================================================
1008
1009    mod concurrency {
1010        use super::*;
1011
1012        #[test]
1013        fn it_should_prevent_concurrent_lock_acquisition() {
1014            // Arrange
1015            let scenario = TestLockScenario::new()
1016                .with_file_name("concurrent.json")
1017                .with_timeout(Duration::from_millis(500));
1018
1019            // Act: First lock succeeds
1020            let _lock1 = scenario
1021                .acquire_lock()
1022                .expect("Failed to acquire first lock for concurrency test");
1023
1024            // Act: Second lock fails immediately (timeout < retry interval)
1025            let lock2_result = FileLock::acquire(&scenario.file_path(), Duration::from_millis(50));
1026
1027            // Assert
1028            assert_timeout_error_with_holder(lock2_result, ProcessId::current());
1029        }
1030
1031        #[test]
1032        fn it_should_handle_concurrent_acquisitions_with_threads() {
1033            // Arrange
1034            let scenario = TestLockScenario::for_success_test().with_file_name("thread_test.json");
1035            let file_path = scenario.file_path();
1036            let file_path_clone = file_path.clone();
1037
1038            // Act: Try to acquire lock from two threads
1039            let handle1 =
1040                thread::spawn(move || FileLock::acquire(&file_path, Duration::from_secs(2)));
1041
1042            // Give first thread a head start
1043            thread::sleep(Duration::from_millis(50));
1044
1045            let handle2 = thread::spawn(move || {
1046                FileLock::acquire(&file_path_clone, Duration::from_millis(100))
1047            });
1048
1049            let result1 = handle1
1050                .join()
1051                .expect("Failed to join first thread in concurrency test");
1052            let result2 = handle2
1053                .join()
1054                .expect("Failed to join second thread in concurrency test");
1055
1056            // Assert: One should succeed, one should timeout
1057            assert!(result1.is_ok() ^ result2.is_ok());
1058        }
1059    }
1060
1061    // ========================================================================
1062    // Stale Lock Handling - Tests for cleaning up stale locks
1063    // ========================================================================
1064
1065    mod stale_lock_handling {
1066        use super::*;
1067
1068        #[test]
1069        fn it_should_clean_up_stale_lock_with_invalid_pid() {
1070            // Arrange
1071            let scenario = TestLockScenario::for_success_test().with_file_name("stale.json");
1072            scenario
1073                .with_stale_lock(FAKE_DEAD_PROCESS_PID)
1074                .expect("Failed to create stale lock file");
1075
1076            // Act
1077            let lock_result = scenario.acquire_lock();
1078
1079            // Assert: Should succeed by cleaning up stale lock
1080            assert!(lock_result.is_ok());
1081
1082            // Verify new lock file has our PID
1083            assert_lock_file_contains_current_pid(&scenario.file_path());
1084        }
1085
1086        #[test]
1087        fn it_should_handle_invalid_lock_file_content() {
1088            // Arrange
1089            let scenario = TestLockScenario::for_timeout_test().with_file_name("invalid.json");
1090            scenario
1091                .with_invalid_lock("not-a-number")
1092                .expect("Failed to create invalid lock file");
1093
1094            // Act
1095            let lock_result = scenario.acquire_lock();
1096
1097            // Assert
1098            assert_invalid_lock_file_error(lock_result, "not-a-number");
1099        }
1100    }
1101
1102    // ========================================================================
1103    // Timeout Behavior - Tests for timeout and retry mechanisms
1104    // ========================================================================
1105
1106    mod timeout_behavior {
1107        use super::*;
1108
1109        #[test]
1110        fn it_should_timeout_when_lock_held_by_another_process() {
1111            // Arrange
1112            let scenario = TestLockScenario::for_timeout_test().with_file_name("timeout.json");
1113            let short_timeout = Duration::from_millis(200);
1114
1115            // Act: Hold lock in first acquisition
1116            let _lock1 = FileLock::acquire(&scenario.file_path(), Duration::from_secs(5))
1117                .expect("Failed to acquire first lock for timeout test");
1118
1119            // Try to acquire in same process (simulates another process)
1120            let lock2_result = FileLock::acquire(&scenario.file_path(), short_timeout);
1121
1122            // Assert: Should timeout
1123            assert_timeout_error(lock2_result);
1124        }
1125
1126        #[test]
1127        fn it_should_handle_lock_acquisition_with_retries() {
1128            // Arrange
1129            let scenario = TestLockScenario::for_success_test().with_file_name("retry.json");
1130            let file_path = scenario.file_path();
1131            let file_path_clone = file_path.clone();
1132
1133            // Act: Hold lock briefly then release
1134            let handle = thread::spawn(move || {
1135                let lock = FileLock::acquire(&file_path, Duration::from_secs(1))
1136                    .expect("Failed to acquire lock in retry test thread");
1137                thread::sleep(Duration::from_millis(300));
1138                drop(lock); // Release after 300ms
1139            });
1140
1141            // Give first thread time to acquire
1142            thread::sleep(Duration::from_millis(50));
1143
1144            // Try to acquire with longer timeout - should succeed after retry
1145            let lock2_result = FileLock::acquire(&file_path_clone, Duration::from_secs(2));
1146
1147            handle.join().expect("Failed to join thread in retry test");
1148
1149            // Assert: Second lock should eventually succeed
1150            assert!(lock2_result.is_ok());
1151        }
1152    }
1153
1154    // ========================================================================
1155    // Error Handling - Tests for error messages and error source preservation
1156    // ========================================================================
1157
1158    mod error_handling {
1159        use super::*;
1160
1161        #[test]
1162        fn it_should_include_brief_tips_in_error_messages() {
1163            let path = PathBuf::from("/test/path.json");
1164
1165            // Test AcquisitionTimeout includes tip
1166            let timeout_err = FileLockError::AcquisitionTimeout {
1167                path: path.clone(),
1168                holder_pid: Some(ProcessId::from_raw(12345)),
1169                timeout: Duration::from_secs(5),
1170            };
1171            let msg = timeout_err.to_string();
1172            assert!(msg.contains("Tip:"), "Error message should contain a tip");
1173            assert!(
1174                msg.contains("ps -p"),
1175                "Tip should mention process check command"
1176            );
1177
1178            // Test CreateFailed includes tip
1179            let io_error =
1180                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
1181            let create_err = FileLockError::CreateFailed {
1182                path: path.clone(),
1183                source: io_error,
1184            };
1185            let msg = create_err.to_string();
1186            assert!(msg.contains("Tip:"), "Error message should contain a tip");
1187            assert!(
1188                msg.contains("permissions"),
1189                "Tip should mention permissions"
1190            );
1191
1192            // Test ReadFailed includes tip
1193            let io_error =
1194                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
1195            let read_err = FileLockError::ReadFailed {
1196                path: path.clone(),
1197                source: io_error,
1198            };
1199            let msg = read_err.to_string();
1200            assert!(msg.contains("Tip:"), "Error message should contain a tip");
1201
1202            // Test InvalidLockFile includes tip
1203            let invalid_err = FileLockError::InvalidLockFile {
1204                path: path.clone(),
1205                content: "bad-content".to_string(),
1206            };
1207            let msg = invalid_err.to_string();
1208            assert!(msg.contains("Tip:"), "Error message should contain a tip");
1209            assert!(
1210                msg.contains("Remove"),
1211                "Tip should mention removing the file"
1212            );
1213
1214            // Test ReleaseFailed includes tip
1215            let io_error =
1216                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
1217            let release_err = FileLockError::ReleaseFailed {
1218                path: path.clone(),
1219                source: io_error,
1220            };
1221            let msg = release_err.to_string();
1222            assert!(msg.contains("Tip:"), "Error message should contain a tip");
1223        }
1224
1225        #[test]
1226        fn it_should_provide_detailed_help_for_all_error_variants() {
1227            let path = PathBuf::from("/test/path.json");
1228            let io_error =
1229                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
1230
1231            let test_cases = vec![
1232                (
1233                    "AcquisitionTimeout",
1234                    FileLockError::AcquisitionTimeout {
1235                        path: path.clone(),
1236                        holder_pid: Some(ProcessId::from_raw(12345)),
1237                        timeout: Duration::from_secs(5),
1238                    },
1239                ),
1240                (
1241                    "CreateFailed",
1242                    FileLockError::CreateFailed {
1243                        path: path.clone(),
1244                        source: io_error.kind().into(),
1245                    },
1246                ),
1247                (
1248                    "ReadFailed",
1249                    FileLockError::ReadFailed {
1250                        path: path.clone(),
1251                        source: io_error.kind().into(),
1252                    },
1253                ),
1254                (
1255                    "InvalidLockFile",
1256                    FileLockError::InvalidLockFile {
1257                        path: path.clone(),
1258                        content: "bad-content".to_string(),
1259                    },
1260                ),
1261                (
1262                    "ReleaseFailed",
1263                    FileLockError::ReleaseFailed {
1264                        path: path.clone(),
1265                        source: io_error.kind().into(),
1266                    },
1267                ),
1268                (
1269                    "LockHeldByProcess",
1270                    FileLockError::LockHeldByProcess {
1271                        pid: ProcessId::from_raw(12345),
1272                    },
1273                ),
1274            ];
1275
1276            for (variant_name, error) in test_cases {
1277                let help = error.help();
1278                assert!(!help.is_empty(), "{variant_name}: Help should not be empty");
1279                assert!(
1280                    help.len() > 50,
1281                    "{variant_name}: Help should be detailed (at least 50 chars)"
1282                );
1283            }
1284        }
1285
1286        #[test]
1287        fn it_should_include_platform_specific_commands_in_help() {
1288            let timeout_err = FileLockError::AcquisitionTimeout {
1289                path: PathBuf::from("/test/path.json"),
1290                holder_pid: Some(ProcessId::from_raw(12345)),
1291                timeout: Duration::from_secs(5),
1292            };
1293
1294            let help = timeout_err.help();
1295
1296            // Check for Unix commands
1297            assert!(
1298                help.contains("ps -p"),
1299                "Help should include Unix process check command"
1300            );
1301            assert!(
1302                help.contains("kill"),
1303                "Help should include Unix kill command"
1304            );
1305
1306            // Check for Windows commands
1307            assert!(
1308                help.contains("tasklist"),
1309                "Help should include Windows process check command"
1310            );
1311            assert!(
1312                help.contains("taskkill"),
1313                "Help should include Windows kill command"
1314            );
1315        }
1316
1317        #[test]
1318        fn it_should_display_error_messages_correctly() {
1319            let path = PathBuf::from("/test/path.json");
1320
1321            // Test AcquisitionTimeout display
1322            let timeout_err = FileLockError::AcquisitionTimeout {
1323                path: path.clone(),
1324                holder_pid: Some(ProcessId::from_raw(12345)),
1325                timeout: Duration::from_secs(5),
1326            };
1327            let msg = timeout_err.to_string();
1328            assert!(msg.contains("Failed to acquire lock"));
1329            assert!(msg.contains("12345"));
1330
1331            // Test LockHeldByProcess display
1332            let held_err = FileLockError::LockHeldByProcess {
1333                pid: ProcessId::from_raw(67890),
1334            };
1335            let msg = held_err.to_string();
1336            assert!(msg.contains("Lock held"));
1337            assert!(msg.contains("67890"));
1338
1339            // Test InvalidLockFile display
1340            let invalid_err = FileLockError::InvalidLockFile {
1341                path: path.clone(),
1342                content: "bad-content".to_string(),
1343            };
1344            let msg = invalid_err.to_string();
1345            assert!(msg.contains("Invalid lock file"));
1346            assert!(msg.contains("bad-content"));
1347        }
1348
1349        #[test]
1350        fn it_should_preserve_error_source_chain() {
1351            // Test that errors preserve source information
1352            let path = PathBuf::from("/test/path.json");
1353            let io_error =
1354                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
1355
1356            let create_failed = FileLockError::CreateFailed {
1357                path,
1358                source: io_error,
1359            };
1360
1361            // Verify source is preserved
1362            assert!(create_failed.source().is_some());
1363        }
1364    }
1365
1366    // ========================================================================
1367    // Lock File Path Generation - Tests for lock file path generation
1368    // ========================================================================
1369
1370    mod lock_file_path_generation {
1371        use super::*;
1372
1373        #[rstest]
1374        #[case("test.json", "test.json.lock")]
1375        #[case("data/state.json", "data/state.json.lock")]
1376        #[case("/abs/path/file.txt", "/abs/path/file.txt.lock")]
1377        #[case("no_extension", "no_extension.lock")]
1378        fn it_should_generate_correct_lock_file_path(#[case] input: &str, #[case] expected: &str) {
1379            let input_path = Path::new(input);
1380            let lock_path = FileLock::lock_file_path(input_path);
1381            assert_eq!(lock_path.to_string_lossy(), expected);
1382        }
1383    }
1384
1385    // ========================================================================
1386    // Lock State Detection - Tests for lock acquisition state machine
1387    // ========================================================================
1388
1389    mod lock_state_detection {
1390        use super::*;
1391
1392        #[test]
1393        fn it_should_detect_acquired_state_when_no_lock_exists() {
1394            // Arrange
1395            let scenario = TestLockScenario::new().with_file_name("state_acquired.json");
1396
1397            // Act
1398            let state = FileLock::check_lock_state(&scenario.file_path());
1399
1400            // Assert
1401            assert_eq!(state, LockAcquisitionState::Acquired);
1402        }
1403
1404        #[test]
1405        fn it_should_detect_stale_lock_state() {
1406            // Arrange
1407            let scenario = TestLockScenario::new().with_file_name("state_stale.json");
1408            scenario
1409                .with_stale_lock(FAKE_DEAD_PROCESS_PID)
1410                .expect("Failed to create stale lock file for state test");
1411
1412            // Act
1413            let state = FileLock::check_lock_state(&scenario.file_path());
1414
1415            // Assert
1416            assert_eq!(
1417                state,
1418                LockAcquisitionState::FoundStaleLock(ProcessId::from_raw(FAKE_DEAD_PROCESS_PID))
1419            );
1420        }
1421
1422        #[test]
1423        fn it_should_detect_blocked_state_when_lock_held() {
1424            // Arrange
1425            let scenario = TestLockScenario::new().with_file_name("state_blocked.json");
1426            let _lock = scenario
1427                .acquire_lock()
1428                .expect("Failed to acquire lock for state test");
1429
1430            // Act
1431            let state = FileLock::check_lock_state(&scenario.file_path());
1432
1433            // Assert
1434            assert_eq!(state, LockAcquisitionState::Blocked(ProcessId::current()));
1435        }
1436
1437        #[test]
1438        fn it_should_detect_attempting_state_on_error() {
1439            // Arrange
1440            let scenario = TestLockScenario::new().with_file_name("state_error.json");
1441            scenario
1442                .with_invalid_lock("invalid-pid-content")
1443                .expect("Failed to create invalid lock file for state test");
1444
1445            // Act
1446            let state = FileLock::check_lock_state(&scenario.file_path());
1447
1448            // Assert
1449            assert_eq!(state, LockAcquisitionState::Attempting);
1450        }
1451    }
1452
1453    // ========================================================================
1454    // Tracing - Tests for observability and tracing instrumentation
1455    // ========================================================================
1456
1457    mod tracing {
1458        use super::*;
1459
1460        #[test]
1461        fn it_should_complete_lock_operations_with_tracing_enabled() {
1462            // Arrange
1463            let scenario = TestLockScenario::new().with_file_name("traced.json");
1464
1465            // Act: Acquire lock (tracing happens in background)
1466            let lock = scenario
1467                .acquire_lock()
1468                .expect("Failed to acquire lock with tracing");
1469
1470            // Assert: Lock was acquired successfully
1471            assert_lock_file_exists(&scenario.file_path());
1472            assert_lock_file_contains_current_pid(&scenario.file_path());
1473
1474            // Act: Release lock explicitly (tracing happens in background)
1475            lock.release().expect("Failed to release lock with tracing");
1476
1477            // Assert: Lock was released successfully
1478            assert_lock_file_absent(&scenario.file_path());
1479        }
1480
1481        #[test]
1482        fn it_should_trace_stale_lock_cleanup() {
1483            // Arrange
1484            let scenario = TestLockScenario::new().with_file_name("stale_traced.json");
1485            scenario
1486                .with_stale_lock(FAKE_DEAD_PROCESS_PID)
1487                .expect("Failed to create stale lock for tracing test");
1488
1489            // Act: Acquire should clean up stale lock (tracing shows cleanup)
1490            let lock = scenario
1491                .acquire_lock()
1492                .expect("Failed to acquire after stale lock cleanup");
1493
1494            // Assert: Lock acquired successfully after cleanup
1495            assert_lock_file_contains_current_pid(&scenario.file_path());
1496
1497            drop(lock);
1498        }
1499
1500        #[test]
1501        fn it_should_trace_timeout_scenario() {
1502            // Arrange
1503            let scenario =
1504                TestLockScenario::for_timeout_test().with_file_name("timeout_traced.json");
1505
1506            let _blocking_lock = scenario
1507                .acquire_lock()
1508                .expect("Failed to acquire blocking lock");
1509
1510            // Act: Try to acquire with short timeout (tracing shows retry attempts)
1511            let result = FileLock::acquire(&scenario.file_path(), Duration::from_millis(200));
1512
1513            // Assert: Should timeout (tracing shows all retry attempts)
1514            assert_timeout_error(result);
1515        }
1516
1517        #[test]
1518        fn it_should_trace_invalid_lock_file_scenario() {
1519            // Arrange
1520            let scenario = TestLockScenario::new().with_file_name("invalid_traced.json");
1521            let invalid_content = "not-a-valid-pid";
1522            scenario
1523                .with_invalid_lock(invalid_content)
1524                .expect("Failed to create invalid lock for tracing test");
1525
1526            // Act: Try to acquire (tracing shows invalid content detection)
1527            let result = scenario.acquire_lock();
1528
1529            // Assert: Should fail with invalid lock file error
1530            assert_invalid_lock_file_error(result, invalid_content);
1531        }
1532
1533        #[test]
1534        fn it_should_trace_concurrent_acquisition_attempts() {
1535            // Arrange
1536            let scenario = TestLockScenario::new().with_file_name("concurrent_traced.json");
1537
1538            // Act: Spawn threads that try to acquire concurrently
1539            let handles: Vec<_> = (0..3)
1540                .map(|_| {
1541                    let path = scenario.file_path();
1542                    std::thread::spawn(move || FileLock::acquire(&path, Duration::from_millis(200)))
1543                })
1544                .collect();
1545
1546            // Collect results
1547            let results: Vec<_> = handles
1548                .into_iter()
1549                .map(|h| h.join().expect("Thread panicked"))
1550                .collect();
1551
1552            // Assert: Exactly one should succeed, others should timeout
1553            let success_count = results.iter().filter(|r| r.is_ok()).count();
1554            assert_eq!(
1555                success_count, 1,
1556                "Exactly one thread should acquire the lock"
1557            );
1558        }
1559    }
1560}