1use std::fs::{self, File, OpenOptions};
16use std::io::{self, Write};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use thiserror::Error;
22
23use crate::config::ConfigSet;
24use crate::environment::Environment;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LockWait {
29 Immediately,
31 Backoff {
33 budget: Duration,
35 },
36 Indefinitely,
38}
39
40impl LockWait {
41 #[must_use]
43 pub fn files_ref_lock(config: Option<&ConfigSet>) -> Self {
44 Self::from_files_ref_lock_timeout_ms(Self::parse_files_ref_lock_timeout_ms(config))
45 }
46
47 #[must_use]
49 pub fn parse_files_ref_lock_timeout_ms(config: Option<&ConfigSet>) -> i64 {
50 config
51 .and_then(|cfg| cfg.get("core.filesreflocktimeout"))
52 .and_then(|v| v.trim().parse::<i64>().ok())
53 .unwrap_or(100)
54 }
55
56 #[must_use]
57 fn from_files_ref_lock_timeout_ms(timeout_ms: i64) -> Self {
58 match timeout_ms {
59 0 => Self::Immediately,
60 ms if ms < 0 => Self::Indefinitely,
61 ms => Self::Backoff {
62 budget: Duration::from_millis(ms as u64),
63 },
64 }
65 }
66
67 #[must_use]
69 pub fn packed_refs(config: Option<&ConfigSet>) -> Self {
70 Self::from_milliseconds_config(config, "core.packedrefstimeout", 1_000)
71 }
72
73 #[must_use]
75 pub fn reftable_tables_list(config: Option<&ConfigSet>) -> Self {
76 Self::from_milliseconds_config(config, "reftable.locktimeout", 1_000)
77 }
78
79 #[must_use]
81 pub fn files_ref_lock_for_git_dir(git_dir: &Path) -> Self {
82 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).ok();
83 Self::files_ref_lock(config.as_ref())
84 }
85
86 #[must_use]
88 pub fn packed_refs_for_git_dir(git_dir: &Path) -> Self {
89 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).ok();
90 Self::packed_refs(config.as_ref())
91 }
92
93 fn from_milliseconds_config(config: Option<&ConfigSet>, key: &str, default_ms: u64) -> Self {
94 let ms = config
95 .and_then(|cfg| cfg.get(key))
96 .and_then(|v| v.trim().parse::<i64>().ok())
97 .map(|v| v.max(0) as u64)
98 .unwrap_or(default_ms);
99 if ms == 0 {
100 Self::Immediately
101 } else {
102 Self::Backoff {
103 budget: Duration::from_millis(ms),
104 }
105 }
106 }
107}
108
109#[derive(Debug, Error)]
111#[non_exhaustive]
112pub enum LockError {
113 #[error("lock held at {path}")]
115 Held {
116 path: PathBuf,
118 },
119
120 #[error("lock I/O at {path}")]
122 Io {
123 path: PathBuf,
125 #[source]
127 source: io::Error,
128 },
129}
130
131pub trait LockClock: Send + Sync {
133 fn now(&self) -> Instant;
135 fn sleep(&self, duration: Duration);
137}
138
139pub trait LockJitter: Send + Sync {
141 fn fraction(&self, lock_path: &Path, attempt: u32) -> f64;
143}
144
145#[derive(Debug, Default, Clone, Copy)]
147pub struct SystemLockJitter;
148
149impl LockJitter for SystemLockJitter {
150 fn fraction(&self, _lock_path: &Path, _attempt: u32) -> f64 {
151 let mut buf = [0u8; 8];
152 if getrandom::getrandom(&mut buf).is_ok() {
153 let n = u64::from_le_bytes(buf);
154 (n % 10_000) as f64 / 10_000.0
155 } else {
156 0.5
157 }
158 }
159}
160
161#[derive(Debug, Default, Clone, Copy)]
163pub struct SystemLockClock;
164
165impl LockClock for SystemLockClock {
166 fn now(&self) -> Instant {
167 Instant::now()
168 }
169
170 fn sleep(&self, duration: Duration) {
171 if !duration.is_zero() {
172 std::thread::sleep(duration);
173 }
174 }
175}
176
177pub struct LockFile {
182 lock_path: PathBuf,
183 commit_path: Option<PathBuf>,
184 file: Option<File>,
185 committed: bool,
186}
187
188impl LockFile {
189 pub fn acquire(target: &Path, wait: LockWait) -> Result<Self, LockError> {
200 Self::acquire_with_clock(
201 target,
202 wait,
203 Arc::new(SystemLockClock),
204 Arc::new(SystemLockJitter),
205 )
206 }
207
208 pub fn acquire_with_clock(
210 target: &Path,
211 wait: LockWait,
212 clock: Arc<dyn LockClock>,
213 jitter: Arc<dyn LockJitter>,
214 ) -> Result<Self, LockError> {
215 if let Some(parent) = target.parent() {
216 fs::create_dir_all(parent).map_err(|source| LockError::Io {
217 path: target.to_path_buf(),
218 source,
219 })?;
220 }
221
222 let deadline = match wait {
223 LockWait::Immediately => Some(clock.now()),
224 LockWait::Backoff { budget } => Some(clock.now() + budget),
225 LockWait::Indefinitely => None,
226 };
227
228 let mut attempt: u32 = 0;
229 loop {
230 match OpenOptions::new().write(true).create_new(true).open(target) {
231 Ok(file) => {
232 return Ok(Self {
233 lock_path: target.to_path_buf(),
234 commit_path: None,
235 file: Some(file),
236 committed: false,
237 });
238 }
239 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
240 if matches!(wait, LockWait::Immediately)
241 || deadline.is_some_and(|deadline| clock.now() >= deadline)
242 {
243 return Err(LockError::Held {
244 path: target.to_path_buf(),
245 });
246 }
247 let delay = backoff_delay(target, attempt, jitter.as_ref());
248 attempt = attempt.saturating_add(1);
249 if let Some(deadline) = deadline {
250 let remaining = deadline.saturating_duration_since(clock.now());
251 clock.sleep(delay.min(remaining));
252 } else {
253 clock.sleep(delay);
254 }
255 }
256 Err(source) => {
257 return Err(LockError::Io {
258 path: target.to_path_buf(),
259 source,
260 });
261 }
262 }
263 }
264 }
265
266 #[must_use]
268 pub fn with_commit_target(mut self, path: &Path) -> Self {
269 self.commit_path = Some(path.to_path_buf());
270 self
271 }
272
273 #[must_use]
275 pub fn lock_path(&self) -> &Path {
276 &self.lock_path
277 }
278
279 pub fn as_file(&self) -> Result<&File, LockError> {
286 self.file.as_ref().ok_or_else(|| LockError::Io {
287 path: self.lock_path.clone(),
288 source: io::Error::new(io::ErrorKind::NotConnected, "lock file handle missing"),
289 })
290 }
291
292 pub fn as_file_mut(&mut self) -> Result<&mut File, LockError> {
298 self.file.as_mut().ok_or_else(|| LockError::Io {
299 path: self.lock_path.clone(),
300 source: io::Error::new(io::ErrorKind::NotConnected, "lock file handle missing"),
301 })
302 }
303
304 pub(crate) fn into_open_file(mut self) -> Result<(PathBuf, File), LockError> {
306 self.committed = true;
307 let file = self.file.take().ok_or_else(|| LockError::Io {
308 path: self.lock_path.clone(),
309 source: io::Error::new(io::ErrorKind::NotConnected, "lock file handle missing"),
310 })?;
311 Ok((self.lock_path.clone(), file))
312 }
313
314 pub fn commit(self) -> Result<(), LockError> {
321 let dest = self.commit_path.clone().ok_or_else(|| LockError::Io {
322 path: self.lock_path.clone(),
323 source: io::Error::new(
324 io::ErrorKind::InvalidInput,
325 "lock commit target not configured",
326 ),
327 })?;
328 self.commit_to(&dest)
329 }
330
331 pub fn commit_to(mut self, other_path: &Path) -> Result<(), LockError> {
337 let file = self.file.take().ok_or_else(|| LockError::Io {
338 path: self.lock_path.clone(),
339 source: io::Error::new(io::ErrorKind::NotConnected, "lock file handle missing"),
340 })?;
341 file.sync_all().map_err(|source| LockError::Io {
342 path: self.lock_path.clone(),
343 source,
344 })?;
345 drop(file);
346 fs::rename(&self.lock_path, other_path).map_err(|source| LockError::Io {
347 path: self.lock_path.clone(),
348 source,
349 })?;
350 self.committed = true;
351 Ok(())
352 }
353
354 pub fn rollback(mut self) {
356 if self.committed {
357 return;
358 }
359 drop(self.file.take());
360 let _ = fs::remove_file(&self.lock_path);
361 self.committed = true;
362 }
363
364 pub fn detach(mut self) {
366 self.committed = true;
367 }
368}
369
370impl Write for LockFile {
371 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
372 match self.file.as_mut() {
373 Some(file) => file.write(buf),
374 None => Err(io::Error::new(
375 io::ErrorKind::NotConnected,
376 "lock file handle missing",
377 )),
378 }
379 }
380
381 fn flush(&mut self) -> io::Result<()> {
382 match self.file.as_mut() {
383 Some(file) => file.flush(),
384 None => Err(io::Error::new(
385 io::ErrorKind::NotConnected,
386 "lock file handle missing",
387 )),
388 }
389 }
390}
391
392impl Drop for LockFile {
393 fn drop(&mut self) {
394 if self.committed {
395 return;
396 }
397 drop(self.file.take());
398 let _ = fs::remove_file(&self.lock_path);
399 }
400}
401
402fn backoff_delay(lock_path: &Path, attempt: u32, jitter: &dyn LockJitter) -> Duration {
403 const INITIAL_MS: u64 = 1;
404 const CAP_MS: u64 = 50;
405 let shift = attempt.min(16);
406 let base_ms = (INITIAL_MS.saturating_mul(1_u64 << shift)).min(CAP_MS);
407 let span_ms = base_ms / 2 + 1;
408 let jitter_ms = (jitter.fraction(lock_path, attempt) * span_ms as f64) as u64;
409 Duration::from_millis(base_ms.saturating_add(jitter_ms))
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub struct LockHolder {
418 pub pid: u64,
420 pub running: bool,
425}
426
427impl LockHolder {
428 #[must_use]
434 pub fn read(lock_path: &Path) -> Option<Self> {
435 let pid = read_lock_pid(&pid_path_for_lock(lock_path))?;
436 Some(Self {
437 pid,
438 running: is_process_running(pid),
439 })
440 }
441}
442
443pub(crate) fn pid_path_for_lock(lock_path: &Path) -> PathBuf {
445 let file_name = lock_path
446 .file_name()
447 .map(|s| s.to_string_lossy().into_owned())
448 .unwrap_or_else(|| "index.lock".to_owned());
449 let base = file_name.strip_suffix(".lock").unwrap_or(&file_name);
450 lock_path.with_file_name(format!("{base}~pid.lock"))
451}
452
453fn read_lock_pid(pid_path: &Path) -> Option<u64> {
454 let raw = fs::read_to_string(pid_path).ok()?;
455 let trimmed = raw.trim();
456 trimmed
457 .strip_prefix("pid ")
458 .unwrap_or(trimmed)
459 .trim()
460 .parse::<u64>()
461 .ok()
462}
463
464pub(crate) fn is_process_running(pid: u64) -> bool {
466 if pid == 0 || pid > i32::MAX as u64 {
467 return false;
468 }
469 #[cfg(windows)]
470 {
471 use windows_sys::Win32::Foundation::CloseHandle;
472 use windows_sys::Win32::System::Threading::{
473 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
474 };
475 unsafe {
477 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid as u32);
478 if handle.is_null() {
479 return false;
480 }
481 CloseHandle(handle);
482 true
483 }
484 }
485 #[cfg(all(unix, target_os = "linux"))]
486 {
487 Path::new(&format!("/proc/{pid}")).exists()
488 }
489 #[cfg(all(unix, not(target_os = "linux")))]
490 {
491 use rustix::io::Errno;
492 use rustix::process::{test_kill_process, Pid};
493 let Some(pid) = Pid::from_raw(pid as i32) else {
494 return false;
495 };
496 match test_kill_process(pid) {
497 Ok(()) => true,
498 Err(Errno::SRCH) => false,
499 Err(Errno::PERM) => true,
500 Err(_) => false,
501 }
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use std::sync::Mutex;
509
510 struct FakeLockClock {
511 now: Mutex<Instant>,
512 sleeps: Mutex<Vec<Duration>>,
513 }
514
515 impl FakeLockClock {
516 fn new(start: Instant) -> Arc<Self> {
517 Arc::new(Self {
518 now: Mutex::new(start),
519 sleeps: Mutex::new(Vec::new()),
520 })
521 }
522
523 fn sleeps(&self) -> Vec<Duration> {
524 self.sleeps.lock().unwrap().clone()
525 }
526 }
527
528 impl LockClock for FakeLockClock {
529 fn now(&self) -> Instant {
530 *self.now.lock().unwrap()
531 }
532
533 fn sleep(&self, duration: Duration) {
534 self.sleeps.lock().unwrap().push(duration);
535 *self.now.lock().unwrap() += duration;
536 }
537 }
538
539 #[test]
540 fn drop_without_commit_removes_own_lock() {
541 let dir = tempfile::tempdir().unwrap();
542 let lock = dir.path().join("resource.lock");
543 {
544 let _lock = LockFile::acquire(&lock, LockWait::Immediately).unwrap();
545 assert!(lock.is_file());
546 }
547 assert!(!lock.exists());
548 }
549
550 #[test]
551 fn commit_to_replaces_target() {
552 let dir = tempfile::tempdir().unwrap();
553 let target = dir.path().join("resource");
554 let lock_path = dir.path().join("resource.lock");
555 let mut lock = LockFile::acquire(&lock_path, LockWait::Immediately).unwrap();
556 lock.write_all(b"payload\n").unwrap();
557 lock.commit_to(&target).unwrap();
558 assert!(!lock_path.exists());
559 assert_eq!(fs::read_to_string(&target).unwrap(), "payload\n");
560 }
561
562 struct FixedJitter(f64);
563
564 impl LockJitter for FixedJitter {
565 fn fraction(&self, _lock_path: &Path, _attempt: u32) -> f64 {
566 self.0
567 }
568 }
569
570 #[test]
571 fn held_when_budget_exhausted() {
572 let dir = tempfile::tempdir().unwrap();
573 let lock_path = dir.path().join("x.lock");
574 fs::write(&lock_path, b"foreign").unwrap();
575 let start = Instant::now();
576 let clock = FakeLockClock::new(start);
577 let err = match LockFile::acquire_with_clock(
578 &lock_path,
579 LockWait::Backoff {
580 budget: Duration::from_millis(10),
581 },
582 clock.clone(),
583 Arc::new(FixedJitter(0.5)),
584 ) {
585 Err(e) => e,
586 Ok(_) => panic!("expected lock held error"),
587 };
588 assert!(matches!(err, LockError::Held { .. }));
589 assert!(lock_path.is_file());
590 assert_eq!(fs::read_to_string(&lock_path).unwrap(), "foreign");
591 assert!(!clock.sleeps().is_empty());
592 }
593
594 #[test]
595 fn pid_sidecar_path_replaces_lock_suffix() {
596 assert_eq!(
597 pid_path_for_lock(Path::new("/r/.git/index.lock")),
598 Path::new("/r/.git/index~pid.lock")
599 );
600 assert_eq!(
601 pid_path_for_lock(Path::new("/r/.git/refs/heads/main.lock")),
602 Path::new("/r/.git/refs/heads/main~pid.lock")
603 );
604 assert_eq!(
605 pid_path_for_lock(Path::new("/r/odd")),
606 Path::new("/r/odd~pid.lock")
607 );
608 }
609
610 #[test]
611 fn held_converts_to_typed_error_with_holder() {
612 let dir = tempfile::tempdir().unwrap();
613 let lock_path = dir.path().join("index.lock");
614 fs::write(&lock_path, b"").unwrap();
615 fs::write(
616 dir.path().join("index~pid.lock"),
617 format!("pid {}\n", std::process::id()),
618 )
619 .unwrap();
620 let err = LockFile::acquire(&lock_path, LockWait::Immediately)
621 .err()
622 .expect("held");
623 match crate::error::Error::from(err) {
624 crate::error::Error::LockHeld { path, holder } => {
625 assert_eq!(path, lock_path);
626 assert_eq!(
627 holder,
628 Some(LockHolder {
629 pid: u64::from(std::process::id()),
630 running: true,
631 })
632 );
633 }
634 other => panic!("expected LockHeld, got {other:?}"),
635 }
636 }
637
638 #[test]
639 fn io_failure_converts_to_io_error() {
640 let err = LockError::Io {
641 path: PathBuf::from("x.lock"),
642 source: io::Error::from(io::ErrorKind::PermissionDenied),
643 };
644 assert!(matches!(
645 crate::error::Error::from(err),
646 crate::error::Error::Io(e) if e.kind() == io::ErrorKind::PermissionDenied
647 ));
648 }
649
650 #[test]
651 fn rollback_removes_own_lock() {
652 let dir = tempfile::tempdir().unwrap();
653 let lock = dir.path().join("resource.lock");
654 let lock_file = LockFile::acquire(&lock, LockWait::Immediately).unwrap();
655 assert!(lock.is_file());
656 lock_file.rollback();
657 assert!(!lock.exists());
658 }
659
660 #[test]
661 fn files_ref_lock_timeout_uses_milliseconds() {
662 let mut cfg = ConfigSet::new();
663 cfg.add_command_override("core.filesreflocktimeout", "100")
664 .expect("set timeout");
665 assert_eq!(LockWait::parse_files_ref_lock_timeout_ms(Some(&cfg)), 100);
666 match LockWait::files_ref_lock(Some(&cfg)) {
667 LockWait::Backoff { budget } => {
668 assert_eq!(budget, Duration::from_millis(100));
669 }
670 other => panic!("expected backoff, got {other:?}"),
671 }
672 }
673
674 #[test]
675 fn files_ref_lock_timeout_negative_retries_indefinitely() {
676 let mut cfg = ConfigSet::new();
677 cfg.add_command_override("core.filesreflocktimeout", "-1")
678 .expect("set timeout");
679 assert!(matches!(
680 LockWait::files_ref_lock(Some(&cfg)),
681 LockWait::Indefinitely
682 ));
683 }
684
685 #[test]
686 fn indefinite_wait_succeeds_when_lock_cleared() {
687 let dir = tempfile::tempdir().unwrap();
688 let lock_path = dir.path().join("x.lock");
689 fs::write(&lock_path, b"foreign").unwrap();
690 let clock = FakeLockClock::new(Instant::now());
691 let lock_path_thread = lock_path.clone();
692 std::thread::spawn(move || {
693 std::thread::sleep(Duration::from_millis(5));
694 let _ = fs::remove_file(&lock_path_thread);
695 });
696 let _guard = LockFile::acquire_with_clock(
697 &lock_path,
698 LockWait::Indefinitely,
699 clock,
700 Arc::new(FixedJitter(0.0)),
701 )
702 .expect("indefinite wait should eventually acquire");
703 }
704}