241 lines
8.0 KiB
Rust
241 lines
8.0 KiB
Rust
//! Every fail-closed message `loopd` produces ends with the runbook entry that explains it.
|
|
//! `scripts/check-runbook.sh` checks that the entries exist; these tests check that the messages
|
|
//! name them. Do not edit.
|
|
|
|
mod support;
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::time::Duration;
|
|
|
|
use loopd::baseline::Baseline;
|
|
use loopd::broker_port::{not_configured_line, unavailable_line};
|
|
use loopd::session::{Session, SessionError};
|
|
use loopd::tools::Registry;
|
|
use proto::SessionId;
|
|
use support::{FakeServer, Home, Reply};
|
|
|
|
// `want` is the whole pointer, written out: scripts/check-runbook.sh reads the anchors of every
|
|
// pointer in the source, and cannot read one built with `format!`.
|
|
fn ends_with_pointer(message: &str, want: &str) {
|
|
assert!(
|
|
message.ends_with(want),
|
|
"{message:?} must end with {want:?}"
|
|
);
|
|
assert_eq!(
|
|
message.matches("runbook.md#").count(),
|
|
1,
|
|
"one pointer, not two: {message:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_damaged_session_log_names_its_entry() {
|
|
let home = Home::new();
|
|
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
|
let id = SessionId::new("a").unwrap();
|
|
let baseline = Baseline::assemble(&cfg, &Registry::m3a()).unwrap();
|
|
drop(Session::create(&home.dir, id.clone(), baseline, 0).unwrap());
|
|
let log = home.dir.join("sessions/a/0.jsonl");
|
|
let mut text = std::fs::read_to_string(&log).unwrap();
|
|
text.push_str("{\"type\":\"user\",\"time\":");
|
|
std::fs::write(&log, text).unwrap();
|
|
match Session::open(&home.dir, id) {
|
|
Err(e @ SessionError::Torn { .. }) => {
|
|
let message = e.to_string();
|
|
assert!(message.contains("0.jsonl:2: "), "{message}");
|
|
ends_with_pointer(&message, "see docs/runbook.md#session-log-damaged");
|
|
}
|
|
Err(other) => panic!("{other:?}"),
|
|
Ok(_) => panic!("a torn log was opened"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_torn_error_points_at_the_damaged_log_entry() {
|
|
let home = Home::new();
|
|
let missing = Session::open(&home.dir, SessionId::new("nobody").unwrap());
|
|
let message = match missing {
|
|
Err(e) => e.to_string(),
|
|
Ok(_) => panic!("a session nobody created was opened"),
|
|
};
|
|
assert!(!message.contains("runbook"), "{message}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unreadable_core_memory_names_its_entry_and_a_missing_system_prompt_does_not() {
|
|
let home = Home::new();
|
|
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
|
home.write("memory/core.md", "memory\n");
|
|
let core = home.dir.join("memory/core.md");
|
|
if !running_as_root() {
|
|
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o000)).unwrap();
|
|
let result = Baseline::assemble(&cfg, &Registry::m3a());
|
|
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o644)).unwrap();
|
|
let message = result.expect_err("unreadable core.md").to_string();
|
|
assert!(message.contains("core.md"), "{message}");
|
|
ends_with_pointer(&message, "see docs/runbook.md#core-memory-unreadable");
|
|
}
|
|
// The system prompt is a different file with a different remedy: no pointer to this entry.
|
|
std::fs::remove_file(home.dir.join("system.md")).unwrap();
|
|
let message = Baseline::assemble(&cfg, &Registry::m3a())
|
|
.expect_err("no system.md")
|
|
.to_string();
|
|
assert!(!message.contains("core-memory-unreadable"), "{message}");
|
|
}
|
|
|
|
fn running_as_root() -> bool {
|
|
std::fs::read_to_string("/proc/self/status")
|
|
.map(|s| s.lines().any(|l| l.starts_with("Uid:\t0\t")))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[test]
|
|
fn the_broker_lines_name_their_entry() {
|
|
let line = unavailable_line("cannot connect to /run/x.sock: No such file");
|
|
assert_eq!(
|
|
line,
|
|
"loopd: the tool broker is unavailable: cannot connect to /run/x.sock: No such file; \
|
|
see docs/runbook.md#broker-unavailable"
|
|
);
|
|
ends_with_pointer(&line, "see docs/runbook.md#broker-unavailable");
|
|
let line = not_configured_line();
|
|
assert!(
|
|
line.starts_with("loopd: no tool broker is configured"),
|
|
"{line}"
|
|
);
|
|
ends_with_pointer(&line, "see docs/runbook.md#broker-unavailable");
|
|
}
|
|
|
|
fn config_file(
|
|
home: &Home,
|
|
server: &FakeServer,
|
|
expect_slots: u32,
|
|
extra: &str,
|
|
) -> std::path::PathBuf {
|
|
let text = format!(
|
|
r#"
|
|
[infer]
|
|
socket = "{}"
|
|
model = "test-model"
|
|
[slots]
|
|
main = 0
|
|
background = 1
|
|
[expect]
|
|
template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
|
|
n_ctx = 131072
|
|
slots = {expect_slots}
|
|
[limits]
|
|
poll_ms = 40
|
|
liveness_ms = 500
|
|
retry_backoff_ms = [10]
|
|
[paths]
|
|
home = "{}"
|
|
{extra}
|
|
"#,
|
|
server.socket.display(),
|
|
home.dir.display()
|
|
);
|
|
let path = home.dir.join("config.toml");
|
|
std::fs::write(&path, text).unwrap();
|
|
path
|
|
}
|
|
|
|
fn healthy_routes(server: &FakeServer) {
|
|
server.route("/props", vec![Reply::fixture("props")]);
|
|
server.route(
|
|
"/v1/chat/completions",
|
|
vec![
|
|
Reply::fixture("tool_call"),
|
|
Reply::fixture("turn1"),
|
|
Reply::fixture("turn2"),
|
|
Reply::fixture("plain"),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Waits for `loopd serve` to bind its socket, stops it, and returns what it printed.
|
|
fn stderr_once_serving(mut child: Child, socket: &Path) -> String {
|
|
let mut up = false;
|
|
for _ in 0..200 {
|
|
if socket.exists() {
|
|
up = true;
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
child.kill().unwrap();
|
|
let output = child.wait_with_output().unwrap();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
assert!(up, "the socket never appeared; stderr: {stderr}");
|
|
stderr
|
|
}
|
|
|
|
fn serve(config: &Path) -> Child {
|
|
Command::new(env!("CARGO_BIN_EXE_loopd"))
|
|
.args(["serve", "--config"])
|
|
.arg(config)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_self_test_names_its_entry() {
|
|
let home = Home::new();
|
|
let server = FakeServer::start();
|
|
healthy_routes(&server);
|
|
// The server has two slots; the config expects three.
|
|
let config = config_file(&home, &server, 3, "");
|
|
for command in ["selftest", "serve"] {
|
|
let output = Command::new(env!("CARGO_BIN_EXE_loopd"))
|
|
.args([command, "--config"])
|
|
.arg(&config)
|
|
.output()
|
|
.unwrap();
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert_eq!(output.status.code(), Some(1), "{command}: {stderr}");
|
|
let line = stderr
|
|
.lines()
|
|
.find(|l| l.starts_with("selftest: FAILED: "))
|
|
.unwrap_or_else(|| panic!("{command}: no FAILED line: {stderr}"));
|
|
ends_with_pointer(line, "see docs/runbook.md#loopd-selftest-failed");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn serve_without_a_broker_says_so_once_with_the_entry() {
|
|
let home = Home::new();
|
|
let server = FakeServer::start();
|
|
healthy_routes(&server);
|
|
let config = config_file(&home, &server, 2, "");
|
|
let socket = home.dir.join("run/loop/loop.sock");
|
|
let stderr = stderr_once_serving(serve(&config), &socket);
|
|
let lines: Vec<&str> = stderr
|
|
.lines()
|
|
.filter(|l| l.contains("no tool broker is configured"))
|
|
.collect();
|
|
assert_eq!(lines.len(), 1, "once, at startup: {stderr}");
|
|
ends_with_pointer(lines[0], "see docs/runbook.md#broker-unavailable");
|
|
}
|
|
|
|
#[test]
|
|
fn serve_with_a_broker_socket_does_not_say_it() {
|
|
let home = Home::new();
|
|
let server = FakeServer::start();
|
|
healthy_routes(&server);
|
|
// The socket need not exist: loopd connects per call, and a missing broker is not a reason
|
|
// to refuse to start.
|
|
let extra = format!(
|
|
"[broker]\nsocket = \"{}\"\n",
|
|
home.dir.join("run/loop-broker/broker.sock").display()
|
|
);
|
|
let config = config_file(&home, &server, 2, &extra);
|
|
let socket = home.dir.join("run/loop/loop.sock");
|
|
let stderr = stderr_once_serving(serve(&config), &socket);
|
|
assert!(!stderr.contains("no tool broker"), "{stderr}");
|
|
assert!(stderr.contains("serving on"), "{stderr}");
|
|
}
|