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
109/// Failure to create or commit a lock file.
110#[derive(Debug, Error)]
111#[non_exhaustive]
112pub enum LockError {
113    /// Another process holds the lock (or the lock path already exists).
114    #[error("lock held at {path}")]
115    Held {
116        /// Path to the lock file that could not be created exclusively.
117        path: PathBuf,
118    },
119
120    /// Underlying I/O failure while creating, writing, syncing, or renaming.
121    #[error("lock I/O at {path}")]
122    Io {
123        /// Lock file path associated with the failure.
124        path: PathBuf,
125        /// Underlying OS error.
126        #[source]
127        source: io::Error,
128    },
129}
130
131/// Injectable clock and sleep for deterministic lock backoff tests.
132pub trait LockClock: Send + Sync {
133    /// Current instant (used for backoff deadlines).
134    fn now(&self) -> Instant;
135    /// Pause until the next acquisition attempt.
136    fn sleep(&self, duration: Duration);
137}
138
139/// Injectable jitter so concurrent processes do not sleep in lockstep.
140pub trait LockJitter: Send + Sync {
141    /// Fraction in `[0.0, 1.0)` scaled into backoff delays.
142    fn fraction(&self, lock_path: &Path, attempt: u32) -> f64;
143}
144
145/// OS random bytes for cross-process jitter (Git-style lock backoff).
146#[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/// Wall-clock backoff using [`std::thread::sleep`].
162#[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
177/// Exclusive lock file created by this process.
178///
179/// Drop without [`Self::commit`] or [`Self::commit_to`] removes the lock file when this
180/// handle created it. Foreign lock files are never removed.
181pub struct LockFile {
182    lock_path: PathBuf,
183    commit_path: Option<PathBuf>,
184    file: Option<File>,
185    committed: bool,
186}
187
188impl LockFile {
189    /// Create `target` exclusively (`O_CREAT|O_EXCL`), optionally waiting per `wait`.
190    ///
191    /// `target` is the lock file path (for example `refs/heads/main.lock`), not the file being
192    /// updated. Persist staged bytes with [`Self::commit_to`] (or [`Self::commit`] after
193    /// [`Self::with_commit_target`]).
194    ///
195    /// # Errors
196    ///
197    /// Returns [`LockError::Held`] when the lock exists and the wait budget is exhausted, or
198    /// [`LockError::Io`] for other failures.
199    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    /// Like [`Self::acquire`], with injectable clock and jitter for unit tests.
209    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    /// Set the destination path used by [`Self::commit`].
267    #[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    /// Path to the lock file held by this handle.
274    #[must_use]
275    pub fn lock_path(&self) -> &Path {
276        &self.lock_path
277    }
278
279    /// Borrow the open lock file for writing.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`LockError::Io`] when the handle was already taken (for example after
284    /// the open file handle was moved out for index integration).
285    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    /// Borrow the open lock file mutably for writing.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`LockError::Io`] when the handle was already taken.
297    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    /// Yield the lock path and open file without removing the lock on drop.
305    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    /// Fsync the lock file, rename it over `commit_path` from [`Self::with_commit_target`], and
315    /// disarm drop cleanup.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`LockError::Io`] when no commit target was configured or rename fails.
320    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    /// Fsync the lock file and atomically rename it over `other_path`.
332    ///
333    /// # Errors
334    ///
335    /// Returns [`LockError::Io`] on sync or rename failure.
336    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    /// Release the lock without committing (same as drop).
355    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    /// Close this handle but leave the lock file on disk (mutex-only locks).
365    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/// Process recorded as the owner of a lock file.
413///
414/// With `core.lockfilePid` enabled, the lock owner writes `pid <n>` to a sidecar named after
415/// the lock (`index.lock` → `index~pid.lock`). Git and grit share this layout.
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub struct LockHolder {
418    /// Process id read from the sidecar.
419    pub pid: u64,
420    /// Whether a process with [`Self::pid`] exists right now.
421    ///
422    /// `false` means the lock is stale. `true` is only a hint: the id may have been reused by
423    /// an unrelated process.
424    pub running: bool,
425}
426
427impl LockHolder {
428    /// Read the holder recorded for `lock_path` and probe whether it is still running.
429    ///
430    /// `lock_path` is the lock file itself (for example `.git/index.lock`).
431    ///
432    /// Returns `None` when no pid sidecar exists or it does not contain a process id.
433    #[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
443/// Path of the pid sidecar for `lock_path` (`name.lock` → `name~pid.lock`).
444pub(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
464/// Whether a process with `pid` exists on this machine.
465pub(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        // SAFETY: `OpenProcess` is called with a numeric pid and a read-only access mask.
476        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}