Skip to content

Commit 6a3fd6f

Browse files
deseratclaude
andauthored
[EPIC-3-2] feat(sync): add stale lock detection to file locking mechanism (#26)
- What: Enhanced FileLock with stale lock detection and automatic cleanup - Why: Prevents deadlocks from abandoned locks when processes crash (Issue #13) - How: Added is_stale() and force_unlock() methods with 5-minute threshold Implementation: - Added is_stale() private method to detect locks older than 5 minutes - Added force_unlock() private method to remove stale lock files - Enhanced acquire() to automatically detect and clean stale locks before timeout - Locks are now recovered automatically without manual intervention Testing: - Added test_stale_lock_cleanup() using filetime crate - Total test suite: 6 tests (90% → 100% of acceptance criteria) - Coverage target: 80%+ (sync-service standard) Dependencies: - Added filetime = "0.2" to dev-dependencies for timestamp testing - All existing dependencies (fs2, anyhow, tempfile) already present Files modified: - sync-service/src/vault/lock.rs (~40 lines added): - Lines 224-237: is_stale() method - Lines 246-252: force_unlock() method - Lines 167-171: Stale lock check in acquire() - Lines 369-398: test_stale_lock_cleanup() test - sync-service/Cargo.toml (2 lines added): - Added [dev-dependencies] section - Added filetime = "0.2" for testing Acceptance criteria addressed: - [x] FileLock with RAII pattern (existing) - [x] acquire() with timeout (existing) - [x] release() for cleanup (existing) - [x] Drop implementation (existing) - [x] Stale lock detection (NEW - 5-minute threshold) - [x] Automatic cleanup of abandoned locks (NEW) - [x] Lock files in .noosphere/locks/ (existing) - [x] fs2, anyhow, tempfile dependencies (existing) - [x] filetime dev-dependency (NEW) - [x] 6+ comprehensive tests (5 existing + 1 new) - [x] Integration-ready for vault writer/parser Design decisions: - 5-minute stale threshold: Balances quick recovery vs false positives - Private methods: is_stale() and force_unlock() are implementation details - Automatic in acquire(): Transparent to consumers, no API changes - SystemTime for age calculation: Standard Rust approach for file timestamps Related to EPIC-3 (Vault Structure and File Utilities) #13 Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4d0ca57 commit 6a3fd6f

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

sync-service/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,7 @@ chrono = { version = "0.4", features = ["serde"] }
5656
# Temporary files (atomic writes)
5757
tempfile = "3.0"
5858
shellexpand = "3.1.1"
59+
60+
[dev-dependencies]
61+
# File timestamp manipulation for testing
62+
filetime = "0.2"

sync-service/src/vault/lock.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub struct FileLock {
3636
}
3737

3838
impl FileLock {
39+
/// Duration after which a lock is considered stale (5 minutes)
40+
const STALE_LOCK_DURATION: Duration = Duration::from_secs(300);
41+
3942
/// Create a new file lock for the given file path
4043
///
4144
/// Lock files are created in `.noosphere/locks/` directory
@@ -164,6 +167,13 @@ impl FileLock {
164167
return Ok(());
165168
}
166169
Err(_e) => {
170+
// Check if existing lock is stale and clean it up
171+
if self.is_stale()? {
172+
self.force_unlock()?;
173+
continue; // Retry immediately after cleaning stale lock
174+
}
175+
176+
// Lock is not stale, check timeout
167177
if start.elapsed()? > timeout {
168178
anyhow::bail!("Lock timeout: failed to acquire lock within {:?}", timeout);
169179
}
@@ -200,6 +210,54 @@ impl FileLock {
200210
}
201211
Ok(())
202212
}
213+
214+
/// Check if the lock file is stale (older than 5 minutes)
215+
///
216+
/// A stale lock indicates that the process holding the lock has died
217+
/// or is otherwise unable to release it properly.
218+
///
219+
/// # Returns
220+
///
221+
/// `Ok(true)` if lock file exists and is older than 5 minutes
222+
/// `Ok(false)` if lock file doesn't exist or is recent
223+
///
224+
/// # Errors
225+
///
226+
/// Returns an error if unable to read file metadata or system time
227+
fn is_stale(&self) -> Result<bool> {
228+
// If lock file doesn't exist, it's not stale
229+
if !self.lock_file.exists() {
230+
return Ok(false);
231+
}
232+
233+
// Get file metadata to check modification time
234+
let metadata = fs::metadata(&self.lock_file)?;
235+
let modified = metadata.modified()?;
236+
let age = SystemTime::now().duration_since(modified)?;
237+
238+
// Consider lock stale if older than threshold
239+
Ok(age > Self::STALE_LOCK_DURATION)
240+
}
241+
242+
/// Force removal of a stale lock file
243+
///
244+
/// Attempts to remove the lock file. If the file doesn't exist,
245+
/// this is considered success (desired state achieved).
246+
///
247+
/// Should only be called after confirming the lock is stale via `is_stale()`.
248+
///
249+
/// # Errors
250+
///
251+
/// Returns an error only for actual I/O failures (permission denied, etc.).
252+
/// Returns `Ok(())` if the file is already removed.
253+
fn force_unlock(&self) -> Result<()> {
254+
if let Err(e) = fs::remove_file(&self.lock_file) {
255+
if e.kind() != std::io::ErrorKind::NotFound {
256+
return Err(e.into());
257+
}
258+
}
259+
Ok(())
260+
}
203261
}
204262

205263
/// Automatic lock cleanup using RAII pattern
@@ -316,4 +374,35 @@ mod tests {
316374

317375
Ok(())
318376
}
377+
378+
#[test]
379+
fn test_stale_lock_cleanup() -> Result<()> {
380+
use filetime::{set_file_mtime, FileTime};
381+
382+
let mut temp_file = NamedTempFile::new()?;
383+
write!(temp_file, "Test content")?;
384+
temp_file.flush()?;
385+
386+
let mut lock = FileLock::new(temp_file.path())?;
387+
388+
// Create a stale lock file manually (simulating abandoned lock)
389+
File::create(&lock.lock_file)?;
390+
391+
// Set modification time to 6 minutes ago (beyond 5-minute stale threshold)
392+
let six_min_ago = SystemTime::now()
393+
.checked_sub(Duration::from_secs(360))
394+
.unwrap();
395+
set_file_mtime(&lock.lock_file, FileTime::from_system_time(six_min_ago))?;
396+
397+
// Verify lock is detected as stale
398+
assert!(lock.is_stale()?);
399+
400+
// Should successfully acquire lock despite existing stale lock file
401+
lock.acquire(Duration::from_secs(1))?;
402+
403+
// Lock should now be held
404+
assert!(lock._guard.is_some());
405+
406+
Ok(())
407+
}
319408
}

0 commit comments

Comments
 (0)