Skip to content

Commit

Permalink
new: Add file locking with guard. (#124)
Browse files Browse the repository at this point in the history
* Add lock file.

* Fix msrv.
  • Loading branch information
milesj authored Jan 11, 2025
1 parent b79bd0b commit f576874
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 47 deletions.
2 changes: 1 addition & 1 deletion crates/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition = "2021"
license = "MIT"
description = "Framework for building performant command line applications and developer tools."
repository = "https://github.com/moonrepo/starbase"
rust-version = "1.70.0"
rust-version = "1.80.0"

[package.metadata.docs.rs]
all-features = true
Expand Down
6 changes: 3 additions & 3 deletions crates/app/src/tracing/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ pub struct EventFormatter {

impl FormatTime for EventFormatter {
fn format_time(&self, writer: &mut fmt::format::Writer<'_>) -> std::fmt::Result {
if TEST_ENV.load(Ordering::Relaxed) {
return write!(writer, "YYYY-MM-DD");
}
// if TEST_ENV.load(Ordering::Relaxed) {
// return write!(writer, "YYYY-MM-DD");
// }

let mut date_format = "%Y-%m-%d %H:%M:%S%.3f";
let current_timestamp = Local::now();
Expand Down
2 changes: 1 addition & 1 deletion crates/shell/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition = "2021"
license = "MIT"
description = "Utilities for detecting shells and managing profile files."
repository = "https://github.com/moonrepo/starbase"
rust-version = "1.65.0"
rust-version = "1.74.0"

[package.metadata.docs.rs]
all-features = true
Expand Down
7 changes: 7 additions & 0 deletions crates/utils/src/fs_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ pub enum FsError {
#[error("A directory is required for path {}.", .path.style(Style::Path))]
RequireDir { path: PathBuf },

#[error("A file is required for path {}.", .path.style(Style::Path))]
RequireFile { path: PathBuf },

#[error("Failed to rename {} to {}.\n{error}", .from.style(Style::Path), .to.style(Style::Path))]
Rename {
from: PathBuf,
Expand Down Expand Up @@ -130,6 +133,10 @@ pub enum FsError {
#[error("A directory is required for path {}.", .path.style(Style::Path))]
RequireDir { path: PathBuf },

#[diagnostic(code(fs::require_file))]
#[error("A file is required for path {}.", .path.style(Style::Path))]
RequireFile { path: PathBuf },

#[diagnostic(code(fs::rename), help("Does the source file exist?"))]
#[error("Failed to rename {} to {}.", .from.style(Style::Path), .to.style(Style::Path))]
Rename {
Expand Down
107 changes: 65 additions & 42 deletions crates/utils/src/fs_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,57 @@ use tracing::{instrument, trace};

pub const LOCK_FILE: &str = ".lock";

pub struct DirLock {
pub struct FileLock {
lock: PathBuf,
file: File,
unlocked: bool,
}

impl DirLock {
impl FileLock {
pub fn new(path: PathBuf) -> Result<Self, FsError> {
use std::io::prelude::*;

let mut file = fs::create_file_if_missing(&path)?;

trace!(
lock = ?path,
"Waiting to acquire lock",
);

// This blocks if another process has access!
file.lock_exclusive().map_err(|error| FsError::Lock {
path: path.clone(),
error: Box::new(error),
})?;

let pid = std::process::id();

trace!(
lock = ?path,
pid,
"Acquired lock, writing PID",
);

// Let other processes know that we have locked it
file.write(format!("{}", pid).as_ref())
.map_err(|error| FsError::Write {
path: path.clone(),
error: Box::new(error),
})?;

Ok(Self {
lock: path,
file,
unlocked: false,
})
}

pub fn unlock(&mut self) -> Result<(), FsError> {
if self.unlocked {
return Ok(());
}

trace!(dir = ?self.lock.parent().unwrap(), "Unlocking directory");
trace!(path = ?self.lock.parent().unwrap(), "Unlocking path");

let handle_error = |error: std::io::Error| FsError::Unlock {
path: self.lock.to_path_buf(),
Expand Down Expand Up @@ -49,18 +87,16 @@ impl DirLock {
}
}

impl Drop for DirLock {
impl Drop for FileLock {
fn drop(&mut self) {
self.unlock().unwrap_or_else(|error| {
panic!(
"Failed to remove directory lock {}: {}",
self.lock.display(),
error
)
panic!("Failed to remove lock {}: {}", self.lock.display(), error)
});
}
}

pub type DirLock = FileLock;

/// Return true if the directory is currently locked (via [`lock_directory`]).
pub fn is_dir_locked<T: AsRef<Path>>(path: T) -> bool {
path.as_ref().join(LOCK_FILE).exists()
Expand Down Expand Up @@ -89,13 +125,11 @@ pub fn is_file_locked<T: AsRef<Path>>(path: T) -> bool {
/// to lock the directory and the `.lock` file currently exists, it will
/// block waiting for it to be unlocked.
///
/// This function returns a `DirLock` instance that will automatically unlock
/// This function returns a `DirLock` guard that will automatically unlock
/// when being dropped.
#[inline]
#[instrument]
pub fn lock_directory<T: AsRef<Path> + Debug>(path: T) -> Result<DirLock, FsError> {
use std::io::prelude::*;

let path = path.as_ref();

fs::create_dir_all(path)?;
Expand All @@ -116,40 +150,29 @@ pub fn lock_directory<T: AsRef<Path> + Debug>(path: T) -> Result<DirLock, FsErro
// for write access, and will be "unlocked" automatically by the kernel.
//
// Context: https://www.reddit.com/r/rust/comments/14hlx8u/comment/jpbmsh2/?utm_source=reddit&utm_medium=web2x&context=3
let lock = path.join(LOCK_FILE);
let mut file = fs::create_file_if_missing(&lock)?;

trace!(
lock = ?lock,
"Waiting to acquire lock on directory",
);

// This blocks if another process has access!
file.lock_exclusive().map_err(|error| FsError::Lock {
path: path.to_path_buf(),
error: Box::new(error),
})?;

let pid = std::process::id();
DirLock::new(path.join(LOCK_FILE))
}

trace!(
lock = ?lock,
pid,
"Acquired lock on directory, writing PID",
);
/// Lock the provided file with exclusive access and write the current process ID
/// as content. If another process attempts to lock the file, it will
/// block waiting for it to be unlocked.
///
/// This function returns a `FileLock` guard that will automatically unlock
/// when being dropped.
#[inline]
#[instrument]
pub fn lock_file<T: AsRef<Path> + Debug>(path: T) -> Result<FileLock, FsError> {
let path = path.as_ref();

// Let other processes know that we have locked it
file.write(format!("{}", pid).as_ref())
.map_err(|error| FsError::Write {
if !path.is_file() {
return Err(FsError::RequireFile {
path: path.to_path_buf(),
error: Box::new(error),
})?;
});
}

Ok(DirLock {
lock,
file,
unlocked: false,
})
trace!(file = ?path, "Locking file");

FileLock::new(path.to_path_buf())
}

/// Lock the provided file with exclusive access and execute the operation.
Expand Down
33 changes: 33 additions & 0 deletions crates/utils/tests/fs_lock_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,37 @@ mod fs_lock {
assert!(elapsed >= Duration::from_millis(2500));
}
}

mod lock_file {
use super::*;

#[test]
fn all_wait() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join(".lock");
let mut handles = vec![];
let start = Instant::now();

for i in 0..10 {
let file_clone = file.clone();

handles.push(thread::spawn(move || {
// Stagger
thread::sleep(Duration::from_millis(i * 25));

let _lock = fs::lock_directory(file_clone).unwrap();

thread::sleep(Duration::from_millis(250));
}));
}

for handle in handles {
handle.join().unwrap();
}

let elapsed = start.elapsed();

assert!(elapsed >= Duration::from_millis(2500));
}
}
}

0 comments on commit f576874

Please sign in to comment.