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_handle<T: std::os::windows::io::AsRawHandle>(
62    file: &T,
63) -> std::io::Result<FileIdentity> {
64    use std::mem::MaybeUninit;
65    use windows_sys::Win32::Storage::FileSystem::{
66        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
67    };
68
69    let mut information = MaybeUninit::<BY_HANDLE_FILE_INFORMATION>::uninit();
70    // SAFETY: `file` owns a live handle for the duration of this call, and the output pointer
71    // points to writable storage for the exact structure required by the Win32 API.
72    let result =
73        unsafe { GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr()) };
74    if result == 0 {
75        return Err(std::io::Error::last_os_error());
76    }
77
78    // SAFETY: Win32 reports success only after initializing the output structure.
79    let information = unsafe { information.assume_init() };
80    Ok(FileIdentity::Windows {
81        volume_serial_number: information.dwVolumeSerialNumber,
82        file_index: (u64::from(information.nFileIndexHigh) << 32)
83            | u64::from(information.nFileIndexLow),
84    })
85}
86
87#[cfg(not(any(unix, windows)))]
88fn identity_from_metadata(_metadata: &std::fs::Metadata) -> std::io::Result<FileIdentity> {
89    Err(std::io::Error::other(
90        "lock file identity is unsupported on this platform",
91    ))
92}
93
94#[cfg(windows)]
95async fn file_identity(file: &tokio::fs::File) -> std::io::Result<FileIdentity> {
96    identity_from_handle(file)
97}
98
99#[cfg(not(windows))]
100async fn file_identity(file: &tokio::fs::File) -> std::io::Result<FileIdentity> {
101    let metadata = file.metadata().await?;
102    identity_from_metadata(&metadata)
103}
104
105#[cfg(windows)]
106async fn path_identity(path: &Path) -> std::io::Result<FileIdentity> {
107    let file = tokio::fs::File::open(path).await?;
108    identity_from_handle(&file)
109}
110
111#[cfg(not(windows))]
112async fn path_identity(path: &Path) -> std::io::Result<FileIdentity> {
113    let metadata = tokio::fs::metadata(path).await?;
114    identity_from_metadata(&metadata)
115}
116
117#[cfg(windows)]
118fn sync_path_identity(path: &Path) -> std::io::Result<FileIdentity> {
119    let file = std::fs::File::open(path)?;
120    identity_from_handle(&file)
121}
122
123#[cfg(not(windows))]
124fn sync_path_identity(path: &Path) -> std::io::Result<FileIdentity> {
125    let metadata = std::fs::metadata(path)?;
126    identity_from_metadata(&metadata)
127}
128
129fn locked_error(path: &Path) -> Error {
130    Error::Locked {
131        path: path.to_path_buf(),
132    }
133}
134
135/// Resolve the lock wait limit without loading board configuration.
136///
137/// Write operations acquire the lock before opening `config.toml`, so a board-level setting cannot
138/// safely control this value. An optional process environment override keeps the normal path
139/// simple while allowing slow filesystems and Git hooks to use a longer timeout.
140fn max_wait_from_env(value: Option<&str>) -> Duration {
141    value
142        .and_then(|seconds| seconds.parse::<u64>().ok())
143        .map(Duration::from_secs)
144        .unwrap_or(DEFAULT_MAX_WAIT)
145}
146
147async fn write_owner_marker(file: &mut tokio::fs::File) -> std::io::Result<()> {
148    file.set_len(0).await?;
149    file.seek(SeekFrom::Start(0)).await?;
150    file.write_all(format!("{}\n", std::process::id()).as_bytes())
151        .await?;
152    file.flush().await
153}
154
155impl BoardLock {
156    /// Acquire the lock for `board_dir` (`.pinto/`), retrying briefly if another process holds it.
157    ///
158    /// Waits up to the default timeout, or `PINTO_LOCK_TIMEOUT_SECS` when set, for the other
159    /// process to release it, then returns [`Error::Locked`].
160    pub(crate) async fn acquire(board_dir: &Path) -> Result<Self> {
161        let configured = std::env::var(MAX_WAIT_ENV).ok();
162        Self::acquire_within(board_dir, max_wait_from_env(configured.as_deref())).await
163    }
164
165    /// Acquire the lock, waiting no longer than `max_wait`.
166    async fn acquire_within(board_dir: &Path, max_wait: Duration) -> Result<Self> {
167        let path = board_dir.join(LOCK_FILE);
168        let start = Instant::now();
169        loop {
170            let file = match tokio::fs::OpenOptions::new()
171                .read(true)
172                .write(true)
173                .create(true)
174                .truncate(false)
175                .open(&path)
176                .await
177            {
178                Ok(file) => file,
179                Err(error) => return Err(Error::io(&path, &error)),
180            };
181
182            match file.try_lock() {
183                Ok(()) => {
184                    let identity = match file_identity(&file).await {
185                        Ok(identity) => identity,
186                        Err(_) => {
187                            let _ = file.unlock();
188                            return Err(locked_error(&path));
189                        }
190                    };
191
192                    let path_matches = match path_identity(&path).await {
193                        Ok(path_identity) => path_identity == identity,
194                        Err(error) if error.kind() == ErrorKind::NotFound => false,
195                        Err(_) => {
196                            let _ = file.unlock();
197                            return Err(locked_error(&path));
198                        }
199                    };
200                    if !path_matches {
201                        let _ = file.unlock();
202                        if start.elapsed() >= max_wait {
203                            return Err(locked_error(&path));
204                        }
205                        tokio::time::sleep(RETRY_INTERVAL).await;
206                        continue;
207                    }
208
209                    let mut file = file;
210                    if let Err(error) = write_owner_marker(&mut file).await {
211                        let _ = file.unlock();
212                        return Err(Error::io(&path, &error));
213                    }
214                    return Ok(Self {
215                        path,
216                        file: Some(file),
217                        identity,
218                    });
219                }
220                Err(fs4::TryLockError::WouldBlock) => {
221                    if start.elapsed() >= max_wait {
222                        return Err(locked_error(&path));
223                    }
224                    tokio::time::sleep(RETRY_INTERVAL).await;
225                }
226                Err(fs4::TryLockError::Error(error)) => return Err(Error::io(&path, &error)),
227            }
228        }
229    }
230}
231
232impl Drop for BoardLock {
233    fn drop(&mut self) {
234        let Some(file) = self.file.take() else {
235            return;
236        };
237
238        // Remove the path while the OS lock is still held. Releasing first would let another
239        // process acquire the path before this cleanup, and then delete that process's lock.
240        // Compare the file identity so a manually replaced path is never removed accidentally.
241        let same_file = sync_path_identity(&self.path).ok() == Some(self.identity);
242        if same_file {
243            let _ = std::fs::remove_file(&self.path);
244        }
245        let _ = file.unlock();
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use tempfile::tempdir;
253
254    #[tokio::test]
255    async fn acquire_then_release_allows_reacquire() {
256        let dir = tempdir().expect("tempdir");
257        let lock = BoardLock::acquire(dir.path()).await.expect("first acquire");
258        // The lock file exists while the guard is held.
259        assert!(dir.path().join(LOCK_FILE).exists());
260        drop(lock);
261        // Releasing the guard removes the file, so the lock can be acquired again.
262        assert!(!dir.path().join(LOCK_FILE).exists());
263        BoardLock::acquire(dir.path()).await.expect("reacquire");
264    }
265
266    #[cfg(windows)]
267    #[tokio::test]
268    async fn windows_file_identity_matches_across_handles() {
269        let dir = tempdir().expect("tempdir");
270        let path = dir.path().join("identity");
271        let file = tokio::fs::File::create(&path)
272            .await
273            .expect("create identity file");
274        let reopened = tokio::fs::File::open(&path)
275            .await
276            .expect("reopen identity file");
277
278        assert_eq!(
279            identity_from_handle(&file).expect("failed to inspect the first handle"),
280            identity_from_handle(&reopened).expect("failed to inspect the reopened handle"),
281        );
282    }
283
284    #[test]
285    fn lock_timeout_uses_the_default_and_accepts_a_valid_environment_value() {
286        assert_eq!(max_wait_from_env(None), DEFAULT_MAX_WAIT);
287        assert_eq!(
288            max_wait_from_env(Some("30")),
289            Duration::from_secs(30),
290            "a slow Git hook can extend the wait through the environment"
291        );
292        assert_eq!(
293            max_wait_from_env(Some("not-a-duration")),
294            DEFAULT_MAX_WAIT,
295            "invalid values fall back to the safe default"
296        );
297    }
298
299    #[tokio::test]
300    async fn second_acquire_while_held_times_out_with_locked() {
301        let dir = tempdir().expect("tempdir");
302        let _held = BoardLock::acquire(dir.path()).await.expect("hold");
303        // A second acquisition returns `Locked` after the configured wait limit; use a short limit
304        // here so the test remains fast.
305        let err = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
306            .await
307            .unwrap_err();
308        assert!(matches!(err, Error::Locked { .. }), "got {err:?}");
309    }
310
311    #[tokio::test]
312    async fn stale_lock_owned_by_a_dead_process_is_recovered() {
313        let dir = tempdir().expect("tempdir");
314        tokio::fs::write(dir.path().join(LOCK_FILE), "999999999\n")
315            .await
316            .expect("write stale lock");
317
318        let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
319            .await
320            .expect("recover stale lock");
321
322        drop(lock);
323        assert!(!dir.path().join(LOCK_FILE).exists());
324    }
325
326    #[tokio::test]
327    async fn pid_reuse_like_lock_file_is_recovered_without_trusting_pid_text() {
328        let dir = tempdir().expect("tempdir");
329        tokio::fs::write(
330            dir.path().join(LOCK_FILE),
331            format!("{}\n", std::process::id()),
332        )
333        .await
334        .expect("write PID-reuse-like lock");
335
336        let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
337            .await
338            .expect("recover lock whose PID text belongs to another owner");
339
340        drop(lock);
341    }
342
343    #[test]
344    fn lock_test_child() {
345        let Some(board_dir) = std::env::var_os("PINTO_LOCK_TEST_BOARD") else {
346            return;
347        };
348        let ready = std::path::PathBuf::from(
349            std::env::var_os("PINTO_LOCK_TEST_READY").expect("ready path"),
350        );
351        let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
352        let _lock = runtime
353            .block_on(BoardLock::acquire(Path::new(&board_dir)))
354            .expect("child lock");
355        std::fs::write(ready, b"ready").expect("ready marker");
356        std::thread::park();
357    }
358
359    #[tokio::test]
360    async fn lock_left_by_a_terminated_process_is_recovered() {
361        struct ChildGuard(std::process::Child);
362
363        impl Drop for ChildGuard {
364            fn drop(&mut self) {
365                let _ = self.0.kill();
366                let _ = self.0.wait();
367            }
368        }
369
370        let dir = tempdir().expect("tempdir");
371        let ready = dir.path().join("lock-child.ready");
372        let mut child = ChildGuard(
373            std::process::Command::new(std::env::current_exe().expect("test executable"))
374                .args(["--exact", "storage::lock::tests::lock_test_child"])
375                .env("PINTO_LOCK_TEST_BOARD", dir.path())
376                .env("PINTO_LOCK_TEST_READY", &ready)
377                .spawn()
378                .expect("spawn lock child"),
379        );
380
381        for _ in 0..200 {
382            if ready.is_file() {
383                break;
384            }
385            tokio::time::sleep(Duration::from_millis(10)).await;
386        }
387        assert!(ready.is_file(), "child did not acquire the lock");
388
389        child.0.kill().expect("terminate lock child");
390        child.0.wait().expect("wait for lock child");
391
392        let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
393            .await
394            .expect("recover lock after owner termination");
395        drop(lock);
396    }
397}