Files
AWG2_harness/traffic_core/src/lock.rs
T
founder bf43b763cd refactor(lock): use kernel file locking instead of a hand-rolled PID lock [P2]
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.
2026-08-06 22:56:59 +05:00

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");
}
}