use std::cell::Cell;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard, Weak};
static GATE: RwLock<()> = RwLock::new(());
static WAIT_GENERATION: AtomicUsize = AtomicUsize::new(0);
static REGISTRY: std::sync::LazyLock<Mutex<HashMap<usize, Weak<Registration>>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
thread_local! { static DEPTH: Cell<usize> = const { Cell::new(0) }; }
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct CleanupReport {
pub removed: Vec<PathBuf>,
pub preserved: Vec<PathBuf>,
pub failures: Vec<CleanupFailure>,
}
#[derive(Debug, thiserror::Error)]
#[error("lock cleanup at {path:?}")]
#[non_exhaustive]
pub struct CleanupFailure {
pub path: PathBuf,
#[source]
pub source: io::Error,
}
pub fn cleanup() -> io::Result<CleanupReport> {
cleanup_matching(|_| true, true)
}
#[cfg(test)]
pub(crate) fn cleanup_under(root: &std::path::Path) -> io::Result<CleanupReport> {
cleanup_matching(|path| path.starts_with(root), false)
}
fn cleanup_matching(
select: impl Fn(&std::path::Path) -> bool,
cancel_waits: bool,
) -> io::Result<CleanupReport> {
if DEPTH.with(|depth| depth.get() != 0) {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"cleanup inside a lock operation",
));
}
if cancel_waits {
WAIT_GENERATION.fetch_add(1, Ordering::Relaxed);
}
let _exclusive = GATE.write().unwrap_or_else(|poison| poison.into_inner());
let registrations = {
let registry = REGISTRY.lock().unwrap_or_else(|poison| poison.into_inner());
registry
.values()
.filter_map(Weak::upgrade)
.collect::<Vec<_>>()
};
let mut report = CleanupReport::default();
for registration in registrations {
if !select(®istration.path) || !registration.active.load(Ordering::Relaxed) {
continue;
}
#[cfg(unix)]
registration.active.store(false, Ordering::Relaxed);
match registration.remove_owned() {
Ok(true) => {
registration.active.store(false, Ordering::Relaxed);
report.removed.push(registration.path.clone());
}
Ok(false) => {
registration.active.store(false, Ordering::Relaxed);
report.preserved.push(registration.path.clone());
}
Err(source) => report.failures.push(CleanupFailure {
path: registration.path.clone(),
source,
}),
}
}
Ok(report)
}
pub(crate) fn lock_options() -> fs::OpenOptions {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{
DELETE, FILE_GENERIC_WRITE, FILE_READ_ATTRIBUTES,
};
options.access_mode(FILE_GENERIC_WRITE | FILE_READ_ATTRIBUTES | DELETE);
}
options
}
pub(crate) fn unreplaceable_lock_options() -> fs::OpenOptions {
#[cfg_attr(not(windows), expect(unused_mut))]
let mut options = lock_options();
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE);
}
options
}
pub(crate) fn wait_generation() -> usize {
WAIT_GENERATION.load(Ordering::Relaxed)
}
#[derive(Clone, Debug)]
pub(crate) struct SharedFile(Arc<File>);
impl From<File> for SharedFile {
fn from(file: File) -> Self {
Self(Arc::new(file))
}
}
impl std::ops::Deref for SharedFile {
type Target = File;
fn deref(&self) -> &File {
&self.0
}
}
impl std::io::Write for SharedFile {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let mut file = self.0.as_ref();
std::io::Write::write(&mut file, bytes)
}
fn flush(&mut self) -> io::Result<()> {
let mut file = self.0.as_ref();
std::io::Write::flush(&mut file)
}
}
#[derive(Debug, thiserror::Error)]
#[error("lock identity unavailable at {path:?}; cleanup refused")]
struct IdentityFailure {
path: PathBuf,
#[source]
source: io::Error,
}
pub(crate) fn capture_identity(path: &std::path::Path, file: &File) -> io::Result<fs::Metadata> {
file.metadata().map_err(|source| {
io::Error::new(
source.kind(),
IdentityFailure {
path: path.to_owned(),
source,
},
)
})
}
pub(crate) struct Operation {
_guard: Option<RwLockReadGuard<'static, ()>>,
}
pub(crate) fn operation() -> Operation {
let guard = DEPTH.with(|depth| {
let guard = if depth.get() == 0 {
Some(GATE.read().unwrap_or_else(|poison| poison.into_inner()))
} else {
None
};
depth.set(depth.get() + 1);
guard
});
Operation { _guard: guard }
}
impl Drop for Operation {
fn drop(&mut self) {
DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
#[derive(Debug)]
pub(crate) struct Guard(Arc<Registration>);
#[derive(Debug)]
struct Registration {
path: PathBuf,
#[cfg(unix)]
identity: fs::Metadata,
#[cfg(unix)]
_pinned: SharedFile,
#[cfg(windows)]
writer: Weak<File>,
#[cfg(windows)]
file_id: io::Result<windows::FileId>,
active: AtomicBool,
}
impl Guard {
pub(crate) fn new(path: &std::path::Path, file: &SharedFile, identity: fs::Metadata) -> Self {
debug_assert!(path.is_absolute());
#[cfg(not(any(unix, windows)))]
let _ = file;
#[cfg(not(unix))]
let _ = identity;
let registration = Arc::new(Registration {
path: path.to_owned(),
#[cfg(unix)]
identity,
#[cfg(unix)]
_pinned: file.clone(),
#[cfg(windows)]
writer: Arc::downgrade(&file.0),
#[cfg(windows)]
file_id: windows::FileId::of(file),
active: AtomicBool::new(true),
});
let mut registry = REGISTRY.lock().unwrap_or_else(|poison| poison.into_inner());
registry.insert(
Arc::as_ptr(®istration) as usize,
Arc::downgrade(®istration),
);
Self(registration)
}
pub(crate) fn check_path(&self) -> io::Result<()> {
self.check()?;
self.0.check_identity()
}
pub(crate) fn check(&self) -> io::Result<()> {
if self.0.active.load(Ordering::Relaxed) {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::Interrupted,
"lock invalidated by caller cleanup",
))
}
}
}
impl Drop for Guard {
fn drop(&mut self) {
REGISTRY
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.remove(&(Arc::as_ptr(&self.0) as usize));
}
}
impl Registration {
fn check_identity(&self) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let current = fs::symlink_metadata(&self.path)?;
if !current.is_file()
|| current.dev() != self.identity.dev()
|| current.ino() != self.identity.ino()
{
return Err(io::Error::other("registered lock path replaced"));
}
}
Ok(())
}
#[cfg(unix)]
fn remove_owned(&self) -> io::Result<bool> {
use std::os::unix::fs::MetadataExt;
let current = match fs::symlink_metadata(&self.path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
Err(error) => return Err(error),
};
if !current.is_file()
|| current.dev() != self.identity.dev()
|| current.ino() != self.identity.ino()
{
return Ok(false);
}
fs::remove_file(&self.path)?;
Ok(true)
}
#[cfg(windows)]
fn remove_owned(&self) -> io::Result<bool> {
let owned = match &self.file_id {
Ok(id) => *id,
Err(source) => {
let message =
format!("lock file identity unavailable since registration: {source}");
return Err(io::Error::new(source.kind(), message));
}
};
let probe = match windows::open_existing(&self.path, false) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
Err(error) => return Err(error),
};
if !windows::names(&probe, owned)? {
return Ok(false);
}
drop(probe);
if let Some(writer) = self.writer.upgrade() {
let aside = windows::move_aside(&writer, &self.path)?;
self.active.store(false, Ordering::Relaxed);
windows::delete_on_close(&writer).map_err(|source| {
let message = format!(
"released lock moved to {aside:?} but not marked for deletion: {source}"
);
io::Error::new(source.kind(), message)
})?;
return Ok(true);
}
let file = match windows::open_existing(&self.path, true) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
Err(error) => return Err(error),
};
if !windows::names(&file, owned)? {
return Ok(false);
}
windows::delete_on_close(&file)?;
Ok(true)
}
#[cfg(not(any(unix, windows)))]
fn remove_owned(&self) -> io::Result<bool> {
match fs::symlink_metadata(&self.path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
Err(error) => return Err(error),
Ok(_) => {}
}
Err(io::Error::new(
io::ErrorKind::Unsupported,
"owned lock cleanup identity on this platform",
))
}
}
#[cfg(windows)]
pub(crate) mod windows {
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::io::AsRawHandle;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use windows_sys::Win32::Storage::FileSystem::{
DELETE, FILE_DISPOSITION_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
FILE_ID_INFO, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ,
FILE_SHARE_WRITE, FileDispositionInfo, FileIdInfo, FileRenameInfo,
GetFileInformationByHandleEx, SetFileInformationByHandle,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct FileId {
volume: u64,
file: [u8; 16],
}
impl FileId {
pub(super) fn of(file: &File) -> io::Result<Self> {
let mut info = FILE_ID_INFO::default();
let ok = unsafe {
GetFileInformationByHandleEx(
file.as_raw_handle(),
FileIdInfo,
(&raw mut info).cast(),
size_of::<FILE_ID_INFO>() as u32,
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(Self {
volume: info.VolumeSerialNumber,
file: info.FileId.Identifier,
})
}
}
pub(super) fn open_existing(path: &Path, delete: bool) -> io::Result<File> {
let access = if delete {
FILE_READ_ATTRIBUTES | DELETE
} else {
FILE_READ_ATTRIBUTES
};
std::fs::OpenOptions::new()
.access_mode(access)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
.open(path)
}
pub(super) fn names(file: &File, owned: FileId) -> io::Result<bool> {
let kind = file.metadata()?.file_type();
Ok(kind.is_file() && !kind.is_symlink() && FileId::of(file)? == owned)
}
pub(super) fn move_aside(file: &File, path: &Path) -> io::Result<PathBuf> {
static NEXT: AtomicU64 = AtomicU64::new(0);
let mut aside = path
.file_name()
.ok_or_else(|| io::Error::other("lock path has no file name"))?
.to_os_string();
let next = NEXT.fetch_add(1, Ordering::Relaxed);
aside.push(format!(".girt-released-{}-{next}.lock", std::process::id()));
let aside = path.with_file_name(aside);
let name: Vec<u16> = aside.as_os_str().encode_wide().collect();
let size = size_of::<FILE_RENAME_INFO>() + size_of_val(name.as_slice());
let mut buffer = vec![0u64; size.div_ceil(8)];
let info = buffer.as_mut_ptr().cast::<FILE_RENAME_INFO>();
unsafe {
(&raw mut (*info).FileNameLength).write(size_of_val(name.as_slice()) as u32);
std::ptr::copy_nonoverlapping(
name.as_ptr(),
(&raw mut (*info).FileName).cast::<u16>(),
name.len(),
);
}
let ok = unsafe {
SetFileInformationByHandle(
file.as_raw_handle(),
FileRenameInfo,
info.cast(),
size as u32,
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(aside)
}
#[cfg(test)]
thread_local! {
pub(crate) static FAIL_DELETE_MARK: std::cell::Cell<bool> = const {
std::cell::Cell::new(false)
};
}
pub(super) fn delete_on_close(file: &File) -> io::Result<()> {
#[cfg(test)]
if FAIL_DELETE_MARK.with(std::cell::Cell::get) {
return Err(io::Error::from(io::ErrorKind::PermissionDenied));
}
let info = FILE_DISPOSITION_INFO { DeleteFile: true };
let ok = unsafe {
SetFileInformationByHandle(
file.as_raw_handle(),
FileDispositionInfo,
(&raw const info).cast(),
size_of::<FILE_DISPOSITION_INFO>() as u32,
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleanup_waits_for_registration_and_nested_operation() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("owned.lock");
let (created, observe) = std::sync::mpsc::channel();
let (release, wait) = std::sync::mpsc::channel();
let (cleaned, done) = std::sync::mpsc::channel();
std::thread::scope(|scope| {
let path_ref = &path;
let creator = scope.spawn(move || {
let _operation = operation();
let file = lock_options().open(path_ref).unwrap();
created.send(()).unwrap();
wait.recv().unwrap();
let _nested = operation();
let identity = file.metadata().unwrap();
let file = SharedFile::from(file);
let guard = Guard::new(path_ref, &file, identity);
guard
});
observe.recv().unwrap();
let worker = scope.spawn(|| {
let report = cleanup_under(root.path()).unwrap();
cleaned.send(()).unwrap();
report
});
assert!(
done.recv_timeout(std::time::Duration::from_millis(50))
.is_err()
);
release.send(()).unwrap();
let report = worker.join().unwrap();
assert!(report.failures.is_empty());
assert_eq!(report.removed, vec![path.clone()]);
let guard = creator.join().unwrap();
assert!(guard.check().is_err());
});
assert!(!path.exists());
}
fn register(path: &std::path::Path, options: &fs::OpenOptions) -> (SharedFile, Guard) {
let _operation = operation();
let file = options.open(path).unwrap();
let identity = capture_identity(path, &file).unwrap();
let file = SharedFile::from(file);
let guard = Guard::new(path, &file, identity);
(file, guard)
}
#[test]
fn cleanup_frees_the_name_while_an_unreplaceable_writer_stays_open() {
let root = tempfile::tempdir().unwrap();
let path = std::path::absolute(root.path().join("owned.lock")).unwrap();
let (mut file, guard) = register(&path, &unreplaceable_lock_options());
std::io::Write::write_all(&mut file, b"draft").unwrap();
let report = cleanup_under(root.path()).unwrap();
assert!(report.failures.is_empty(), "{:?}", report.failures);
assert_eq!(report.removed, std::slice::from_ref(&path));
assert!(guard.check().is_err());
assert!(!path.exists());
let next = lock_options().open(&path).unwrap();
drop((file, guard));
assert!(path.is_file());
drop(next);
let names = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
assert_eq!(names, ["owned.lock"]);
}
#[test]
fn cleanup_after_the_writer_closed_removes_only_the_registered_file() {
let root = tempfile::tempdir().unwrap();
let path = std::path::absolute(root.path().join("owned.lock")).unwrap();
let (file, guard) = register(&path, &unreplaceable_lock_options());
drop(file);
let report = cleanup_under(root.path()).unwrap();
assert!(report.failures.is_empty(), "{:?}", report.failures);
assert_eq!(report.removed, std::slice::from_ref(&path));
assert!(guard.check().is_err());
assert!(!path.exists());
let (file, guard) = register(&path, &unreplaceable_lock_options());
drop(file);
fs::remove_file(&path).unwrap();
fs::write(&path, b"replacement").unwrap();
let report = cleanup_under(root.path()).unwrap();
assert!(report.failures.is_empty(), "{:?}", report.failures);
assert_eq!(report.preserved, std::slice::from_ref(&path));
assert!(guard.check().is_err());
drop(guard);
assert_eq!(fs::read(&path).unwrap(), b"replacement");
}
#[cfg(windows)]
#[test]
fn failed_windows_cleanup_reports_the_path_and_keeps_the_guard_valid() {
let root = tempfile::tempdir().unwrap();
let path = std::path::absolute(root.path().join("owned.lock")).unwrap();
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
std::os::windows::fs::OpenOptionsExt::share_mode(&mut options, 3);
let (file, guard) = register(&path, &options);
let report = cleanup_under(root.path()).unwrap();
assert!(report.removed.is_empty() && report.preserved.is_empty());
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].path, path);
assert!(guard.check().is_ok());
assert!(path.is_file());
drop(file);
}
#[cfg(windows)]
#[test]
fn failed_delete_mark_after_rename_invalidates_the_guard() {
let root = tempfile::tempdir().unwrap();
let path = std::path::absolute(root.path().join("owned.lock")).unwrap();
let (file, guard) = register(&path, &unreplaceable_lock_options());
windows::FAIL_DELETE_MARK.with(|fail| fail.set(true));
let report = cleanup_under(root.path());
windows::FAIL_DELETE_MARK.with(|fail| fail.set(false));
let report = report.unwrap();
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].path, path);
assert!(
report.failures[0]
.source
.to_string()
.contains("girt-released")
);
assert!(guard.check().is_err());
fs::write(&path, b"replacement").unwrap();
drop((file, guard));
assert_eq!(fs::read(&path).unwrap(), b"replacement");
}
#[test]
fn cleanup_from_a_lock_callback_refuses_without_deadlock() {
let _operation = operation();
assert_eq!(cleanup().unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
}
#[cfg(all(test, unix))]
mod failure_tests {
use super::*;
#[test]
fn inspection_failure_preserves_the_old_file_and_reports_its_path() {
let root = tempfile::tempdir().unwrap();
let parent = root.path().join("parent");
fs::create_dir(&parent).unwrap();
let path = parent.join("owned.lock");
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.unwrap();
let guard = {
let _operation = operation();
let identity = file.metadata().unwrap();
let file = SharedFile::from(file);
Guard::new(&path, &file, identity)
};
fs::rename(&parent, root.path().join("saved-parent")).unwrap();
fs::write(&parent, b"foreign parent replacement").unwrap();
let report = cleanup_under(root.path()).unwrap();
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].path, path);
assert!(guard.check().is_err());
assert!(root.path().join("saved-parent/owned.lock").is_file());
assert_eq!(fs::read(parent).unwrap(), b"foreign parent replacement");
}
}
#[cfg(all(test, unix))]
mod handle_tests {
use std::os::fd::AsRawFd;
use super::*;
#[test]
fn identity_pin_shares_the_writers_single_descriptor() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("owned.lock");
let raw = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.unwrap();
let identity = raw.metadata().unwrap();
let file = SharedFile::from(raw);
let _operation = operation();
let guard = Guard::new(&path, &file, identity);
assert_eq!(file.as_raw_fd(), guard.0._pinned.as_raw_fd());
}
}