use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::os::unix::io::AsRawFd;
use std::path::Path;
#[cfg(test)]
pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[must_use = "the lock releases as soon as the guard is dropped"]
#[derive(Debug)]
pub struct FileLock {
_file: File,
}
impl FileLock {
pub fn acquire(name: &str) -> io::Result<FileLock> {
Self::acquire_path(&crate::paths::lock_file(name))
}
pub fn acquire_path(path: &Path) -> io::Result<FileLock> {
let file = Self::open_lock_file(path)?;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok(FileLock { _file: file })
}
pub fn try_acquire_path(path: &Path) -> io::Result<Option<FileLock>> {
let file = Self::open_lock_file(path)?;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
let err = io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
return Ok(None);
}
return Err(err);
}
Ok(Some(FileLock { _file: file }))
}
fn open_lock_file(path: &Path) -> io::Result<File> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)
}
}
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let dir = path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?;
std::fs::create_dir_all(dir)?;
let stem = path.file_name().and_then(|n| n.to_str()).unwrap_or("state");
let tmp = dir.join(format!(".{stem}.{}.tmp", std::process::id()));
{
let mut f = File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
match std::fs::rename(&tmp, path) {
Ok(()) => {}
Err(e) => {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
}
if let Ok(dirf) = File::open(dir) {
let _ = dirf.sync_all();
}
Ok(())
}
pub fn append_line(path: &Path, line: &str) -> io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut f = OpenOptions::new().create(true).append(true).open(path)?;
let mut buf = String::with_capacity(line.len() + 1);
buf.push_str(line);
buf.push('\n');
f.write_all(buf.as_bytes())?;
f.sync_data()?;
Ok(())
}
pub fn read_if_present(path: &Path) -> io::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Env {
_guard: std::sync::MutexGuard<'static, ()>,
_dir: tempfile::TempDir,
}
fn env() -> Env {
let guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
std::env::set_var("ROSTER_ROOT", dir.path());
Env {
_guard: guard,
_dir: dir,
}
}
#[test]
fn atomic_write_roundtrips_and_replaces() {
let _e = env();
let p = crate::paths::state_root().join("t.json");
write_atomic(&p, b"one").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "one");
write_atomic(&p, b"two").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "two");
let leftovers: Vec<_> = std::fs::read_dir(p.parent().unwrap())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(leftovers.is_empty(), "temp file left behind");
}
#[test]
fn read_if_present_distinguishes_absent_from_content() {
let _e = env();
let p = crate::paths::state_root().join("maybe.json");
assert!(read_if_present(&p).unwrap().is_none());
write_atomic(&p, b"hi").unwrap();
assert_eq!(read_if_present(&p).unwrap().as_deref(), Some("hi"));
}
#[test]
fn lock_is_reentrant_across_sequential_acquires() {
let _e = env();
{
let _g = FileLock::acquire("unit-test").unwrap();
}
let _g = FileLock::acquire("unit-test").unwrap();
}
#[test]
fn lock_grants_mutual_exclusion_under_contention() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let _e = env();
let inside = Arc::new(AtomicUsize::new(0));
let max_seen = Arc::new(AtomicUsize::new(0));
let lock_path = crate::paths::lock_file("contended");
let _ = std::fs::create_dir_all(lock_path.parent().unwrap());
let handles: Vec<_> = (0..2)
.map(|_| {
let inside = inside.clone();
let max_seen = max_seen.clone();
let lock_path = lock_path.clone();
std::thread::spawn(move || {
for _ in 0..50 {
let _g = FileLock::acquire_path(&lock_path).unwrap();
let now = inside.fetch_add(1, Ordering::SeqCst) + 1;
max_seen.fetch_max(now, Ordering::SeqCst);
std::thread::yield_now();
inside.fetch_sub(1, Ordering::SeqCst);
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(max_seen.load(Ordering::SeqCst), 1, "lock allowed overlap");
}
}