use anyhow::Context;
#[cfg(unix)]
use libc::c_int;
use std::{
collections::HashMap,
error::Error,
fmt, fs,
io::{ErrorKind, Read, Write},
path::{Path, PathBuf},
sync::{
Arc, Mutex, MutexGuard, OnceLock, TryLockError, Weak,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
pub(crate) const LOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(10);
const STALE_LOCK_MAX_AGE: Duration = Duration::from_secs(5);
const LEASE_RENEWAL_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) struct CrossProcessFileLock {
path: PathBuf,
owner: LockSnapshot,
renewer: Option<LeaseRenewer>,
}
impl CrossProcessFileLock {
pub(crate) fn acquire(target: &Path) -> anyhow::Result<Self> {
Self::acquire_until(target, Instant::now() + LOCK_WAIT_TIMEOUT)
}
pub(crate) fn acquire_until(target: &Path, deadline: Instant) -> anyhow::Result<Self> {
loop {
if Instant::now() >= deadline {
anyhow::bail!(
"timed out waiting for file lock {} for {}",
lock_path(target).display(),
target.display()
);
}
if let Some(lock) = Self::try_acquire(target)? {
return Ok(lock);
}
if !sleep_until(deadline) {
anyhow::bail!(
"timed out waiting for file lock {} for {}",
lock_path(target).display(),
target.display()
);
}
}
}
#[cfg(unix)]
pub(crate) fn acquire_until_cancellable<F>(
target: &Path,
deadline: Instant,
is_canceled: F,
) -> anyhow::Result<Self>
where
F: Fn() -> bool,
{
loop {
if is_canceled() {
anyhow::bail!("prompt canceled");
}
if Instant::now() >= deadline {
anyhow::bail!(
"timed out waiting for file lock {} for {}",
lock_path(target).display(),
target.display()
);
}
if let Some(lock) = Self::try_acquire(target)? {
return Ok(lock);
}
if !sleep_until(deadline) {
anyhow::bail!(
"timed out waiting for file lock {} for {}",
lock_path(target).display(),
target.display()
);
}
}
}
pub(crate) fn try_acquire(target: &Path) -> anyhow::Result<Option<Self>> {
let parent = target.parent().ok_or_else(|| {
anyhow::anyhow!("lock target path has no parent: {}", target.display())
})?;
fs::create_dir_all(parent)?;
let path = lock_path(target);
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
writeln!(file, "pid={}", std::process::id())?;
writeln!(file, "token={}", lock_owner_token())?;
file.flush()?;
let owner = LockSnapshot::read(&path)?.ok_or_else(|| {
anyhow::anyhow!(
"created file lock disappeared before ownership snapshot: {}",
path.display()
)
})?;
let renewer = LeaseRenewer::spawn(path.clone(), owner.clone());
Ok(Some(Self {
path,
owner,
renewer: Some(renewer),
}))
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
if recover_stale_lock(&path)? {
Self::try_acquire(target)
} else {
Ok(None)
}
}
Err(error) => Err(error.into()),
}
}
}
fn sleep_until(deadline: Instant) -> bool {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return false;
}
thread::sleep(LOCK_POLL_INTERVAL.min(remaining));
true
}
pub(crate) fn lock_mutex_until<'a, T>(
mutex: &'a Mutex<T>,
deadline: Instant,
label: &str,
) -> anyhow::Result<MutexGuard<'a, T>> {
loop {
if Instant::now() >= deadline {
anyhow::bail!("timed out waiting for {label}");
}
match mutex.try_lock() {
Ok(guard) => return Ok(guard),
Err(TryLockError::Poisoned(_)) => anyhow::bail!("{label} was poisoned"),
Err(TryLockError::WouldBlock) => {}
}
if !sleep_until(deadline) {
anyhow::bail!("timed out waiting for {label}");
}
}
}
#[cfg(unix)]
fn recover_stale_lock(path: &Path) -> anyhow::Result<bool> {
let Some(observed) = LockSnapshot::read(path)? else {
return Ok(true);
};
match lock_pid_from_contents(&observed.contents) {
LockPid::Alive => Ok(false),
LockPid::Dead => remove_lock_file_if_unchanged(path, &observed),
LockPid::MissingOrCorrupt => steal_lock_if_old_enough(path, &observed),
}
}
#[cfg(not(unix))]
fn recover_stale_lock(path: &Path) -> anyhow::Result<bool> {
let Some(observed) = LockSnapshot::read(path)? else {
return Ok(true);
};
recover_lock_if_lease_expired(path, &observed, STALE_LOCK_MAX_AGE)
}
#[cfg(not(unix))]
fn recover_lock_if_lease_expired(
path: &Path,
observed: &LockSnapshot,
max_age: Duration,
) -> anyhow::Result<bool> {
if !observed.is_older_than(max_age) {
return Ok(false);
}
remove_lock_file_if_unchanged(path, observed)
}
#[cfg(unix)]
fn steal_lock_if_old_enough(path: &Path, observed: &LockSnapshot) -> anyhow::Result<bool> {
if !observed.is_older_than(STALE_LOCK_MAX_AGE) {
return Ok(false);
}
remove_lock_file_if_unchanged(path, observed)
}
fn remove_lock_file_if_unchanged(path: &Path, observed: &LockSnapshot) -> anyhow::Result<bool> {
let Some(_guard) = StaleLockRecoveryGuard::try_acquire(path)? else {
return Ok(false);
};
let Some(current) = LockSnapshot::read(path)? else {
return Ok(true);
};
if !current.same_file_and_contents(observed) {
return Ok(false);
}
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
Err(error) => Err(error.into()),
}
}
struct StaleLockRecoveryGuard {
path: PathBuf,
owner: LockSnapshot,
}
impl StaleLockRecoveryGuard {
fn try_acquire(lock_path: &Path) -> anyhow::Result<Option<Self>> {
let path = stale_lock_recovery_guard_path(lock_path);
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
writeln!(file, "pid={}", std::process::id())?;
file.flush()?;
let owner = LockSnapshot::read(&path)?.ok_or_else(|| {
anyhow::anyhow!(
"created recovery guard disappeared before ownership snapshot: {}",
path.display()
)
})?;
Ok(Some(Self { path, owner }))
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
let Some(observed) = LockSnapshot::read(&path)? else {
return Ok(None);
};
let orphaned = {
#[cfg(unix)]
{
match lock_pid_from_contents(&observed.contents) {
LockPid::Dead => true,
LockPid::MissingOrCorrupt => observed.is_older_than(STALE_LOCK_MAX_AGE),
LockPid::Alive => false,
}
}
#[cfg(not(unix))]
{
observed.is_older_than(STALE_LOCK_MAX_AGE)
}
};
if orphaned {
let Some(current) = LockSnapshot::read(&path)? else {
return Ok(None);
};
if current.same_file_and_contents(&observed) {
match fs::remove_file(&path) {
Ok(()) => return Self::try_acquire(lock_path),
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
}
Ok(None)
}
Err(error) => Err(error.into()),
}
}
}
impl Drop for StaleLockRecoveryGuard {
fn drop(&mut self) {
if let Err(error) = remove_owned_lock_file(&self.path, &self.owner) {
eprintln!(
"warning: failed to clean up stale-lock recovery guard {}: {error:#}",
self.path.display()
);
}
}
}
fn stale_lock_recovery_guard_path(path: &Path) -> PathBuf {
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("state.lock");
path.with_file_name(format!(".{name}.recovery"))
}
pub(crate) fn open_regular_file(path: &Path, write: bool) -> std::io::Result<fs::File> {
let mut options = fs::OpenOptions::new();
options.read(true).write(write);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(0x0020_0000); if !fs::symlink_metadata(path)?.is_file() {
return Err(std::io::Error::other("expected a regular file"));
}
}
let file = options.open(path)?;
if !file.metadata()?.file_type().is_file() {
return Err(std::io::Error::other("expected a regular file"));
}
Ok(file)
}
pub(crate) fn read_regular_file_bounded(file: &fs::File, limit: u64) -> std::io::Result<Vec<u8>> {
if file.metadata()?.len() > limit {
return Err(std::io::Error::other(format!(
"file exceeds read limit of {limit} bytes"
)));
}
let mut contents = Vec::new();
file.take(limit.saturating_add(1))
.read_to_end(&mut contents)?;
if contents.len() as u64 > limit {
return Err(std::io::Error::other(format!(
"file exceeds read limit of {limit} bytes"
)));
}
Ok(contents)
}
const MAX_LOCK_BYTES: u64 = 4096;
#[cfg(unix)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct LockSnapshot {
dev: u64,
ino: u64,
modified: Option<SystemTime>,
contents: Vec<u8>,
}
#[cfg(not(unix))]
#[derive(Debug, Clone, PartialEq, Eq)]
struct LockSnapshot {
modified: Option<SystemTime>,
contents: Vec<u8>,
}
#[cfg(unix)]
impl LockSnapshot {
fn read(path: &Path) -> anyhow::Result<Option<Self>> {
let file = match open_regular_file(path, false) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
Self::from_file(&file).map(Some)
}
fn from_file(file: &fs::File) -> anyhow::Result<Self> {
use std::os::unix::fs::MetadataExt;
let metadata = file.metadata()?;
let contents = read_regular_file_bounded(file, MAX_LOCK_BYTES)?;
Ok(Self {
dev: metadata.dev(),
ino: metadata.ino(),
modified: metadata.modified().ok(),
contents,
})
}
fn is_older_than(&self, max_age: Duration) -> bool {
self.modified
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age >= max_age)
}
fn same_file_and_contents(&self, other: &Self) -> bool {
self.dev == other.dev && self.ino == other.ino && self.contents == other.contents
}
}
#[cfg(not(unix))]
impl LockSnapshot {
fn read(path: &Path) -> anyhow::Result<Option<Self>> {
let file = match open_regular_file(path, false) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
Self::from_file(&file).map(Some)
}
fn from_file(file: &fs::File) -> anyhow::Result<Self> {
let metadata = file.metadata()?;
let contents = read_regular_file_bounded(file, MAX_LOCK_BYTES)?;
Ok(Self {
modified: metadata.modified().ok(),
contents,
})
}
fn is_older_than(&self, max_age: Duration) -> bool {
self.modified
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age >= max_age)
}
fn same_file_and_contents(&self, other: &Self) -> bool {
self.contents == other.contents
}
}
#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LockPid {
Alive,
Dead,
MissingOrCorrupt,
}
#[cfg(unix)]
fn lock_pid_from_contents(contents: &[u8]) -> LockPid {
let Ok(contents) = std::str::from_utf8(contents) else {
return LockPid::MissingOrCorrupt;
};
let Some(pid) = contents.lines().find_map(parse_lock_pid) else {
return LockPid::MissingOrCorrupt;
};
pid_liveness(pid)
}
#[cfg(unix)]
fn parse_lock_pid(line: &str) -> Option<u32> {
line.strip_prefix("pid=")?.trim().parse().ok()
}
#[cfg(unix)]
fn pid_liveness(pid: u32) -> LockPid {
let Ok(pid) = c_int::try_from(pid) else {
return LockPid::MissingOrCorrupt;
};
if pid <= 0 {
return LockPid::MissingOrCorrupt;
}
let result = unsafe { libc::kill(pid, 0) };
if result == 0 {
return LockPid::Alive;
}
match std::io::Error::last_os_error().raw_os_error() {
Some(libc::ESRCH) => LockPid::Dead,
Some(libc::EPERM) => LockPid::Alive,
_ => LockPid::Alive,
}
}
struct LeaseRenewer {
stop: Arc<AtomicBool>,
thread: Option<thread::Thread>,
handle: Option<thread::JoinHandle<()>>,
}
impl LeaseRenewer {
fn spawn(path: PathBuf, owner: LockSnapshot) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let stop_for_thread = stop.clone();
let handle = thread::spawn(move || renew_loop(path, owner, stop_for_thread));
let thread = handle.thread().clone();
Self {
stop,
thread: Some(thread),
handle: Some(handle),
}
}
}
fn renew_loop(path: PathBuf, owner: LockSnapshot, stop: Arc<AtomicBool>) {
loop {
thread::park_timeout(LEASE_RENEWAL_INTERVAL);
if stop.load(Ordering::Acquire) {
break;
}
if renew_owned_lock(&path, &owner).is_err() {
break;
}
}
}
fn renew_owned_lock(path: &Path, owner: &LockSnapshot) -> anyhow::Result<()> {
let file = open_regular_file(path, true)?;
let current = LockSnapshot::from_file(&file)?;
if !current.same_file_and_contents(owner) {
anyhow::bail!("lock ownership changed");
}
file.set_modified(SystemTime::now())?;
Ok(())
}
impl Drop for LeaseRenewer {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
if let Some(thread) = self.thread.take() {
thread.unpark();
}
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl Drop for CrossProcessFileLock {
fn drop(&mut self) {
self.renewer.take();
if let Err(error) = remove_owned_lock_file(&self.path, &self.owner) {
eprintln!(
"warning: failed to clean up file lock {}: {error:#}",
self.path.display()
);
}
}
}
fn remove_owned_lock_file(path: &Path, owner: &LockSnapshot) -> anyhow::Result<()> {
let Some(current) = LockSnapshot::read(path)? else {
return Ok(());
};
if !current.same_file_and_contents(owner) {
anyhow::bail!(
"lock file ownership changed; leaving replacement lock in place: {}",
path.display()
);
}
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn lock_owner_token() -> String {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{}-{stamp}", std::process::id())
}
fn lock_path(path: &Path) -> PathBuf {
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("state");
path.with_file_name(format!(".{name}.lock"))
}
static IN_PROCESS_FILE_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
pub(crate) fn in_process_file_lock(
path: &Path,
registry_label: &'static str,
) -> anyhow::Result<Arc<Mutex<()>>> {
let key = normalize_lock_path(path);
let registry = IN_PROCESS_FILE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut locks = registry
.lock()
.map_err(|_| anyhow::anyhow!("{registry_label} lock registry was poisoned"))?;
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
return Ok(lock);
}
locks.retain(|_, lock| lock.strong_count() > 0);
let lock = Arc::new(Mutex::new(()));
locks.insert(key, Arc::downgrade(&lock));
Ok(lock)
}
fn normalize_lock_path(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name())
&& let Ok(parent) = parent.canonicalize()
{
return parent.join(file_name);
}
path.to_path_buf()
}
#[derive(Debug)]
pub(crate) struct AtomicWriteCommittedButUndurable {
path: PathBuf,
parent: PathBuf,
source: anyhow::Error,
}
impl fmt::Display for AtomicWriteCommittedButUndurable {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"atomic write committed to {} but parent directory sync failed for {}; file contents changed but durability is uncertain",
self.path.display(),
self.parent.display()
)
}
}
impl Error for AtomicWriteCommittedButUndurable {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(self.source.as_ref())
}
}
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
atomic_write_with_permissions(path, bytes, None)
}
pub(crate) fn atomic_write_with_permissions(
path: &Path,
bytes: &[u8],
unix_mode: Option<u32>,
) -> anyhow::Result<()> {
atomic_write_with_permissions_and_parent_sync(path, bytes, unix_mode, sync_parent_dir)
}
fn atomic_write_with_permissions_and_parent_sync(
path: &Path,
bytes: &[u8],
#[cfg_attr(not(unix), expect(unused_variables))] unix_mode: Option<u32>,
sync_parent: impl FnOnce(&Path) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("target path has no parent: {}", path.display()))?;
let step_failed = |step: &str| format!("atomic write {step} failed: {}", path.display());
fs::create_dir_all(parent).with_context(|| step_failed("create parent directory"))?;
let temp = temp_path(path);
let mut temp_guard = TempFileCleanupGuard::new(temp.clone());
#[cfg(unix)]
let target_mode = unix_target_mode(path, unix_mode)?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(target_mode.unwrap_or(0o666));
}
let mut file = options
.open(&temp)
.with_context(|| step_failed("open temporary file"))?;
file.write_all(bytes)
.with_context(|| step_failed("write"))?;
file.flush().with_context(|| step_failed("flush"))?;
file.sync_all().with_context(|| step_failed("sync"))?;
drop(file);
#[cfg(unix)]
if let Some(mode) = target_mode {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&temp, fs::Permissions::from_mode(mode))
.with_context(|| step_failed("set permissions"))?;
}
fs::rename(&temp, path).with_context(|| step_failed("rename"))?;
temp_guard.disarm();
sync_parent(parent).map_err(|error| AtomicWriteCommittedButUndurable {
path: path.to_path_buf(),
parent: parent.to_path_buf(),
source: error,
})?;
Ok(())
}
#[cfg(unix)]
fn unix_target_mode(path: &Path, unix_mode: Option<u32>) -> anyhow::Result<Option<u32>> {
use std::os::unix::fs::PermissionsExt;
if unix_mode.is_some() {
return Ok(unix_mode);
}
match fs::metadata(path) {
Ok(metadata) => Ok(Some(metadata.permissions().mode() & 0o777)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
struct TempFileCleanupGuard {
path: Option<PathBuf>,
}
impl TempFileCleanupGuard {
fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
fn disarm(&mut self) {
self.path = None;
}
}
impl Drop for TempFileCleanupGuard {
fn drop(&mut self) {
if let Some(path) = &self.path {
let _ = fs::remove_file(path);
}
}
}
fn temp_path(path: &Path) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id();
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("atomic");
path.with_file_name(format!(".{name}.{pid}.{stamp}.tmp"))
}
pub(crate) fn sync_parent_dir(parent: &Path) -> anyhow::Result<()> {
#[cfg(unix)]
{
fs::File::open(parent)?.sync_all()?;
}
#[cfg(not(unix))]
{
let _ = parent;
}
Ok(())
}