Skip to main content

spec_driven_docs/transaction/
lock.rs

1//! An OS advisory lock with holder metadata.
2//!
3//! One writer at a time over one record. The lock is the kernel's, so it is
4//! released by drop and by process death and never by a timestamp this tool
5//! wrote: a stale lock file cannot outlive the process that took it, and no
6//! command has to decide whether an hour-old lock is abandoned.
7//!
8//! A busy lock refuses at once, naming the holder. There is no wait and no
9//! `--force`: waiting turns a fast refusal into a hang, and forcing past a
10//! live writer is the interleaving the lock exists to stop.
11//!
12//! The lock itself is the standard library's, `flock(2)` on Unix, so no
13//! dependency carries it and no second implementation can disagree with the
14//! kernel about what a lock means.
15
16use camino::{Utf8Path, Utf8PathBuf};
17use serde::{Deserialize, Serialize};
18
19use crate::error::AppError;
20
21/// Whether a lock excludes other readers.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Mode {
24    /// Several readers may hold it at once.
25    Shared,
26    /// One writer holds it alone.
27    Exclusive,
28}
29
30/// Who holds a lock, for the refusal message.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Holder {
33    /// The holding process.
34    pub pid: u32,
35    /// What that process is doing.
36    pub purpose: String,
37    /// When it took the lock.
38    pub since: String,
39}
40
41/// A held advisory lock. Dropping it releases the lock.
42#[derive(Debug)]
43pub struct Lock {
44    handle: std::fs::File,
45    holder: Utf8PathBuf,
46    mode: Mode,
47}
48
49fn holder_path(path: &Utf8Path) -> Utf8PathBuf {
50    Utf8PathBuf::from(format!("{path}.holder"))
51}
52
53impl Lock {
54    /// Take the lock at `path`, or refuse naming its holder.
55    ///
56    /// # Errors
57    ///
58    /// [`AppError::Busy`] when another process holds it, and I/O errors
59    /// when the lock file cannot be created.
60    pub fn acquire(path: &Utf8Path, mode: Mode, purpose: &str) -> Result<Self, AppError> {
61        if let Some(parent) = path.parent() {
62            std::fs::create_dir_all(parent)?;
63        }
64        let handle = std::fs::OpenOptions::new()
65            .read(true)
66            .write(true)
67            .create(true)
68            .truncate(false)
69            .open(path)?;
70        let taken = match mode {
71            Mode::Shared => handle.try_lock_shared(),
72            Mode::Exclusive => handle.try_lock(),
73        };
74        if taken.is_err() {
75            return Err(AppError::Busy(busy_message(path)));
76        }
77        let holder = holder_path(path);
78        let record = Holder {
79            pid: std::process::id(),
80            purpose: purpose.to_string(),
81            since: jiff::Timestamp::now().to_string(),
82        };
83        // Written after the lock is held, so two processes never race to
84        // describe one holder. A failure to describe it is not a failure to
85        // hold it, so the refusal message degrades rather than the run.
86        let _ = serde_json::to_string(&record)
87            .map_err(|_| ())
88            .and_then(|text| std::fs::write(&holder, text).map_err(|_| ()));
89        Ok(Self {
90            handle,
91            holder,
92            mode,
93        })
94    }
95
96    /// Take the lock alone.
97    ///
98    /// # Errors
99    ///
100    /// As [`Lock::acquire`].
101    pub fn exclusive(path: &Utf8Path, purpose: &str) -> Result<Self, AppError> {
102        Self::acquire(path, Mode::Exclusive, purpose)
103    }
104
105    /// Take the lock alone, waiting a bounded time for a holder to leave.
106    ///
107    /// For a lock whose critical section is short and whose contention is
108    /// ordinary rather than exceptional: two operators planning at once
109    /// should queue, not fail. The bound keeps a dead holder from hanging
110    /// a command, so a wait that runs out still refuses and names it.
111    ///
112    /// # Errors
113    ///
114    /// [`AppError::Busy`] when the wait runs out, and I/O errors when the
115    /// lock file cannot be created.
116    pub fn exclusive_waiting(
117        path: &Utf8Path,
118        purpose: &str,
119        budget: std::time::Duration,
120    ) -> Result<Self, AppError> {
121        let deadline = std::time::Instant::now() + budget;
122        loop {
123            match Self::acquire(path, Mode::Exclusive, purpose) {
124                Ok(held) => return Ok(held),
125                Err(AppError::Busy(message)) => {
126                    if std::time::Instant::now() >= deadline {
127                        return Err(AppError::Busy(message));
128                    }
129                    std::thread::sleep(std::time::Duration::from_millis(25));
130                }
131                Err(other) => return Err(other),
132            }
133        }
134    }
135
136    /// Take the lock beside other readers.
137    ///
138    /// # Errors
139    ///
140    /// As [`Lock::acquire`].
141    pub fn shared(path: &Utf8Path, purpose: &str) -> Result<Self, AppError> {
142        Self::acquire(path, Mode::Shared, purpose)
143    }
144
145    /// Whether this lock excludes other readers.
146    #[must_use]
147    pub const fn mode(&self) -> Mode {
148        self.mode
149    }
150}
151
152/// What the refusal says, read from whatever the holder left.
153fn busy_message(path: &Utf8Path) -> String {
154    let described = std::fs::read_to_string(holder_path(path))
155        .ok()
156        .and_then(|text| serde_json::from_str::<Holder>(&text).ok())
157        .map_or_else(
158            || "another process".to_string(),
159            |holder| {
160                format!(
161                    "process {} ({}) since {}",
162                    holder.pid, holder.purpose, holder.since
163                )
164            },
165        );
166    format!("{path} is held by {described}; wait for it to finish and run this again")
167}
168
169impl Drop for Lock {
170    fn drop(&mut self) {
171        // The holder note describes a lock that is about to be released, so
172        // it goes first. The kernel releases the lock when the handle
173        // closes, whether or not this succeeds.
174        let _ = std::fs::remove_file(&self.holder);
175        let _ = self.handle.unlock();
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    #![allow(
182        clippy::unwrap_used,
183        reason = "a test panics as its failure signal, not as control flow"
184    )]
185
186    use super::*;
187
188    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
189        Utf8PathBuf::from(dir.path().to_str().unwrap())
190    }
191
192    #[test]
193    fn an_exclusive_lock_is_released_by_drop() {
194        let dir = tempfile::tempdir().unwrap();
195        let path = root(&dir).join("skills.lock");
196        {
197            let held = Lock::exclusive(&path, "install").unwrap();
198            assert_eq!(held.mode(), Mode::Exclusive);
199            assert!(holder_path(&path).is_file());
200        }
201        assert!(!holder_path(&path).is_file());
202        Lock::exclusive(&path, "install").unwrap();
203    }
204
205    #[test]
206    fn a_second_holder_is_refused_with_the_first_named() {
207        let dir = tempfile::tempdir().unwrap();
208        let path = root(&dir).join("skills.lock");
209        let _held = Lock::exclusive(&path, "install").unwrap();
210        // The same process holds it, and `flock` is per open file
211        // description, so a second open refuses exactly as another process
212        // would.
213        let error = Lock::exclusive(&path, "uninstall").unwrap_err();
214        let message = error.to_string();
215        assert!(message.contains("is held by"), "{message}");
216        assert!(message.contains("install"), "{message}");
217        assert_eq!(error.exit_code(), 73);
218        assert_eq!(error.kind(), "Busy");
219    }
220
221    #[test]
222    fn a_shared_lock_admits_a_second_reader_and_refuses_a_writer() {
223        let dir = tempfile::tempdir().unwrap();
224        let path = root(&dir).join("plans.lock");
225        let _first = Lock::shared(&path, "plan").unwrap();
226        let _second = Lock::shared(&path, "plan").unwrap();
227        assert!(Lock::exclusive(&path, "apply").is_err());
228    }
229
230    #[test]
231    fn a_lock_file_in_a_missing_directory_is_created() {
232        let dir = tempfile::tempdir().unwrap();
233        let path = root(&dir).join("deep/state/skills.lock");
234        Lock::exclusive(&path, "install").unwrap();
235        assert!(path.is_file());
236    }
237}