1use crate::error::{Error, Result};
4use fs4::tokio::AsyncFileExt;
5use std::io::{ErrorKind, SeekFrom};
6use std::path::{Path, PathBuf};
7use std::time::{Duration, Instant};
8use tokio::io::{AsyncSeekExt, AsyncWriteExt};
9
10const LOCK_FILE: &str = ".lock";
12const RETRY_INTERVAL: Duration = Duration::from_millis(50);
14const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(5);
16const MAX_WAIT_ENV: &str = "PINTO_LOCK_TIMEOUT_SECS";
18
19#[derive(Debug)]
32pub struct BoardLock {
33 path: PathBuf,
34 file: Option<tokio::fs::File>,
35 identity: FileIdentity,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum FileIdentity {
41 #[cfg(unix)]
42 Unix { device: u64, inode: u64 },
43 #[cfg(windows)]
44 Windows {
45 volume_serial_number: u32,
46 file_index: u64,
47 },
48}
49
50#[cfg(unix)]
51fn identity_from_metadata(metadata: &std::fs::Metadata) -> std::io::Result<FileIdentity> {
52 use std::os::unix::fs::MetadataExt;
53
54 Ok(FileIdentity::Unix {
55 device: metadata.dev(),
56 inode: metadata.ino(),
57 })
58}
59
60#[cfg(windows)]
61fn identity_from_metadata(metadata: &std::fs::Metadata) -> std::io::Result<FileIdentity> {
62 use std::os::windows::fs::MetadataExt;
63
64 let (Some(volume_serial_number), Some(file_index)) =
65 (metadata.volume_serial_number(), metadata.file_index())
66 else {
67 return Err(std::io::Error::other("lock file identity is unavailable"));
68 };
69 Ok(FileIdentity::Windows {
70 volume_serial_number,
71 file_index,
72 })
73}
74
75#[cfg(not(any(unix, windows)))]
76fn identity_from_metadata(_metadata: &std::fs::Metadata) -> std::io::Result<FileIdentity> {
77 Err(std::io::Error::other(
78 "lock file identity is unsupported on this platform",
79 ))
80}
81
82async fn file_identity(file: &tokio::fs::File) -> std::io::Result<FileIdentity> {
83 let metadata = file.metadata().await?;
84 identity_from_metadata(&metadata)
85}
86
87async fn path_identity(path: &Path) -> std::io::Result<FileIdentity> {
88 let metadata = tokio::fs::metadata(path).await?;
89 identity_from_metadata(&metadata)
90}
91
92fn locked_error(path: &Path) -> Error {
93 Error::Locked {
94 path: path.to_path_buf(),
95 }
96}
97
98fn max_wait_from_env(value: Option<&str>) -> Duration {
104 value
105 .and_then(|seconds| seconds.parse::<u64>().ok())
106 .map(Duration::from_secs)
107 .unwrap_or(DEFAULT_MAX_WAIT)
108}
109
110async fn write_owner_marker(file: &mut tokio::fs::File) -> std::io::Result<()> {
111 file.set_len(0).await?;
112 file.seek(SeekFrom::Start(0)).await?;
113 file.write_all(format!("{}\n", std::process::id()).as_bytes())
114 .await?;
115 file.flush().await
116}
117
118impl BoardLock {
119 pub(crate) async fn acquire(board_dir: &Path) -> Result<Self> {
124 let configured = std::env::var(MAX_WAIT_ENV).ok();
125 Self::acquire_within(board_dir, max_wait_from_env(configured.as_deref())).await
126 }
127
128 async fn acquire_within(board_dir: &Path, max_wait: Duration) -> Result<Self> {
130 let path = board_dir.join(LOCK_FILE);
131 let start = Instant::now();
132 loop {
133 let file = match tokio::fs::OpenOptions::new()
134 .read(true)
135 .write(true)
136 .create(true)
137 .truncate(false)
138 .open(&path)
139 .await
140 {
141 Ok(file) => file,
142 Err(error) => return Err(Error::io(&path, &error)),
143 };
144
145 match file.try_lock() {
146 Ok(()) => {
147 let identity = match file_identity(&file).await {
148 Ok(identity) => identity,
149 Err(_) => {
150 let _ = file.unlock();
151 return Err(locked_error(&path));
152 }
153 };
154
155 let path_matches = match path_identity(&path).await {
156 Ok(path_identity) => path_identity == identity,
157 Err(error) if error.kind() == ErrorKind::NotFound => false,
158 Err(_) => {
159 let _ = file.unlock();
160 return Err(locked_error(&path));
161 }
162 };
163 if !path_matches {
164 let _ = file.unlock();
165 if start.elapsed() >= max_wait {
166 return Err(locked_error(&path));
167 }
168 tokio::time::sleep(RETRY_INTERVAL).await;
169 continue;
170 }
171
172 let mut file = file;
173 if let Err(error) = write_owner_marker(&mut file).await {
174 let _ = file.unlock();
175 return Err(Error::io(&path, &error));
176 }
177 return Ok(Self {
178 path,
179 file: Some(file),
180 identity,
181 });
182 }
183 Err(fs4::TryLockError::WouldBlock) => {
184 if start.elapsed() >= max_wait {
185 return Err(locked_error(&path));
186 }
187 tokio::time::sleep(RETRY_INTERVAL).await;
188 }
189 Err(fs4::TryLockError::Error(error)) => return Err(Error::io(&path, &error)),
190 }
191 }
192 }
193}
194
195impl Drop for BoardLock {
196 fn drop(&mut self) {
197 let Some(file) = self.file.take() else {
198 return;
199 };
200
201 let same_file = std::fs::metadata(&self.path)
205 .ok()
206 .and_then(|metadata| identity_from_metadata(&metadata).ok())
207 == Some(self.identity);
208 if same_file {
209 let _ = std::fs::remove_file(&self.path);
210 }
211 let _ = file.unlock();
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use tempfile::tempdir;
219
220 #[tokio::test]
221 async fn acquire_then_release_allows_reacquire() {
222 let dir = tempdir().expect("tempdir");
223 let lock = BoardLock::acquire(dir.path()).await.expect("first acquire");
224 assert!(dir.path().join(LOCK_FILE).exists());
226 drop(lock);
227 assert!(!dir.path().join(LOCK_FILE).exists());
229 BoardLock::acquire(dir.path()).await.expect("reacquire");
230 }
231
232 #[test]
233 fn lock_timeout_uses_the_default_and_accepts_a_valid_environment_value() {
234 assert_eq!(max_wait_from_env(None), DEFAULT_MAX_WAIT);
235 assert_eq!(
236 max_wait_from_env(Some("30")),
237 Duration::from_secs(30),
238 "a slow Git hook can extend the wait through the environment"
239 );
240 assert_eq!(
241 max_wait_from_env(Some("not-a-duration")),
242 DEFAULT_MAX_WAIT,
243 "invalid values fall back to the safe default"
244 );
245 }
246
247 #[tokio::test]
248 async fn second_acquire_while_held_times_out_with_locked() {
249 let dir = tempdir().expect("tempdir");
250 let _held = BoardLock::acquire(dir.path()).await.expect("hold");
251 let err = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
254 .await
255 .unwrap_err();
256 assert!(matches!(err, Error::Locked { .. }), "got {err:?}");
257 }
258
259 #[tokio::test]
260 async fn stale_lock_owned_by_a_dead_process_is_recovered() {
261 let dir = tempdir().expect("tempdir");
262 tokio::fs::write(dir.path().join(LOCK_FILE), "999999999\n")
263 .await
264 .expect("write stale lock");
265
266 let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
267 .await
268 .expect("recover stale lock");
269
270 drop(lock);
271 assert!(!dir.path().join(LOCK_FILE).exists());
272 }
273
274 #[tokio::test]
275 async fn pid_reuse_like_lock_file_is_recovered_without_trusting_pid_text() {
276 let dir = tempdir().expect("tempdir");
277 tokio::fs::write(
278 dir.path().join(LOCK_FILE),
279 format!("{}\n", std::process::id()),
280 )
281 .await
282 .expect("write PID-reuse-like lock");
283
284 let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
285 .await
286 .expect("recover lock whose PID text belongs to another owner");
287
288 drop(lock);
289 }
290
291 #[test]
292 fn lock_test_child() {
293 let Some(board_dir) = std::env::var_os("PINTO_LOCK_TEST_BOARD") else {
294 return;
295 };
296 let ready = std::path::PathBuf::from(
297 std::env::var_os("PINTO_LOCK_TEST_READY").expect("ready path"),
298 );
299 let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
300 let _lock = runtime
301 .block_on(BoardLock::acquire(Path::new(&board_dir)))
302 .expect("child lock");
303 std::fs::write(ready, b"ready").expect("ready marker");
304 std::thread::park();
305 }
306
307 #[tokio::test]
308 async fn lock_left_by_a_terminated_process_is_recovered() {
309 struct ChildGuard(std::process::Child);
310
311 impl Drop for ChildGuard {
312 fn drop(&mut self) {
313 let _ = self.0.kill();
314 let _ = self.0.wait();
315 }
316 }
317
318 let dir = tempdir().expect("tempdir");
319 let ready = dir.path().join("lock-child.ready");
320 let mut child = ChildGuard(
321 std::process::Command::new(std::env::current_exe().expect("test executable"))
322 .args(["--exact", "storage::lock::tests::lock_test_child"])
323 .env("PINTO_LOCK_TEST_BOARD", dir.path())
324 .env("PINTO_LOCK_TEST_READY", &ready)
325 .spawn()
326 .expect("spawn lock child"),
327 );
328
329 for _ in 0..200 {
330 if ready.is_file() {
331 break;
332 }
333 tokio::time::sleep(Duration::from_millis(10)).await;
334 }
335 assert!(ready.is_file(), "child did not acquire the lock");
336
337 child.0.kill().expect("terminate lock child");
338 child.0.wait().expect("wait for lock child");
339
340 let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
341 .await
342 .expect("recover lock after owner termination");
343 drop(lock);
344 }
345}