M5b plan: task 12, instance lock in bxcore, loopd and inferproxy

bxcore::instance::lock (File::try_lock, file 0600, missing directory
0700), LockError::line for the two lines the daemons print, and the
already-running runbook fragment; loopd locks <home>/run/loopd.lock
before its socket and self-test, inferproxy <socket>.lock.

Checks: given tests fail at ref 11 as the task says (bxcore E0432;
loopd 3 and inferproxy 3 fail); the task's quoted edits applied to
ref 11 reproduce ref 12 byte for byte; replay gate ok with 1113 tests
(1099 + 14), /tmp unchanged; 25 mutations, 24 caught (the uncaught
one maps TryLockError::Error to Held, not reachable from a test);
given files rustfmt-clean, under 500 lines; no chat-template tags.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 17:21:14 -07:00
co-authored by Claude Opus 5.5
parent ef2add03f0
commit 291d82f21b
6 changed files with 1120 additions and 0 deletions
@@ -0,0 +1,70 @@
//! One instance per daemon (M5b spec, section 9a). Every daemon removes a socket it finds at its
//! path before it binds, so a second copy started by mistake would take the sockets of the one
//! that runs. Before it touches them, a daemon takes an exclusive lock on its lock file with
//! `lock`, and keeps the `InstanceLock` until the process ends.
use std::fs::{DirBuilder, File, OpenOptions, TryLockError};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::Path;
/// The runbook entry for both ways `lock` can fail.
pub const RUNBOOK: &str = "see docs/runbook.md#already-running";
/// A held lock: the open lock file. The system releases the lock when the file is closed, which is
/// when this is dropped or the process ends, however it ends. The file itself stays: removing it
/// would let a second copy lock a new file while the first still runs.
#[derive(Debug)]
#[must_use = "the lock is released when this value is dropped"]
pub struct InstanceLock {
_file: File,
}
/// Why `lock` did not take the lock.
#[derive(Debug)]
pub enum LockError {
/// Another open file holds the lock: another instance is running.
Held,
/// The lock file, or its directory, could not be created or opened, or the lock failed.
Io(std::io::Error),
}
impl std::fmt::Display for LockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LockError::Held => write!(f, "another instance holds the lock"),
LockError::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for LockError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
LockError::Held => None,
LockError::Io(e) => Some(e),
}
}
}
impl LockError {
/// The line a daemon prints when it does not start: `role` is its name, `path` the lock file.
pub fn line(&self, role: &str, path: &Path) -> String {
// `Held`: "{role}: another {role} is running (it holds {path}); {RUNBOOK}".
// `Io(e)`: "{role}: cannot take the lock {path}: {e}; {RUNBOOK}".
// Each with `format!`, the path shown with `path.display()`.
todo!()
}
}
/// Take the exclusive lock on `path`, creating the file with mode 0600, and its directory with
/// mode 0700 when it is missing, as the daemons make their socket directories. The lock is tried
/// once, never waited for.
pub fn lock(path: &Path) -> Result<InstanceLock, LockError> {
// 1. If `path.parent()` is `Some(dir)`: `DirBuilder::new().recursive(true).mode(0o700)
// .create(dir)`. 2. `OpenOptions::new().write(true).create(true).truncate(false)
// .mode(0o600).open(path)`. An error of either is `LockError::Io`, through `map_err`.
// 3. `file.try_lock()`: `Ok(())` is `Ok(InstanceLock { _file: file })`,
// `Err(TryLockError::WouldBlock)` is `Err(LockError::Held)`, and
// `Err(TryLockError::Error(e))` is `Err(LockError::Io(e))`.
todo!()
}
@@ -0,0 +1,137 @@
//! `bxcore::instance`: the lock that keeps a daemon to one instance (M5b spec, section 9a). Two
//! opens of the same file in one process are two holders, as two processes are. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use bxcore::instance::{LockError, RUNBOOK, lock};
use tmp::TempDir;
/// Gives a directory back its owner's permissions when dropped, so the `TempDir` declared before
/// it can be removed even when an assertion fails (tip T44).
struct Restore(PathBuf);
impl Drop for Restore {
fn drop(&mut self) {
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
}
}
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn a_second_lock_is_held_while_the_first_lives() {
let dir = TempDir::new("lock-held");
let path = dir.path().join("run").join("loopd.lock");
let first = lock(&path).expect("the first lock");
for _ in 0..3 {
match lock(&path) {
Err(LockError::Held) => {}
other => panic!("a second lock while the first lives: {other:?}"),
}
}
drop(first);
}
#[test]
fn the_lock_is_free_again_once_the_first_is_dropped() {
let dir = TempDir::new("lock-free");
let path = dir.path().join("run").join("loopd.lock");
let first = lock(&path).expect("the first lock");
drop(first);
let second = lock(&path).expect("the lock after the first was dropped");
assert!(
matches!(lock(&path), Err(LockError::Held)),
"the second holds it now"
);
drop(second);
assert!(path.exists(), "the lock file is left in place");
}
#[test]
fn a_lock_file_left_behind_is_taken() {
// A killed daemon leaves its lock file, unlocked; the next start takes it and keeps its bytes.
let dir = TempDir::new("lock-left");
let path = dir.write("run/memoryd.lock", "left\n");
let held = lock(&path).expect("a lock file nobody holds");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "left\n");
drop(held);
}
#[test]
fn the_lock_file_is_created_with_mode_0600_and_its_directory_0700() {
let dir = TempDir::new("lock-mode");
let run = dir.path().join("home").join("run");
let path = run.join("brokerd.lock");
let held = lock(&path).expect("a lock in a directory that does not exist yet");
assert_eq!(mode(&path), 0o600, "the lock file");
assert_eq!(mode(&run), 0o700, "the directory it made");
assert_eq!(mode(&dir.path().join("home")), 0o700, "and the one above");
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
0,
"nothing is written"
);
drop(held);
}
#[test]
fn a_directory_that_cannot_be_written_is_an_io_error() {
let dir = TempDir::new("lock-ro");
let run = dir.path().join("run");
std::fs::create_dir(&run).unwrap();
let _restore = Restore(run.clone());
std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o500)).unwrap();
match lock(&run.join("gatewayd.lock")) {
Err(LockError::Io(e)) => {
assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}");
}
other => panic!("a lock in a read-only directory: {other:?}"),
}
assert!(!run.join("gatewayd.lock").exists());
}
#[test]
fn a_path_through_a_file_is_an_io_error() {
let dir = TempDir::new("lock-file");
let file = dir.write("run", "not a directory\n");
match lock(&file.join("loopd.lock")) {
Err(LockError::Io(_)) => {}
other => panic!("a lock under a file: {other:?}"),
}
}
#[test]
fn the_lines_a_daemon_prints() {
let path = Path::new("/home/bx/run/loopd.lock");
assert_eq!(
LockError::Held.line("loopd", path),
"loopd: another loopd is running (it holds /home/bx/run/loopd.lock); \
see docs/runbook.md#already-running"
);
let io = LockError::Io(std::io::Error::other("the disk is on fire"));
assert_eq!(
io.line("inferproxy", Path::new("/run/bx/infer.sock.lock")),
"inferproxy: cannot take the lock /run/bx/infer.sock.lock: the disk is on fire; \
see docs/runbook.md#already-running"
);
assert_eq!(RUNBOOK, "see docs/runbook.md#already-running");
}
#[test]
fn the_error_displays_and_has_a_source() {
use std::error::Error;
assert_eq!(
LockError::Held.to_string(),
"another instance holds the lock"
);
assert!(LockError::Held.source().is_none());
let io = LockError::Io(std::io::Error::other("no room"));
assert_eq!(io.to_string(), "no room");
assert!(io.source().is_some());
}