bf43b763cd
The previous implementation had every failure mode a home-grown PID lock has: - contention was detected by matching the substring "lock already exists" in an error message, so any change to the message would have turned a busy lock into a hard error; - stale-lock recovery removed the file and created it again as two separate operations, so two processes could each decide the lock was stale and each take it; - a reused PID made a live lock look stale, since the check was only whether /proc/<pid> exists; - Drop deleted the lock file, racing with a process that had already opened it and was sleeping on the retry loop - it would then lock a file that no longer has a directory entry, and the next arrival would create a fresh one and lock that instead. Replaced with advisory kernel locking through std::fs::File::try_lock, stabilised in Rust 1.89 and backed by flock on Linux. No new dependency. The kernel releases the lock when the holder exits by any means, including kill -9, so there is no stale state to reason about and no recovery heuristic to get wrong. The PID is still written to the file, but purely as diagnostics - nothing reads it back to make a decision. The lock file is now never deleted. Its presence carries no meaning; only the lock held on it does. That removes the race the old Drop introduced. Five tests: a second acquisition of a live lock fails, timeout reports lock busy timeout, the lock is free again once the guard drops, the file survives release, and a leftover file from a previous run does not block acquisition. Behaviour note for the four call sites (traffic, firewall, sysctl_tuning, swap): timeouts are unchanged, and a process killed mid-reconcile no longer leaves anything that needs cleaning up before the next run. cargo test --workspace: 158 + 4 passed. clippy -D warnings clean.
152 lines
5.1 KiB
Rust
152 lines
5.1 KiB
Rust
//! Advisory locks for the reconcile paths.
|
|
//!
|
|
//! Backed by kernel file locking (`flock` on Linux) through `std::fs::File`, not by a
|
|
//! PID file. The previous implementation had the failure modes that home-grown PID locks
|
|
//! always have:
|
|
//!
|
|
//! - it detected contention by matching a substring of an error message;
|
|
//! - "stale lock" recovery deleted the file and re-created it as two separate steps, so
|
|
//! two processes could both decide a lock was stale and both take it;
|
|
//! a PID can also be reused by an unrelated process, making a live lock look stale;
|
|
//! - `Drop` deleted the file, which races with another process that already has it open.
|
|
//!
|
|
//! The kernel releases the lock when the holding process exits, however it exits, so
|
|
//! there is no stale state to reason about. The lock file itself is never deleted: its
|
|
//! presence means nothing, only the lock held on it does.
|
|
|
|
use std::{
|
|
fs::{self, File, OpenOptions},
|
|
io::{Seek, SeekFrom, Write},
|
|
path::Path,
|
|
thread,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
/// Holding this guard holds the lock; dropping it releases it.
|
|
///
|
|
/// There is no path field: nothing needs to know where the lock lives after acquisition,
|
|
/// because the guard never deletes or re-opens the file.
|
|
#[derive(Debug)]
|
|
pub struct FileLock {
|
|
file: File,
|
|
}
|
|
|
|
impl FileLock {
|
|
#[cfg(test)]
|
|
pub fn acquire(path: &str) -> anyhow::Result<Self> {
|
|
Self::acquire_timeout(path, Duration::from_secs(0))
|
|
}
|
|
|
|
pub fn acquire_timeout(path: &str, timeout: Duration) -> anyhow::Result<Self> {
|
|
let path = Path::new(path);
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| {
|
|
anyhow::anyhow!("failed to create lock directory {}: {e}", parent.display())
|
|
})?;
|
|
}
|
|
|
|
let file = OpenOptions::new()
|
|
.create(true)
|
|
.read(true)
|
|
.write(true)
|
|
.truncate(false)
|
|
.open(path)
|
|
.map_err(|e| anyhow::anyhow!("failed to open lock {}: {e}", path.display()))?;
|
|
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
match file.try_lock() {
|
|
Ok(()) => break,
|
|
Err(fs::TryLockError::WouldBlock) => {
|
|
if Instant::now() >= deadline {
|
|
anyhow::bail!("lock busy timeout: {}", path.display());
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
}
|
|
Err(fs::TryLockError::Error(e)) => {
|
|
return Err(anyhow::anyhow!("failed to lock {}: {e}", path.display()));
|
|
}
|
|
}
|
|
}
|
|
|
|
let lock = Self { file };
|
|
// Diagnostic only. Nothing reads this back to make decisions - the kernel lock is
|
|
// the authority.
|
|
lock.record_owner();
|
|
Ok(lock)
|
|
}
|
|
|
|
fn record_owner(&self) {
|
|
let mut f = &self.file;
|
|
let _ = f.set_len(0);
|
|
let _ = f.seek(SeekFrom::Start(0));
|
|
let _ = writeln!(f, "{}", std::process::id());
|
|
let _ = f.flush();
|
|
}
|
|
}
|
|
|
|
impl Drop for FileLock {
|
|
fn drop(&mut self) {
|
|
// Explicit for clarity; closing the handle would release it anyway. The file is
|
|
// deliberately left in place - deleting it would race with a process that has
|
|
// already opened it and is waiting on the lock.
|
|
let _ = self.file.unlock();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn lock_path(name: &str) -> String {
|
|
std::env::temp_dir()
|
|
.join(format!("awg2_lock_{}_{}", name, std::process::id()))
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn second_acquire_fails_for_live_lock() {
|
|
let p = lock_path("live");
|
|
let _first = FileLock::acquire(&p).expect("first lock");
|
|
assert!(FileLock::acquire(&p).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn acquire_timeout_times_out_for_live_lock() {
|
|
let p = lock_path("timeout");
|
|
let _first = FileLock::acquire(&p).expect("first lock");
|
|
let err = FileLock::acquire_timeout(&p, Duration::from_millis(250)).expect_err("busy");
|
|
assert!(err.to_string().contains("lock busy timeout"));
|
|
}
|
|
|
|
#[test]
|
|
fn lock_is_released_when_guard_is_dropped() {
|
|
let p = lock_path("release");
|
|
{
|
|
let _first = FileLock::acquire(&p).expect("first lock");
|
|
}
|
|
let _second = FileLock::acquire(&p).expect("must be free again");
|
|
}
|
|
|
|
/// The lock file outlives the guard on purpose: removing it would race with a waiter
|
|
/// that already holds an open handle to it.
|
|
#[test]
|
|
fn lock_file_is_not_deleted_on_release() {
|
|
let p = lock_path("persist");
|
|
{
|
|
let _guard = FileLock::acquire(&p).expect("lock");
|
|
assert!(Path::new(&p).exists());
|
|
}
|
|
assert!(Path::new(&p).exists());
|
|
}
|
|
|
|
/// A leftover file from a previous run carries no meaning; only the kernel lock does.
|
|
#[test]
|
|
fn existing_lock_file_does_not_block_acquisition() {
|
|
let p = lock_path("leftover");
|
|
fs::write(&p, "999999\n").expect("seed stale pid");
|
|
let _guard = FileLock::acquire(&p).expect("must acquire despite leftover file");
|
|
}
|
|
}
|