Skip to main content

gix_lock/
acquire.rs

1use std::{
2    fmt,
3    path::{Path, PathBuf},
4    time::Duration,
5};
6
7use gix_tempfile::{AutoRemove, ContainingDirectory};
8
9use crate::{DOT_LOCK_SUFFIX, File, Marker, backoff};
10
11/// Describe what to do if a lock cannot be obtained as it's already held elsewhere.
12#[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
13pub enum Fail {
14    /// Fail after the first unsuccessful attempt of obtaining a lock.
15    #[default]
16    Immediately,
17    /// Retry after failure with quadratically 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 fmt::Display for Fail {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Fail::Immediately => f.write_str("immediately"),
26            Fail::AfterDurationWithBackoff(duration) => {
27                write!(f, "after {:.02}s", duration.as_secs_f32())
28            }
29        }
30    }
31}
32
33impl From<Duration> for Fail {
34    fn from(value: Duration) -> Self {
35        if value.is_zero() {
36            Fail::Immediately
37        } else {
38            Fail::AfterDurationWithBackoff(value)
39        }
40    }
41}
42
43/// The error returned when acquiring a [`File`] or [`Marker`].
44#[derive(Debug, thiserror::Error)]
45#[allow(missing_docs)]
46pub enum Error {
47    #[error("Another IO error occurred while obtaining the lock")]
48    Io(#[from] std::io::Error),
49    #[error(
50        "The lock for resource '{resource_path}' could not be obtained {mode} after {attempts} attempt(s). The lockfile at '{resource_path}{}' might need manual deletion.",
51        super::DOT_LOCK_SUFFIX
52    )]
53    PermanentlyLocked {
54        resource_path: PathBuf,
55        mode: Fail,
56        attempts: usize,
57    },
58}
59
60impl File {
61    /// Create a writable lock file with failure `mode` whose content will eventually overwrite the given resource `at_path`.
62    ///
63    /// If `boundary_directory` is given, non-existing directories will be created automatically and removed in the case of
64    /// a rollback. Otherwise the containing directory is expected to exist, even though the resource doesn't have to.
65    ///
66    /// Note that permissions will be set to `0o666`, which usually results in `0o644` after passing a default umask, on Unix systems.
67    ///
68    /// ### Warning of potential resource leak
69    ///
70    /// Please note that the underlying file will remain if destructors don't run, as is the case when interrupting the application.
71    /// This results in the resource being locked permanently unless the lock file is removed by other means.
72    /// See [the crate documentation](crate) for more information.
73    pub fn acquire_to_update_resource(
74        at_path: impl AsRef<Path>,
75        mode: Fail,
76        boundary_directory: Option<PathBuf>,
77    ) -> Result<File, Error> {
78        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, &|p, d, c| {
79            if let Some(permissions) = default_permissions() {
80                gix_tempfile::writable_at_with_permissions(p, d, c, permissions)
81            } else {
82                gix_tempfile::writable_at(p, d, c)
83            }
84        })?;
85        Ok(File {
86            inner: handle,
87            lock_path,
88        })
89    }
90
91    /// Like [`acquire_to_update_resource()`](File::acquire_to_update_resource), but allows to set filesystem permissions using `make_permissions`.
92    pub fn acquire_to_update_resource_with_permissions(
93        at_path: impl AsRef<Path>,
94        mode: Fail,
95        boundary_directory: Option<PathBuf>,
96        make_permissions: impl Fn() -> std::fs::Permissions,
97    ) -> Result<File, Error> {
98        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, &|p, d, c| {
99            gix_tempfile::writable_at_with_permissions(p, d, c, make_permissions())
100        })?;
101        Ok(File {
102            inner: handle,
103            lock_path,
104        })
105    }
106}
107
108impl Marker {
109    /// Like [`acquire_to_update_resource()`](File::acquire_to_update_resource()) but _without_ the possibility to make changes
110    /// and commit them.
111    ///
112    /// If `boundary_directory` is given, non-existing directories will be created automatically and removed in the case of
113    /// a rollback.
114    ///
115    /// Note that permissions will be set to `0o666`, which usually results in `0o644` after passing a default umask, on Unix systems.
116    ///
117    /// ### Warning of potential resource leak
118    ///
119    /// Please note that the underlying file will remain if destructors don't run, as is the case when interrupting the application.
120    /// This results in the resource being locked permanently unless the lock file is removed by other means.
121    /// See [the crate documentation](crate) for more information.
122    pub fn acquire_to_hold_resource(
123        at_path: impl AsRef<Path>,
124        mode: Fail,
125        boundary_directory: Option<PathBuf>,
126    ) -> Result<Marker, Error> {
127        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, &|p, d, c| {
128            if let Some(permissions) = default_permissions() {
129                gix_tempfile::mark_at_with_permissions(p, d, c, permissions)
130            } else {
131                gix_tempfile::mark_at(p, d, c)
132            }
133        })?;
134        Ok(Marker {
135            created_from_file: false,
136            inner: handle,
137            lock_path,
138        })
139    }
140
141    /// Like [`acquire_to_hold_resource()`](Marker::acquire_to_hold_resource), but allows to set filesystem permissions using `make_permissions`.
142    pub fn acquire_to_hold_resource_with_permissions(
143        at_path: impl AsRef<Path>,
144        mode: Fail,
145        boundary_directory: Option<PathBuf>,
146        make_permissions: impl Fn() -> std::fs::Permissions,
147    ) -> Result<Marker, Error> {
148        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, &|p, d, c| {
149            gix_tempfile::mark_at_with_permissions(p, d, c, make_permissions())
150        })?;
151        Ok(Marker {
152            created_from_file: false,
153            inner: handle,
154            lock_path,
155        })
156    }
157}
158
159fn dir_cleanup(boundary: Option<PathBuf>) -> (ContainingDirectory, AutoRemove) {
160    match boundary {
161        None => (ContainingDirectory::Exists, AutoRemove::Tempfile),
162        Some(boundary_directory) => (
163            ContainingDirectory::CreateAllRaceProof(Default::default()),
164            AutoRemove::TempfileAndEmptyParentDirectoriesUntil { boundary_directory },
165        ),
166    }
167}
168
169fn lock_with_mode<T>(
170    resource: &Path,
171    mode: Fail,
172    boundary_directory: Option<PathBuf>,
173    try_lock: &dyn Fn(&Path, ContainingDirectory, AutoRemove) -> std::io::Result<T>,
174) -> Result<(PathBuf, T), Error> {
175    use std::io::ErrorKind::*;
176    let (directory, cleanup) = dir_cleanup(boundary_directory);
177    let lock_path = add_lock_suffix(resource);
178    let mut attempts = 1;
179    match mode {
180        Fail::Immediately => try_lock(&lock_path, directory, cleanup),
181        Fail::AfterDurationWithBackoff(time) => {
182            for wait in backoff::Quadratic::default_with_random().until_no_remaining(time) {
183                attempts += 1;
184                match try_lock(&lock_path, directory, cleanup.clone()) {
185                    Ok(v) => return Ok((lock_path, v)),
186                    #[cfg(windows)]
187                    Err(err) if err.kind() == AlreadyExists || err.kind() == PermissionDenied => {
188                        std::thread::sleep(wait);
189                        continue;
190                    }
191                    #[cfg(not(windows))]
192                    Err(err) if err.kind() == AlreadyExists => {
193                        std::thread::sleep(wait);
194                        continue;
195                    }
196                    Err(err) => return Err(Error::from(err)),
197                }
198            }
199            try_lock(&lock_path, directory, cleanup)
200        }
201    }
202    .map(|v| (lock_path, v))
203    .map_err(|err| match err.kind() {
204        AlreadyExists => Error::PermanentlyLocked {
205            resource_path: resource.into(),
206            mode,
207            attempts,
208        },
209        _ => Error::Io(err),
210    })
211}
212
213fn add_lock_suffix(resource_path: &Path) -> PathBuf {
214    resource_path.with_extension(resource_path.extension().map_or_else(
215        || DOT_LOCK_SUFFIX.chars().skip(1).collect(),
216        |ext| format!("{}{}", ext.to_string_lossy(), DOT_LOCK_SUFFIX),
217    ))
218}
219
220fn default_permissions() -> Option<std::fs::Permissions> {
221    #[cfg(unix)]
222    {
223        use std::os::unix::fs::PermissionsExt;
224        Some(std::fs::Permissions::from_mode(0o666))
225    }
226    #[cfg(not(unix))]
227    {
228        None
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn add_lock_suffix_to_file_with_extension() {
238        assert_eq!(add_lock_suffix(Path::new("hello.ext")), Path::new("hello.ext.lock"));
239    }
240
241    #[test]
242    fn add_lock_suffix_to_file_without_extension() {
243        assert_eq!(add_lock_suffix(Path::new("hello")), Path::new("hello.lock"));
244    }
245}