use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::Result;
use crate::error::AtomwriteError;
use crate::retry::{Backoff, RetryPolicy, RetryStop};
#[must_use = "holding LockGuard keeps the advisory flock; dropping releases it"]
pub struct LockGuard {
target: PathBuf,
_held: std::rc::Rc<Held>,
pub held_ms: u64,
}
struct Held {
#[cfg(unix)]
lock_path: Option<PathBuf>,
files: Vec<std::fs::File>,
locked_target: bool,
}
thread_local! {
static HELD: std::cell::RefCell<std::collections::HashMap<PathBuf, std::rc::Weak<Held>>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
impl std::fmt::Debug for LockGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LockGuard")
.field("target", &self.target)
.field("held_ms", &self.held_ms)
.finish_non_exhaustive()
}
}
impl Drop for Held {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(lock_path) = &self.lock_path {
crate::best_effort::best_effort(std::fs::remove_file(lock_path), "remove lock sidecar");
}
for file in &self.files {
crate::best_effort::best_effort(file.unlock(), "release advisory lock");
}
}
}
pub fn acquire_for_update(target: &Path) -> Result<LockGuard> {
acquire_exclusive(target, crate::config::tuning().lock_acquire_timeout_ms)
}
pub fn acquire_exclusive(target: &Path, timeout_ms: u64) -> Result<LockGuard> {
let start = Instant::now();
let key = lock_key(target);
if let Some(held) = HELD.with(|m| m.borrow().get(&key).and_then(std::rc::Weak::upgrade)) {
return Ok(LockGuard {
target: target.to_path_buf(),
_held: held,
held_ms: 0,
});
}
let mut backoff = Backoff::new(RetryPolicy::new(
crate::constants::LOCK_RETRY_BASE_MS,
crate::constants::LOCK_RETRY_CAP_MS,
timeout_ms,
crate::constants::LOCK_RETRY_MAX_ATTEMPTS,
));
let mut files: Vec<std::fs::File> = Vec::new();
let mut locked_target = false;
match open_target_for_lock(target) {
Ok(Some(file)) => {
let mut current = Some(file);
let mut restarts = 0;
loop {
let Some(handle) = current.as_ref() else {
break;
};
try_acquire_loop(handle, target, timeout_ms, &mut backoff)?;
if target_is_same_inode(handle, target)
|| restarts >= crate::constants::LOCK_STALE_INODE_MAX_RESTARTS
{
break;
}
restarts += 1;
current = open_target_for_lock(target).ok().flatten();
}
if let Some(handle) = current {
files.push(handle);
locked_target = true;
}
}
Ok(None) => {}
Err(e) => tracing::debug!(
path = %target.display(),
error = %e,
"target cannot be opened for locking; falling back to the sidecar"
),
}
let lock_path = sidecar_path(target);
let mut sidecar_held = None;
let mut sidecar_error = None;
for _ in 0..=crate::constants::LOCK_STALE_INODE_MAX_RESTARTS {
let file = match std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
{
Ok(file) => file,
Err(e) => {
if locked_target {
tracing::debug!(
path = %target.display(),
error = %e,
"lock sidecar unavailable; the target inode lock carries the run"
);
}
sidecar_error = Some(e);
break;
}
};
if let Err(e) = try_acquire_loop(&file, target, timeout_ms, &mut backoff) {
if locked_target
&& matches!(
e.downcast_ref::<crate::error::AtomwriteError>(),
Some(crate::error::AtomwriteError::LockTimeout { .. })
)
{
return Err(e);
}
sidecar_error = Some(
crate::runtime::find_io_error(&e)
.map(std::io::Error::from)
.unwrap_or_else(|| std::io::Error::other(e.to_string())),
);
break;
}
if is_current_sidecar(&file, &lock_path) {
files.push(file);
sidecar_held = Some(lock_path.clone());
break;
}
}
let Some(_) = files.first() else {
let error = sidecar_error
.unwrap_or_else(|| std::io::Error::other("no lockable handle for the target"));
tracing::warn!(
path = %target.display(),
sidecar = %lock_path.display(),
error = %error,
"no lockable handle for the target"
);
return Err(AtomwriteError::from_io_at(target, error).into());
};
let held_ms = start.elapsed().as_millis() as u64;
tracing::debug!(
target = %target.display(),
inode_locked = locked_target,
sidecar = sidecar_held.is_some(),
held_ms,
"advisory lock acquired"
);
#[cfg(unix)]
let held = std::rc::Rc::new(Held {
lock_path: sidecar_held,
files,
locked_target,
});
#[cfg(not(unix))]
let held = std::rc::Rc::new(Held {
files,
locked_target,
});
HELD.with(|m| {
let mut map = m.borrow_mut();
map.retain(|_, w| w.strong_count() > 0);
map.insert(key, std::rc::Rc::downgrade(&held));
});
Ok(LockGuard {
target: target.to_path_buf(),
_held: held,
held_ms,
})
}
fn open_target_for_lock(target: &Path) -> std::io::Result<Option<std::fs::File>> {
match std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(target)
{
Ok(file) => Ok(Some(file)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
impl LockGuard {
#[must_use]
pub fn target_unchanged(&self) -> bool {
if !self._held.locked_target {
return true;
}
let Some(file) = self._held.files.first() else {
return true;
};
let Ok(clone) = file.try_clone() else {
return true;
};
match (
same_file::Handle::from_file(clone),
same_file::Handle::from_path(&self.target),
) {
(Ok(held), Ok(current)) => held == current,
_ => true,
}
}
}
fn target_is_same_inode(file: &std::fs::File, target: &Path) -> bool {
let Ok(clone) = file.try_clone() else {
return true;
};
match (
same_file::Handle::from_file(clone),
same_file::Handle::from_path(target),
) {
(Ok(held), Ok(current)) => held == current,
_ => true,
}
}
fn lock_key(lock_path: &Path) -> PathBuf {
let resolved = lock_path
.parent()
.and_then(|dir| dir.canonicalize().ok())
.zip(lock_path.file_name());
match resolved {
Some((dir, name)) => dir.join(name),
None => lock_path.to_path_buf(),
}
}
#[cfg(not(unix))]
fn is_current_sidecar(_file: &std::fs::File, _lock_path: &Path) -> bool {
true
}
#[cfg(unix)]
fn is_current_sidecar(file: &std::fs::File, lock_path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let Ok(held) = file.metadata() else {
return true;
};
match std::fs::metadata(lock_path) {
Ok(current) => held.dev() == current.dev() && held.ino() == current.ino(),
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
}
}
#[cfg(unix)]
fn is_lock_unsupported(e: &std::io::Error) -> bool {
use nix::errno::Errno;
matches!(
e.raw_os_error(),
Some(code)
if code == Errno::EOPNOTSUPP as i32
|| code == Errno::ENOLCK as i32
|| code == Errno::ENOSYS as i32
)
}
fn try_acquire_loop(
file: &std::fs::File,
target: &Path,
timeout_ms: u64,
backoff: &mut Backoff,
) -> Result<()> {
loop {
match file.try_lock() {
Ok(()) => return Ok(()),
Err(std::fs::TryLockError::WouldBlock) => match backoff.wait() {
Ok(()) => {}
Err(RetryStop::Cancelled) => {
return Err(crate::signal::cancelled_error(format!(
"lock wait cancelled for {}",
target.display()
))
.into());
}
Err(RetryStop::DeadlineElapsed | RetryStop::AttemptsExhausted) => {
return Err(AtomwriteError::LockTimeout {
path: target.to_path_buf(),
timeout_ms,
}
.into());
}
},
Err(std::fs::TryLockError::Error(e)) => {
#[cfg(unix)]
if is_lock_unsupported(&e) {
tracing::warn!(
path = %target.display(),
"filesystem does not support flock; continuing without advisory lock"
);
return Ok(());
}
return Err(AtomwriteError::Io { source: e }.into());
}
}
}
}
fn sidecar_path(target: &Path) -> PathBuf {
let parent = target.parent().unwrap_or_else(|| Path::new("."));
let filename = target
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
let prefix = crate::constants::HIDDEN_FILE_PREFIX;
let suffix = crate::constants::LOCK_SIDECAR_SUFFIX;
let name = if prefix.len() + filename.len() + suffix.len() > 255 {
let digest = crate::checksum::hash_bytes(filename.as_bytes());
format!("{prefix}{}", &digest[..16])
} else {
format!("{prefix}{filename}")
};
parent.join(format!("{name}{suffix}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn sidecar_path_appends_dot_atomwrite_lock() {
let target = Path::new("/tmp/foo.txt");
let lock = sidecar_path(target);
assert_eq!(lock, Path::new("/tmp/.foo.txt.atomwrite.lock"));
}
#[test]
fn acquire_release_cycle() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test.txt");
std::fs::write(&target, "hello").unwrap();
{
let guard = acquire_exclusive(&target, 1000).unwrap();
assert!(guard.held_ms < 1000);
}
let guard2 = acquire_exclusive(&target, 1000).unwrap();
drop(guard2);
}
#[cfg(unix)]
#[test]
fn release_removes_the_sidecar() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("clean.txt");
std::fs::write(&target, "x").unwrap();
let guard = acquire_exclusive(&target, 1000).unwrap();
assert!(sidecar_path(&target).exists(), "sidecar exists while held");
drop(guard);
assert!(!sidecar_path(&target).exists(), "sidecar must not survive");
}
#[cfg(unix)]
#[test]
fn second_acquire_blocks_until_release() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("concurrent.txt");
std::fs::write(&target, "shared").unwrap();
let guard1 = acquire_exclusive(&target, 1000).unwrap();
let start = std::time::Instant::now();
let other = target.clone();
let result = std::thread::spawn(move || acquire_exclusive(&other, 50).map(drop))
.join()
.expect("acquire thread");
let elapsed = start.elapsed().as_millis();
assert!(result.is_err(), "second acquire must time out");
assert!(
elapsed >= 50,
"must wait at least the configured timeout, got {elapsed}ms"
);
assert!(
elapsed < 50 + 300,
"the last wait is cut at the deadline, got {elapsed}ms"
);
let err = result.unwrap_err();
let ae = err.downcast_ref::<AtomwriteError>();
assert!(
matches!(
ae,
Some(AtomwriteError::LockTimeout { path, .. }) if path == &target
),
"expected LockTimeout carrying the target path, got: {err:?}"
);
drop(guard1);
}
#[cfg(unix)]
#[test]
fn nested_acquire_on_the_same_thread_shares_the_lock() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("nested.txt");
std::fs::write(&target, "x").unwrap();
let outer = acquire_for_update(&target).unwrap();
let begun = std::time::Instant::now();
let inner = acquire_exclusive(&target, 50).expect("nested acquire must not block");
assert!(begun.elapsed().as_millis() < 40, "nested acquire waited");
drop(outer);
assert!(
sidecar_path(&target).exists(),
"the real lock lives until the last guard drops"
);
drop(inner);
assert!(!sidecar_path(&target).exists(), "sidecar must not survive");
drop(acquire_exclusive(&target, 1000).unwrap());
}
#[cfg(unix)]
#[test]
fn a_held_lock_serialises_threads_of_one_process() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("serial.txt");
std::fs::write(&target, "0").unwrap();
let target = std::sync::Arc::new(target);
let workers: Vec<_> = (0..8)
.map(|_| {
let target = std::sync::Arc::clone(&target);
std::thread::spawn(move || {
let _lock = acquire_for_update(&target).expect("lock");
let n: u32 = std::fs::read_to_string(&*target).unwrap().parse().unwrap();
for _ in 0..200 {
std::thread::yield_now();
}
std::fs::write(&*target, (n + 1).to_string()).unwrap();
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
assert_eq!(std::fs::read_to_string(&*target).unwrap(), "8");
}
}