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>
312 lines
9.5 KiB
Rust
312 lines
9.5 KiB
Rust
//! One `loopd` per home (M5b spec, section 9a): a second `loopd serve` against the same home is
|
|
//! refused before it touches the socket or the server, and the first keeps serving; once the first
|
|
//! is killed, a new one removes the stale socket and serves. Every wait has a deadline, and every
|
|
//! process is killed and every path removed by a guard (tip T44). Do not edit.
|
|
|
|
mod support;
|
|
|
|
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::time::{Duration, Instant};
|
|
|
|
use bxcore::{
|
|
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, WireError, read_frame,
|
|
write_frame,
|
|
};
|
|
use support::{FakeServer, Home, Reply, tokenizer};
|
|
|
|
const POINTER: &str = "see docs/runbook.md#already-running";
|
|
|
|
fn config_file(home: &Home, server: &FakeServer) -> PathBuf {
|
|
let text = format!(
|
|
r#"
|
|
[infer]
|
|
socket = "{}"
|
|
model = "test-model"
|
|
[slots]
|
|
main = 0
|
|
background = 1
|
|
[expect]
|
|
template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
|
|
n_ctx = 131072
|
|
slots = 2
|
|
[limits]
|
|
poll_ms = 40
|
|
liveness_ms = 500
|
|
retry_backoff_ms = [10]
|
|
[paths]
|
|
home = "{}"
|
|
"#,
|
|
server.socket.display(),
|
|
home.dir.display()
|
|
);
|
|
let path = home.dir.join("config.toml");
|
|
std::fs::write(&path, text).unwrap();
|
|
path
|
|
}
|
|
|
|
/// A server that passes the self-test twice: once for the first `loopd`, once for the one started
|
|
/// after it is killed.
|
|
fn healthy_routes(server: &FakeServer) {
|
|
server.route("/props", vec![Reply::fixture("props")]);
|
|
server.respond("/tokenize", tokenizer);
|
|
server.route(
|
|
"/v1/chat/completions",
|
|
vec![
|
|
Reply::fixture("tool_call"),
|
|
Reply::fixture("turn1"),
|
|
Reply::fixture("turn2"),
|
|
Reply::fixture("tool_call"),
|
|
Reply::fixture("turn1"),
|
|
Reply::fixture("turn2"),
|
|
],
|
|
);
|
|
}
|
|
|
|
fn socket(home: &Home) -> PathBuf {
|
|
home.dir.join("run").join("loop").join("loop.sock")
|
|
}
|
|
|
|
/// A running `loopd serve`, killed and waited for when dropped. Declared after the `Home` and the
|
|
/// `FakeServer`, so it drops first.
|
|
struct Daemon(Child);
|
|
|
|
impl Drop for Daemon {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
|
|
impl Daemon {
|
|
fn running(&mut self) -> bool {
|
|
matches!(self.0.try_wait(), Ok(None))
|
|
}
|
|
}
|
|
|
|
/// `loopd serve` with its stderr in `<home>/<name>.err`, for the messages of a failing test.
|
|
fn start(home: &Home, config: &Path, name: &str) -> Daemon {
|
|
let err = std::fs::File::create(home.dir.join(format!("{name}.err"))).unwrap();
|
|
let child = Command::new(env!("CARGO_BIN_EXE_loopd"))
|
|
.args(["serve", "--config"])
|
|
.arg(config)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::from(err))
|
|
.spawn()
|
|
.unwrap();
|
|
Daemon(child)
|
|
}
|
|
|
|
fn stderr_of(home: &Home, name: &str) -> String {
|
|
std::fs::read_to_string(home.dir.join(format!("{name}.err"))).unwrap_or_default()
|
|
}
|
|
|
|
/// Runs `loopd serve` to its end, at most 20 s (then it is killed), and gives its exit code
|
|
/// (`None` if it had to be killed) and its stderr.
|
|
fn run_to_end(config: &Path) -> (Option<i32>, String) {
|
|
let mut child = Daemon(
|
|
Command::new(env!("CARGO_BIN_EXE_loopd"))
|
|
.args(["serve", "--config"])
|
|
.arg(config)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap(),
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
let mut code = None;
|
|
while Instant::now() < deadline {
|
|
if let Ok(Some(status)) = child.0.try_wait() {
|
|
code = status.code();
|
|
break;
|
|
}
|
|
std::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 _ = std::io::Read::read_to_string(&mut pipe, &mut stderr);
|
|
}
|
|
(code, stderr)
|
|
}
|
|
|
|
/// True when `loopd` answers on `socket`: a turn that resumes a session that does not exist gets
|
|
/// the error frame for it. Any other answer, or none within 5 s, is false.
|
|
fn answers(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;
|
|
}
|
|
let turn = Envelope {
|
|
v: PROTOCOL_VERSION,
|
|
id: 7,
|
|
r#final: true,
|
|
msg: Message::Turn(Turn {
|
|
session: SessionId::new("probe").unwrap(),
|
|
content: "are you there".to_string(),
|
|
resume: true,
|
|
}),
|
|
};
|
|
if write_frame(&mut stream, &turn).is_err() {
|
|
return false;
|
|
}
|
|
matches!(
|
|
read_frame(&mut stream),
|
|
Ok(Envelope {
|
|
msg: Message::Error(WireError {
|
|
code: ErrorCode::NoSuchSession,
|
|
..
|
|
}),
|
|
..
|
|
})
|
|
)
|
|
}
|
|
|
|
/// Waits up to 20 s for `loopd` to answer on `socket`.
|
|
fn wait_until_it_answers(socket: &Path) -> bool {
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
while Instant::now() < deadline {
|
|
if answers(socket) {
|
|
return true;
|
|
}
|
|
std::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_loopd_on_the_same_home_is_refused_and_the_first_keeps_serving() {
|
|
let home = Home::new();
|
|
let server = FakeServer::start();
|
|
healthy_routes(&server);
|
|
let config = config_file(&home, &server);
|
|
let socket = socket(&home);
|
|
let mut first = start(&home, &config, "first");
|
|
assert!(
|
|
wait_until_it_answers(&socket),
|
|
"the first loopd never answered; stderr: {}",
|
|
stderr_of(&home, "first")
|
|
);
|
|
let before = inode(&socket);
|
|
let completions = server.requests_to("/v1/chat/completions").len();
|
|
let tokenized = server.requests_to("/tokenize").len();
|
|
|
|
let (code, stderr) = run_to_end(&config);
|
|
let lock = home.dir.join("run").join("loopd.lock");
|
|
let line = format!(
|
|
"loopd: another loopd is running (it holds {}); {POINTER}",
|
|
lock.display()
|
|
);
|
|
assert_eq!(code, Some(1), "the second loopd: {stderr}");
|
|
assert!(
|
|
stderr.lines().any(|l| l == line),
|
|
"wanted the line\n{line}\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("selftest"),
|
|
"the second ran no self-test: {stderr}"
|
|
);
|
|
assert_eq!(
|
|
(
|
|
server.requests_to("/v1/chat/completions").len(),
|
|
server.requests_to("/tokenize").len()
|
|
),
|
|
(completions, tokenized),
|
|
"the second sent the server nothing"
|
|
);
|
|
|
|
assert!(first.running(), "the first loopd is still running");
|
|
assert_eq!(inode(&socket), before, "the first's socket is still there");
|
|
assert!(
|
|
answers(&socket),
|
|
"the first loopd still answers; its stderr: {}",
|
|
stderr_of(&home, "first")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn after_the_first_is_killed_a_new_loopd_removes_the_stale_socket_and_serves() {
|
|
let home = Home::new();
|
|
let server = FakeServer::start();
|
|
healthy_routes(&server);
|
|
let config = config_file(&home, &server);
|
|
let socket = socket(&home);
|
|
let mut first = start(&home, &config, "first");
|
|
assert!(
|
|
wait_until_it_answers(&socket),
|
|
"the first loopd never answered; stderr: {}",
|
|
stderr_of(&home, "first")
|
|
);
|
|
let _ = first.0.kill();
|
|
let _ = first.0.wait();
|
|
assert!(socket.exists(), "a killed loopd leaves its socket");
|
|
assert!(!answers(&socket), "nobody serves the stale socket");
|
|
|
|
let _next = start(&home, &config, "next");
|
|
assert!(
|
|
wait_until_it_answers(&socket),
|
|
"the new loopd never answered; stderr: {}",
|
|
stderr_of(&home, "next")
|
|
);
|
|
let lock = home.dir.join("run").join("loopd.lock");
|
|
let mode = std::fs::metadata(&lock).map(|m| m.permissions().mode() & 0o777);
|
|
assert_eq!(mode.ok(), Some(0o600), "the lock file, mode 0600");
|
|
assert_eq!(
|
|
server.requests_to("/v1/chat/completions").len(),
|
|
6,
|
|
"each of the two ran the self-test"
|
|
);
|
|
}
|
|
|
|
/// Gives `<home>/run` back its owner's permissions when dropped, before the `Home` is removed.
|
|
struct Restore(PathBuf);
|
|
|
|
impl Drop for Restore {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_run_directory_that_cannot_be_written_stops_loopd_naming_the_error() {
|
|
let home = Home::new();
|
|
let server = FakeServer::start();
|
|
healthy_routes(&server);
|
|
let config = config_file(&home, &server);
|
|
let run = home.dir.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();
|
|
|
|
let (code, stderr) = run_to_end(&config);
|
|
let line = format!(
|
|
"loopd: cannot take the lock {}: Permission denied (os error 13); {POINTER}",
|
|
run.join("loopd.lock").display()
|
|
);
|
|
assert_eq!(code, Some(1), "{stderr}");
|
|
assert!(
|
|
stderr.lines().any(|l| l == line),
|
|
"wanted the line\n{line}\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
server.requests().is_empty(),
|
|
"loopd sent the server nothing"
|
|
);
|
|
assert!(!socket(&home).exists(), "no socket");
|
|
}
|