1use std::fs;
32use std::path::{Path, PathBuf};
33use std::time::{Duration, Instant};
34use thiserror::Error;
35use tracing;
36
37use super::process_id::ProcessId;
38
39const LOCK_RETRY_INTERVAL_MS: u64 = 100;
41
42const LOCK_RETRY_SLEEP: Duration = Duration::from_millis(LOCK_RETRY_INTERVAL_MS);
44
45#[derive(Debug)]
68pub struct FileLock {
69 lock_file_path: PathBuf,
70 acquired: bool,
71}
72
73impl FileLock {
74 #[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 drop(fs::remove_file(&lock_file_path));
158 }
160 AcquireAttemptResult::TransientError => {
161 tracing::trace!(
162 attempt,
163 "Transient error during lock acquisition (likely race condition), retrying"
164 );
165 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 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 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 #[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 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 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 AcquireAttemptResult::TransientError
286 }
287 Err(e) => AcquireAttemptResult::Error(e),
288 }
289 }
290
291 #[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 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!(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 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 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 #[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 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 fn drop(&mut self) {
404 if self.acquired {
405 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
423enum AcquireAttemptResult {
429 Success,
431 StaleProcess(ProcessId),
433 HeldByLiveProcess(ProcessId),
435 TransientError,
437 Error(FileLockError),
439}
440
441#[cfg(test)]
447#[derive(Debug, PartialEq, Eq)]
448pub enum LockAcquisitionState {
449 Attempting,
451 FoundStaleLock(ProcessId),
453 Blocked(ProcessId),
455 Acquired,
457}
458
459struct LockRetryStrategy {
463 start: Instant,
464 timeout: Duration,
465}
466
467impl LockRetryStrategy {
468 fn new(timeout: Duration) -> Self {
470 Self {
471 start: Instant::now(),
472 timeout,
473 }
474 }
475
476 fn is_expired(&self) -> bool {
478 self.start.elapsed() >= self.timeout
479 }
480
481 fn wait() {
483 std::thread::sleep(LOCK_RETRY_SLEEP);
484 }
485}
486
487#[derive(Debug, Error)]
491pub enum FileLockError {
492 #[error("Lock held by process {pid}")]
497 LockHeldByProcess { pid: ProcessId },
498
499 #[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 #[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 #[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 #[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 #[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 #[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 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 const FAKE_DEAD_PROCESS_PID: u32 = 999_999;
772
773 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 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 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 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 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 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 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 struct TestLockScenario {
859 temp_dir: TempDir,
860 file_name: String,
861 timeout: Duration,
862 }
863
864 impl TestLockScenario {
865 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 fn with_file_name(mut self, name: &str) -> Self {
876 self.file_name = name.to_string();
877 self
878 }
879
880 fn with_timeout(mut self, timeout: Duration) -> Self {
882 self.timeout = timeout;
883 self
884 }
885
886 fn file_path(&self) -> PathBuf {
888 self.temp_dir.path().join(&self.file_name)
889 }
890
891 fn lock_file_path(&self) -> PathBuf {
893 FileLock::lock_file_path(&self.file_path())
894 }
895
896 fn acquire_lock(&self) -> Result<FileLock, FileLockError> {
898 FileLock::acquire(&self.file_path(), self.timeout)
899 }
900
901 fn for_timeout_test() -> Self {
903 Self::new().with_timeout(Duration::from_millis(200))
904 }
905
906 fn for_success_test() -> Self {
908 Self::new().with_timeout(Duration::from_secs(5))
909 }
910
911 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 fn with_invalid_lock(&self, content: &str) -> Result<(), std::io::Error> {
918 fs::write(self.lock_file_path(), content)
919 }
920 }
921
922 mod basic_operations {
927 use super::*;
928
929 #[test]
930 fn it_should_successfully_acquire_lock() {
931 let scenario = TestLockScenario::new();
933
934 let lock = scenario.acquire_lock();
936
937 assert!(lock.is_ok());
939 let lock = lock.expect("Failed to acquire lock for basic operations test");
940 assert!(lock.acquired);
941
942 assert_lock_file_contains_current_pid(&scenario.file_path());
944 }
945
946 #[test]
947 fn it_should_release_lock_explicitly() {
948 let scenario = TestLockScenario::new().with_file_name("explicit_release.json");
950
951 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!(release_result.is_ok());
961 assert!(!scenario.lock_file_path().exists());
962
963 let lock2 = scenario.acquire_lock();
965 assert!(lock2.is_ok());
966 }
967
968 #[test]
969 fn it_should_release_lock_on_drop() {
970 let scenario = TestLockScenario::new().with_file_name("drop_release.json");
972
973 {
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 } assert_lock_file_absent(&scenario.file_path());
983
984 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 let scenario = TestLockScenario::new().with_file_name("sequential.json");
993
994 let lock1 = scenario
996 .acquire_lock()
997 .expect("Failed to acquire first lock for sequential test");
998 drop(lock1); let lock2 = scenario.acquire_lock();
1001 assert!(lock2.is_ok());
1002 }
1003 }
1004
1005 mod concurrency {
1010 use super::*;
1011
1012 #[test]
1013 fn it_should_prevent_concurrent_lock_acquisition() {
1014 let scenario = TestLockScenario::new()
1016 .with_file_name("concurrent.json")
1017 .with_timeout(Duration::from_millis(500));
1018
1019 let _lock1 = scenario
1021 .acquire_lock()
1022 .expect("Failed to acquire first lock for concurrency test");
1023
1024 let lock2_result = FileLock::acquire(&scenario.file_path(), Duration::from_millis(50));
1026
1027 assert_timeout_error_with_holder(lock2_result, ProcessId::current());
1029 }
1030
1031 #[test]
1032 fn it_should_handle_concurrent_acquisitions_with_threads() {
1033 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 let handle1 =
1040 thread::spawn(move || FileLock::acquire(&file_path, Duration::from_secs(2)));
1041
1042 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!(result1.is_ok() ^ result2.is_ok());
1058 }
1059 }
1060
1061 mod stale_lock_handling {
1066 use super::*;
1067
1068 #[test]
1069 fn it_should_clean_up_stale_lock_with_invalid_pid() {
1070 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 let lock_result = scenario.acquire_lock();
1078
1079 assert!(lock_result.is_ok());
1081
1082 assert_lock_file_contains_current_pid(&scenario.file_path());
1084 }
1085
1086 #[test]
1087 fn it_should_handle_invalid_lock_file_content() {
1088 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 let lock_result = scenario.acquire_lock();
1096
1097 assert_invalid_lock_file_error(lock_result, "not-a-number");
1099 }
1100 }
1101
1102 mod timeout_behavior {
1107 use super::*;
1108
1109 #[test]
1110 fn it_should_timeout_when_lock_held_by_another_process() {
1111 let scenario = TestLockScenario::for_timeout_test().with_file_name("timeout.json");
1113 let short_timeout = Duration::from_millis(200);
1114
1115 let _lock1 = FileLock::acquire(&scenario.file_path(), Duration::from_secs(5))
1117 .expect("Failed to acquire first lock for timeout test");
1118
1119 let lock2_result = FileLock::acquire(&scenario.file_path(), short_timeout);
1121
1122 assert_timeout_error(lock2_result);
1124 }
1125
1126 #[test]
1127 fn it_should_handle_lock_acquisition_with_retries() {
1128 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 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); });
1140
1141 thread::sleep(Duration::from_millis(50));
1143
1144 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!(lock2_result.is_ok());
1151 }
1152 }
1153
1154 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 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 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 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 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 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 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 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 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 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 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 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 assert!(create_failed.source().is_some());
1363 }
1364 }
1365
1366 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 mod lock_state_detection {
1390 use super::*;
1391
1392 #[test]
1393 fn it_should_detect_acquired_state_when_no_lock_exists() {
1394 let scenario = TestLockScenario::new().with_file_name("state_acquired.json");
1396
1397 let state = FileLock::check_lock_state(&scenario.file_path());
1399
1400 assert_eq!(state, LockAcquisitionState::Acquired);
1402 }
1403
1404 #[test]
1405 fn it_should_detect_stale_lock_state() {
1406 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 let state = FileLock::check_lock_state(&scenario.file_path());
1414
1415 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 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 let state = FileLock::check_lock_state(&scenario.file_path());
1432
1433 assert_eq!(state, LockAcquisitionState::Blocked(ProcessId::current()));
1435 }
1436
1437 #[test]
1438 fn it_should_detect_attempting_state_on_error() {
1439 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 let state = FileLock::check_lock_state(&scenario.file_path());
1447
1448 assert_eq!(state, LockAcquisitionState::Attempting);
1450 }
1451 }
1452
1453 mod tracing {
1458 use super::*;
1459
1460 #[test]
1461 fn it_should_complete_lock_operations_with_tracing_enabled() {
1462 let scenario = TestLockScenario::new().with_file_name("traced.json");
1464
1465 let lock = scenario
1467 .acquire_lock()
1468 .expect("Failed to acquire lock with tracing");
1469
1470 assert_lock_file_exists(&scenario.file_path());
1472 assert_lock_file_contains_current_pid(&scenario.file_path());
1473
1474 lock.release().expect("Failed to release lock with tracing");
1476
1477 assert_lock_file_absent(&scenario.file_path());
1479 }
1480
1481 #[test]
1482 fn it_should_trace_stale_lock_cleanup() {
1483 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 let lock = scenario
1491 .acquire_lock()
1492 .expect("Failed to acquire after stale lock cleanup");
1493
1494 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 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 let result = FileLock::acquire(&scenario.file_path(), Duration::from_millis(200));
1512
1513 assert_timeout_error(result);
1515 }
1516
1517 #[test]
1518 fn it_should_trace_invalid_lock_file_scenario() {
1519 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 let result = scenario.acquire_lock();
1528
1529 assert_invalid_lock_file_error(result, invalid_content);
1531 }
1532
1533 #[test]
1534 fn it_should_trace_concurrent_acquisition_attempts() {
1535 let scenario = TestLockScenario::new().with_file_name("concurrent_traced.json");
1537
1538 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 let results: Vec<_> = handles
1548 .into_iter()
1549 .map(|h| h.join().expect("Thread panicked"))
1550 .collect();
1551
1552 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}