Files
boxmaker/crates/loopd/tests/channel.rs
T
kyleandClaude Opus 5.5 a15c428ea6 tests: every temporary path is removed by a guard; no leftovers in /tmp
About 10,000 test leftovers had built up in titan's /tmp. loopd's Home
and FakeServer, its fake broker's socket, bxctl's temp sockets, fake
loopd and command homes, inferproxy's socket, and a few brokerd and
toolkit tests created uniquely named directories or sockets and never
removed them, or removed them by hand at the end, which a failing test
skips. Each is now a guard removed on drop (bxctl gains
tests/support/temp.rs). Two races also left gatewayd homes behind: the
test Running only signalled its server to stop, and two Stage structs
declared the home first, so it was removed while the server could still
write into it. Running's drop now waits for the thread (with a 5-second
limit), and every home is declared last.

Checked: make gate twice, then the gate and three rounds of gatewayd and
loopd tests; no new entry in /tmp; 944 tests pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:29:32 -07:00

385 lines
11 KiB
Rust

//! Tests for the channel protocol on `loop.sock`. Do not edit.
//!
//! A real `channel::serve` runs on a socket in a temporary home, with the fake inference server
//! behind it. The tests speak the frame protocol to it directly.
mod support;
use loopd::channel::{Context, serve};
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::tools::Registry;
use proto::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnEvent, read_frame,
write_frame,
};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
struct Loopd {
server: FakeServer,
socket: PathBuf,
// Declared last so it is dropped last: fields drop in order, and the home must outlive
// everything that may still write into it (tip T44).
home: Home,
}
fn start(replies: Vec<proto::ToolResponse>) -> Loopd {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
let socket = cfg.channel_socket();
let listener = UnixListener::bind(&socket).unwrap();
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(replies)),
Registry::m2b(),
Box::new(NoMemory),
));
thread::spawn(move || serve(listener, ctx));
Loopd {
home,
server,
socket,
}
}
fn id(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
/// Sends one turn and collects every frame that comes back.
fn turn(socket: &PathBuf, session: &str, content: &str, resume: bool) -> Vec<Envelope> {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let msg = Message::Turn(Turn {
session: id(session),
content: content.to_string(),
resume,
});
write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id: 42,
r#final: true,
msg,
},
)
.unwrap();
let mut frames = Vec::new();
loop {
let frame = read_frame(&mut stream).unwrap();
let last = frame.r#final;
frames.push(frame);
if last {
break;
}
}
frames
}
fn error_code(frames: &[Envelope]) -> Option<ErrorCode> {
match &frames.last()?.msg {
Message::Error(e) => Some(e.code),
_ => None,
}
}
#[test]
fn a_turn_streams_events_and_ends_with_turn_done() {
let l = start(vec![]);
l.server.route(CHAT, vec![Reply::fixture("thinking")]);
let frames = turn(&l.socket, "a", "what is 17 * 23?", false);
assert!(frames.len() > 3, "{frames:?}");
assert!(
frames.iter().all(|f| f.id == 42),
"every frame carries the request id"
);
assert!(frames.iter().all(|f| f.v == PROTOCOL_VERSION));
let (last, events) = frames.split_last().unwrap();
assert!(
events
.iter()
.all(|f| !f.r#final && matches!(f.msg, Message::TurnEvent(_)))
);
assert!(
events
.iter()
.any(|f| matches!(f.msg, Message::TurnEvent(TurnEvent::Reasoning { .. })))
);
assert!(
events
.iter()
.any(|f| matches!(f.msg, Message::TurnEvent(TurnEvent::Content { .. })))
);
match &last.msg {
Message::TurnDone(done) => {
assert_eq!(
done.content,
support::expected("thinking")["content"].as_str().unwrap()
);
assert_eq!(done.usage.reasoning_tokens, 49);
assert_eq!(done.usage.prompt_n, 40);
}
other => panic!("{other:?}"),
}
// The content events, concatenated, are the answer.
let streamed: String = events
.iter()
.filter_map(|f| match &f.msg {
Message::TurnEvent(TurnEvent::Content { text }) => Some(text.as_str()),
_ => None,
})
.collect();
let Message::TurnDone(done) = &last.msg else {
unreachable!()
};
assert_eq!(streamed, done.content);
// And the session is on disk.
assert_eq!(l.home.records("a").len(), 4);
}
#[test]
fn tool_calls_are_reported_by_name_only() {
let l = start(vec![support::ok_result("straylight\n")]);
l.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let frames = turn(&l.socket, "a", "hostname?", false);
let tool_events: Vec<&TurnEvent> = frames
.iter()
.filter_map(|f| match &f.msg {
Message::TurnEvent(
e @ (TurnEvent::ToolCallStarted { .. } | TurnEvent::ToolResult { .. }),
) => Some(e),
_ => None,
})
.collect();
assert_eq!(tool_events.len(), 2);
assert_eq!(
tool_events[0],
&TurnEvent::ToolCallStarted {
name: "read_file".to_string()
}
);
assert_eq!(
tool_events[1],
&TurnEvent::ToolResult {
name: "read_file".to_string(),
class: proto::DataClass::Private,
truncated: false
}
);
let text = serde_json::to_string(&frames).unwrap();
assert!(
!text.contains("straylight"),
"the result body is not on the channel: {text}"
);
}
#[test]
fn resume_and_create_are_checked() {
let l = start(vec![]);
l.server
.route(CHAT, vec![Reply::fixture("turn1"), Reply::fixture("turn2")]);
assert_eq!(
error_code(&turn(&l.socket, "a", "x", true)),
Some(ErrorCode::NoSuchSession),
"resume needs an existing session"
);
assert!(matches!(
turn(&l.socket, "a", "one", false).last().unwrap().msg,
Message::TurnDone(_)
));
assert_eq!(
error_code(&turn(&l.socket, "a", "x", false)),
Some(ErrorCode::SessionExists),
"create needs a new one"
);
let frames = turn(&l.socket, "a", "two", true);
assert!(
matches!(frames.last().unwrap().msg, Message::TurnDone(_)),
"{frames:?}"
);
let sent = l.server.requests_to(CHAT);
assert_eq!(sent.len(), 2);
assert_eq!(
sent[1].json()["messages"].as_array().unwrap().len(),
4,
"the second turn carried the first"
);
}
#[test]
fn a_busy_session_is_refused_at_once_and_another_session_is_not() {
let l = start(vec![]);
let size = support::fixture_bytes("http", "plain.http").len();
l.server
.route(CHAT, vec![Reply::fixture("plain").trickle(size / 4, 120)]);
let socket = l.socket.clone();
let first = thread::spawn(move || turn(&socket, "a", "slow", false));
thread::sleep(Duration::from_millis(100));
let started = std::time::Instant::now();
assert_eq!(
error_code(&turn(&l.socket, "a", "again", true)),
Some(ErrorCode::SessionBusy)
);
assert!(
started.elapsed() < Duration::from_millis(100),
"refused at once"
);
assert!(
matches!(
turn(&l.socket, "b", "other", false).last().unwrap().msg,
Message::TurnDone(_)
),
"another session runs"
);
assert!(matches!(
first.join().unwrap().last().unwrap().msg,
Message::TurnDone(_)
));
}
#[test]
fn limits_and_server_errors_come_back_as_error_codes() {
let l = start(vec![]);
l.server.route(CHAT, vec![Reply::fixture("context_full")]);
assert_eq!(
error_code(&turn(&l.socket, "a", "x", false)),
Some(ErrorCode::SessionFull)
);
l.server.route(CHAT, vec![Reply::fixture("tool_call")]);
let frames = turn(&l.socket, "b", "x", false);
assert_eq!(
error_code(&frames),
Some(ErrorCode::TurnLimit),
"a repeated call twice"
);
assert!(
frames
.iter()
.any(|f| matches!(f.msg, Message::TurnEvent(TurnEvent::ToolCallStarted { .. }))),
"events before the error were delivered"
);
l.server.route(CHAT, vec![Reply::fixture("bad_request")]);
let frames = turn(&l.socket, "c", "x", false);
assert_eq!(error_code(&frames), Some(ErrorCode::Inference));
let Message::Error(e) = &frames.last().unwrap().msg else {
unreachable!()
};
assert!(e.detail.contains("400"), "{}", e.detail);
}
#[test]
fn bad_frames_get_an_error_and_a_close() {
let l = start(vec![]);
// A frame that is not a turn.
let mut stream = UnixStream::connect(&l.socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let msg = Message::Error(proto::WireError {
code: ErrorCode::Internal,
detail: String::new(),
});
write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
},
)
.unwrap();
let reply = read_frame(&mut stream).unwrap();
assert!(reply.r#final);
assert!(
matches!(
reply.msg,
Message::Error(proto::WireError {
code: ErrorCode::BadMessage,
..
})
),
"{reply:?}"
);
assert!(
matches!(read_frame(&mut stream), Err(proto::FrameError::Closed)),
"then the connection is closed"
);
// Bytes that are not a frame at all.
let mut stream = UnixStream::connect(&l.socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
std::io::Write::write_all(&mut stream, &[0, 0, 0, 3, b'{', b'{', b'{']).unwrap();
let reply = read_frame(&mut stream).unwrap();
assert!(
matches!(
reply.msg,
Message::Error(proto::WireError {
code: ErrorCode::BadMessage,
..
})
),
"{reply:?}"
);
// A wrong protocol version.
let mut stream = UnixStream::connect(&l.socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let msg = Message::Turn(Turn {
session: id("a"),
content: "x".to_string(),
resume: false,
});
write_frame(
&mut stream,
&Envelope {
v: 2,
id: 1,
r#final: true,
msg,
},
)
.unwrap();
let reply = read_frame(&mut stream).unwrap();
assert!(
matches!(
reply.msg,
Message::Error(proto::WireError {
code: ErrorCode::BadVersion,
..
})
),
"{reply:?}"
);
// A client that connects and leaves.
drop(UnixStream::connect(&l.socket).unwrap());
thread::sleep(Duration::from_millis(50));
l.server.route(CHAT, vec![Reply::fixture("plain")]);
assert!(matches!(
turn(&l.socket, "z", "still up", false).last().unwrap().msg,
Message::TurnDone(_)
));
}