gix-lock 22.0.0

A git-style lock-file implementation
Documentation
use std::{
    fmt,
    path::{Path, PathBuf},
    time::Duration,
};

use gix_tempfile::{AutoRemove, ContainingDirectory};

use crate::{backoff, File, Marker, DOT_LOCK_SUFFIX};

/// Describe what to do if a lock cannot be obtained as it's already held elsewhere.
#[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub enum Fail {
    /// Fail after the first unsuccessful attempt of obtaining a lock.
    #[default]
    Immediately,
    /// Retry after failure with quadratically longer sleep times to block the current thread.
    /// Fail once the given duration is exceeded, similar to [Fail::Immediately]
    AfterDurationWithBackoff(Duration),
}

impl fmt::Display for Fail {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Fail::Immediately => f.write_str("immediately"),
            Fail::AfterDurationWithBackoff(duration) => {
                write!(f, "after {:.02}s", duration.as_secs_f32())
            }
        }
    }
}

impl From<Duration> for Fail {
    fn from(value: Duration) -> Self {
        if value.is_zero() {
            Fail::Immediately
        } else {
            Fail::AfterDurationWithBackoff(value)
        }
    }
}

/// The error returned when acquiring a [`File`] or [`Marker`].
#[derive(Debug, thiserror::Error)]
#[allow(missing_docs)]
pub enum Error {
    #[error("Another IO error occurred while obtaining the lock")]
    Io(#[from] std::io::Error),
    #[error("The lock for resource '{resource_path}' could not be obtained {mode} after {attempts} attempt(s). The lockfile at '{resource_path}{}' might need manual deletion.", super::DOT_LOCK_SUFFIX)]
    PermanentlyLocked {
        resource_path: PathBuf,
        mode: Fail,
        attempts: usize,
    },
}

impl File {
    /// Create a writable lock file with failure `mode` whose content will eventually overwrite the given resource `at_path`.
    ///
    /// If `boundary_directory` is given, non-existing directories will be created automatically and removed in the case of
    /// a rollback. Otherwise the containing directory is expected to exist, even though the resource doesn't have to.
    ///
    /// Note that permissions will be set to `0o666`, which usually results in `0o644` after passing a default umask, on Unix systems.
    ///
    /// ### Warning of potential resource leak
    ///
    /// Please note that the underlying file will remain if destructors don't run, as is the case when interrupting the application.
    /// This results in the resource being locked permanently unless the lock file is removed by other means.
    /// See [the crate documentation](crate) for more information.
    pub fn acquire_to_update_resource(
        at_path: impl AsRef<Path>,
        mode: Fail,
        boundary_directory: Option<PathBuf>,
    ) -> Result<File, Error> {
        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, &|p, d, c| {
            if let Some(permissions) = default_permissions() {
                gix_tempfile::writable_at_with_permissions(p, d, c, permissions)
            } else {
                gix_tempfile::writable_at(p, d, c)
            }
        })?;
        Ok(File {
            inner: handle,
            lock_path,
        })
    }

    /// Like [`acquire_to_update_resource()`](File::acquire_to_update_resource), but allows to set filesystem permissions using `make_permissions`.
    pub fn acquire_to_update_resource_with_permissions(
        at_path: impl AsRef<Path>,
        mode: Fail,
        boundary_directory: Option<PathBuf>,
        make_permissions: impl Fn() -> std::fs::Permissions,
    ) -> Result<File, Error> {
        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, &|p, d, c| {
            gix_tempfile::writable_at_with_permissions(p, d, c, make_permissions())
        })?;
        Ok(File {
            inner: handle,
            lock_path,
        })
    }
}

impl Marker {
    /// Like [`acquire_to_update_resource()`](File::acquire_to_update_resource()) but _without_ the possibility to make changes
    /// and commit them.
    ///
    /// If `boundary_directory` is given, non-existing directories will be created automatically and removed in the case of
    /// a rollback.
    ///
    /// Note that permissions will be set to `0o666`, which usually results in `0o644` after passing a default umask, on Unix systems.
    ///
    /// ### Warning of potential resource leak
    ///
    /// Please note that the underlying file will remain if destructors don't run, as is the case when interrupting the application.
    /// This results in the resource being locked permanently unless the lock file is removed by other means.
    /// See [the crate documentation](crate) for more information.
    pub fn acquire_to_hold_resource(
        at_path: impl AsRef<Path>,
        mode: Fail,
        boundary_directory: Option<PathBuf>,
    ) -> Result<Marker, Error> {
        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, &|p, d, c| {
            if let Some(permissions) = default_permissions() {
                gix_tempfile::mark_at_with_permissions(p, d, c, permissions)
            } else {
                gix_tempfile::mark_at(p, d, c)
            }
        })?;
        Ok(Marker {
            created_from_file: false,
            inner: handle,
            lock_path,
        })
    }

    /// Like [`acquire_to_hold_resource()`](Marker::acquire_to_hold_resource), but allows to set filesystem permissions using `make_permissions`.
    pub fn acquire_to_hold_resource_with_permissions(
        at_path: impl AsRef<Path>,
        mode: Fail,
        boundary_directory: Option<PathBuf>,
        make_permissions: impl Fn() -> std::fs::Permissions,
    ) -> Result<Marker, Error> {
        let (lock_path, handle) = lock_with_mode(at_path.as_ref(), mode, boundary_directory, &|p, d, c| {
            gix_tempfile::mark_at_with_permissions(p, d, c, make_permissions())
        })?;
        Ok(Marker {
            created_from_file: false,
            inner: handle,
            lock_path,
        })
    }
}

fn dir_cleanup(boundary: Option<PathBuf>) -> (ContainingDirectory, AutoRemove) {
    match boundary {
        None => (ContainingDirectory::Exists, AutoRemove::Tempfile),
        Some(boundary_directory) => (
            ContainingDirectory::CreateAllRaceProof(Default::default()),
            AutoRemove::TempfileAndEmptyParentDirectoriesUntil { boundary_directory },
        ),
    }
}

fn lock_with_mode<T>(
    resource: &Path,
    mode: Fail,
    boundary_directory: Option<PathBuf>,
    try_lock: &dyn Fn(&Path, ContainingDirectory, AutoRemove) -> std::io::Result<T>,
) -> Result<(PathBuf, T), Error> {
    use std::io::ErrorKind::*;
    let (directory, cleanup) = dir_cleanup(boundary_directory);
    let lock_path = add_lock_suffix(resource);
    let mut attempts = 1;
    match mode {
        Fail::Immediately => try_lock(&lock_path, directory, cleanup),
        Fail::AfterDurationWithBackoff(time) => {
            for wait in backoff::Quadratic::default_with_random().until_no_remaining(time) {
                attempts += 1;
                match try_lock(&lock_path, directory, cleanup.clone()) {
                    Ok(v) => return Ok((lock_path, v)),
                    #[cfg(windows)]
                    Err(err) if err.kind() == AlreadyExists || err.kind() == PermissionDenied => {
                        std::thread::sleep(wait);
                        continue;
                    }
                    #[cfg(not(windows))]
                    Err(err) if err.kind() == AlreadyExists => {
                        std::thread::sleep(wait);
                        continue;
                    }
                    Err(err) => return Err(Error::from(err)),
                }
            }
            try_lock(&lock_path, directory, cleanup)
        }
    }
    .map(|v| (lock_path, v))
    .map_err(|err| match err.kind() {
        AlreadyExists => Error::PermanentlyLocked {
            resource_path: resource.into(),
            mode,
            attempts,
        },
        _ => Error::Io(err),
    })
}

fn add_lock_suffix(resource_path: &Path) -> PathBuf {
    resource_path.with_extension(resource_path.extension().map_or_else(
        || DOT_LOCK_SUFFIX.chars().skip(1).collect(),
        |ext| format!("{}{}", ext.to_string_lossy(), DOT_LOCK_SUFFIX),
    ))
}

fn default_permissions() -> Option<std::fs::Permissions> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        Some(std::fs::Permissions::from_mode(0o666))
    }
    #[cfg(not(unix))]
    {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_lock_suffix_to_file_with_extension() {
        assert_eq!(add_lock_suffix(Path::new("hello.ext")), Path::new("hello.ext.lock"));
    }

    #[test]
    fn add_lock_suffix_to_file_without_extension() {
        assert_eq!(add_lock_suffix(Path::new("hello")), Path::new("hello.lock"));
    }
}