git_lock/
acquire.rs

1use std::{
2    fmt,
3    path::{Path, PathBuf},
4    time::Duration,
5};
6
7use git_tempfile::{AutoRemove, ContainingDirectory};
8use quick_error::quick_error;
9
10use crate::{backoff, File, Marker, DOT_LOCK_SUFFIX};
11
12/// Describe what to do if a lock cannot be obtained as it's already held elsewhere.
13#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
14pub enum Fail {
15    /// Fail after the first unsuccessful attempt of obtaining a lock.
16    Immediately,
17    /// Retry after failure with exponentially longer sleep times to block the current thread.
18    /// Fail once the given duration is exceeded, similar to [Fail::Immediately]
19    AfterDurationWithBackoff(Duration),
20}
21
22impl Default for Fail {
23    fn default() -> Self {
24        Fail::Immediately
25    }
26}
27
28impl fmt::Display for Fail {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Fail::Immediately => f.write_str("immediately"),
32            Fail::AfterDurationWithBackoff(duration) => {
33                write!(f, "after {:.02}s", duration.as_secs_f32())
34            }
35        }
36    }
37}
38
39quick_error! {
40    /// The error returned when acquiring a [`File`] or [`Marker`].
41    #[derive(Debug)]
42    #[allow(missing_docs)]
43    pub enum Error {
44        Io(err: std::io::Error) {
45            display("Another IO error occurred while obtaining the lock")
46            from()
47            source(err)
48        }
49        PermanentlyLocked { resource_path: PathBuf, mode: Fail, attempts: usize } {
50            display("The lock for resource '{} could not be obtained {} after {} attempt(s). The lockfile at '{}{}' might need manual deletion.", resource_path.display(), mode, attempts, resource_path.display(), super::DOT_LOCK_SUFFIX)
51        }
52    }
53}
54
55impl File {
56    /// Create a writable lock file with failure `mode` whose content will eventually overwrite the given resource `at_path`.
57    ///
58    /// If `boundary_directory` is given, non-existing directories will be created automatically and removed in the case of
59    /// a rollback. Otherwise the containing directory is expected to exist, even though the resource doesn't have to.
60    pub fn acquire_to_update_resource(
61        at_path: impl AsRef<Path>,
62        mode: Fail,
63        boundary_directory: Option<PathBuf>,
64    ) -> Result<File, Error> {
65        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, |p, d, c| {
66            git_tempfile::writable_at(p, d, c)
67        })?;
68        Ok(File {
69            inner: handle,
70            lock_path,
71        })
72    }
73}
74
75impl Marker {
76    /// Like [`acquire_to_update_resource()`][File::acquire_to_update_resource()] but _without_ the possibility to make changes
77    /// and commit them.
78    ///
79    /// If `boundary_directory` is given, non-existing directories will be created automatically and removed in the case of
80    /// a rollback.
81    pub fn acquire_to_hold_resource(
82        at_path: impl AsRef<Path>,
83        mode: Fail,
84        boundary_directory: Option<PathBuf>,
85    ) -> Result<Marker, Error> {
86        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, |p, d, c| {
87            git_tempfile::mark_at(p, d, c)
88        })?;
89        Ok(Marker {
90            created_from_file: false,
91            inner: handle,
92            lock_path,
93        })
94    }
95}
96
97fn dir_cleanup(boundary: Option<PathBuf>) -> (ContainingDirectory, AutoRemove) {
98    match boundary {
99        None => (ContainingDirectory::Exists, AutoRemove::Tempfile),
100        Some(boundary_directory) => (
101            ContainingDirectory::CreateAllRaceProof(Default::default()),
102            AutoRemove::TempfileAndEmptyParentDirectoriesUntil { boundary_directory },
103        ),
104    }
105}
106
107fn lock_with_mode<T>(
108    resource: &Path,
109    mode: Fail,
110    boundary_directory: Option<PathBuf>,
111    try_lock: impl Fn(&Path, ContainingDirectory, AutoRemove) -> std::io::Result<T>,
112) -> Result<(PathBuf, T), Error> {
113    use std::io::ErrorKind::*;
114    let (directory, cleanup) = dir_cleanup(boundary_directory);
115    let lock_path = add_lock_suffix(resource);
116    let mut attempts = 1;
117    match mode {
118        Fail::Immediately => try_lock(&lock_path, directory, cleanup),
119        Fail::AfterDurationWithBackoff(time) => {
120            for wait in backoff::Exponential::default_with_random().until_no_remaining(time) {
121                attempts += 1;
122                match try_lock(&lock_path, directory, cleanup.clone()) {
123                    Ok(v) => return Ok((lock_path, v)),
124                    #[cfg(windows)]
125                    Err(err) if err.kind() == AlreadyExists || err.kind() == PermissionDenied => {
126                        std::thread::sleep(wait);
127                        continue;
128                    }
129                    #[cfg(not(windows))]
130                    Err(err) if err.kind() == AlreadyExists => {
131                        std::thread::sleep(wait);
132                        continue;
133                    }
134                    Err(err) => return Err(Error::from(err)),
135                }
136            }
137            try_lock(&lock_path, directory, cleanup)
138        }
139    }
140    .map(|v| (lock_path, v))
141    .map_err(|err| match err.kind() {
142        AlreadyExists => Error::PermanentlyLocked {
143            resource_path: resource.into(),
144            mode,
145            attempts,
146        },
147        _ => Error::Io(err),
148    })
149}
150
151fn add_lock_suffix(resource_path: &Path) -> PathBuf {
152    resource_path.with_extension(resource_path.extension().map_or_else(
153        || DOT_LOCK_SUFFIX.chars().skip(1).collect(),
154        |ext| format!("{}{}", ext.to_string_lossy(), DOT_LOCK_SUFFIX),
155    ))
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn add_lock_suffix_to_file_with_extension() {
164        assert_eq!(add_lock_suffix(Path::new("hello.ext")), Path::new("hello.ext.lock"));
165    }
166
167    #[test]
168    fn add_lock_suffix_to_file_without_extension() {
169        assert_eq!(add_lock_suffix(Path::new("hello")), Path::new("hello.lock"));
170    }
171}