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, and what holds the target is the
13//! advisory lock the operating system puts on the open file, not the
14//! file's existence. The kernel owns that lock: it releases when the
15//! holder exits, however it exits, so a run killed outright frees the
16//! target rather than stranding it. The file itself is left in place,
17//! because removing one another run has already opened would leave two
18//! runs holding locks on two different inodes under one name.
19//!
20//! It lives outside the target because a target's cleanliness is judged
21//! byte by byte, and a lock file inside it would be drift.
22//!
23//! Where the lock cannot be taken, the apply refuses. A guard that
24//! silently does nothing is worse than none, because the call site still
25//! reads as guarded.
26//!
27//! It is advisory, and it bounds this engine's own runs rather than
28//! every writer: a hand edit during an apply is what the before-digests
29//! and the postconditions are for.
30
31use std::fs::{File, TryLockError};
32use std::io::Write;
33use std::path::{Path, PathBuf};
34
35use camino::Utf8Path;
36
37use crate::applog;
38use crate::diagnostic::{Diagnostic, Reason};
39use crate::digest::Digest;
40use crate::error::RkError;
41
42/// The directory holding the locks, under the state root.
43pub const LOCKS_DIR: &str = "locks";
44
45/// One target held for the life of this value.
46///
47/// The held file stays open for as long as this value lives, and the
48/// operating system releases its lock when the file closes: on a drop,
49/// on a refusal, on an error, and on a process that dies without
50/// unwinding. A held lock always names its file, and the only
51/// constructors either take the lock or refuse, so there is no such
52/// thing as a lock that holds nothing.
53#[derive(Debug)]
54pub struct TargetLock {
55 /// Held open, because closing it is what releases the lock. Dropped
56 /// with this value.
57 _file: File,
58 path: PathBuf,
59}
60
61impl TargetLock {
62 /// The lock file's path, while it is held.
63 #[must_use]
64 pub fn path(&self) -> &Path {
65 &self.path
66 }
67}
68
69/// Take `target` for this run, or refuse.
70///
71/// Exclusive ownership or a refusal, never a silent pass. Where no state
72/// root resolves there is nowhere to put the lock, and an apply that
73/// proceeds anyway is the unguarded apply this module exists to stop, so
74/// it refuses before it stages. A second location is no answer either: a
75/// run that locked elsewhere would not exclude a run that locked here,
76/// and two processes disagreeing about where the lock lives hold no lock
77/// at all.
78///
79/// # Errors
80///
81/// Returns a `target-busy` refusal where another live run holds the
82/// target, a `prerequisite-unmet` refusal where no state root resolves,
83/// and [`RkError::Io`] where the lock file cannot be opened.
84pub fn acquire(target: &Utf8Path) -> Result<TargetLock, RkError> {
85 let Some(root) = applog::state_root() else {
86 return Err(rootless());
87 };
88 acquire_in(&root.join(LOCKS_DIR), target)
89}
90
91/// The refusal for a host where the lock has nowhere to live.
92fn rootless() -> RkError {
93 RkError::refusal(
94 Diagnostic::new(
95 Reason::PrerequisiteUnmet,
96 "no state root resolves, so this run cannot take the target, and nothing was written",
97 )
98 .expected("one apply against a target at a time, held by a lock under the state root")
99 .action("set XDG_STATE_HOME, or HOME, and run it again")
100 .target_state("unchanged"),
101 )
102}
103
104/// The acquisition against one locks directory, which is what the tests
105/// drive so no test has to move the state root out from under itself.
106///
107/// The target path is digested rather than flattened, so a path carrying
108/// a separator cannot name another target's lock.
109///
110/// # Errors
111///
112/// As [`acquire`].
113pub fn acquire_in(dir: &Path, target: &Utf8Path) -> Result<TargetLock, RkError> {
114 let canonical = std::fs::canonicalize(target)
115 .map_or_else(|_| target.to_string(), |path| path.display().to_string());
116 std::fs::create_dir_all(dir)?;
117 let path = dir.join(format!("{}.lock", Digest::of(canonical.as_bytes())));
118 // Opened rather than created exclusively: the file outlives every
119 // run that took it, so its existence says a target was locked once,
120 // never that it is locked now. Only the lock below says that.
121 let mut file = std::fs::OpenOptions::new()
122 .read(true)
123 .write(true)
124 .create(true)
125 .truncate(false)
126 .open(&path)?;
127 match file.try_lock() {
128 Ok(()) => {
129 // Best effort: the body is for the operator reading a
130 // refusal, and a lock that cannot be described still holds.
131 // Truncated first, because the previous holder's line is
132 // still there and a short write would leave its tail.
133 let _ = file.set_len(0);
134 let _ = writeln!(file, "{}", std::process::id());
135 let _ = writeln!(file, "{canonical}");
136 let _ = file.flush();
137 Ok(TargetLock { _file: file, path })
138 }
139 Err(TryLockError::WouldBlock) => Err(busy(&canonical, &path)),
140 Err(TryLockError::Error(error)) => Err(RkError::Io(error)),
141 }
142}
143
144/// The refusal for a target another run holds.
145fn busy(target: &str, path: &Path) -> RkError {
146 let holder = std::fs::read_to_string(path)
147 .ok()
148 .and_then(|text| text.lines().next().map(str::to_owned))
149 .filter(|line| !line.is_empty())
150 .map_or_else(
151 || "another run".to_owned(),
152 |pid| format!("the run at process {pid}"),
153 );
154 RkError::refusal(
155 Diagnostic::new(
156 Reason::TargetBusy,
157 format!("{holder} holds {target}, and nothing was written"),
158 )
159 .expected("one apply against a target at a time")
160 .action("wait for that run to finish, then run it again")
161 .target_state("unchanged"),
162 )
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{acquire_in, busy, rootless};
168 use crate::diagnostic::Reason;
169
170 fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
171 camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
172 }
173
174 /// The second acquisition refuses while the first is held, and the
175 /// drop frees the target for the next one.
176 #[test]
177 fn one_run_holds_a_target_at_a_time() {
178 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
179 let target = tempfile::tempdir().expect("a scratch target exists");
180 let path = utf8(&target);
181 let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
182 let second = acquire_in(locks.path(), &path);
183 assert_eq!(
184 second.expect_err("the second refuses").reason(),
185 Reason::TargetBusy
186 );
187 drop(first);
188 acquire_in(locks.path(), &path).expect("the target is free again");
189 }
190
191 /// Two targets are two locks, so one apply does not block another.
192 #[test]
193 fn two_targets_are_two_locks() {
194 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
195 let a = tempfile::tempdir().expect("a scratch target exists");
196 let b = tempfile::tempdir().expect("a second scratch target exists");
197 let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
198 acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
199 }
200
201 /// A lock file a dead run left behind holds nothing, so the next
202 /// apply takes the target rather than refusing until somebody
203 /// removes the file by hand.
204 ///
205 /// The file is what a killed process leaves: the operating system
206 /// released its lock when the process died, and the bytes stayed.
207 #[test]
208 fn a_lock_file_without_a_live_holder_is_taken_over() {
209 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
210 let target = tempfile::tempdir().expect("a scratch target exists");
211 let path = utf8(&target);
212 let held = acquire_in(locks.path(), &path)
213 .expect("the first run takes the target")
214 .path()
215 .to_path_buf();
216
217 // The holder gone the way a kill leaves it: the file and its
218 // line survive, the lock does not.
219 drop(acquire_in(locks.path(), &path));
220 std::fs::write(&held, "4242\n/some/target\n").expect("the corpse's line writes");
221 assert!(held.exists(), "a killed run leaves its lock file");
222
223 let taken = acquire_in(locks.path(), &path).expect("the next run takes the target");
224 assert_eq!(taken.path(), held, "it is the same lock file");
225 let body = std::fs::read_to_string(&held).expect("the lock file reads");
226 assert!(
227 body.starts_with(&format!("{}\n", std::process::id())),
228 "the taking run names itself, and no tail of the corpse survives: {body:?}"
229 );
230 }
231
232 /// A host where no state root resolves refuses rather than applying
233 /// unguarded, and names what the operator can set.
234 #[test]
235 fn a_host_with_no_state_root_refuses() {
236 let error = rootless();
237 assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
238 let diagnostic = error.diagnostic();
239 assert!(
240 diagnostic
241 .action
242 .unwrap_or_default()
243 .contains("XDG_STATE_HOME"),
244 "{:?}",
245 diagnostic.message
246 );
247 assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
248 }
249
250 /// The refusal names the holder, so an operator meeting it knows
251 /// which run to wait for.
252 #[test]
253 fn the_refusal_names_the_holder() {
254 let dir = tempfile::tempdir().expect("a scratch directory exists");
255 let path = dir.path().join("held.lock");
256 std::fs::write(&path, "4242\n/some/target\n").expect("the lock file is written");
257 let diagnostic = busy("/some/target", &path).diagnostic();
258 assert!(diagnostic.message.contains("4242"), "{diagnostic:?}");
259 assert!(
260 diagnostic.message.contains("/some/target"),
261 "{diagnostic:?}"
262 );
263 }
264}