use crate::atomic::{AtomicWriteOptions, AtomicWriter, TempWriter, WriteReport};
use crate::error::{FsError, Result};
use crate::file_key::FileKey;
use crate::lock::{FileLock, LockOptions};
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
#[derive(Clone, Debug)]
pub struct WriteLockOptions {
timeout: Duration,
process_lock_dir: Option<PathBuf>,
}
impl Default for WriteLockOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
process_lock_dir: None,
}
}
}
impl WriteLockOptions {
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn process_lock_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.process_lock_dir = Some(dir.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct DestinationKey {
parent: FileKey,
name: OsString,
}
#[derive(Debug)]
struct Slot {
held: Mutex<bool>,
ready: Condvar,
}
#[derive(Debug, Default)]
struct Registry {
slots: Mutex<HashMap<DestinationKey, (Arc<Slot>, usize)>>,
}
fn lock_ignoring_poison<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
struct SlotGuard {
registry: Arc<Registry>,
key: DestinationKey,
slot: Arc<Slot>,
held: bool,
}
impl Drop for SlotGuard {
fn drop(&mut self) {
if self.held {
*lock_ignoring_poison(&self.slot.held) = false;
self.slot.ready.notify_one();
}
let mut slots = lock_ignoring_poison(&self.registry.slots);
let remove = match slots.get_mut(&self.key) {
Some(entry) => {
entry.1 = entry.1.saturating_sub(1);
entry.1 == 0
}
None => false,
};
if remove {
slots.remove(&self.key);
}
}
}
#[derive(Clone, Debug, Default)]
pub struct KeyedWriteLocks {
registry: Arc<Registry>,
options: WriteLockOptions,
}
#[derive(Debug)]
pub struct LockedDestination {
path: PathBuf,
}
impl LockedDestination {
pub fn path(&self) -> &Path {
&self.path
}
pub fn atomic_writer(&self, options: AtomicWriteOptions) -> Result<LockedAtomicWriter<'_>> {
Ok(LockedAtomicWriter {
writer: AtomicWriter::create_with(&self.path, options)?,
_held: self,
})
}
}
pub struct LockedAtomicWriter<'held> {
writer: AtomicWriter,
_held: &'held LockedDestination,
}
impl LockedAtomicWriter<'_> {
pub fn temp_path(&self) -> &Path {
self.writer.temp_path()
}
pub fn file_mut(&mut self) -> &mut std::fs::File {
self.writer.file_mut()
}
pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
self.writer.write_all(bytes)
}
pub fn writer(&mut self) -> TempWriter<'_> {
self.writer.writer()
}
pub fn commit(self) -> Result<()> {
self.writer.commit()
}
pub fn commit_reporting(self) -> Result<WriteReport> {
self.writer.commit_reporting()
}
pub fn commit_checked(self, check: impl FnOnce(&Path) -> std::io::Result<()>) -> Result<()> {
self.writer.commit_checked(check)
}
#[cfg(feature = "digest")]
pub fn commit_with_digest(self) -> Result<String> {
self.writer.commit_with_digest()
}
}
impl KeyedWriteLocks {
pub fn new() -> Self {
Self::default()
}
pub fn with_options(options: WriteLockOptions) -> Self {
Self {
registry: Arc::new(Registry::default()),
options,
}
}
pub fn with_destination<T>(
&self,
path: &Path,
operation: impl FnOnce(&LockedDestination) -> T,
) -> Result<T> {
self.run(path, self.options.timeout, operation)
}
pub fn try_with_destination<T>(
&self,
path: &Path,
operation: impl FnOnce(&LockedDestination) -> T,
) -> Result<T> {
self.run(path, Duration::ZERO, operation)
}
pub fn lock_file_for(&self, path: &Path) -> Result<Option<PathBuf>> {
let (key, _) = destination_key(path)?;
Ok(self
.options
.process_lock_dir
.as_deref()
.map(|dir| process_lock_path(dir, &key)))
}
pub fn active_destinations(&self) -> usize {
lock_ignoring_poison(&self.registry.slots).len()
}
fn run<T>(
&self,
path: &Path,
timeout: Duration,
operation: impl FnOnce(&LockedDestination) -> T,
) -> Result<T> {
let (key, _parent) = destination_key(path)?;
let started = Instant::now();
let deadline = started.checked_add(timeout);
let slot = self.acquire_slot(&key, deadline, path, timeout)?;
let process_lock = match &self.options.process_lock_dir {
Some(dir) => {
crate::private::ensure_private_dir(dir)?;
let remaining =
deadline.map_or(timeout, |at| at.saturating_duration_since(Instant::now()));
let options = LockOptions::wait(remaining).labeled("rightkit-fs keyed write");
Some(FileLock::acquire(&process_lock_path(dir, &key), &options)?)
}
None => None,
};
let destination = LockedDestination {
path: path.to_path_buf(),
};
let value = operation(&destination);
drop(destination);
drop(process_lock);
drop(slot);
Ok(value)
}
fn acquire_slot(
&self,
key: &DestinationKey,
deadline: Option<Instant>,
path: &Path,
timeout: Duration,
) -> Result<SlotGuard> {
let slot = {
let mut slots = lock_ignoring_poison(&self.registry.slots);
let entry = slots.entry(key.clone()).or_insert_with(|| {
(
Arc::new(Slot {
held: Mutex::new(false),
ready: Condvar::new(),
}),
0,
)
});
entry.1 += 1;
Arc::clone(&entry.0)
};
let mut guard = SlotGuard {
registry: Arc::clone(&self.registry),
key: key.clone(),
slot: Arc::clone(&slot),
held: false,
};
let mut held = lock_ignoring_poison(&slot.held);
while *held {
let now = Instant::now();
if deadline.is_some_and(|at| now >= at) {
drop(held);
return Err(if timeout.is_zero() {
FsError::Locked {
path: path.to_path_buf(),
owner: "another writer of this destination in this process".into(),
}
} else {
FsError::LockTimeout {
path: path.to_path_buf(),
waited_ms: u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
}
});
}
held = match deadline {
Some(at) => {
slot.ready
.wait_timeout(held, at - now)
.unwrap_or_else(PoisonError::into_inner)
.0
}
None => slot
.ready
.wait(held)
.unwrap_or_else(PoisonError::into_inner),
};
}
*held = true;
drop(held);
guard.held = true;
Ok(guard)
}
}
fn destination_key(path: &Path) -> Result<(DestinationKey, PathBuf)> {
let name = path
.file_name()
.ok_or_else(|| FsError::BadName(path.display().to_string()))?;
crate::atomic::reject_reserved_name(&name.to_string_lossy())?;
let parent = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
};
let meta = std::fs::metadata(&parent).map_err(|e| FsError::io(&parent, e))?;
if !meta.is_dir() {
return Err(FsError::NotADirectory(parent));
}
let key = DestinationKey {
parent: FileKey::of_path(&parent)?,
name: fold_name(name),
};
Ok((key, parent))
}
fn fold_name(name: &OsStr) -> OsString {
if cfg!(any(windows, target_vendor = "apple")) {
if let Some(text) = name.to_str() {
return OsString::from(text.to_lowercase());
}
}
name.to_os_string()
}
fn fnv1a(bytes: &[u8], mut hash: u64) -> u64 {
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn process_lock_path(dir: &Path, key: &DestinationKey) -> PathBuf {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
hash = fnv1a(&key.parent.volume.to_le_bytes(), hash);
hash = fnv1a(&key.parent.index.to_le_bytes(), hash);
hash = fnv1a(key.name.as_encoded_bytes(), hash);
dir.join(format!("write-{hash:016x}.lock"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_file_names_are_stable_and_distinguish_destinations() {
let dir = tempfile::tempdir().unwrap();
let locks = KeyedWriteLocks::with_options(
WriteLockOptions::default().process_lock_dir(dir.path().join("locks")),
);
let a = locks.lock_file_for(&dir.path().join("a.txt")).unwrap();
let again = locks
.lock_file_for(&dir.path().join(".").join("a.txt"))
.unwrap();
let b = locks.lock_file_for(&dir.path().join("b.txt")).unwrap();
assert!(a.is_some());
assert_eq!(
a, again,
"a spelling of the same destination is one lock file"
);
assert_ne!(a, b);
}
#[test]
fn fnv1a_matches_the_published_vector() {
assert_eq!(fnv1a(b"a", 0xcbf2_9ce4_8422_2325), 0xaf63_dc4c_8601_ec8c);
}
#[test]
fn no_process_lock_dir_means_no_lock_file() {
let dir = tempfile::tempdir().unwrap();
let locks = KeyedWriteLocks::new();
assert_eq!(locks.lock_file_for(&dir.path().join("a")).unwrap(), None);
}
}