Skip to main content

release_kit/plan/
lock.rs

1//! One target held by one apply at a time.
2//!
3//! An apply reads the target, decides against what it read, stages, and
4//! renames. Two of them interleaved can each pass their own validation
5//! and then commit over each other, leaving one plan's files beside
6//! another's record: a target describing a landing that never happened.
7//! The transaction cannot see that, because it renames files it staged
8//! before the other run existed.
9//!
10//! So an apply takes the target first and holds it until its
11//! postconditions have run. The lock is one file under the state root,
12//! named for the canonical target path, created with `create_new` so the
13//! creation is the acquisition, and removed when the guard drops. It
14//! lives outside the target because a target's cleanliness is judged
15//! byte by byte, and a lock file inside it would be drift.
16//!
17//! Where the lock cannot be taken, the apply refuses. A guard that
18//! silently does nothing is worse than none, because the call site still
19//! reads as guarded.
20//!
21//! It is advisory, and it bounds this engine's own runs rather than
22//! every writer: a hand edit during an apply is what the before-digests
23//! and the postconditions are for.
24
25use std::io::Write;
26use std::path::{Path, PathBuf};
27
28use camino::Utf8Path;
29
30use crate::applog;
31use crate::diagnostic::{Diagnostic, Reason};
32use crate::digest::Digest;
33use crate::error::RkError;
34
35/// The directory holding the locks, under the state root.
36pub const LOCKS_DIR: &str = "locks";
37
38/// One target held for the life of this value.
39///
40/// The file is removed on drop, on every path out: a refusal, an error,
41/// or a clean apply. A process killed outright leaves the file behind,
42/// and the refusal it causes names the file so the operator can remove
43/// it.
44/// A held lock always names its file: the only two constructors either
45/// create one or refuse, so there is no such thing as a lock that holds
46/// nothing.
47#[derive(Debug)]
48pub struct TargetLock {
49    path: PathBuf,
50}
51
52impl TargetLock {
53    /// The lock file's path, while it is held.
54    #[must_use]
55    pub fn path(&self) -> &Path {
56        &self.path
57    }
58}
59
60impl Drop for TargetLock {
61    fn drop(&mut self) {
62        let _ = std::fs::remove_file(&self.path);
63    }
64}
65
66/// Take `target` for this run, or refuse.
67///
68/// Exclusive ownership or a refusal, never a silent pass. Where no state
69/// root resolves there is nowhere to put the lock, and an apply that
70/// proceeds anyway is the unguarded apply this module exists to stop, so
71/// it refuses before it stages. A second location is no answer either: a
72/// run that locked elsewhere would not exclude a run that locked here,
73/// and two processes disagreeing about where the lock lives hold no lock
74/// at all.
75///
76/// # Errors
77///
78/// Returns a `target-busy` refusal where another run holds the target, a
79/// `prerequisite-unmet` refusal where no state root resolves, and
80/// [`RkError::Io`] where the lock cannot be written.
81pub fn acquire(target: &Utf8Path) -> Result<TargetLock, RkError> {
82    let Some(root) = applog::state_root() else {
83        return Err(rootless());
84    };
85    acquire_in(&root.join(LOCKS_DIR), target)
86}
87
88/// The refusal for a host where the lock has nowhere to live.
89fn rootless() -> RkError {
90    RkError::refusal(
91        Diagnostic::new(
92            Reason::PrerequisiteUnmet,
93            "no state root resolves, so this run cannot take the target, and nothing was written",
94        )
95        .expected("one apply against a target at a time, held by a lock under the state root")
96        .action("set XDG_STATE_HOME, or HOME, and run it again")
97        .target_state("unchanged"),
98    )
99}
100
101/// The acquisition against one locks directory, which is what the tests
102/// drive so no test has to move the state root out from under itself.
103///
104/// The target path is digested rather than flattened, so a path carrying
105/// a separator cannot name another target's lock.
106///
107/// # Errors
108///
109/// As [`acquire`].
110pub fn acquire_in(dir: &Path, target: &Utf8Path) -> Result<TargetLock, RkError> {
111    let canonical = std::fs::canonicalize(target)
112        .map_or_else(|_| target.to_string(), |path| path.display().to_string());
113    std::fs::create_dir_all(dir)?;
114    let path = dir.join(format!("{}.lock", Digest::of(canonical.as_bytes())));
115    match std::fs::OpenOptions::new()
116        .write(true)
117        .create_new(true)
118        .open(&path)
119    {
120        Ok(mut file) => {
121            // Best effort: the body is for the operator reading a
122            // refusal, and a lock that cannot be described still holds.
123            let _ = writeln!(file, "{}", std::process::id());
124            let _ = writeln!(file, "{canonical}");
125            Ok(TargetLock { path })
126        }
127        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
128            Err(busy(&canonical, &path))
129        }
130        Err(error) => Err(RkError::Io(error)),
131    }
132}
133
134/// The refusal for a target another run holds.
135fn busy(target: &str, path: &Path) -> RkError {
136    let holder = std::fs::read_to_string(path)
137        .ok()
138        .and_then(|text| text.lines().next().map(str::to_owned))
139        .map_or_else(
140            || "another run".to_owned(),
141            |pid| format!("the run at process {pid}"),
142        );
143    RkError::refusal(
144        Diagnostic::new(
145            Reason::TargetBusy,
146            format!("{holder} holds {target}, and nothing was written"),
147        )
148        .expected("one apply against a target at a time")
149        .action(format!(
150            "wait for that run to finish; where it is gone, remove {}",
151            path.display()
152        ))
153        .target_state("unchanged"),
154    )
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{acquire_in, busy, rootless};
160    use crate::diagnostic::Reason;
161
162    fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
163        camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
164    }
165
166    /// The second acquisition refuses while the first is held, and the
167    /// drop frees the target for the next one.
168    #[test]
169    fn one_run_holds_a_target_at_a_time() {
170        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
171        let target = tempfile::tempdir().expect("a scratch target exists");
172        let path = utf8(&target);
173        let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
174        let second = acquire_in(locks.path(), &path);
175        assert_eq!(
176            second.expect_err("the second refuses").reason(),
177            Reason::TargetBusy
178        );
179        drop(first);
180        acquire_in(locks.path(), &path).expect("the target is free again");
181    }
182
183    /// Two targets are two locks, so one apply does not block another.
184    #[test]
185    fn two_targets_are_two_locks() {
186        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
187        let a = tempfile::tempdir().expect("a scratch target exists");
188        let b = tempfile::tempdir().expect("a second scratch target exists");
189        let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
190        acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
191    }
192
193    /// A host where no state root resolves refuses rather than applying
194    /// unguarded, and names what the operator can set.
195    #[test]
196    fn a_host_with_no_state_root_refuses() {
197        let error = rootless();
198        assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
199        let diagnostic = error.diagnostic();
200        assert!(
201            diagnostic
202                .action
203                .unwrap_or_default()
204                .contains("XDG_STATE_HOME"),
205            "{:?}",
206            diagnostic.message
207        );
208        assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
209    }
210
211    /// The refusal names the holder and the lock file, so an operator
212    /// whose run died can clear it.
213    #[test]
214    fn the_refusal_names_the_lock_file() {
215        let dir = tempfile::tempdir().expect("a scratch directory exists");
216        let path = dir.path().join("held.lock");
217        std::fs::write(&path, "4242\n/some/target\n").expect("the lock file is written");
218        let diagnostic = busy("/some/target", &path).diagnostic();
219        assert!(diagnostic.message.contains("4242"), "{diagnostic:?}");
220        let action = diagnostic.action.unwrap_or_default();
221        assert!(action.contains("held.lock"), "{action}");
222    }
223}