Skip to main content

subx_cli/core/
lock.rs

1//! Exclusive file-lock infrastructure for SubX operations.
2//!
3//! This module provides [`acquire_subx_lock`], an async helper that acquires
4//! an exclusive advisory file lock on `$CONFIG_DIR/subx/subx.lock` with a
5//! 2-second timeout. The returned [`SubxLockGuard`] releases the lock on drop.
6//!
7//! All commands that mutate `match_cache.json` or `match_journal.json` must
8//! acquire this lock before proceeding (Design Decision D9).
9
10use crate::Result;
11use crate::core::matcher::journal::lock_path;
12use crate::error::SubXError;
13use std::path::PathBuf;
14use std::time::{Duration, Instant};
15
16/// RAII guard that holds an exclusive file lock on the SubX lock file.
17///
18/// Dropping this guard releases the lock via [`std::fs::File::unlock`].
19#[derive(Debug)]
20pub struct SubxLockGuard {
21    file: std::fs::File,
22}
23
24impl Drop for SubxLockGuard {
25    fn drop(&mut self) {
26        let _ = self.file.unlock();
27    }
28}
29
30/// Acquire the SubX exclusive file lock with a 2-second timeout.
31///
32/// Creates/opens `$CONFIG_DIR/subx/subx.lock` and attempts a non-blocking
33/// exclusive lock ([`std::fs::File::try_lock`]), retrying every 100ms for up
34/// to 2 seconds. Returns a [`SubxLockGuard`] on success. If the lock cannot
35/// be acquired within the timeout, returns an error with a diagnostic message.
36pub async fn acquire_subx_lock() -> Result<SubxLockGuard> {
37    let path = lock_path()?;
38    tokio::task::spawn_blocking(move || acquire_lock_blocking(&path))
39        .await
40        .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))?
41}
42
43fn acquire_lock_blocking(path: &PathBuf) -> Result<SubxLockGuard> {
44    if let Some(parent) = path.parent() {
45        std::fs::create_dir_all(parent)?;
46    }
47
48    let file = std::fs::OpenOptions::new()
49        .create(true)
50        .read(true)
51        .write(true)
52        .truncate(false)
53        .open(path)?;
54
55    let deadline = Instant::now() + Duration::from_secs(2);
56    loop {
57        match file.try_lock() {
58            Ok(()) => return Ok(SubxLockGuard { file }),
59            Err(std::fs::TryLockError::WouldBlock) => {}
60            Err(e) => return Err(SubXError::Io(e.into())),
61        }
62        if Instant::now() >= deadline {
63            return Err(SubXError::config(format!(
64                "Another SubX operation is in progress. \
65                 Please wait for it to finish or terminate the other process. \
66                 Lock file: {}",
67                path.display()
68            )));
69        }
70        std::thread::sleep(Duration::from_millis(100));
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use tempfile::TempDir;
78
79    #[tokio::test]
80    async fn acquire_lock_succeeds_when_uncontended() {
81        let tmp = TempDir::new().unwrap();
82        unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp.path()) };
83        let guard = acquire_subx_lock().await;
84        assert!(guard.is_ok());
85        drop(guard);
86        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
87    }
88
89    #[tokio::test]
90    async fn lock_is_released_on_drop() {
91        let tmp = TempDir::new().unwrap();
92        unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp.path()) };
93        {
94            let _g1 = acquire_subx_lock().await.unwrap();
95        }
96        // Second acquire should succeed immediately after drop
97        let g2 = acquire_subx_lock().await;
98        assert!(g2.is_ok());
99        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
100    }
101
102    #[tokio::test]
103    async fn contention_produces_timeout_error() {
104        let tmp = TempDir::new().unwrap();
105        let lock_file = tmp.path().join("subx").join("subx.lock");
106        std::fs::create_dir_all(lock_file.parent().unwrap()).unwrap();
107
108        // Hold lock via std file locking
109        let file = std::fs::OpenOptions::new()
110            .create(true)
111            .truncate(false)
112            .read(true)
113            .write(true)
114            .open(&lock_file)
115            .unwrap();
116        file.lock().unwrap();
117
118        unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp.path()) };
119        let start = Instant::now();
120        let result = acquire_subx_lock().await;
121        let elapsed = start.elapsed();
122
123        assert!(result.is_err());
124        let err_msg = format!("{}", result.unwrap_err());
125        assert!(err_msg.contains("Another SubX operation is in progress"));
126        assert!(elapsed >= Duration::from_secs(2));
127
128        file.unlock().unwrap();
129        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
130    }
131}