use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use thiserror::Error;
use tracing;
use super::process_id::ProcessId;
const LOCK_RETRY_INTERVAL_MS: u64 = 100;
const LOCK_RETRY_SLEEP: Duration = Duration::from_millis(LOCK_RETRY_INTERVAL_MS);
#[derive(Debug)]
pub struct FileLock {
lock_file_path: PathBuf,
acquired: bool,
}
impl FileLock {
#[tracing::instrument(
name = "file_lock_acquire",
skip(file_path),
fields(
file = %file_path.display(),
timeout_ms = timeout.as_millis(),
pid = %ProcessId::current(),
)
)]
pub fn acquire(file_path: &Path, timeout: Duration) -> Result<Self, FileLockError> {
tracing::debug!("Attempting to acquire lock");
let lock_file_path = Self::lock_file_path(file_path);
let current_pid = ProcessId::current();
let retry_strategy = LockRetryStrategy::new(timeout);
tracing::trace!(
lock_file = %lock_file_path.display(),
"Lock file path determined"
);
let mut attempt = 0;
loop {
attempt += 1;
tracing::trace!(attempt, "Lock acquisition attempt");
match Self::try_acquire_once(&lock_file_path, current_pid) {
AcquireAttemptResult::Success => {
tracing::debug!(attempts = attempt, "Lock acquired successfully");
return Ok(Self {
lock_file_path,
acquired: true,
});
}
AcquireAttemptResult::StaleProcess(pid) => {
tracing::warn!(
stale_pid = %pid,
attempt,
"Detected stale lock, cleaning up"
);
drop(fs::remove_file(&lock_file_path));
}
AcquireAttemptResult::TransientError => {
tracing::trace!(
attempt,
"Transient error during lock acquisition (likely race condition), retrying"
);
LockRetryStrategy::wait();
}
AcquireAttemptResult::HeldByLiveProcess(pid) => {
tracing::trace!(
holder_pid = %pid,
attempt,
elapsed_ms = retry_strategy.start.elapsed().as_millis(),
"Lock held by live process"
);
if retry_strategy.is_expired() {
tracing::warn!(
holder_pid = %pid,
attempts = attempt,
timeout_ms = timeout.as_millis(),
"Lock acquisition timeout"
);
return Err(FileLockError::AcquisitionTimeout {
path: lock_file_path,
holder_pid: Some(pid),
timeout,
});
}
LockRetryStrategy::wait();
}
AcquireAttemptResult::Error(e) => {
tracing::warn!(
error = %e,
attempt,
"Error during lock acquisition"
);
return Err(e);
}
}
}
}
#[tracing::instrument(
name = "file_lock_release",
skip(self),
fields(lock_file = %self.lock_file_path.display())
)]
pub fn release(mut self) -> Result<(), FileLockError> {
tracing::debug!("Releasing lock");
if self.acquired {
fs::remove_file(&self.lock_file_path).map_err(|source| {
tracing::warn!(error = %source, "Failed to remove lock file");
FileLockError::ReleaseFailed {
path: self.lock_file_path.clone(),
source,
}
})?;
self.acquired = false;
tracing::debug!("Lock released successfully");
} else {
tracing::trace!("Lock was not acquired, nothing to release");
}
Ok(())
}
fn lock_file_path(file_path: &Path) -> PathBuf {
let mut lock_path = file_path.to_path_buf();
let current_extension = lock_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let new_extension = if current_extension.is_empty() {
"lock".to_string()
} else {
format!("{current_extension}.lock")
};
lock_path.set_extension(new_extension);
lock_path
}
fn try_acquire_once(lock_path: &Path, current_pid: ProcessId) -> AcquireAttemptResult {
match Self::try_create_lock(lock_path, current_pid) {
Ok(()) => AcquireAttemptResult::Success,
Err(FileLockError::LockHeldByProcess { pid }) => {
if pid.is_alive() {
AcquireAttemptResult::HeldByLiveProcess(pid)
} else {
AcquireAttemptResult::StaleProcess(pid)
}
}
Err(FileLockError::InvalidLockFile { ref content, .. }) if content.is_empty() => {
AcquireAttemptResult::TransientError
}
Err(e) => AcquireAttemptResult::Error(e),
}
}
#[tracing::instrument(
name = "file_lock_try_create",
skip(lock_path),
fields(lock_file = %lock_path.display(), pid = %pid)
)]
fn try_create_lock(lock_path: &Path, pid: ProcessId) -> Result<(), FileLockError> {
use std::fs::OpenOptions;
use std::io::Write;
tracing::trace!("Attempting to create lock file");
match OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)
{
Ok(mut file) => {
tracing::trace!("Lock file created, writing PID");
write!(file, "{pid}").map_err(|source| {
tracing::warn!(error = %source, "Failed to write PID to lock file");
FileLockError::CreateFailed {
path: lock_path.to_path_buf(),
source,
}
})?;
file.flush().map_err(|source| {
tracing::warn!(error = %source, "Failed to flush PID to lock file");
FileLockError::CreateFailed {
path: lock_path.to_path_buf(),
source,
}
})?;
tracing::debug!("Lock file created successfully");
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
tracing::trace!("Lock file already exists, reading holder PID");
let content = fs::read_to_string(lock_path).map_err(|source| {
tracing::warn!(error = %source, "Failed to read lock file");
FileLockError::ReadFailed {
path: lock_path.to_path_buf(),
source,
}
})?;
let holder_pid = content.trim().parse::<ProcessId>().map_err(|_| {
tracing::warn!(content = %content, "Invalid PID content in lock file");
FileLockError::InvalidLockFile {
path: lock_path.to_path_buf(),
content,
}
})?;
tracing::trace!(holder_pid = %holder_pid, "Lock held by process");
Err(FileLockError::LockHeldByProcess { pid: holder_pid })
}
Err(source) => {
tracing::warn!(error = %source, "Failed to create lock file");
Err(FileLockError::CreateFailed {
path: lock_path.to_path_buf(),
source,
})
}
}
}
#[cfg(test)]
#[must_use]
pub fn check_lock_state(file_path: &Path) -> LockAcquisitionState {
let lock_path = Self::lock_file_path(file_path);
let current_pid = ProcessId::current();
match Self::try_create_lock(&lock_path, current_pid) {
Ok(()) => {
drop(fs::remove_file(&lock_path));
LockAcquisitionState::Acquired
}
Err(FileLockError::LockHeldByProcess { pid }) => {
if pid.is_alive() {
LockAcquisitionState::Blocked(pid)
} else {
LockAcquisitionState::FoundStaleLock(pid)
}
}
Err(_) => LockAcquisitionState::Attempting,
}
}
}
impl Drop for FileLock {
fn drop(&mut self) {
if self.acquired {
if let Err(e) = fs::remove_file(&self.lock_file_path) {
tracing::warn!(
lock_file = %self.lock_file_path.display(),
error = %e,
"Failed to remove lock file during drop"
);
} else {
tracing::trace!(
lock_file = %self.lock_file_path.display(),
"Lock file removed successfully during drop"
);
}
self.acquired = false;
}
}
}
enum AcquireAttemptResult {
Success,
StaleProcess(ProcessId),
HeldByLiveProcess(ProcessId),
TransientError,
Error(FileLockError),
}
#[cfg(test)]
#[derive(Debug, PartialEq, Eq)]
pub enum LockAcquisitionState {
Attempting,
FoundStaleLock(ProcessId),
Blocked(ProcessId),
Acquired,
}
struct LockRetryStrategy {
start: Instant,
timeout: Duration,
}
impl LockRetryStrategy {
fn new(timeout: Duration) -> Self {
Self {
start: Instant::now(),
timeout,
}
}
fn is_expired(&self) -> bool {
self.start.elapsed() >= self.timeout
}
fn wait() {
std::thread::sleep(LOCK_RETRY_SLEEP);
}
}
#[derive(Debug, Error)]
pub enum FileLockError {
#[error("Lock held by process {pid}")]
LockHeldByProcess { pid: ProcessId },
#[error(
"Failed to acquire lock for '{path}' within {timeout:?} (held by process {holder_pid:?})
Tip: Use 'ps -p {holder_pid:?}' to check if process is running"
)]
AcquisitionTimeout {
path: PathBuf,
holder_pid: Option<ProcessId>,
timeout: Duration,
},
#[error(
"Failed to create lock file at '{path}': {source}
Tip: Check directory permissions and disk space"
)]
CreateFailed {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"Failed to read lock file at '{path}': {source}
Tip: Check file permissions and file system status"
)]
ReadFailed {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"Invalid lock file content at '{path}': expected PID, found '{content}'
Tip: Remove the invalid lock file and let the system recreate it"
)]
InvalidLockFile { path: PathBuf, content: String },
#[error(
"Failed to release lock file at '{path}': {source}
Tip: The lock file may need manual cleanup"
)]
ReleaseFailed {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl FileLockError {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn help(&self) -> &'static str {
match self {
Self::AcquisitionTimeout { .. } => {
"Lock Acquisition Timeout - Detailed Troubleshooting:
1. Check if the holder process is still running:
Unix/Linux/macOS: ps -p <pid>
Windows: tasklist /FI \"PID eq <pid>\"
2. If the process is running and should release the lock:
- Wait for the process to complete its operation
- Or increase the timeout duration in your configuration
3. If the process is stuck or hung:
- Try graceful termination: kill <pid> (Unix) or taskkill /PID <pid> (Windows)
- Force terminate if needed: kill -9 <pid> (Unix) or taskkill /F /PID <pid> (Windows)
4. If the process doesn't exist (stale lock):
- This should be handled automatically by the lock system
- If you see this error repeatedly, it indicates a bug
- Please report at: https://github.com/torrust/torrust-tracker-deployer/issues
For more information, see the documentation on file locking."
}
Self::CreateFailed { .. } => {
"Lock Creation Failed - Detailed Troubleshooting:
1. Check directory permissions:
Unix: ls -la <directory>
Windows: icacls <directory>
- Ensure write access: chmod u+w <directory> (Unix)
2. Verify parent directory exists:
- Create if needed: mkdir -p <directory> (Unix/Linux/macOS)
- Create if needed: mkdir <directory> (Windows)
3. Check available disk space:
Unix: df -h
Windows: wmic logicaldisk get size,freespace,caption
- Free up space or use a different location if disk is full
4. Check for file system issues:
- Run file system checks if problems persist
- Try using a different directory
- Check system logs for file system errors
If the problem persists, report it with system details."
}
Self::ReadFailed { .. } => {
"Lock File Read Failed - Detailed Troubleshooting:
This error may indicate:
1. File system corruption
2. Permission changes after lock creation
3. Concurrent file deletion by another process
Troubleshooting steps:
1. Check if the lock file still exists:
Unix: ls -la <path>.lock
Windows: dir <path>.lock
2. Check file permissions:
Unix: stat <path>.lock
Windows: icacls <path>.lock
3. Check file system status:
Unix: df -h && dmesg | tail
Windows: chkdsk
4. If the error persists:
- The lock file may be corrupted
- You can manually remove it: rm <path>.lock (Unix) or del <path>.lock (Windows)
- Let the system recreate it on next lock acquisition
Report persistent issues with full error context."
}
Self::InvalidLockFile { .. } => {
"Invalid Lock File Content - Detailed Troubleshooting:
The lock file should contain only a process ID (numeric value).
This error indicates the file contains invalid content.
Common causes:
1. Manual modification of lock file (not recommended)
2. File system corruption
3. Lock file created by incompatible software
4. Encoding issues
Resolution steps:
1. Remove the invalid lock file:
Unix: rm <path>.lock
Windows: del <path>.lock
2. Let the system recreate it properly on next lock acquisition
3. Ensure no external tools or scripts are modifying .lock files
4. If using shared storage (NFS, CIFS, etc.):
- Check for file system compatibility issues
- Verify proper file locking support
Prevention:
- Never manually edit .lock files
- Ensure proper file system support for atomic operations
- Use appropriate locking mechanisms for shared storage
Report if this error occurs without manual intervention."
}
Self::ReleaseFailed { .. } => {
"Lock Release Failed - Detailed Troubleshooting:
This is a cleanup error that occurs when removing the lock file.
It typically doesn't affect functionality, but the lock file may persist.
Common causes:
1. File was already deleted (race condition with another process)
2. Permissions changed after lock creation
3. File system issue during cleanup
4. File is open by another process
Steps to resolve:
1. Check if the lock file still exists:
Unix: ls -la <path>.lock
Windows: dir <path>.lock
2. If it exists and causes issues, manually remove it:
Unix: rm <path>.lock
Windows: del <path>.lock
3. Verify no other processes have the file open:
Unix: lsof <path>.lock
Windows: handle.exe <path>.lock (requires Sysinternals)
Impact:
- This error usually doesn't affect the current operation
- The lock was already released from the application perspective
- Stale lock files will be cleaned up on next acquisition
Only report if this error occurs frequently or causes operational issues."
}
Self::LockHeldByProcess { .. } => {
"This is an internal error used during lock acquisition.
If you see this error directly, it may indicate a logic error in the application.
Please report it with full context."
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::error::Error;
use std::fs;
use std::thread;
use tempfile::TempDir;
const FAKE_DEAD_PROCESS_PID: u32 = 999_999;
fn assert_lock_file_contains_current_pid(file_path: &Path) {
assert_lock_file_exists(file_path);
assert_lock_file_contains_pid(file_path, ProcessId::current());
}
fn assert_lock_file_absent(file_path: &Path) {
let lock_file_path = FileLock::lock_file_path(file_path);
assert!(
!lock_file_path.exists(),
"Lock file should not exist at {lock_file_path:?}"
);
}
fn assert_lock_file_exists(file_path: &Path) {
let lock_file_path = FileLock::lock_file_path(file_path);
assert!(
lock_file_path.exists(),
"Lock file should exist at {lock_file_path:?}"
);
}
fn assert_lock_file_contains_pid(file_path: &Path, expected_pid: ProcessId) {
let lock_file_path = FileLock::lock_file_path(file_path);
let pid_content =
fs::read_to_string(&lock_file_path).expect("Should be able to read lock file");
assert_eq!(
pid_content.trim(),
expected_pid.to_string(),
"Lock file should contain PID {expected_pid}"
);
}
fn assert_timeout_error(result: Result<FileLock, FileLockError>) {
assert!(result.is_err(), "Expected timeout error");
match result.unwrap_err() {
FileLockError::AcquisitionTimeout { .. } => {}
other => panic!("Expected AcquisitionTimeout, got: {other:?}"),
}
}
fn assert_timeout_error_with_holder(
result: Result<FileLock, FileLockError>,
expected_holder: ProcessId,
) {
assert!(result.is_err(), "Expected timeout error");
match result.unwrap_err() {
FileLockError::AcquisitionTimeout { holder_pid, .. } => {
assert_eq!(
holder_pid,
Some(expected_holder),
"Expected holder PID {expected_holder}"
);
}
other => panic!("Expected AcquisitionTimeout, got: {other:?}"),
}
}
fn assert_invalid_lock_file_error(
result: Result<FileLock, FileLockError>,
expected_content: &str,
) {
assert!(result.is_err(), "Expected invalid lock file error");
match result.unwrap_err() {
FileLockError::InvalidLockFile { content, .. } => {
assert_eq!(
content, expected_content,
"Expected invalid content '{expected_content}'"
);
}
other => panic!("Expected InvalidLockFile, got: {other:?}"),
}
}
struct TestLockScenario {
temp_dir: TempDir,
file_name: String,
timeout: Duration,
}
impl TestLockScenario {
fn new() -> Self {
Self {
temp_dir: TempDir::new().expect("Failed to create temporary directory for test"),
file_name: "test.json".to_string(),
timeout: Duration::from_secs(1),
}
}
fn with_file_name(mut self, name: &str) -> Self {
self.file_name = name.to_string();
self
}
fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
fn file_path(&self) -> PathBuf {
self.temp_dir.path().join(&self.file_name)
}
fn lock_file_path(&self) -> PathBuf {
FileLock::lock_file_path(&self.file_path())
}
fn acquire_lock(&self) -> Result<FileLock, FileLockError> {
FileLock::acquire(&self.file_path(), self.timeout)
}
fn for_timeout_test() -> Self {
Self::new().with_timeout(Duration::from_millis(200))
}
fn for_success_test() -> Self {
Self::new().with_timeout(Duration::from_secs(5))
}
fn with_stale_lock(&self, fake_pid: u32) -> Result<(), std::io::Error> {
fs::write(self.lock_file_path(), fake_pid.to_string())
}
fn with_invalid_lock(&self, content: &str) -> Result<(), std::io::Error> {
fs::write(self.lock_file_path(), content)
}
}
mod basic_operations {
use super::*;
#[test]
fn it_should_successfully_acquire_lock() {
let scenario = TestLockScenario::new();
let lock = scenario.acquire_lock();
assert!(lock.is_ok());
let lock = lock.expect("Failed to acquire lock for basic operations test");
assert!(lock.acquired);
assert_lock_file_contains_current_pid(&scenario.file_path());
}
#[test]
fn it_should_release_lock_explicitly() {
let scenario = TestLockScenario::new().with_file_name("explicit_release.json");
let lock = scenario
.acquire_lock()
.expect("Failed to acquire lock for explicit release test");
assert!(scenario.lock_file_path().exists());
let release_result = lock.release();
assert!(release_result.is_ok());
assert!(!scenario.lock_file_path().exists());
let lock2 = scenario.acquire_lock();
assert!(lock2.is_ok());
}
#[test]
fn it_should_release_lock_on_drop() {
let scenario = TestLockScenario::new().with_file_name("drop_release.json");
{
let _lock = scenario
.acquire_lock()
.expect("Failed to acquire lock for drop release test");
assert!(scenario.lock_file_path().exists());
}
assert_lock_file_absent(&scenario.file_path());
let lock2 = scenario.acquire_lock();
assert!(lock2.is_ok());
}
#[test]
fn it_should_allow_sequential_locks_by_same_process() {
let scenario = TestLockScenario::new().with_file_name("sequential.json");
let lock1 = scenario
.acquire_lock()
.expect("Failed to acquire first lock for sequential test");
drop(lock1);
let lock2 = scenario.acquire_lock();
assert!(lock2.is_ok());
}
}
mod concurrency {
use super::*;
#[test]
fn it_should_prevent_concurrent_lock_acquisition() {
let scenario = TestLockScenario::new()
.with_file_name("concurrent.json")
.with_timeout(Duration::from_millis(500));
let _lock1 = scenario
.acquire_lock()
.expect("Failed to acquire first lock for concurrency test");
let lock2_result = FileLock::acquire(&scenario.file_path(), Duration::from_millis(50));
assert_timeout_error_with_holder(lock2_result, ProcessId::current());
}
#[test]
fn it_should_handle_concurrent_acquisitions_with_threads() {
let scenario = TestLockScenario::for_success_test().with_file_name("thread_test.json");
let file_path = scenario.file_path();
let file_path_clone = file_path.clone();
let handle1 =
thread::spawn(move || FileLock::acquire(&file_path, Duration::from_secs(2)));
thread::sleep(Duration::from_millis(50));
let handle2 = thread::spawn(move || {
FileLock::acquire(&file_path_clone, Duration::from_millis(100))
});
let result1 = handle1
.join()
.expect("Failed to join first thread in concurrency test");
let result2 = handle2
.join()
.expect("Failed to join second thread in concurrency test");
assert!(result1.is_ok() ^ result2.is_ok());
}
}
mod stale_lock_handling {
use super::*;
#[test]
fn it_should_clean_up_stale_lock_with_invalid_pid() {
let scenario = TestLockScenario::for_success_test().with_file_name("stale.json");
scenario
.with_stale_lock(FAKE_DEAD_PROCESS_PID)
.expect("Failed to create stale lock file");
let lock_result = scenario.acquire_lock();
assert!(lock_result.is_ok());
assert_lock_file_contains_current_pid(&scenario.file_path());
}
#[test]
fn it_should_handle_invalid_lock_file_content() {
let scenario = TestLockScenario::for_timeout_test().with_file_name("invalid.json");
scenario
.with_invalid_lock("not-a-number")
.expect("Failed to create invalid lock file");
let lock_result = scenario.acquire_lock();
assert_invalid_lock_file_error(lock_result, "not-a-number");
}
}
mod timeout_behavior {
use super::*;
#[test]
fn it_should_timeout_when_lock_held_by_another_process() {
let scenario = TestLockScenario::for_timeout_test().with_file_name("timeout.json");
let short_timeout = Duration::from_millis(200);
let _lock1 = FileLock::acquire(&scenario.file_path(), Duration::from_secs(5))
.expect("Failed to acquire first lock for timeout test");
let lock2_result = FileLock::acquire(&scenario.file_path(), short_timeout);
assert_timeout_error(lock2_result);
}
#[test]
fn it_should_handle_lock_acquisition_with_retries() {
let scenario = TestLockScenario::for_success_test().with_file_name("retry.json");
let file_path = scenario.file_path();
let file_path_clone = file_path.clone();
let handle = thread::spawn(move || {
let lock = FileLock::acquire(&file_path, Duration::from_secs(1))
.expect("Failed to acquire lock in retry test thread");
thread::sleep(Duration::from_millis(300));
drop(lock); });
thread::sleep(Duration::from_millis(50));
let lock2_result = FileLock::acquire(&file_path_clone, Duration::from_secs(2));
handle.join().expect("Failed to join thread in retry test");
assert!(lock2_result.is_ok());
}
}
mod error_handling {
use super::*;
#[test]
fn it_should_include_brief_tips_in_error_messages() {
let path = PathBuf::from("/test/path.json");
let timeout_err = FileLockError::AcquisitionTimeout {
path: path.clone(),
holder_pid: Some(ProcessId::from_raw(12345)),
timeout: Duration::from_secs(5),
};
let msg = timeout_err.to_string();
assert!(msg.contains("Tip:"), "Error message should contain a tip");
assert!(
msg.contains("ps -p"),
"Tip should mention process check command"
);
let io_error =
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
let create_err = FileLockError::CreateFailed {
path: path.clone(),
source: io_error,
};
let msg = create_err.to_string();
assert!(msg.contains("Tip:"), "Error message should contain a tip");
assert!(
msg.contains("permissions"),
"Tip should mention permissions"
);
let io_error =
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
let read_err = FileLockError::ReadFailed {
path: path.clone(),
source: io_error,
};
let msg = read_err.to_string();
assert!(msg.contains("Tip:"), "Error message should contain a tip");
let invalid_err = FileLockError::InvalidLockFile {
path: path.clone(),
content: "bad-content".to_string(),
};
let msg = invalid_err.to_string();
assert!(msg.contains("Tip:"), "Error message should contain a tip");
assert!(
msg.contains("Remove"),
"Tip should mention removing the file"
);
let io_error =
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
let release_err = FileLockError::ReleaseFailed {
path: path.clone(),
source: io_error,
};
let msg = release_err.to_string();
assert!(msg.contains("Tip:"), "Error message should contain a tip");
}
#[test]
fn it_should_provide_detailed_help_for_all_error_variants() {
let path = PathBuf::from("/test/path.json");
let io_error =
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
let test_cases = vec![
(
"AcquisitionTimeout",
FileLockError::AcquisitionTimeout {
path: path.clone(),
holder_pid: Some(ProcessId::from_raw(12345)),
timeout: Duration::from_secs(5),
},
),
(
"CreateFailed",
FileLockError::CreateFailed {
path: path.clone(),
source: io_error.kind().into(),
},
),
(
"ReadFailed",
FileLockError::ReadFailed {
path: path.clone(),
source: io_error.kind().into(),
},
),
(
"InvalidLockFile",
FileLockError::InvalidLockFile {
path: path.clone(),
content: "bad-content".to_string(),
},
),
(
"ReleaseFailed",
FileLockError::ReleaseFailed {
path: path.clone(),
source: io_error.kind().into(),
},
),
(
"LockHeldByProcess",
FileLockError::LockHeldByProcess {
pid: ProcessId::from_raw(12345),
},
),
];
for (variant_name, error) in test_cases {
let help = error.help();
assert!(!help.is_empty(), "{variant_name}: Help should not be empty");
assert!(
help.len() > 50,
"{variant_name}: Help should be detailed (at least 50 chars)"
);
}
}
#[test]
fn it_should_include_platform_specific_commands_in_help() {
let timeout_err = FileLockError::AcquisitionTimeout {
path: PathBuf::from("/test/path.json"),
holder_pid: Some(ProcessId::from_raw(12345)),
timeout: Duration::from_secs(5),
};
let help = timeout_err.help();
assert!(
help.contains("ps -p"),
"Help should include Unix process check command"
);
assert!(
help.contains("kill"),
"Help should include Unix kill command"
);
assert!(
help.contains("tasklist"),
"Help should include Windows process check command"
);
assert!(
help.contains("taskkill"),
"Help should include Windows kill command"
);
}
#[test]
fn it_should_display_error_messages_correctly() {
let path = PathBuf::from("/test/path.json");
let timeout_err = FileLockError::AcquisitionTimeout {
path: path.clone(),
holder_pid: Some(ProcessId::from_raw(12345)),
timeout: Duration::from_secs(5),
};
let msg = timeout_err.to_string();
assert!(msg.contains("Failed to acquire lock"));
assert!(msg.contains("12345"));
let held_err = FileLockError::LockHeldByProcess {
pid: ProcessId::from_raw(67890),
};
let msg = held_err.to_string();
assert!(msg.contains("Lock held"));
assert!(msg.contains("67890"));
let invalid_err = FileLockError::InvalidLockFile {
path: path.clone(),
content: "bad-content".to_string(),
};
let msg = invalid_err.to_string();
assert!(msg.contains("Invalid lock file"));
assert!(msg.contains("bad-content"));
}
#[test]
fn it_should_preserve_error_source_chain() {
let path = PathBuf::from("/test/path.json");
let io_error =
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
let create_failed = FileLockError::CreateFailed {
path,
source: io_error,
};
assert!(create_failed.source().is_some());
}
}
mod lock_file_path_generation {
use super::*;
#[rstest]
#[case("test.json", "test.json.lock")]
#[case("data/state.json", "data/state.json.lock")]
#[case("/abs/path/file.txt", "/abs/path/file.txt.lock")]
#[case("no_extension", "no_extension.lock")]
fn it_should_generate_correct_lock_file_path(#[case] input: &str, #[case] expected: &str) {
let input_path = Path::new(input);
let lock_path = FileLock::lock_file_path(input_path);
assert_eq!(lock_path.to_string_lossy(), expected);
}
}
mod lock_state_detection {
use super::*;
#[test]
fn it_should_detect_acquired_state_when_no_lock_exists() {
let scenario = TestLockScenario::new().with_file_name("state_acquired.json");
let state = FileLock::check_lock_state(&scenario.file_path());
assert_eq!(state, LockAcquisitionState::Acquired);
}
#[test]
fn it_should_detect_stale_lock_state() {
let scenario = TestLockScenario::new().with_file_name("state_stale.json");
scenario
.with_stale_lock(FAKE_DEAD_PROCESS_PID)
.expect("Failed to create stale lock file for state test");
let state = FileLock::check_lock_state(&scenario.file_path());
assert_eq!(
state,
LockAcquisitionState::FoundStaleLock(ProcessId::from_raw(FAKE_DEAD_PROCESS_PID))
);
}
#[test]
fn it_should_detect_blocked_state_when_lock_held() {
let scenario = TestLockScenario::new().with_file_name("state_blocked.json");
let _lock = scenario
.acquire_lock()
.expect("Failed to acquire lock for state test");
let state = FileLock::check_lock_state(&scenario.file_path());
assert_eq!(state, LockAcquisitionState::Blocked(ProcessId::current()));
}
#[test]
fn it_should_detect_attempting_state_on_error() {
let scenario = TestLockScenario::new().with_file_name("state_error.json");
scenario
.with_invalid_lock("invalid-pid-content")
.expect("Failed to create invalid lock file for state test");
let state = FileLock::check_lock_state(&scenario.file_path());
assert_eq!(state, LockAcquisitionState::Attempting);
}
}
mod tracing {
use super::*;
#[test]
fn it_should_complete_lock_operations_with_tracing_enabled() {
let scenario = TestLockScenario::new().with_file_name("traced.json");
let lock = scenario
.acquire_lock()
.expect("Failed to acquire lock with tracing");
assert_lock_file_exists(&scenario.file_path());
assert_lock_file_contains_current_pid(&scenario.file_path());
lock.release().expect("Failed to release lock with tracing");
assert_lock_file_absent(&scenario.file_path());
}
#[test]
fn it_should_trace_stale_lock_cleanup() {
let scenario = TestLockScenario::new().with_file_name("stale_traced.json");
scenario
.with_stale_lock(FAKE_DEAD_PROCESS_PID)
.expect("Failed to create stale lock for tracing test");
let lock = scenario
.acquire_lock()
.expect("Failed to acquire after stale lock cleanup");
assert_lock_file_contains_current_pid(&scenario.file_path());
drop(lock);
}
#[test]
fn it_should_trace_timeout_scenario() {
let scenario =
TestLockScenario::for_timeout_test().with_file_name("timeout_traced.json");
let _blocking_lock = scenario
.acquire_lock()
.expect("Failed to acquire blocking lock");
let result = FileLock::acquire(&scenario.file_path(), Duration::from_millis(200));
assert_timeout_error(result);
}
#[test]
fn it_should_trace_invalid_lock_file_scenario() {
let scenario = TestLockScenario::new().with_file_name("invalid_traced.json");
let invalid_content = "not-a-valid-pid";
scenario
.with_invalid_lock(invalid_content)
.expect("Failed to create invalid lock for tracing test");
let result = scenario.acquire_lock();
assert_invalid_lock_file_error(result, invalid_content);
}
#[test]
fn it_should_trace_concurrent_acquisition_attempts() {
let scenario = TestLockScenario::new().with_file_name("concurrent_traced.json");
let handles: Vec<_> = (0..3)
.map(|_| {
let path = scenario.file_path();
std::thread::spawn(move || FileLock::acquire(&path, Duration::from_millis(200)))
})
.collect();
let results: Vec<_> = handles
.into_iter()
.map(|h| h.join().expect("Thread panicked"))
.collect();
let success_count = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(
success_count, 1,
"Exactly one thread should acquire the lock"
);
}
}
}