spec_driven_docs/transaction/
lock.rs1use camino::{Utf8Path, Utf8PathBuf};
17use serde::{Deserialize, Serialize};
18
19use crate::error::AppError;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Mode {
24 Shared,
26 Exclusive,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Holder {
33 pub pid: u32,
35 pub purpose: String,
37 pub since: String,
39}
40
41#[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 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 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 pub fn exclusive(path: &Utf8Path, purpose: &str) -> Result<Self, AppError> {
102 Self::acquire(path, Mode::Exclusive, purpose)
103 }
104
105 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 pub fn shared(path: &Utf8Path, purpose: &str) -> Result<Self, AppError> {
142 Self::acquire(path, Mode::Shared, purpose)
143 }
144
145 #[must_use]
147 pub const fn mode(&self) -> Mode {
148 self.mode
149 }
150}
151
152fn 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 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 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}