Skip to main content

grit_lib/
lockfile.rs

1//! Exclusive lock files with optional Git-style acquisition backoff.
2//!
3//! Grit uses lock files (`.lock` siblings, `index.lock`, `packed-refs.lock`, …) the same way
4//! Git does: create exclusively, write staged content, then rename over the target path.
5//! [`LockFile::acquire`] retries with jittered exponential backoff when configured, matching
6//! `core.filesRefLockTimeout`, `core.packedRefsTimeout`, and `reftable.lockTimeout`.
7//!
8//! ```rust
9//! use grit_lib::config::ConfigSet;
10//! use grit_lib::lockfile::LockWait;
11//! let wait = LockWait::files_ref_lock(Some(&ConfigSet::new()));
12//! assert!(matches!(wait, LockWait::Immediately | LockWait::Backoff { .. } | LockWait::Indefinitely));
13//! ```
14
15use 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/// Whether to wait for an existing lock, and for how long.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LockWait {
29    /// Fail immediately when the lock file already exists.
30    Immediately,
31    /// Retry with jittered exponential backoff until `budget` elapses.
32    Backoff {
33        /// Total time budget for acquisition attempts.
34        budget: Duration,
35    },
36    /// Retry until the lock is acquired (Git `core.filesRefLockTimeout = -1`).
37    Indefinitely,
38}
39
40impl LockWait {
41    /// `core.filesRefLockTimeout` (milliseconds), default 100 ms when unset.
42    #[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    /// Parse `core.filesRefLockTimeout` the way Git does (milliseconds).
48    #[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    /// `core.packedRefsTimeout` (milliseconds), default 1000 ms when unset.
68    #[must_use]
69    pub fn packed_refs(config: Option<&ConfigSet>) -> Self {
70        Self::from_milliseconds_config(config, "core.packedrefstimeout", 1_000)
71    }
72
73    /// `reftable.lockTimeout` (milliseconds), default 1000 ms when unset.
74    #[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    /// Load repository config from `git_dir` and return [`Self::files_ref_lock`].
80    #[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    /// Load repository config from `git_dir` and return [`Self::packed_refs`].
87    #[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    /// Remaining wait budget after `elapsed` time since the outer wait started.
109    #[must_use]
110    pub fn remaining_after(self, elapsed: Duration) -> Self {
111        match self {
112            Self::Immediately => Self::Immediately,
113            Self::Indefinitely => Self::Indefinitely,
114            Self::Backoff { budget } => {
115                if elapsed >= budget {
116                    Self::Immediately
117                } else {
118                    Self::Backoff {
119                        budget: budget - elapsed,
120                    }
121                }
122            }
123        }
124    }
125
126    /// Remaining wait budget given `start` and `now`.
127    #[must_use]
128    pub fn remaining_from(self, start: Instant, now: Instant) -> Self {
129        self.remaining_after(now.saturating_duration_since(start))
130    }
131}
132
133/// Failure to create or commit a lock file.
134#[derive(Debug, Error)]
135#[non_exhaustive]
136pub enum LockError {
137    /// Another process holds the lock (or the lock path already exists).
138    #[error("lock held at {path}")]
139    Held {
140        /// Path to the lock file that could not be created exclusively.
141        path: PathBuf,
142    },
143
144    /// Underlying I/O failure while creating, writing, syncing, or renaming.
145    #[error("lock I/O at {path}")]
146    Io {
147        /// Lock file path associated with the failure.
148        path: PathBuf,
149        /// Underlying OS error.
150        #[source]
151        source: io::Error,
152    },
153}
154
155/// Injectable clock and sleep for deterministic lock backoff tests.
156pub trait LockClock: Send + Sync {
157    /// Current instant (used for backoff deadlines).
158    fn now(&self) -> Instant;
159    /// Pause until the next acquisition attempt.
160    fn sleep(&self, duration: Duration);
161}
162
163/// Injectable jitter so concurrent processes do not sleep in lockstep.
164pub trait LockJitter: Send + Sync {
165    /// Fraction in `[0.0, 1.0)` scaled into backoff delays.
166    fn fraction(&self, lock_path: &Path, attempt: u32) -> f64;
167}
168
169/// OS random bytes for cross-process jitter (Git-style lock backoff).
170#[derive(Debug, Default, Clone, Copy)]
171pub struct SystemLockJitter;
172
173impl LockJitter for SystemLockJitter {
174    fn fraction(&self, _lock_path: &Path, _attempt: u32) -> f64 {
175        let mut buf = [0u8; 8];
176        if getrandom::getrandom(&mut buf).is_ok() {
177            let n = u64::from_le_bytes(buf);
178            (n % 10_000) as f64 / 10_000.0
179        } else {
180            0.5
181        }
182    }
183}
184
185/// Wall-clock backoff using [`std::thread::sleep`].
186#[derive(Debug, Default, Clone, Copy)]
187pub struct SystemLockClock;
188
189impl LockClock for SystemLockClock {
190    fn now(&self) -> Instant {
191        Instant::now()
192    }
193
194    fn sleep(&self, duration: Duration) {
195        if !duration.is_zero() {
196            std::thread::sleep(duration);
197        }
198    }
199}
200
201/// Exclusive lock file created by this process.
202///
203/// Drop without [`Self::commit`] or [`Self::commit_to`] removes the lock file when this
204/// handle created it. Foreign lock files are never removed.
205pub struct LockFile {
206    lock_path: PathBuf,
207    commit_path: Option<PathBuf>,
208    file: Option<File>,
209    committed: bool,
210    wrote_holder_pid: bool,
211}
212
213impl LockFile {
214    /// Create `target` exclusively (`O_CREAT|O_EXCL`), optionally waiting per `wait`.
215    ///
216    /// `target` is the lock file path (for example `refs/heads/main.lock`), not the file being
217    /// updated. Persist staged bytes with [`Self::commit_to`] (or [`Self::commit`] after
218    /// [`Self::with_commit_target`]).
219    ///
220    /// # Errors
221    ///
222    /// Returns [`LockError::Held`] when the lock exists and the wait budget is exhausted, or
223    /// [`LockError::Io`] for other failures.
224    pub fn acquire(target: &Path, wait: LockWait) -> Result<Self, LockError> {
225        Self::acquire_with_clock(
226            target,
227            wait,
228            Arc::new(SystemLockClock),
229            Arc::new(SystemLockJitter),
230        )
231    }
232
233    /// Like [`Self::acquire`], with injectable clock and jitter for unit tests.
234    pub fn acquire_with_clock(
235        target: &Path,
236        wait: LockWait,
237        clock: Arc<dyn LockClock>,
238        jitter: Arc<dyn LockJitter>,
239    ) -> Result<Self, LockError> {
240        let to_lock_error = |source: io::Error| {
241            if crate::atomic_replace::is_lock_acquire_contention(&source, target) {
242                LockError::Held {
243                    path: target.to_path_buf(),
244                }
245            } else {
246                LockError::Io {
247                    path: target.to_path_buf(),
248                    source,
249                }
250            }
251        };
252        let open = || OpenOptions::new().write(true).create_new(true).open(target);
253        // The parent usually exists, so it is only created once an open fails. Creating it
254        // then still reports a parent blocked by a file as the directory error.
255        let mut parent_ready = false;
256        retry_while_held(
257            target,
258            || wait,
259            clock.as_ref(),
260            jitter.as_ref(),
261            || match open() {
262                Err(_) if !parent_ready => {
263                    parent_ready = true;
264                    if let Some(parent) = target.parent() {
265                        fs::create_dir_all(parent).map_err(|source| LockError::Io {
266                            path: target.to_path_buf(),
267                            source,
268                        })?;
269                    }
270                    open().map_err(to_lock_error)
271                }
272                other => other.map_err(to_lock_error),
273            },
274            |err| matches!(err, LockError::Held { .. }),
275        )
276        .map(|file| Self {
277            lock_path: target.to_path_buf(),
278            commit_path: None,
279            file: Some(file),
280            committed: false,
281            wrote_holder_pid: false,
282        })
283    }
284
285    /// Set the destination path used by [`Self::commit`].
286    #[must_use]
287    pub fn with_commit_target(mut self, path: &Path) -> Self {
288        self.commit_path = Some(path.to_path_buf());
289        self
290    }
291
292    /// Path to the lock file held by this handle.
293    #[must_use]
294    pub fn lock_path(&self) -> &Path {
295        &self.lock_path
296    }
297
298    /// Borrow the open lock file for writing.
299    ///
300    /// # Errors
301    ///
302    /// Returns [`LockError::Io`] when the handle was already taken (for example after
303    /// the open file handle was moved out for index integration).
304    pub fn as_file(&self) -> Result<&File, LockError> {
305        self.file.as_ref().ok_or_else(|| LockError::Io {
306            path: self.lock_path.clone(),
307            source: io::Error::new(io::ErrorKind::NotConnected, "lock file handle missing"),
308        })
309    }
310
311    /// Borrow the open lock file mutably for writing.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`LockError::Io`] when the handle was already taken.
316    pub fn as_file_mut(&mut self) -> Result<&mut File, LockError> {
317        self.file.as_mut().ok_or_else(|| LockError::Io {
318            path: self.lock_path.clone(),
319            source: io::Error::new(io::ErrorKind::NotConnected, "lock file handle missing"),
320        })
321    }
322
323    /// Yield the lock path and open file without removing the lock on drop.
324    pub(crate) fn into_open_file(mut self) -> Result<(PathBuf, File), LockError> {
325        self.committed = true;
326        let file = self.file.take().ok_or_else(|| LockError::Io {
327            path: self.lock_path.clone(),
328            source: io::Error::new(io::ErrorKind::NotConnected, "lock file handle missing"),
329        })?;
330        Ok((self.lock_path.clone(), file))
331    }
332
333    /// Fsync the lock file, rename it over `commit_path` from [`Self::with_commit_target`], and
334    /// disarm drop cleanup.
335    ///
336    /// # Errors
337    ///
338    /// Returns [`LockError::Io`] when no commit target was configured or rename fails.
339    pub fn commit(self) -> Result<(), LockError> {
340        let dest = self.commit_path.clone().ok_or_else(|| LockError::Io {
341            path: self.lock_path.clone(),
342            source: io::Error::new(
343                io::ErrorKind::InvalidInput,
344                "lock commit target not configured",
345            ),
346        })?;
347        self.commit_to(&dest)
348    }
349
350    /// Fsync the lock file and atomically rename it over `other_path`.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`LockError::Io`] on sync or rename failure.
355    pub fn commit_to(mut self, other_path: &Path) -> Result<(), LockError> {
356        let file = self.file.take().ok_or_else(|| LockError::Io {
357            path: self.lock_path.clone(),
358            source: io::Error::new(io::ErrorKind::NotConnected, "lock file handle missing"),
359        })?;
360        file.sync_all().map_err(|source| LockError::Io {
361            path: self.lock_path.clone(),
362            source,
363        })?;
364        drop(file);
365        crate::atomic_replace::replace_file(&self.lock_path, other_path).map_err(|source| {
366            LockError::Io {
367                path: self.lock_path.clone(),
368                source,
369            }
370        })?;
371        self.remove_holder_pid_sidecar();
372        self.committed = true;
373        Ok(())
374    }
375
376    /// Record this process as the holder of [`Self::lock_path`] in a Git-compatible pid sidecar.
377    ///
378    /// # Errors
379    ///
380    /// Returns [`LockError::Io`] when the sidecar cannot be written.
381    pub fn write_holder_pid(&mut self) -> Result<(), LockError> {
382        write_lock_pid_file(&pid_path_for_lock(&self.lock_path)).map_err(|source| {
383            LockError::Io {
384                path: self.lock_path.clone(),
385                source,
386            }
387        })?;
388        self.wrote_holder_pid = true;
389        Ok(())
390    }
391
392    /// Release the lock without committing (same as drop).
393    pub fn rollback(mut self) {
394        if self.committed {
395            return;
396        }
397        drop(self.file.take());
398        let _ = fs::remove_file(&self.lock_path);
399        self.remove_holder_pid_sidecar();
400        self.committed = true;
401    }
402
403    fn remove_holder_pid_sidecar(&self) {
404        if self.wrote_holder_pid {
405            let _ = fs::remove_file(pid_path_for_lock(&self.lock_path));
406        }
407    }
408
409    /// Close this handle but leave the lock file on disk (mutex-only locks).
410    pub fn detach(mut self) {
411        self.committed = true;
412    }
413}
414
415impl Write for LockFile {
416    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
417        match self.file.as_mut() {
418            Some(file) => file.write(buf),
419            None => Err(io::Error::new(
420                io::ErrorKind::NotConnected,
421                "lock file handle missing",
422            )),
423        }
424    }
425
426    fn flush(&mut self) -> io::Result<()> {
427        match self.file.as_mut() {
428            Some(file) => file.flush(),
429            None => Err(io::Error::new(
430                io::ErrorKind::NotConnected,
431                "lock file handle missing",
432            )),
433        }
434    }
435}
436
437impl Drop for LockFile {
438    fn drop(&mut self) {
439        if self.committed {
440            return;
441        }
442        drop(self.file.take());
443        let _ = fs::remove_file(&self.lock_path);
444        self.remove_holder_pid_sidecar();
445    }
446}
447
448/// Run `attempt` until it succeeds, fails with an error `is_held` does not accept, or the wait
449/// budget is spent, sleeping the jittered backoff between attempts.
450///
451/// The deadline starts before the first attempt, so time spent inside `attempt` (which may
452/// itself wait on a lock) counts against the budget. `wait` is resolved only after the first
453/// held failure, so callers that read it from config pay nothing when the lock is free.
454/// `key` names the lock for [`LockJitter::fraction`].
455///
456/// # Errors
457///
458/// Returns the last error from `attempt`: immediately when `is_held` rejects it, otherwise
459/// once the budget is exhausted.
460pub(crate) fn retry_while_held<T, E>(
461    key: &Path,
462    wait: impl FnOnce() -> LockWait,
463    clock: &dyn LockClock,
464    jitter: &dyn LockJitter,
465    mut attempt: impl FnMut() -> Result<T, E>,
466    is_held: impl Fn(&E) -> bool,
467) -> Result<T, E> {
468    let start = clock.now();
469    let mut err = match attempt() {
470        Err(err) if is_held(&err) => err,
471        other => return other,
472    };
473    let deadline = match wait() {
474        LockWait::Immediately => return Err(err),
475        LockWait::Backoff { budget } => Some(start + budget),
476        LockWait::Indefinitely => None,
477    };
478    let mut tries: u32 = 0;
479    loop {
480        if deadline.is_some_and(|deadline| clock.now() >= deadline) {
481            return Err(err);
482        }
483        let delay = backoff_delay(key, tries, jitter);
484        tries = tries.saturating_add(1);
485        clock.sleep(match deadline {
486            Some(deadline) => delay.min(deadline.saturating_duration_since(clock.now())),
487            None => delay,
488        });
489        match attempt() {
490            Err(held) if is_held(&held) => err = held,
491            other => return other,
492        }
493    }
494}
495
496pub(crate) fn backoff_delay(lock_path: &Path, attempt: u32, jitter: &dyn LockJitter) -> Duration {
497    const INITIAL_MS: u64 = 1;
498    const CAP_MS: u64 = 50;
499    let shift = attempt.min(16);
500    let base_ms = (INITIAL_MS.saturating_mul(1_u64 << shift)).min(CAP_MS);
501    let span_ms = base_ms / 2 + 1;
502    let jitter_ms = (jitter.fraction(lock_path, attempt) * span_ms as f64) as u64;
503    Duration::from_millis(base_ms.saturating_add(jitter_ms))
504}
505
506/// Process recorded as the owner of a lock file.
507///
508/// With `core.lockfilePid` enabled, the lock owner writes `pid <n>` to a sidecar named after
509/// the lock (`index.lock` → `index~pid.lock`). Git and grit share this layout.
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub struct LockHolder {
512    /// Process id read from the sidecar.
513    pub pid: u64,
514    /// Whether a process with [`Self::pid`] exists right now.
515    ///
516    /// `false` means the lock is stale. `true` is only a hint: the id may have been reused by
517    /// an unrelated process.
518    pub running: bool,
519}
520
521impl LockHolder {
522    /// Read the holder recorded for `lock_path` and probe whether it is still running.
523    ///
524    /// `lock_path` is the lock file itself (for example `.git/index.lock`).
525    ///
526    /// Returns `None` when no pid sidecar exists or it does not contain a process id.
527    #[must_use]
528    pub fn read(lock_path: &Path) -> Option<Self> {
529        let pid = read_lock_pid(&pid_path_for_lock(lock_path))?;
530        Some(Self {
531            pid,
532            running: is_process_running(pid),
533        })
534    }
535
536    /// Remove `lock_path` and its pid sidecar when [`Self::read`] reports a dead holder.
537    ///
538    /// Returns `true` when both files were removed (or were already absent after the lock).
539    /// Returns `false` when there is no sidecar, the holder is still running, or the sidecar
540    /// looks left over from an earlier holder (lock file newer than the sidecar).
541    ///
542    /// # Errors
543    ///
544    /// Returns [`io::Error`] when metadata or removal fails for reasons other than
545    /// [`io::ErrorKind::NotFound`].
546    pub fn remove_if_stale(lock_path: &Path) -> io::Result<bool> {
547        let Some(holder) = Self::read(lock_path) else {
548            return Ok(false);
549        };
550        if holder.running {
551            return Ok(false);
552        }
553        let pid_path = pid_path_for_lock(lock_path);
554        if lock_path.is_file() && pid_path.is_file() {
555            let lock_mtime = fs::metadata(lock_path)?.modified()?;
556            let pid_mtime = fs::metadata(&pid_path)?.modified()?;
557            if lock_mtime > pid_mtime {
558                return Ok(false);
559            }
560        }
561        for path in [lock_path, &pid_path] {
562            match fs::remove_file(path) {
563                Ok(()) => {}
564                Err(e) if e.kind() == io::ErrorKind::NotFound => {}
565                Err(e) => return Err(e),
566            }
567        }
568        Ok(true)
569    }
570}
571
572/// Whether grit writes pid sidecars for locks it creates.
573///
574/// Honors an explicit `core.lockfilePid=false`. When unset, grit defaults to `true` so crash
575/// recovery can detect stale index locks without requiring Git's opt-in.
576///
577/// When `config` is `None`, values are loaded from `git_dir` when provided.
578#[must_use]
579pub fn grit_lockfile_pid_enabled(config: Option<&ConfigSet>, git_dir: Option<&Path>) -> bool {
580    fn from_config(cfg: &ConfigSet) -> bool {
581        cfg.bool_or("core.lockfilepid", true)
582    }
583    if let Some(cfg) = config {
584        return from_config(cfg);
585    }
586    if let Some(dir) = git_dir {
587        if let Ok(cfg) = ConfigSet::load(&Environment::empty(), Some(dir), true) {
588            return from_config(&cfg);
589        }
590    }
591    true
592}
593
594/// Path of the pid sidecar for `lock_path` (`name.lock` → `name~pid.lock`).
595pub fn pid_path_for_lock(lock_path: &Path) -> PathBuf {
596    let file_name = lock_path
597        .file_name()
598        .map(|s| s.to_string_lossy().into_owned())
599        .unwrap_or_else(|| "index.lock".to_owned());
600    let base = file_name.strip_suffix(".lock").unwrap_or(&file_name);
601    lock_path.with_file_name(format!("{base}~pid.lock"))
602}
603
604pub(crate) fn write_lock_pid_file(pid_path: &Path) -> io::Result<()> {
605    let mut file = OpenOptions::new()
606        .write(true)
607        .create(true)
608        .truncate(true)
609        .open(pid_path)?;
610    writeln!(file, "pid {}", std::process::id())?;
611    Ok(())
612}
613
614fn read_lock_pid(pid_path: &Path) -> Option<u64> {
615    let raw = fs::read_to_string(pid_path).ok()?;
616    let trimmed = raw.trim();
617    trimmed
618        .strip_prefix("pid ")
619        .unwrap_or(trimmed)
620        .trim()
621        .parse::<u64>()
622        .ok()
623}
624
625/// Whether a process with `pid` exists on this machine.
626pub(crate) fn is_process_running(pid: u64) -> bool {
627    if pid == 0 || pid > i32::MAX as u64 {
628        return false;
629    }
630    #[cfg(windows)]
631    {
632        use windows_sys::Win32::Foundation::CloseHandle;
633        use windows_sys::Win32::System::Threading::{
634            OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
635        };
636        // SAFETY: `OpenProcess` is called with a numeric pid and a read-only access mask.
637        unsafe {
638            let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid as u32);
639            if handle.is_null() {
640                return false;
641            }
642            CloseHandle(handle);
643            true
644        }
645    }
646    #[cfg(all(unix, target_os = "linux"))]
647    {
648        Path::new(&format!("/proc/{pid}")).exists()
649    }
650    #[cfg(all(unix, not(target_os = "linux")))]
651    {
652        use rustix::io::Errno;
653        use rustix::process::{test_kill_process, Pid};
654        let Some(pid) = Pid::from_raw(pid as i32) else {
655            return false;
656        };
657        match test_kill_process(pid) {
658            Ok(()) => true,
659            Err(Errno::SRCH) => false,
660            Err(Errno::PERM) => true,
661            Err(_) => false,
662        }
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use std::sync::Mutex;
670
671    struct FakeLockClock {
672        now: Mutex<Instant>,
673        sleeps: Mutex<Vec<Duration>>,
674    }
675
676    impl FakeLockClock {
677        fn new(start: Instant) -> Arc<Self> {
678            Arc::new(Self {
679                now: Mutex::new(start),
680                sleeps: Mutex::new(Vec::new()),
681            })
682        }
683
684        fn sleeps(&self) -> Vec<Duration> {
685            self.sleeps.lock().unwrap().clone()
686        }
687    }
688
689    impl LockClock for FakeLockClock {
690        fn now(&self) -> Instant {
691            *self.now.lock().unwrap()
692        }
693
694        fn sleep(&self, duration: Duration) {
695            self.sleeps.lock().unwrap().push(duration);
696            *self.now.lock().unwrap() += duration;
697        }
698    }
699
700    #[test]
701    fn drop_without_commit_removes_own_lock() {
702        let dir = tempfile::tempdir().unwrap();
703        let lock = dir.path().join("resource.lock");
704        {
705            let _lock = LockFile::acquire(&lock, LockWait::Immediately).unwrap();
706            assert!(lock.is_file());
707        }
708        assert!(!lock.exists());
709    }
710
711    #[test]
712    fn commit_to_replaces_target() {
713        let dir = tempfile::tempdir().unwrap();
714        let target = dir.path().join("resource");
715        let lock_path = dir.path().join("resource.lock");
716        let mut lock = LockFile::acquire(&lock_path, LockWait::Immediately).unwrap();
717        lock.write_all(b"payload\n").unwrap();
718        lock.commit_to(&target).unwrap();
719        assert!(!lock_path.exists());
720        assert_eq!(fs::read_to_string(&target).unwrap(), "payload\n");
721    }
722
723    struct FixedJitter(f64);
724
725    impl LockJitter for FixedJitter {
726        fn fraction(&self, _lock_path: &Path, _attempt: u32) -> f64 {
727            self.0
728        }
729    }
730
731    #[test]
732    fn held_when_budget_exhausted() {
733        let dir = tempfile::tempdir().unwrap();
734        let lock_path = dir.path().join("x.lock");
735        fs::write(&lock_path, b"foreign").unwrap();
736        let start = Instant::now();
737        let clock = FakeLockClock::new(start);
738        let err = match LockFile::acquire_with_clock(
739            &lock_path,
740            LockWait::Backoff {
741                budget: Duration::from_millis(10),
742            },
743            clock.clone(),
744            Arc::new(FixedJitter(0.5)),
745        ) {
746            Err(e) => e,
747            Ok(_) => panic!("expected lock held error"),
748        };
749        assert!(matches!(err, LockError::Held { .. }));
750        assert!(lock_path.is_file());
751        assert_eq!(fs::read_to_string(&lock_path).unwrap(), "foreign");
752        assert!(!clock.sleeps().is_empty());
753    }
754
755    #[test]
756    fn pid_sidecar_path_replaces_lock_suffix() {
757        assert_eq!(
758            pid_path_for_lock(Path::new("/r/.git/index.lock")),
759            Path::new("/r/.git/index~pid.lock")
760        );
761        assert_eq!(
762            pid_path_for_lock(Path::new("/r/.git/refs/heads/main.lock")),
763            Path::new("/r/.git/refs/heads/main~pid.lock")
764        );
765        assert_eq!(
766            pid_path_for_lock(Path::new("/r/odd")),
767            Path::new("/r/odd~pid.lock")
768        );
769    }
770
771    #[test]
772    fn remove_if_stale_skips_when_lock_is_newer_than_sidecar() {
773        use std::thread;
774        use std::time::Duration;
775
776        let dir = tempfile::tempdir().unwrap();
777        let lock_path = dir.path().join("index.lock");
778        fs::write(&lock_path, b"").unwrap();
779        let pid_path = pid_path_for_lock(&lock_path);
780        fs::write(&pid_path, "pid 999999999\n").unwrap();
781        thread::sleep(Duration::from_millis(20));
782        fs::write(&lock_path, b"replaced").unwrap();
783
784        assert!(!LockHolder::remove_if_stale(&lock_path).unwrap());
785        assert!(lock_path.is_file());
786        assert!(pid_path.is_file());
787    }
788
789    #[test]
790    fn held_converts_to_typed_error_with_holder() {
791        let dir = tempfile::tempdir().unwrap();
792        let lock_path = dir.path().join("index.lock");
793        fs::write(&lock_path, b"").unwrap();
794        fs::write(
795            dir.path().join("index~pid.lock"),
796            format!("pid {}\n", std::process::id()),
797        )
798        .unwrap();
799        let err = LockFile::acquire(&lock_path, LockWait::Immediately)
800            .err()
801            .expect("held");
802        match crate::error::Error::from(err) {
803            crate::error::Error::LockHeld { path, holder } => {
804                assert_eq!(path, lock_path);
805                assert_eq!(
806                    holder,
807                    Some(LockHolder {
808                        pid: u64::from(std::process::id()),
809                        running: true,
810                    })
811                );
812            }
813            other => panic!("expected LockHeld, got {other:?}"),
814        }
815    }
816
817    #[test]
818    fn io_failure_converts_to_io_error() {
819        let err = LockError::Io {
820            path: PathBuf::from("x.lock"),
821            source: io::Error::from(io::ErrorKind::PermissionDenied),
822        };
823        assert!(matches!(
824            crate::error::Error::from(err),
825            crate::error::Error::Io(e) if e.kind() == io::ErrorKind::PermissionDenied
826        ));
827    }
828
829    #[test]
830    fn rollback_removes_own_lock() {
831        let dir = tempfile::tempdir().unwrap();
832        let lock = dir.path().join("resource.lock");
833        let lock_file = LockFile::acquire(&lock, LockWait::Immediately).unwrap();
834        assert!(lock.is_file());
835        lock_file.rollback();
836        assert!(!lock.exists());
837    }
838
839    #[test]
840    fn files_ref_lock_timeout_uses_milliseconds() {
841        let mut cfg = ConfigSet::new();
842        cfg.add_command_override("core.filesreflocktimeout", "100")
843            .expect("set timeout");
844        assert_eq!(LockWait::parse_files_ref_lock_timeout_ms(Some(&cfg)), 100);
845        match LockWait::files_ref_lock(Some(&cfg)) {
846            LockWait::Backoff { budget } => {
847                assert_eq!(budget, Duration::from_millis(100));
848            }
849            other => panic!("expected backoff, got {other:?}"),
850        }
851    }
852
853    #[test]
854    fn files_ref_lock_timeout_negative_retries_indefinitely() {
855        let mut cfg = ConfigSet::new();
856        cfg.add_command_override("core.filesreflocktimeout", "-1")
857            .expect("set timeout");
858        assert!(matches!(
859            LockWait::files_ref_lock(Some(&cfg)),
860            LockWait::Indefinitely
861        ));
862    }
863
864    #[test]
865    fn indefinite_wait_succeeds_when_lock_cleared() {
866        let dir = tempfile::tempdir().unwrap();
867        let lock_path = dir.path().join("x.lock");
868        fs::write(&lock_path, b"foreign").unwrap();
869        let clock = FakeLockClock::new(Instant::now());
870        let lock_path_thread = lock_path.clone();
871        std::thread::spawn(move || {
872            std::thread::sleep(Duration::from_millis(5));
873            let _ = fs::remove_file(&lock_path_thread);
874        });
875        let _guard = LockFile::acquire_with_clock(
876            &lock_path,
877            LockWait::Indefinitely,
878            clock,
879            Arc::new(FixedJitter(0.0)),
880        )
881        .expect("indefinite wait should eventually acquire");
882    }
883
884    #[test]
885    fn retry_while_held_resolves_wait_lazily_and_counts_attempt_time() {
886        let key = Path::new("refs");
887        let jitter = FixedJitter(0.0);
888        let clock = FakeLockClock::new(Instant::now());
889
890        let ok: Result<u32, u32> = retry_while_held(
891            key,
892            || panic!("wait resolved without contention"),
893            clock.as_ref(),
894            &jitter,
895            || Ok(7),
896            |_| true,
897        );
898        assert_eq!(ok, Ok(7));
899
900        let mut calls = 0;
901        let other = retry_while_held(
902            key,
903            || LockWait::Indefinitely,
904            clock.as_ref(),
905            &jitter,
906            || {
907                calls += 1;
908                Err::<(), _>("io")
909            },
910            |e| *e == "held",
911        );
912        assert_eq!((other, calls), (Err("io"), 1));
913        assert!(clock.sleeps().is_empty());
914
915        // The first attempt itself takes the whole budget, so nothing is retried.
916        let mut calls = 0;
917        let slow = retry_while_held(
918            key,
919            || LockWait::Backoff {
920                budget: Duration::from_millis(10),
921            },
922            clock.as_ref(),
923            &jitter,
924            || {
925                calls += 1;
926                clock.sleep(Duration::from_millis(10));
927                Err::<(), _>(calls)
928            },
929            |_| true,
930        );
931        assert_eq!(slow, Err(1));
932
933        let mut calls = 0;
934        let later = retry_while_held(
935            key,
936            || LockWait::Backoff {
937                budget: Duration::from_secs(1),
938            },
939            clock.as_ref(),
940            &jitter,
941            || {
942                calls += 1;
943                if calls < 4 {
944                    Err(calls)
945                } else {
946                    Ok(calls)
947                }
948            },
949            |_| true,
950        );
951        assert_eq!(later, Ok(4));
952    }
953}