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>
229 lines
7.2 KiB
Rust
229 lines
7.2 KiB
Rust
//! One `inferproxy` per socket (M5b spec, section 9a): the lock is `<socket>.lock`, beside the
|
|
//! `--listen` socket. A second `inferproxy` on the same socket is refused, and the first keeps
|
|
//! forwarding; once the first is killed, a new one removes the stale socket and forwards. Every
|
|
//! wait has a deadline, and every process is killed and every path removed by a guard (tip T44).
|
|
//! Do not edit.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpListener;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
const POINTER: &str = "see docs/runbook.md#already-running";
|
|
|
|
static NEXT: AtomicU32 = AtomicU32::new(0);
|
|
|
|
/// A temporary directory for the socket, removed when dropped, first with its owner's permissions
|
|
/// put back.
|
|
struct Dir(PathBuf);
|
|
|
|
impl Dir {
|
|
fn new() -> Dir {
|
|
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
|
let dir = std::env::temp_dir().join(format!("ip-lock-{}-{n}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
Dir(dir)
|
|
}
|
|
|
|
fn socket(&self) -> PathBuf {
|
|
self.0.join("infer.sock")
|
|
}
|
|
}
|
|
|
|
impl Drop for Dir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
/// An upstream that reads four bytes from each connection, answers `pong` and closes.
|
|
fn upstream() -> String {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let Ok(mut stream) = stream else { break };
|
|
thread::spawn(move || {
|
|
let mut ping = [0u8; 4];
|
|
if stream.read_exact(&mut ping).is_ok() {
|
|
let _ = stream.write_all(b"pong");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
addr
|
|
}
|
|
|
|
/// A running `inferproxy`, killed and waited for when dropped. Declared after the `Dir`, so it
|
|
/// drops first.
|
|
struct Proxy(Child);
|
|
|
|
impl Drop for Proxy {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
|
|
impl Proxy {
|
|
fn running(&mut self) -> bool {
|
|
matches!(self.0.try_wait(), Ok(None))
|
|
}
|
|
}
|
|
|
|
fn command(socket: &Path, upstream: &str) -> Command {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_inferproxy"));
|
|
command
|
|
.arg("--listen")
|
|
.arg(socket)
|
|
.args(["--upstream", upstream])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null());
|
|
command
|
|
}
|
|
|
|
fn start(socket: &Path, upstream: &str) -> Proxy {
|
|
Proxy(
|
|
command(socket, upstream)
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.unwrap(),
|
|
)
|
|
}
|
|
|
|
/// Runs `inferproxy` to its end, at most 10 s (then it is killed), and gives its exit code
|
|
/// (`None` if it had to be killed) and its stderr.
|
|
fn run_to_end(socket: &Path, upstream: &str) -> (Option<i32>, String) {
|
|
let mut child = Proxy(
|
|
command(socket, upstream)
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap(),
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let mut code = None;
|
|
while Instant::now() < deadline {
|
|
if let Ok(Some(status)) = child.0.try_wait() {
|
|
code = status.code();
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
let _ = child.0.kill();
|
|
let _ = child.0.wait();
|
|
let mut stderr = String::new();
|
|
if let Some(mut pipe) = child.0.stderr.take() {
|
|
let _ = pipe.read_to_string(&mut stderr);
|
|
}
|
|
(code, stderr)
|
|
}
|
|
|
|
/// True when a `ping` sent to `socket` comes back from the upstream as `pong` within 5 s.
|
|
fn forwards(socket: &Path) -> bool {
|
|
let Ok(mut stream) = UnixStream::connect(socket) else {
|
|
return false;
|
|
};
|
|
let limit = Some(Duration::from_secs(5));
|
|
if stream.set_read_timeout(limit).is_err() || stream.set_write_timeout(limit).is_err() {
|
|
return false;
|
|
}
|
|
if stream.write_all(b"ping").is_err() {
|
|
return false;
|
|
}
|
|
let mut answer = Vec::new();
|
|
let _ = stream.read_to_end(&mut answer);
|
|
answer == b"pong"
|
|
}
|
|
|
|
/// Waits up to 10 s for an `inferproxy` to forward on `socket`.
|
|
fn wait_until_it_forwards(socket: &Path) -> bool {
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
while Instant::now() < deadline {
|
|
if forwards(socket) {
|
|
return true;
|
|
}
|
|
thread::sleep(Duration::from_millis(50));
|
|
}
|
|
false
|
|
}
|
|
|
|
fn inode(path: &Path) -> u64 {
|
|
use std::os::unix::fs::MetadataExt;
|
|
std::fs::symlink_metadata(path)
|
|
.map(|m| m.ino())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_inferproxy_on_the_same_socket_is_refused_and_the_first_keeps_forwarding() {
|
|
let dir = Dir::new();
|
|
let socket = dir.socket();
|
|
let upstream = upstream();
|
|
let mut first = start(&socket, &upstream);
|
|
assert!(wait_until_it_forwards(&socket), "the first never forwarded");
|
|
let before = inode(&socket);
|
|
|
|
let (code, stderr) = run_to_end(&socket, &upstream);
|
|
let line = format!(
|
|
"inferproxy: another inferproxy is running (it holds {}.lock); {POINTER}",
|
|
socket.display()
|
|
);
|
|
assert_eq!(code, Some(1), "the second inferproxy: {stderr}");
|
|
assert!(
|
|
stderr.lines().any(|l| l == line),
|
|
"wanted the line\n{line}\nstderr: {stderr}"
|
|
);
|
|
|
|
assert!(first.running(), "the first inferproxy is still running");
|
|
assert_eq!(inode(&socket), before, "the first's socket is still there");
|
|
assert!(forwards(&socket), "the first still forwards");
|
|
}
|
|
|
|
#[test]
|
|
fn after_the_first_is_killed_a_new_inferproxy_removes_the_stale_socket_and_forwards() {
|
|
let dir = Dir::new();
|
|
let socket = dir.socket();
|
|
let upstream = upstream();
|
|
let mut first = start(&socket, &upstream);
|
|
assert!(wait_until_it_forwards(&socket), "the first never forwarded");
|
|
let _ = first.0.kill();
|
|
let _ = first.0.wait();
|
|
assert!(socket.exists(), "a killed inferproxy leaves its socket");
|
|
assert!(!forwards(&socket), "nobody serves the stale socket");
|
|
|
|
let _next = start(&socket, &upstream);
|
|
assert!(
|
|
wait_until_it_forwards(&socket),
|
|
"the new one never forwarded"
|
|
);
|
|
let lock = dir.0.join("infer.sock.lock");
|
|
let mode = std::fs::metadata(&lock).map(|m| m.permissions().mode() & 0o777);
|
|
assert_eq!(mode.ok(), Some(0o600), "the lock file, beside the socket");
|
|
}
|
|
|
|
#[test]
|
|
fn a_socket_directory_that_cannot_be_written_stops_inferproxy_naming_the_error() {
|
|
let dir = Dir::new();
|
|
let socket = dir.socket();
|
|
std::fs::set_permissions(&dir.0, std::fs::Permissions::from_mode(0o500)).unwrap();
|
|
|
|
let (code, stderr) = run_to_end(&socket, &upstream());
|
|
let line = format!(
|
|
"inferproxy: cannot take the lock {}.lock: Permission denied (os error 13); {POINTER}",
|
|
socket.display()
|
|
);
|
|
assert_eq!(code, Some(1), "{stderr}");
|
|
assert!(
|
|
stderr.lines().any(|l| l == line),
|
|
"wanted the line\n{line}\nstderr: {stderr}"
|
|
);
|
|
assert!(!socket.exists(), "no socket");
|
|
}
|