Skip to main content

socket_patch_core/patch/
apply_lock.rs

1//! Advisory file lock used to serialize mutating operations against a
2//! single `.socket/` directory.
3//!
4//! Apply, rollback, repair, and remove can each rewrite manifest state
5//! and on-disk package files. Two of them running at once against the
6//! same project — common when a dev runs `socket-patch apply` while CI
7//! triggers a deploy hook, or when `apply` and a `repair` are stacked
8//! by a wrapper script — race on every file write. The lock turns
9//! that race into a clean refusal: the second invocation reports
10//! `lock_held` and exits non-zero, leaving the first to finish.
11//!
12//! The lock file lives at `<.socket>/apply.lock`. It is created on
13//! demand (the parent `.socket/` directory must exist first; callers
14//! get a clear error otherwise) and is **never deleted** — the file
15//! handle drop releases the OS-level advisory lock, but the inode
16//! sticks around for next time. That keeps the lock idempotent across
17//! restarts and avoids a race where two callers create the lock file
18//! at the same time.
19//!
20//! Locking is advisory (`flock(2)` on Unix, `LockFileEx` on Windows
21//! via the `fs2` crate). Non-cooperating writers (a user shelling
22//! `rm -rf .socket/`) are not stopped — but every socket-patch
23//! mutating command honors the lock, which is what matters in
24//! practice.
25
26use std::path::{Path, PathBuf};
27use std::time::{Duration, Instant};
28
29use fs2::FileExt;
30use thiserror::Error;
31
32/// Errors surfaced when acquiring the apply lock.
33#[derive(Debug, Error)]
34pub enum LockError {
35    /// Another `socket-patch` process holds the lock and `timeout`
36    /// (possibly zero) elapsed without the lock becoming available.
37    #[error("another socket-patch process is operating in this directory")]
38    Held,
39
40    /// We could not create or open the lock file (typically a missing
41    /// `.socket/` directory or a permissions problem).
42    #[error("failed to open lock file at {path:?}: {source}")]
43    Io {
44        path: PathBuf,
45        #[source]
46        source: std::io::Error,
47    },
48}
49
50/// RAII guard for the apply lock.
51///
52/// Drop releases the OS-level advisory lock. There is no explicit
53/// `unlock()` API on purpose — Rust's drop guarantees are simpler to
54/// reason about than a `?`-fallible unlock path.
55#[derive(Debug)]
56#[must_use = "the lock is released when this guard is dropped"]
57pub struct LockGuard {
58    // The std::fs::File holds the OS handle whose drop releases the
59    // lock; we keep it alive for the guard's lifetime. Field is unused
60    // by name but its Drop side effect is the entire point.
61    _file: std::fs::File,
62}
63
64/// Try to acquire the apply lock at `<socket_dir>/apply.lock`.
65///
66/// `timeout = Duration::ZERO` makes this a non-blocking try-once. Any
67/// positive `timeout` re-tries with a 100 ms backoff until the lock
68/// becomes available or the budget elapses.
69///
70/// The lock file is created on demand. Its parent (`socket_dir`) must
71/// already exist — apply and friends create `.socket/` separately
72/// during `setup`, and we don't want lock acquisition to silently
73/// create directories on a misconfigured path.
74pub fn acquire(socket_dir: &Path, timeout: Duration) -> Result<LockGuard, LockError> {
75    let path = socket_dir.join("apply.lock");
76
77    // Open (or create) the lock file. `create(true)` is idempotent if
78    // it already exists; we never write to the file, only flock it.
79    let file = std::fs::OpenOptions::new()
80        .read(true)
81        .write(true)
82        .create(true)
83        .truncate(false)
84        .open(&path)
85        .map_err(|source| LockError::Io {
86            path: path.clone(),
87            source,
88        })?;
89
90    let deadline = Instant::now() + timeout;
91    loop {
92        match file.try_lock_exclusive() {
93            Ok(()) => return Ok(LockGuard { _file: file }),
94            Err(_) => {
95                if Instant::now() >= deadline {
96                    return Err(LockError::Held);
97                }
98                std::thread::sleep(Duration::from_millis(100));
99            }
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    /// Lock file is created on demand and the first acquisition succeeds.
109    #[test]
110    fn first_acquire_succeeds() {
111        let dir = tempfile::tempdir().unwrap();
112        let guard = acquire(dir.path(), Duration::ZERO).unwrap();
113        // Lock file must exist on disk.
114        assert!(dir.path().join("apply.lock").is_file());
115        drop(guard);
116    }
117
118    /// Second concurrent acquire returns `LockError::Held` when the
119    /// first guard is still alive.
120    #[test]
121    fn second_concurrent_acquire_is_held() {
122        let dir = tempfile::tempdir().unwrap();
123        let _first = acquire(dir.path(), Duration::ZERO).unwrap();
124        let err = acquire(dir.path(), Duration::ZERO).unwrap_err();
125        assert!(matches!(err, LockError::Held));
126    }
127
128    /// After the first guard drops, a fresh acquire succeeds.
129    #[test]
130    fn drop_releases_lock() {
131        let dir = tempfile::tempdir().unwrap();
132        {
133            let _g = acquire(dir.path(), Duration::ZERO).unwrap();
134        } // guard dropped here
135        let again = acquire(dir.path(), Duration::ZERO);
136        assert!(again.is_ok());
137    }
138
139    /// Missing socket directory surfaces as `LockError::Io` with the
140    /// original `NotFound` underneath.
141    #[test]
142    fn missing_socket_dir_surfaces_io() {
143        let dir = tempfile::tempdir().unwrap();
144        let missing = dir.path().join("does-not-exist");
145        let err = acquire(&missing, Duration::ZERO).unwrap_err();
146        match err {
147            LockError::Io { source, .. } => {
148                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
149            }
150            _ => panic!("expected Io error, got {:?}", err),
151        }
152    }
153
154    /// Non-zero timeout waits then errors `Held` when the lock never
155    /// frees up.
156    #[test]
157    fn timeout_held() {
158        let dir = tempfile::tempdir().unwrap();
159        let _first = acquire(dir.path(), Duration::ZERO).unwrap();
160        let start = Instant::now();
161        let err = acquire(dir.path(), Duration::from_millis(250)).unwrap_err();
162        let elapsed = start.elapsed();
163        assert!(matches!(err, LockError::Held));
164        // We waited at least the budget (with some slack for the
165        // sleep granularity). Bound the upper end loosely so a slow
166        // CI host doesn't make this flaky.
167        assert!(
168            elapsed >= Duration::from_millis(200),
169            "expected at least 200ms wait, got {:?}",
170            elapsed
171        );
172    }
173}