Skip to main content

pinto/storage/
lock.rs

1//! Exclusive control of board writing (advisory file lock).
2
3use crate::error::{Error, Result};
4use fs4::tokio::AsyncFileExt;
5use std::io::{ErrorKind, SeekFrom};
6use std::path::{Path, PathBuf};
7use std::time::{Duration, Instant};
8use tokio::io::{AsyncSeekExt, AsyncWriteExt};
9
10/// Lock file name (directly under `.pinto/`).
11const LOCK_FILE: &str = ".lock";
12/// Delay between lock-acquisition attempts.
13const RETRY_INTERVAL: Duration = Duration::from_millis(50);
14/// Default maximum time to wait for another process to finish.
15const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(5);
16/// Environment variable used to extend the wait for slow filesystems or Git hooks.
17const MAX_WAIT_ENV: &str = "PINTO_LOCK_TIMEOUT_SECS";
18
19/// Advisory RAII guard that serializes board writes.
20///
21/// The lock is acquired by opening `.pinto/.lock` and taking an OS-level exclusive lock. Unix and
22/// macOS use `flock`, while Windows uses `LockFileEx`; both locks are released by the kernel when
23/// the owner process terminates. If the file is already locked, acquisition retries until the wait
24/// limit is reached, then returns [`Error::Locked`].
25///
26/// Serializing read-modify-write sequences (`list`/`load` → change → `save`) prevents concurrent
27/// CLI/TUI processes from losing updates. Read-only operations do not acquire this lock.
28///
29/// The PID written to the file is diagnostic text only and is never used to decide ownership. This
30/// avoids incorrectly reclaiming a lock when the recorded PID has been reused by another process.
31#[derive(Debug)]
32pub struct BoardLock {
33    path: PathBuf,
34    file: Option<tokio::fs::File>,
35    identity: FileIdentity,
36}
37
38/// Stable identity of the opened lock file, used to avoid deleting a replacement path on release.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum FileIdentity {
41    #[cfg(unix)]
42    Unix { device: u64, inode: u64 },
43    #[cfg(windows)]
44    Windows {
45        volume_serial_number: u32,
46        file_index: u64,
47    },
48}
49
50#[cfg(unix)]
51fn identity_from_metadata(metadata: &std::fs::Metadata) -> std::io::Result<FileIdentity> {
52    use std::os::unix::fs::MetadataExt;
53
54    Ok(FileIdentity::Unix {
55        device: metadata.dev(),
56        inode: metadata.ino(),
57    })
58}
59
60#[cfg(windows)]
61fn identity_from_metadata(metadata: &std::fs::Metadata) -> std::io::Result<FileIdentity> {
62    use std::os::windows::fs::MetadataExt;
63
64    let (Some(volume_serial_number), Some(file_index)) =
65        (metadata.volume_serial_number(), metadata.file_index())
66    else {
67        return Err(std::io::Error::other("lock file identity is unavailable"));
68    };
69    Ok(FileIdentity::Windows {
70        volume_serial_number,
71        file_index,
72    })
73}
74
75#[cfg(not(any(unix, windows)))]
76fn identity_from_metadata(_metadata: &std::fs::Metadata) -> std::io::Result<FileIdentity> {
77    Err(std::io::Error::other(
78        "lock file identity is unsupported on this platform",
79    ))
80}
81
82async fn file_identity(file: &tokio::fs::File) -> std::io::Result<FileIdentity> {
83    let metadata = file.metadata().await?;
84    identity_from_metadata(&metadata)
85}
86
87async fn path_identity(path: &Path) -> std::io::Result<FileIdentity> {
88    let metadata = tokio::fs::metadata(path).await?;
89    identity_from_metadata(&metadata)
90}
91
92fn locked_error(path: &Path) -> Error {
93    Error::Locked {
94        path: path.to_path_buf(),
95    }
96}
97
98/// Resolve the lock wait limit without loading board configuration.
99///
100/// Write operations acquire the lock before opening `config.toml`, so a board-level setting cannot
101/// safely control this value. An optional process environment override keeps the normal path
102/// simple while allowing slow filesystems and Git hooks to use a longer timeout.
103fn max_wait_from_env(value: Option<&str>) -> Duration {
104    value
105        .and_then(|seconds| seconds.parse::<u64>().ok())
106        .map(Duration::from_secs)
107        .unwrap_or(DEFAULT_MAX_WAIT)
108}
109
110async fn write_owner_marker(file: &mut tokio::fs::File) -> std::io::Result<()> {
111    file.set_len(0).await?;
112    file.seek(SeekFrom::Start(0)).await?;
113    file.write_all(format!("{}\n", std::process::id()).as_bytes())
114        .await?;
115    file.flush().await
116}
117
118impl BoardLock {
119    /// Acquire the lock for `board_dir` (`.pinto/`), retrying briefly if another process holds it.
120    ///
121    /// Waits up to the default timeout, or `PINTO_LOCK_TIMEOUT_SECS` when set, for the other
122    /// process to release it, then returns [`Error::Locked`].
123    pub(crate) async fn acquire(board_dir: &Path) -> Result<Self> {
124        let configured = std::env::var(MAX_WAIT_ENV).ok();
125        Self::acquire_within(board_dir, max_wait_from_env(configured.as_deref())).await
126    }
127
128    /// Acquire the lock, waiting no longer than `max_wait`.
129    async fn acquire_within(board_dir: &Path, max_wait: Duration) -> Result<Self> {
130        let path = board_dir.join(LOCK_FILE);
131        let start = Instant::now();
132        loop {
133            let file = match tokio::fs::OpenOptions::new()
134                .read(true)
135                .write(true)
136                .create(true)
137                .truncate(false)
138                .open(&path)
139                .await
140            {
141                Ok(file) => file,
142                Err(error) => return Err(Error::io(&path, &error)),
143            };
144
145            match file.try_lock() {
146                Ok(()) => {
147                    let identity = match file_identity(&file).await {
148                        Ok(identity) => identity,
149                        Err(_) => {
150                            let _ = file.unlock();
151                            return Err(locked_error(&path));
152                        }
153                    };
154
155                    let path_matches = match path_identity(&path).await {
156                        Ok(path_identity) => path_identity == identity,
157                        Err(error) if error.kind() == ErrorKind::NotFound => false,
158                        Err(_) => {
159                            let _ = file.unlock();
160                            return Err(locked_error(&path));
161                        }
162                    };
163                    if !path_matches {
164                        let _ = file.unlock();
165                        if start.elapsed() >= max_wait {
166                            return Err(locked_error(&path));
167                        }
168                        tokio::time::sleep(RETRY_INTERVAL).await;
169                        continue;
170                    }
171
172                    let mut file = file;
173                    if let Err(error) = write_owner_marker(&mut file).await {
174                        let _ = file.unlock();
175                        return Err(Error::io(&path, &error));
176                    }
177                    return Ok(Self {
178                        path,
179                        file: Some(file),
180                        identity,
181                    });
182                }
183                Err(fs4::TryLockError::WouldBlock) => {
184                    if start.elapsed() >= max_wait {
185                        return Err(locked_error(&path));
186                    }
187                    tokio::time::sleep(RETRY_INTERVAL).await;
188                }
189                Err(fs4::TryLockError::Error(error)) => return Err(Error::io(&path, &error)),
190            }
191        }
192    }
193}
194
195impl Drop for BoardLock {
196    fn drop(&mut self) {
197        let Some(file) = self.file.take() else {
198            return;
199        };
200
201        // Remove the path while the OS lock is still held. Releasing first would let another
202        // process acquire the path before this cleanup, and then delete that process's lock.
203        // Compare the file identity so a manually replaced path is never removed accidentally.
204        let same_file = std::fs::metadata(&self.path)
205            .ok()
206            .and_then(|metadata| identity_from_metadata(&metadata).ok())
207            == Some(self.identity);
208        if same_file {
209            let _ = std::fs::remove_file(&self.path);
210        }
211        let _ = file.unlock();
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use tempfile::tempdir;
219
220    #[tokio::test]
221    async fn acquire_then_release_allows_reacquire() {
222        let dir = tempdir().expect("tempdir");
223        let lock = BoardLock::acquire(dir.path()).await.expect("first acquire");
224        // The lock file exists while the guard is held.
225        assert!(dir.path().join(LOCK_FILE).exists());
226        drop(lock);
227        // Releasing the guard removes the file, so the lock can be acquired again.
228        assert!(!dir.path().join(LOCK_FILE).exists());
229        BoardLock::acquire(dir.path()).await.expect("reacquire");
230    }
231
232    #[test]
233    fn lock_timeout_uses_the_default_and_accepts_a_valid_environment_value() {
234        assert_eq!(max_wait_from_env(None), DEFAULT_MAX_WAIT);
235        assert_eq!(
236            max_wait_from_env(Some("30")),
237            Duration::from_secs(30),
238            "a slow Git hook can extend the wait through the environment"
239        );
240        assert_eq!(
241            max_wait_from_env(Some("not-a-duration")),
242            DEFAULT_MAX_WAIT,
243            "invalid values fall back to the safe default"
244        );
245    }
246
247    #[tokio::test]
248    async fn second_acquire_while_held_times_out_with_locked() {
249        let dir = tempdir().expect("tempdir");
250        let _held = BoardLock::acquire(dir.path()).await.expect("hold");
251        // A second acquisition returns `Locked` after the configured wait limit; use a short limit
252        // here so the test remains fast.
253        let err = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
254            .await
255            .unwrap_err();
256        assert!(matches!(err, Error::Locked { .. }), "got {err:?}");
257    }
258
259    #[tokio::test]
260    async fn stale_lock_owned_by_a_dead_process_is_recovered() {
261        let dir = tempdir().expect("tempdir");
262        tokio::fs::write(dir.path().join(LOCK_FILE), "999999999\n")
263            .await
264            .expect("write stale lock");
265
266        let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
267            .await
268            .expect("recover stale lock");
269
270        drop(lock);
271        assert!(!dir.path().join(LOCK_FILE).exists());
272    }
273
274    #[tokio::test]
275    async fn pid_reuse_like_lock_file_is_recovered_without_trusting_pid_text() {
276        let dir = tempdir().expect("tempdir");
277        tokio::fs::write(
278            dir.path().join(LOCK_FILE),
279            format!("{}\n", std::process::id()),
280        )
281        .await
282        .expect("write PID-reuse-like lock");
283
284        let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
285            .await
286            .expect("recover lock whose PID text belongs to another owner");
287
288        drop(lock);
289    }
290
291    #[test]
292    fn lock_test_child() {
293        let Some(board_dir) = std::env::var_os("PINTO_LOCK_TEST_BOARD") else {
294            return;
295        };
296        let ready = std::path::PathBuf::from(
297            std::env::var_os("PINTO_LOCK_TEST_READY").expect("ready path"),
298        );
299        let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
300        let _lock = runtime
301            .block_on(BoardLock::acquire(Path::new(&board_dir)))
302            .expect("child lock");
303        std::fs::write(ready, b"ready").expect("ready marker");
304        std::thread::park();
305    }
306
307    #[tokio::test]
308    async fn lock_left_by_a_terminated_process_is_recovered() {
309        struct ChildGuard(std::process::Child);
310
311        impl Drop for ChildGuard {
312            fn drop(&mut self) {
313                let _ = self.0.kill();
314                let _ = self.0.wait();
315            }
316        }
317
318        let dir = tempdir().expect("tempdir");
319        let ready = dir.path().join("lock-child.ready");
320        let mut child = ChildGuard(
321            std::process::Command::new(std::env::current_exe().expect("test executable"))
322                .args(["--exact", "storage::lock::tests::lock_test_child"])
323                .env("PINTO_LOCK_TEST_BOARD", dir.path())
324                .env("PINTO_LOCK_TEST_READY", &ready)
325                .spawn()
326                .expect("spawn lock child"),
327        );
328
329        for _ in 0..200 {
330            if ready.is_file() {
331                break;
332            }
333            tokio::time::sleep(Duration::from_millis(10)).await;
334        }
335        assert!(ready.is_file(), "child did not acquire the lock");
336
337        child.0.kill().expect("terminate lock child");
338        child.0.wait().expect("wait for lock child");
339
340        let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
341            .await
342            .expect("recover lock after owner termination");
343        drop(lock);
344    }
345}