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_handle<T: std::os::windows::io::AsRawHandle>(
62 file: &T,
63) -> std::io::Result<FileIdentity> {
64 use std::mem::MaybeUninit;
65 use windows_sys::Win32::Storage::FileSystem::{
66 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
67 };
68
69 let mut information = MaybeUninit::<BY_HANDLE_FILE_INFORMATION>::uninit();
70 let result =
73 unsafe { GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr()) };
74 if result == 0 {
75 return Err(std::io::Error::last_os_error());
76 }
77
78 let information = unsafe { information.assume_init() };
80 Ok(FileIdentity::Windows {
81 volume_serial_number: information.dwVolumeSerialNumber,
82 file_index: (u64::from(information.nFileIndexHigh) << 32)
83 | u64::from(information.nFileIndexLow),
84 })
85}
86
87#[cfg(not(any(unix, windows)))]
88fn identity_from_metadata(_metadata: &std::fs::Metadata) -> std::io::Result<FileIdentity> {
89 Err(std::io::Error::other(
90 "lock file identity is unsupported on this platform",
91 ))
92}
93
94#[cfg(windows)]
95async fn file_identity(file: &tokio::fs::File) -> std::io::Result<FileIdentity> {
96 identity_from_handle(file)
97}
98
99#[cfg(not(windows))]
100async fn file_identity(file: &tokio::fs::File) -> std::io::Result<FileIdentity> {
101 let metadata = file.metadata().await?;
102 identity_from_metadata(&metadata)
103}
104
105#[cfg(windows)]
106async fn path_identity(path: &Path) -> std::io::Result<FileIdentity> {
107 let file = tokio::fs::File::open(path).await?;
108 identity_from_handle(&file)
109}
110
111#[cfg(not(windows))]
112async fn path_identity(path: &Path) -> std::io::Result<FileIdentity> {
113 let metadata = tokio::fs::metadata(path).await?;
114 identity_from_metadata(&metadata)
115}
116
117#[cfg(windows)]
118fn sync_path_identity(path: &Path) -> std::io::Result<FileIdentity> {
119 let file = std::fs::File::open(path)?;
120 identity_from_handle(&file)
121}
122
123#[cfg(not(windows))]
124fn sync_path_identity(path: &Path) -> std::io::Result<FileIdentity> {
125 let metadata = std::fs::metadata(path)?;
126 identity_from_metadata(&metadata)
127}
128
129fn locked_error(path: &Path) -> Error {
130 Error::Locked {
131 path: path.to_path_buf(),
132 }
133}
134
135fn max_wait_from_env(value: Option<&str>) -> Duration {
141 value
142 .and_then(|seconds| seconds.parse::<u64>().ok())
143 .map(Duration::from_secs)
144 .unwrap_or(DEFAULT_MAX_WAIT)
145}
146
147async fn write_owner_marker(file: &mut tokio::fs::File) -> std::io::Result<()> {
148 file.set_len(0).await?;
149 file.seek(SeekFrom::Start(0)).await?;
150 file.write_all(format!("{}\n", std::process::id()).as_bytes())
151 .await?;
152 file.flush().await
153}
154
155impl BoardLock {
156 pub(crate) async fn acquire(board_dir: &Path) -> Result<Self> {
161 let configured = std::env::var(MAX_WAIT_ENV).ok();
162 Self::acquire_within(board_dir, max_wait_from_env(configured.as_deref())).await
163 }
164
165 async fn acquire_within(board_dir: &Path, max_wait: Duration) -> Result<Self> {
167 let path = board_dir.join(LOCK_FILE);
168 let start = Instant::now();
169 loop {
170 let file = match tokio::fs::OpenOptions::new()
171 .read(true)
172 .write(true)
173 .create(true)
174 .truncate(false)
175 .open(&path)
176 .await
177 {
178 Ok(file) => file,
179 Err(error) => return Err(Error::io(&path, &error)),
180 };
181
182 match file.try_lock() {
183 Ok(()) => {
184 let identity = match file_identity(&file).await {
185 Ok(identity) => identity,
186 Err(_) => {
187 let _ = file.unlock();
188 return Err(locked_error(&path));
189 }
190 };
191
192 let path_matches = match path_identity(&path).await {
193 Ok(path_identity) => path_identity == identity,
194 Err(error) if error.kind() == ErrorKind::NotFound => false,
195 Err(_) => {
196 let _ = file.unlock();
197 return Err(locked_error(&path));
198 }
199 };
200 if !path_matches {
201 let _ = file.unlock();
202 if start.elapsed() >= max_wait {
203 return Err(locked_error(&path));
204 }
205 tokio::time::sleep(RETRY_INTERVAL).await;
206 continue;
207 }
208
209 let mut file = file;
210 if let Err(error) = write_owner_marker(&mut file).await {
211 let _ = file.unlock();
212 return Err(Error::io(&path, &error));
213 }
214 return Ok(Self {
215 path,
216 file: Some(file),
217 identity,
218 });
219 }
220 Err(fs4::TryLockError::WouldBlock) => {
221 if start.elapsed() >= max_wait {
222 return Err(locked_error(&path));
223 }
224 tokio::time::sleep(RETRY_INTERVAL).await;
225 }
226 Err(fs4::TryLockError::Error(error)) => return Err(Error::io(&path, &error)),
227 }
228 }
229 }
230}
231
232impl Drop for BoardLock {
233 fn drop(&mut self) {
234 let Some(file) = self.file.take() else {
235 return;
236 };
237
238 let same_file = sync_path_identity(&self.path).ok() == Some(self.identity);
242 if same_file {
243 let _ = std::fs::remove_file(&self.path);
244 }
245 let _ = file.unlock();
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use tempfile::tempdir;
253
254 #[tokio::test]
255 async fn acquire_then_release_allows_reacquire() {
256 let dir = tempdir().expect("tempdir");
257 let lock = BoardLock::acquire(dir.path()).await.expect("first acquire");
258 assert!(dir.path().join(LOCK_FILE).exists());
260 drop(lock);
261 assert!(!dir.path().join(LOCK_FILE).exists());
263 BoardLock::acquire(dir.path()).await.expect("reacquire");
264 }
265
266 #[cfg(windows)]
267 #[tokio::test]
268 async fn windows_file_identity_matches_across_handles() {
269 let dir = tempdir().expect("tempdir");
270 let path = dir.path().join("identity");
271 let file = tokio::fs::File::create(&path)
272 .await
273 .expect("create identity file");
274 let reopened = tokio::fs::File::open(&path)
275 .await
276 .expect("reopen identity file");
277
278 assert_eq!(
279 identity_from_handle(&file).expect("failed to inspect the first handle"),
280 identity_from_handle(&reopened).expect("failed to inspect the reopened handle"),
281 );
282 }
283
284 #[test]
285 fn lock_timeout_uses_the_default_and_accepts_a_valid_environment_value() {
286 assert_eq!(max_wait_from_env(None), DEFAULT_MAX_WAIT);
287 assert_eq!(
288 max_wait_from_env(Some("30")),
289 Duration::from_secs(30),
290 "a slow Git hook can extend the wait through the environment"
291 );
292 assert_eq!(
293 max_wait_from_env(Some("not-a-duration")),
294 DEFAULT_MAX_WAIT,
295 "invalid values fall back to the safe default"
296 );
297 }
298
299 #[tokio::test]
300 async fn second_acquire_while_held_times_out_with_locked() {
301 let dir = tempdir().expect("tempdir");
302 let _held = BoardLock::acquire(dir.path()).await.expect("hold");
303 let err = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
306 .await
307 .unwrap_err();
308 assert!(matches!(err, Error::Locked { .. }), "got {err:?}");
309 }
310
311 #[tokio::test]
312 async fn stale_lock_owned_by_a_dead_process_is_recovered() {
313 let dir = tempdir().expect("tempdir");
314 tokio::fs::write(dir.path().join(LOCK_FILE), "999999999\n")
315 .await
316 .expect("write stale lock");
317
318 let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
319 .await
320 .expect("recover stale lock");
321
322 drop(lock);
323 assert!(!dir.path().join(LOCK_FILE).exists());
324 }
325
326 #[tokio::test]
327 async fn pid_reuse_like_lock_file_is_recovered_without_trusting_pid_text() {
328 let dir = tempdir().expect("tempdir");
329 tokio::fs::write(
330 dir.path().join(LOCK_FILE),
331 format!("{}\n", std::process::id()),
332 )
333 .await
334 .expect("write PID-reuse-like lock");
335
336 let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
337 .await
338 .expect("recover lock whose PID text belongs to another owner");
339
340 drop(lock);
341 }
342
343 #[test]
344 fn lock_test_child() {
345 let Some(board_dir) = std::env::var_os("PINTO_LOCK_TEST_BOARD") else {
346 return;
347 };
348 let ready = std::path::PathBuf::from(
349 std::env::var_os("PINTO_LOCK_TEST_READY").expect("ready path"),
350 );
351 let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
352 let _lock = runtime
353 .block_on(BoardLock::acquire(Path::new(&board_dir)))
354 .expect("child lock");
355 std::fs::write(ready, b"ready").expect("ready marker");
356 std::thread::park();
357 }
358
359 #[tokio::test]
360 async fn lock_left_by_a_terminated_process_is_recovered() {
361 struct ChildGuard(std::process::Child);
362
363 impl Drop for ChildGuard {
364 fn drop(&mut self) {
365 let _ = self.0.kill();
366 let _ = self.0.wait();
367 }
368 }
369
370 let dir = tempdir().expect("tempdir");
371 let ready = dir.path().join("lock-child.ready");
372 let mut child = ChildGuard(
373 std::process::Command::new(std::env::current_exe().expect("test executable"))
374 .args(["--exact", "storage::lock::tests::lock_test_child"])
375 .env("PINTO_LOCK_TEST_BOARD", dir.path())
376 .env("PINTO_LOCK_TEST_READY", &ready)
377 .spawn()
378 .expect("spawn lock child"),
379 );
380
381 for _ in 0..200 {
382 if ready.is_file() {
383 break;
384 }
385 tokio::time::sleep(Duration::from_millis(10)).await;
386 }
387 assert!(ready.is_file(), "child did not acquire the lock");
388
389 child.0.kill().expect("terminate lock child");
390 child.0.wait().expect("wait for lock child");
391
392 let lock = BoardLock::acquire_within(dir.path(), Duration::from_millis(120))
393 .await
394 .expect("recover lock after owner termination");
395 drop(lock);
396 }
397}