sausage core part 1: a process per call, routing, tree, stop and kill, locks, journal, boot

Checkpoint. Removes the old host. A quality rework follows (modules,
journal writer thread, hash cache, finer tests).
This commit is contained in:
2026-10-23 00:12:02 +01:00
parent 0a479b5800
commit 4882edf243
23 changed files with 1650 additions and 5377 deletions
-105
View File
@@ -1,105 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toolbox"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-180
View File
@@ -1,180 +0,0 @@
use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
fn send(out: &mut impl Write, message: Value) {
writeln!(out, "{message}").unwrap();
out.flush().unwrap();
}
fn reply(out: &mut impl Write, message: &Value, body: Value) {
send(
out,
json!({"to": message["from"], "reply_to": message["id"], "body": body}),
);
}
/// Connect to `address`, send one line, and return the line that comes back.
fn tcp_line(address: &str, line: &str) -> io::Result<String> {
let mut stream = std::net::TcpStream::connect(address)?;
writeln!(stream, "{line}")?;
let mut answer = String::new();
io::BufReader::new(stream).read_line(&mut answer)?;
Ok(answer.trim_end().to_string())
}
fn io_error(error: io::Error) -> Value {
json!({"type": "error", "error": error.to_string()})
}
fn main() {
let actor = std::env::var("SAUSAGE_ACTOR").unwrap_or_default();
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_else(|_| "candidate".to_string());
let candidate = std::env::var_os("SAUSAGE_CANDIDATE").is_some();
if actor == "crashloop" && version == "2" && !candidate {
std::fs::write("/data/state", "bad-v2").unwrap();
panic!("version 2 crashes on startup");
}
let mut open = HashMap::<String, std::fs::File>::new();
// Files that this actor serves at /actor/<id>/fs/ (path -> text).
let mut served = std::collections::BTreeMap::<String, String>::new();
let stdin = io::stdin();
let mut out = io::stdout().lock();
let mut emit_shutdown = false;
if actor == "spawn-race" && !candidate {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::fs::write(format!("/data/start-{stamp}"), "started").unwrap();
}
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
let Ok(message) = serde_json::from_str::<Value>(&line) else {
continue;
};
let body = &message["body"];
let path = body["path"].as_str().unwrap_or_default().to_string();
let text = body["text"].as_str().unwrap_or_default();
let response = match body["type"].as_str() {
Some("shutdown") => {
if actor == "queue" {
let _ = std::fs::write("/data/shutdown", "stopping");
std::thread::sleep(Duration::from_millis(800));
}
if emit_shutdown {
send(
&mut out,
json!({"to": "console", "reply_to": message["id"], "body": {"type": "shutdown-reply"}}),
);
send(&mut out, json!({"body": {"type": "shutdown-publish"}}));
}
break;
}
Some("echo") => json!({"type": "echo", "value": body["value"]}),
Some("fs_read") => {
let dir = if path == "/" { "/".to_string() } else { format!("{path}/") };
let mut names: Vec<String> = served
.keys()
.filter_map(|k| k.strip_prefix(&dir))
.map(|rest| match rest.split_once('/') {
Some((first, _)) => format!("{first}/"),
None => rest.to_string(),
})
.collect();
names.dedup();
if let Some(text) = served.get(&path) {
json!({"type": "fs_file", "text": text})
} else if path == "/" || !names.is_empty() {
let entries: Vec<Value> = names
.iter()
.map(|n| match n.strip_suffix('/') {
Some(d) => json!({"name": d, "type": "dir"}),
None => json!({"name": n, "type": "file"}),
})
.collect();
json!({"type": "fs_dir", "entries": entries})
} else {
json!({"type": "fs_error", "errno": "no-entry"})
}
}
Some("fs_write") => {
served.insert(path.clone(), text.to_string());
json!({"type": "fs_done"})
}
Some("fs_remove") => match served.remove(&path) {
Some(_) => json!({"type": "fs_done"}),
None => json!({"type": "fs_error", "errno": "no-entry"}),
},
Some("publish") => {
send(
&mut out,
json!({"body": {"type": "published", "value": body["value"]}}),
);
continue;
}
Some("version") => json!({"type": "version", "version": version}),
Some("ordered") => json!({"type": "ordered", "n": body["n"], "version": version}),
Some("write") => match std::fs::write(&path, text) {
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("mkdir") => match std::fs::create_dir_all(&path) {
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("rename") => match std::fs::rename(&path, body["to"].as_str().unwrap_or_default())
{
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("open") => {
let result = (|| -> io::Result<()> {
let mut file = std::fs::File::create(&path)?;
file.write_all(text.as_bytes())?;
open.insert(path.clone(), file);
Ok(())
})();
match result {
Ok(()) => json!({"type": "opened"}),
Err(error) => io_error(error),
}
}
Some("close") => json!({"type": "closed", "closed": open.remove(&path).is_some()}),
Some("arm-shutdown") => {
emit_shutdown = true;
json!({"type": "armed"})
}
Some("ignore-stdin") => json!({"type": "ignoring"}),
Some("tcp") => match tcp_line(body["address"].as_str().unwrap_or_default(), text) {
Ok(line) => json!({"type": "tcp", "line": line}),
Err(error) => io_error(error),
},
Some("read") => match std::fs::read_to_string(&path) {
Ok(text) => json!({"type": "content", "text": text}),
Err(error) => io_error(error),
},
Some("list") => match std::fs::read_dir(&path) {
Ok(entries) => {
let mut names: Vec<String> = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
names.sort();
json!({"type": "entries", "names": names})
}
Err(error) => io_error(error),
},
_ => json!({"type": "error", "error": "unknown message"}),
};
reply(&mut out, &message, response);
if body["type"] == "ignore-stdin" {
loop {
std::thread::sleep(Duration::from_secs(60));
}
}
}
}
-555
View File
@@ -1,555 +0,0 @@
//! Integration tests for the agent's inference and restart behavior.
#[allow(dead_code)]
mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
use common::{create_world, receive_message, receive_reply, request, source_with_test, start};
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
use tokio::task::{JoinHandle, JoinSet};
const APIS: [Api; 2] = [Api::OpenAi, Api::Anthropic];
const TEST_TOKEN: &str = "fake-token";
#[derive(Clone, Copy)]
enum Api {
OpenAi,
Anthropic,
}
impl Api {
fn name(self) -> &'static str {
match self {
Self::OpenAi => "openai-responses",
Self::Anthropic => "anthropic-messages",
}
}
fn path(self) -> &'static str {
match self {
Self::OpenAi => "/v1/responses",
Self::Anthropic => "/v1/messages",
}
}
fn text_response(self, text: &str) -> Value {
match self {
Self::OpenAi => {
json!({"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]})
}
Self::Anthropic => json!({"content": [{"type": "text", "text": text}]}),
}
}
fn tool_response(self, id: &str, name: &str, arguments: &Value) -> Value {
match self {
Self::OpenAi => {
json!({"output": [{"type": "function_call", "call_id": id, "name": name, "arguments": arguments.to_string()}]})
}
Self::Anthropic => {
json!({"content": [{"type": "tool_use", "id": id, "name": name, "input": arguments}]})
}
}
}
fn check_request(self, request: &HttpRequest) {
assert_eq!(request.method, "POST");
assert_eq!(request.path, self.path());
assert_eq!(request.headers["content-type"], "application/json");
assert_eq!(request.body["model"], "fake-model");
match self {
Self::OpenAi => assert_eq!(request.headers["authorization"], "Bearer fake-token"),
Self::Anthropic => {
assert_eq!(request.headers["x-api-key"], TEST_TOKEN);
assert_eq!(request.headers["anthropic-version"], "2023-06-01");
}
}
}
}
struct HttpRequest {
method: String,
path: String,
headers: HashMap<String, String>,
body: Value,
}
enum ReplyPlan {
Send(Value),
Hold(Value, oneshot::Receiver<()>),
}
struct FakeServer {
base_url: String,
plans: mpsc::Sender<ReplyPlan>,
requests: mpsc::Receiver<HttpRequest>,
task: JoinHandle<()>,
}
impl FakeServer {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (plans, mut plan_rx) = mpsc::channel(16);
let (request_tx, requests) = mpsc::channel(16);
let task = tokio::spawn(async move {
let mut handlers = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let (mut stream, _) = accepted.expect("fake listener accept failed");
let request = read_http_request(&mut stream).await;
let Some(plan) = plan_rx.recv().await else { break };
if request_tx.send(request).await.is_err() {
break;
}
handlers.spawn(async move {
let (body, release) = match plan {
ReplyPlan::Send(body) => (body, None),
ReplyPlan::Hold(body, release) => (body, Some(release)),
};
if let Some(release) = release {
let _ = release.await;
}
write_http_response(stream, body).await;
});
}
Some(_) = handlers.join_next(), if !handlers.is_empty() => {}
}
}
});
Self {
base_url: format!("http://127.0.0.1:{}", address.port()),
plans,
requests,
task,
}
}
async fn answer(&self, body: Value) {
self.plans.send(ReplyPlan::Send(body)).await.unwrap();
}
async fn hold(&self, body: Value) -> oneshot::Sender<()> {
let (release, wait) = oneshot::channel();
self.plans.send(ReplyPlan::Hold(body, wait)).await.unwrap();
release
}
async fn next_request(&mut self) -> HttpRequest {
tokio::time::timeout(Duration::from_secs(20), self.requests.recv())
.await
.expect("timed out waiting for fake inference request")
.expect("fake server request channel closed")
}
}
impl Drop for FakeServer {
fn drop(&mut self) {
self.task.abort();
}
}
async fn read_http_request(stream: &mut TcpStream) -> HttpRequest {
let mut received = Vec::new();
let mut chunk = [0; 4096];
let header_end = loop {
let count = stream.read(&mut chunk).await.expect("read HTTP request");
assert_ne!(count, 0, "client closed before HTTP headers arrived");
received.extend_from_slice(&chunk[..count]);
if let Some(end) = received.windows(4).position(|part| part == b"\r\n\r\n") {
break end;
}
};
let header_text = String::from_utf8_lossy(&received[..header_end]);
let mut lines = header_text.split("\r\n");
let mut request_line = lines.next().unwrap().split_whitespace();
let method = request_line.next().unwrap().to_string();
let path = request_line.next().unwrap().to_string();
let mut headers = HashMap::new();
for line in lines {
if let Some((name, value)) = line.split_once(':') {
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
let body_start = header_end + 4;
let request_body = if let Some(length) = headers.get("content-length") {
let body_length = length.parse::<usize>().unwrap();
while received.len() < body_start + body_length {
let count = stream.read(&mut chunk).await.expect("read HTTP body");
assert_ne!(count, 0, "client closed before HTTP body arrived");
received.extend_from_slice(&chunk[..count]);
}
received[body_start..body_start + body_length].to_vec()
} else {
assert!(
headers
.get("transfer-encoding")
.is_some_and(|value| value.eq_ignore_ascii_case("chunked")),
"request has neither Content-Length nor chunked transfer encoding: {headers:?}"
);
read_chunked_body(stream, &mut received, body_start).await
};
let body = serde_json::from_slice(&request_body).unwrap();
HttpRequest {
method,
path,
headers,
body,
}
}
async fn read_chunked_body(
stream: &mut TcpStream,
received: &mut Vec<u8>,
mut offset: usize,
) -> Vec<u8> {
let mut body = Vec::new();
loop {
let line_end = loop {
if let Some(end) = received[offset..]
.windows(2)
.position(|part| part == b"\r\n")
{
break offset + end;
}
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await.expect("read chunk size");
assert_ne!(count, 0, "client closed before chunk size arrived");
received.extend_from_slice(&chunk[..count]);
};
let size = String::from_utf8_lossy(&received[offset..line_end]);
let size = size.split(';').next().unwrap().trim();
let size = usize::from_str_radix(size, 16).unwrap();
offset = line_end + 2;
if size == 0 {
return body;
}
while received.len() < offset + size + 2 {
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await.expect("read chunk data");
assert_ne!(count, 0, "client closed before chunk data arrived");
received.extend_from_slice(&chunk[..count]);
}
body.extend_from_slice(&received[offset..offset + size]);
assert_eq!(&received[offset + size..offset + size + 2], b"\r\n");
offset += size + 2;
}
}
async fn write_http_response(mut stream: TcpStream, body: Value) {
let body = serde_json::to_vec(&body).unwrap();
let head = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
let mut response = head.into_bytes();
response.extend_from_slice(&body);
let _ = stream.write_all(&response).await;
let _ = stream.shutdown().await;
}
fn agent_wasm() -> &'static PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| {
let root = common::repo_root();
let output = Command::new("cargo")
.args([
"build",
"--release",
"-p",
"agent",
"--target",
"wasm32-wasip2",
"--target-dir",
"target/test-actors",
])
.current_dir(&root)
.output()
.expect("run cargo to build the agent actor");
assert!(
output.status.success(),
"building agent failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let wasm = root.join("target/test-actors/wasm32-wasip2/release/agent.wasm");
assert!(
wasm.is_file(),
"agent build did not create {}",
wasm.display()
);
wasm
})
}
async fn start_agent(
api: Api,
fake: &FakeServer,
label: &str,
) -> (World, Host, mpsc::Receiver<Envelope>) {
let world = create_world(label);
let data = world.data_dir("agent");
std::fs::create_dir_all(&data).unwrap();
let config = json!({"api": api.name(), "base_url": fake.base_url, "model": "fake-model", "token": TEST_TOKEN});
std::fs::write(
data.join("config.json"),
serde_json::to_vec(&config).unwrap(),
)
.unwrap();
let source = source_with_test(
&world,
"agent-candidate",
json!({"steps": [{"send": {"type": "text", "text": "candidate"}, "expect": {"type": "text"}}]}),
);
let (host, mut mailbox) = start(&world).await;
let spawned = common::spawn_actor(&host, &mut mailbox, "agent", agent_wasm(), &source).await;
assert_eq!(
spawned.body["type"], "spawned",
"agent spawn failed: {spawned:?}"
);
let subscribed = request(
&host,
&mut mailbox,
json!({"type": "subscribe", "actor": "agent"}),
)
.await;
assert_eq!(subscribed.body["type"], "subscribed");
(world, host, mailbox)
}
fn send_text(host: &Host, text: &str) -> String {
host.broker.send(
"console",
Some("agent"),
None,
None,
json!({"type": "text", "text": text}),
)
}
async fn assert_published_text(mailbox: &mut mpsc::Receiver<Envelope>, text: &str) {
let message = receive_message(mailbox, Duration::from_secs(20)).await;
assert_eq!(message.from, "agent");
assert_eq!(message.to, "console");
assert_eq!(message.reply_to, None);
assert_eq!(message.body, json!({"type": "text", "text": text}));
}
fn history(world: &World) -> Vec<Value> {
std::fs::read_to_string(world.data_dir("agent").join("history.jsonl"))
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
fn assert_history_tools_have_results(history: &[Value]) {
let calls: Vec<_> = history
.iter()
.filter(|entry| entry["kind"] == "tool_call")
.collect();
let results: Vec<_> = history
.iter()
.filter(|entry| entry["kind"] == "tool_result")
.collect();
assert_eq!(calls.len(), results.len());
for call in calls {
assert!(
results.iter().any(|result| result["id"] == call["id"]),
"tool call has no result: {call}"
);
}
}
fn assert_tool_pair(
request: &HttpRequest,
api: Api,
id: &str,
name: &str,
arguments: &Value,
) -> String {
match api {
Api::OpenAi => {
let input = request.body["input"].as_array().unwrap();
let call_index = input
.iter()
.position(|item| item["type"] == "function_call")
.unwrap();
let result_index = input
.iter()
.position(|item| item["type"] == "function_call_output")
.unwrap();
assert!(call_index < result_index);
let call = &input[call_index];
assert_eq!(call["call_id"], id);
assert_eq!(call["name"], name);
assert_eq!(
serde_json::from_str::<Value>(call["arguments"].as_str().unwrap()).unwrap(),
*arguments
);
let result = &input[result_index];
assert_eq!(result["call_id"], id);
result["output"].as_str().unwrap().to_string()
}
Api::Anthropic => {
let messages = request.body["messages"].as_array().unwrap();
let mut call_position = None;
let mut result_position = None;
let mut call = None;
let mut result = None;
for (index, message) in messages.iter().enumerate() {
let Some(blocks) = message["content"].as_array() else {
continue;
};
for block in blocks {
match block["type"].as_str() {
Some("tool_use") => {
call_position = Some(index);
call = Some(block);
}
Some("tool_result") => {
result_position = Some(index);
result = Some(block);
}
_ => {}
}
}
}
assert!(call_position.unwrap() < result_position.unwrap());
let call = call.unwrap();
assert_eq!(call["id"], id);
assert_eq!(call["name"], name);
assert_eq!(call["input"], *arguments);
let result = result.unwrap();
assert_eq!(result["tool_use_id"], id);
result["content"].as_str().unwrap().to_string()
}
}
}
#[tokio::test]
async fn text_answers_use_both_inference_apis() {
for api in APIS {
let mut fake = FakeServer::start().await;
fake.answer(api.text_response("hello from the fake model"))
.await;
let (_, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-text-{}", api.name())).await;
send_text(&host, "say hello");
api.check_request(&fake.next_request().await);
assert_published_text(&mut mailbox, "hello from the fake model").await;
host.stop().await;
}
}
#[tokio::test]
async fn tool_calls_pair_with_results_in_both_inference_apis() {
let args = json!({"path": "/actor"});
for api in APIS {
let mut fake = FakeServer::start().await;
fake.answer(api.tool_response("list-1", "list_dir", &args))
.await;
let (world, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-tools-{}", api.name())).await;
send_text(&host, "list actors");
api.check_request(&fake.next_request().await);
fake.answer(api.text_response("the actor list is ready"))
.await;
let second = fake.next_request().await;
api.check_request(&second);
let output = assert_tool_pair(&second, api, "list-1", "list_dir", &args);
let result: Value = serde_json::from_str(&output).unwrap();
assert!(
result["entries"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["name"] == "agent")
);
assert_published_text(&mut mailbox, "the actor list is ready").await;
host.stop().await;
let history = history(&world);
assert_eq!(
history
.iter()
.map(|entry| entry["kind"].as_str().unwrap())
.collect::<Vec<_>>(),
["user", "tool_call", "tool_result", "assistant"]
);
assert_eq!(history[1]["id"], "list-1");
assert_eq!(history[2]["id"], "list-1");
}
}
#[tokio::test]
async fn restart_resumes_a_turn_with_completed_tool_history() {
let args = json!({"path": "/actor"});
for api in [Api::OpenAi] {
let mut fake = FakeServer::start().await;
fake.answer(api.tool_response("list-1", "list_dir", &args))
.await;
let (world, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-resume-{}", api.name())).await;
let id = send_text(&host, "list actors before the restart");
api.check_request(&fake.next_request().await);
let release = fake.hold(api.text_response("discarded response")).await;
let second = fake.next_request().await;
api.check_request(&second);
assert_tool_pair(&second, api, "list-1", "list_dir", &args);
let stopped = request(
&host,
&mut mailbox,
json!({"type": "stop", "actor": "agent"}),
)
.await;
assert_eq!(stopped.body["type"], "stopped");
assert_eq!(
serde_json::from_slice::<Value>(
&std::fs::read(world.data_dir("agent").join("turn.json")).unwrap()
)
.unwrap(),
json!(true)
);
let _ = release.send(());
fake.answer(api.text_response("resumed answer")).await;
let start_id = host.broker.send(
"console",
Some("broker"),
None,
None,
json!({"type": "start", "actor": "agent"}),
);
let resumed = fake.next_request().await;
api.check_request(&resumed);
assert_tool_pair(&resumed, api, "list-1", "list_dir", &args);
let started = receive_reply(&mut mailbox, &start_id, Duration::from_secs(20)).await;
assert_eq!(started.body["type"], "started");
assert_published_text(&mut mailbox, "resumed answer").await;
let history = history(&world);
assert_history_tools_have_results(&history);
assert_eq!(
history
.iter()
.filter(|entry| entry["kind"] == "user"
&& entry["text"].as_str().unwrap_or_default().contains(&id))
.count(),
1
);
host.stop().await;
}
}
-85
View File
@@ -1,85 +0,0 @@
//! Tests of the `sausage` command line.
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
fn empty_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("target/test-cli")
.join(format!("{label}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Bare `sausage` (as in `nix run <flake>`) without a world starts `init`
/// for ./world instead of printing a usage error.
#[test]
fn bare_command_without_a_world_starts_init() {
let dir = empty_dir("bare");
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
.current_dir(&dir)
.env_remove("SAUSAGE_API")
.env_remove("SAUSAGE_BASE_URL")
.env_remove("SAUSAGE_MODEL")
.env_remove("SAUSAGE_API_TOKEN")
.stdin(Stdio::null())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// With no answers on stdin, init stops at the first required value.
assert!(!output.status.success(), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(stdout.contains("No world in ./world yet"), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(stderr.contains("SAUSAGE_BASE_URL"), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
// It asks before it creates anything.
assert!(!dir.join("world").exists());
}
/// Start a headless host on a fresh world with stdin at EOF; returns once it
/// printed its ready line (printed after the signal handlers are installed).
fn start_headless(label: &str) -> (std::process::Child, std::io::Lines<std::io::BufReader<std::process::ChildStdout>>) {
use std::io::{BufRead, BufReader};
let dir = empty_dir(label);
let world = dir.join("world");
sausage::World::create(&world).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
.args(["run", "--headless"])
.arg(&world)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let line = lines.next().expect("host exited before it started").unwrap();
assert!(line.contains("headless"), "unexpected first line: {line}");
(child, lines)
}
/// With stdin at EOF (as soon as the host starts), a headless host stays up.
#[test]
fn headless_ignores_stdin_eof() {
let (mut child, _lines) = start_headless("headless-eof");
std::thread::sleep(std::time::Duration::from_millis(500));
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "headless host exited on stdin EOF");
}
/// SIGTERM right after the ready line stops the actors and exits with 0.
#[test]
fn headless_stops_cleanly_on_sigterm_right_after_ready() {
let (mut child, lines) = start_headless("headless-term");
let pid = child.id().to_string();
assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success());
let rest: Vec<String> = lines.map(|l| l.unwrap()).collect();
let status = child.wait().unwrap();
assert!(status.success(), "exit status {status:?}, output {rest:?}");
assert!(rest.iter().any(|l| l.contains("Stopping actors")), "output {rest:?}");
}
-248
View File
@@ -1,248 +0,0 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::sync::mpsc;
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
static WORLD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf()
}
pub fn create_world(label: &str) -> World {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let sequence = WORLD_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = repo_root()
.join("target/test-worlds")
.join(format!("{label}-{}-{nonce}-{sequence}", std::process::id()));
World::create(&path).unwrap()
}
pub fn actor_fixture_source(name: &str) -> PathBuf {
repo_root().join("cli/tests/actors").join(name)
}
pub fn cargo_source(name: &str) -> PathBuf {
repo_root().join("cli/tests/crates").join(name)
}
pub fn toolbox_wasm() -> &'static PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| build_fixture("toolbox"))
}
fn build_fixture(name: &str) -> PathBuf {
let root = repo_root();
let manifest = actor_fixture_source(name).join("Cargo.toml");
let target_dir = root.join("target/test-actors");
let output = Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"--target-dir",
])
.arg(&target_dir)
.arg("--manifest-path")
.arg(&manifest)
.current_dir(&root)
.output()
.expect("run cargo to build the test actor");
assert!(
output.status.success(),
"building fixture {name} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let wasm = target_dir
.join("wasm32-wasip2/release")
.join(format!("{name}.wasm"));
assert!(
wasm.is_file(),
"fixture build did not create {}",
wasm.display()
);
wasm
}
pub fn passing_source(world: &World, name: &str) -> PathBuf {
source_with_test(
world,
name,
json!({
"steps": [{
"send": {"type": "echo", "value": "candidate"},
"expect": {"type": "echo", "value": "candidate"}
}]
}),
)
}
pub fn source_with_test(world: &World, name: &str, test: Value) -> PathBuf {
let source = world.source_dir("console").join("test-fixtures").join(name);
std::fs::create_dir_all(source.join("tests")).unwrap();
std::fs::write(
source.join("tests/candidate.json"),
serde_json::to_vec(&test).unwrap(),
)
.unwrap();
source
}
pub async fn start(world: &World) -> (Host, mpsc::Receiver<Envelope>) {
let host = Host::start(&world.root).await.unwrap();
let mailbox = host.mailbox("console");
(host, mailbox)
}
pub async fn request(host: &Host, mailbox: &mut mpsc::Receiver<Envelope>, body: Value) -> Envelope {
let id = host
.broker
.send("console", Some("broker"), None, None, body);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn receive_message(
mailbox: &mut mpsc::Receiver<Envelope>,
timeout: Duration,
) -> Envelope {
tokio::time::timeout(timeout, mailbox.recv())
.await
.expect("timed out waiting for a message")
.expect("console mailbox closed")
}
pub async fn receive_reply(
mailbox: &mut mpsc::Receiver<Envelope>,
id: &str,
timeout: Duration,
) -> Envelope {
tokio::time::timeout(timeout, async {
loop {
let envelope = mailbox.recv().await.expect("console mailbox closed");
if envelope.reply_to.as_deref() == Some(id) {
return envelope;
}
}
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for reply to {id}"))
}
pub async fn receive_replies(
mailbox: &mut mpsc::Receiver<Envelope>,
ids: &[String],
timeout: Duration,
) -> Vec<Envelope> {
let mut replies = Vec::with_capacity(ids.len());
tokio::time::timeout(timeout, async {
while replies.len() < ids.len() {
let message = mailbox.recv().await.expect("console mailbox closed");
assert!(
ids.iter()
.any(|id| message.reply_to.as_deref() == Some(id.as_str())),
"unexpected message while waiting for replies: {message:?}"
);
replies.push(message);
}
})
.await
.expect("timed out waiting for replies");
replies
}
pub async fn ask(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
) -> Envelope {
let id = host.broker.send("console", Some(actor), None, None, body);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn ask_type(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
expected: &str,
) -> Envelope {
let reply = ask(host, mailbox, actor, body).await;
assert_eq!(reply.body["type"], expected, "unexpected reply: {reply:?}");
reply
}
pub fn spawn_request(host: &Host, actor: &str, wasm: &Path, source: &Path) -> String {
host.broker.send(
"console",
Some("broker"),
None,
None,
json!({
"type": "spawn",
"actor": actor,
"wasm": wasm.display().to_string(),
"source": source.display().to_string()
}),
)
}
pub async fn spawn_actor(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
let id = spawn_request(host, actor, wasm, source);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn spawn_type(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
let reply = spawn_actor(host, mailbox, actor, wasm, source).await;
assert_eq!(
reply.body["type"], "spawned",
"unexpected spawn reply: {reply:?}"
);
reply
}
pub async fn upgrade_actor(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
request(
host,
mailbox,
json!({
"type": "upgrade",
"actor": actor,
"wasm": wasm.display().to_string(),
"source": source.display().to_string()
}),
)
.await
}
+537
View File
@@ -0,0 +1,537 @@
//! End-to-end host behavior with a raw WASI guest.
use sausage::{Host, world::World};
use serde_json::{Value, json};
use std::{path::PathBuf, sync::OnceLock};
fn fixture() -> PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let target = root.join("target/core/guests");
let status = std::process::Command::new("cargo")
.args([
"build",
"--offline",
"--target",
"wasm32-wasip2",
"--manifest-path",
])
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.status()
.unwrap();
assert!(status.success());
target.join("wasm32-wasip2/debug/probe.wasm")
})
.clone()
}
struct TestWorld(PathBuf);
impl TestWorld {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("sausage-{}", uuid::Uuid::now_v7()));
let w = World::create(&p).unwrap();
std::fs::create_dir_all(w.plugin_dir("probe")).unwrap();
std::fs::copy(fixture(), w.plugin_dir("probe").join("plugin.wasm")).unwrap();
Self(p)
}
async fn start(&self) -> Host {
Host::start(&self.0).await.unwrap()
}
}
impl Drop for TestWorld {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
async fn call(h: &Host, m: &str, p: Value) -> Value {
tokio::time::timeout(std::time::Duration::from_secs(20), h.call(m, p))
.await
.unwrap()
.unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn routing_environment_files_and_errors() {
let w = TestWorld::new();
let h = w.start().await;
assert_eq!(call(&h, "probe.echo", json!({"x":1})).await, json!({"x":1}));
assert_eq!(
call(&h, "probe.rpc.discover", json!({"x":2})).await,
json!({"x":2})
);
assert_eq!(
call(
&h,
"probe.call",
json!({"method":"probe.echo","params":{"nested":true}})
)
.await,
json!({"nested":true})
);
assert_eq!(
call(&h, "probe.multicall", json!({})).await,
json!({"count":2})
);
call(
&h,
"probe.notification",
json!({"path":"/state/notification","text":"delivered"}),
)
.await;
assert_eq!(
std::fs::read_to_string(w.0.join("state/notification")).unwrap(),
"delivered"
);
assert_eq!(
h.call("probe.unknownid", json!({})).await.unwrap_err()["code"],
-32002
);
let env = call(&h, "probe.env", json!({})).await;
assert_eq!(env["plugin"], "probe");
assert_eq!(env["parent"], "console");
assert!(env["pid"].as_str().unwrap().starts_with("probe-"));
call(
&h,
"probe.write",
json!({"path":"/state/test","text":"hello"}),
)
.await;
assert_eq!(
call(&h, "probe.read", json!({"path":"/state/test"})).await,
"hello"
);
assert_eq!(
h.call("absent.run", json!({})).await.unwrap_err()["code"],
-32001
);
assert_eq!(
h.call("core.activate", json!({})).await.unwrap_err()["code"],
-32601
);
assert_eq!(
h.call("probe.echo", json!([])).await.unwrap_err()["code"],
-32602
);
let e = h.call("probe.exit", json!({})).await.unwrap_err();
assert_eq!(e["code"], -32002);
assert!(
e["data"]["lines"]
.as_array()
.unwrap()
.iter()
.any(|l| l["line"] == "last line")
);
h.stop().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn journal_notifications_locks_and_kill() {
let w = TestWorld::new();
let h = w.start().await;
let mut notifications = h.subscribe();
call(&h, "probe.notify", json!({})).await;
assert_eq!(
notifications.recv().await.unwrap()["params"]["name"],
"progress"
);
call(&h, "probe.junk", json!({})).await;
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while !h.processes().is_empty() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let starts = call(&h, "core.log", json!({"grep":"start junk"})).await;
let pid = &starts[0]["pid"];
let warnings = call(&h, "core.log", json!({"pid":pid,"grep":"discarded stdout"})).await;
assert_eq!(warnings.as_array().unwrap().len(), 1);
assert!(!warnings.to_string().contains("junk"));
let log = call(&h, "core.log", json!({"grep":"stderr line"})).await;
assert_eq!(log.as_array().unwrap().len(), 1);
assert_eq!(
call(&h, "core.lock", json!({"path":"x"})).await,
json!({"held":true})
);
assert_eq!(
call(&h, "core.lock", json!({"path":"x","wait":false})).await,
json!({"held":false})
);
call(&h, "core.unlock", json!({"path":"x"})).await;
assert_eq!(
call(&h, "probe.lock", json!({"path":"x"})).await,
json!({"held":true})
);
let pid = call(&h, "probe.serve", json!({})).await;
call(&h, "core.kill", json!({"pid":pid})).await;
assert!(!h.processes().contains_key(pid.as_str().unwrap()));
h.stop().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn native_and_reserved_world_paths() {
let w = TestWorld::new();
let h = w.start().await;
h.register_native(
"native",
std::sync::Arc::new(|ctx, method, params| {
Box::pin(async move {
ctx.notify("native", json!({}));
Ok(json!({"pid":ctx.pid,"method":method,"params":params}))
})
}),
);
let result = call(&h, "native.echo", json!({"x":1})).await;
assert_eq!(result["method"], "echo");
assert!(result["pid"].as_str().unwrap().starts_with("native-"));
h.stop().await;
for name in ["proc", "run", "self"] {
std::fs::create_dir(w.0.join(name)).unwrap();
assert!(World::open(&w.0).is_err());
std::fs::remove_dir(w.0.join(name)).unwrap();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn tree_detach_stop_boot_and_scheduler() {
let w = TestWorld::new();
std::fs::write(
w.0.join("config/boot.json"),
serde_json::to_vec(&json!([
{"method":"absent.start","params":{}},
{"method":"probe.write","params":{"path":"/state/boot","text":"ready"}},
{"method":"probe.read","params":{"path":"/state/boot"}}
]))
.unwrap(),
)
.unwrap();
let h = w.start().await;
assert_eq!(
std::fs::read_to_string(w.0.join("state/boot")).unwrap(),
"ready"
);
let attached = call(
&h,
"probe.call",
json!({"method":"probe.serve","params":{}}),
)
.await;
tokio::time::timeout(std::time::Duration::from_secs(7), async {
while h.processes().contains_key(attached.as_str().unwrap()) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let detached = call(
&h,
"probe.call",
json!({"method":"core.call","params":{"method":"probe.serve","params":{},"detach":true}}),
)
.await;
assert!(h.processes()[detached.as_str().unwrap()].detached);
call(&h, "core.stop", json!({"pid":detached})).await;
let hc = h.clone();
let busy = tokio::spawn(async move { hc.call("probe.busy", json!({})).await });
let pid = tokio::time::timeout(std::time::Duration::from_secs(20), async {
loop {
if let Some((pid, _)) = h.processes().into_iter().find(|(_, p)| p.method == "busy") {
break pid;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(
call(&h, "probe.echo", json!({"works":true})).await,
json!({"works":true})
);
call(&h, "core.kill", json!({"pid":pid})).await;
assert_eq!(busy.await.unwrap().unwrap_err()["code"], -32002);
h.stop().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn same_process_lock_acquisitions_queue() {
let w = TestWorld::new();
let h = w.start().await;
h.register_native(
"locks",
std::sync::Arc::new(|ctx, _, _| {
Box::pin(async move {
assert_eq!(
ctx.call("core.lock", json!({"path":"fifo"})).await.unwrap(),
json!({"held":true})
);
assert_eq!(
ctx.call("core.lock", json!({"path":"fifo","wait":false}))
.await
.unwrap(),
json!({"held":false})
);
let mut second = Box::pin(ctx.call("core.lock", json!({"path":"fifo"})));
std::future::poll_fn(|cx| {
assert!(second.as_mut().poll(cx).is_pending());
std::task::Poll::Ready(())
})
.await;
ctx.call("core.unlock", json!({"path":"fifo"}))
.await
.unwrap();
assert_eq!(second.await.unwrap(), json!({"held":true}));
Ok(json!({}))
})
}),
);
call(&h, "locks.test", json!({})).await;
h.stop().await;
assert_eq!(
call(&h, "core.lock", json!({"path":"fifo","wait":false})).await,
json!({"held":true})
);
}
#[tokio::test(flavor = "multi_thread")]
async fn stop_closes_stdin_deadline_and_exit() {
let w = TestWorld::new();
let h = w.start().await;
let hc = h.clone();
let pending = tokio::spawn(async move { hc.call("probe.wait_eof", json!({})).await });
let pid = tokio::time::timeout(std::time::Duration::from_secs(20), async {
loop {
if let Some((pid, _)) = h
.processes()
.into_iter()
.find(|(_, p)| p.method == "wait_eof")
{
break pid;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
call(&h, "core.stop", json!({"pid":pid})).await;
assert_eq!(
std::fs::read_to_string(w.0.join("state/stopped")).unwrap(),
"yes"
);
assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002);
assert_eq!(call(&h, "core.exit", json!({})).await, json!({}));
tokio::time::timeout(std::time::Duration::from_secs(1), h.exit_requested())
.await
.unwrap();
h.stop().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn tcp_udp_and_dns() {
use tokio::io::AsyncWriteExt;
let w = TestWorld::new();
let h = w.start().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let tcp = format!("localhost:{}", listener.local_addr().unwrap().port());
let udp = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
let udp_addr = udp.local_addr().unwrap().to_string();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
stream.write_all(b"tcp").await.unwrap();
});
assert_eq!(
call(&h, "probe.network", json!({"tcp":tcp,"udp":udp_addr})).await,
"tcp"
);
let mut buf = [0; 3];
udp.recv_from(&mut buf).await.unwrap();
assert_eq!(&buf, b"udp");
server.await.unwrap();
h.stop().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn native_kill_drops_future_and_releases_locks() {
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
struct Dropped(Arc<AtomicBool>);
impl Drop for Dropped {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let w = TestWorld::new();
let h = w.start().await;
let dropped = Arc::new(AtomicBool::new(false));
let flag = dropped.clone();
h.register_native(
"native",
Arc::new(move |ctx, _, _| {
let flag = flag.clone();
Box::pin(async move {
let _guard = Dropped(flag);
ctx.call("core.lock", json!({"path":"native-lock"}))
.await
.unwrap();
ctx.notify("ready", json!({"pid":ctx.pid}));
std::future::pending().await
})
}),
);
let mut notifications = h.subscribe();
let hc = h.clone();
let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await });
let notification =
tokio::time::timeout(std::time::Duration::from_secs(10), notifications.recv())
.await
.unwrap()
.unwrap();
let pid = &notification["params"]["params"]["pid"];
assert_eq!(
call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await,
json!({"held":false})
);
call(&h, "core.kill", json!({"pid":pid})).await;
tokio::time::timeout(std::time::Duration::from_secs(1), async {
while !dropped.load(Ordering::SeqCst) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(
call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await,
json!({"held":true})
);
assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002);
h.stop().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn shutdown_stops_busy_processes_in_parallel_after_five_seconds() {
let w = TestWorld::new();
let h = w.start().await;
let mut tasks = Vec::new();
for _ in 0..2 {
let hc = h.clone();
tasks.push(tokio::spawn(async move {
hc.call("probe.busy", json!({})).await
}));
}
tokio::time::timeout(std::time::Duration::from_secs(20), async {
while h.processes().len() != 2 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let before = std::time::Instant::now();
h.stop().await;
assert!(before.elapsed() >= std::time::Duration::from_millis(4900));
assert!(before.elapsed() < std::time::Duration::from_secs(7));
for task in tasks {
assert_eq!(task.await.unwrap().unwrap_err()["code"], -32002);
}
assert!(h.processes().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn journal_filters_persist_and_process_versions_are_hashes() {
use sha2::{Digest, Sha256};
let w = TestWorld::new();
let h = w.start().await;
let pid = call(&h, "probe.serve", json!({"version":1})).await;
let pid = pid.as_str().unwrap();
let info = h.processes()[pid].clone();
assert_eq!(
info.version,
format!("{:x}", Sha256::digest(std::fs::read(fixture()).unwrap()))
);
assert_eq!(info.plugin, "probe");
assert_eq!(info.method, "serve");
assert_eq!(info.params, json!({"version":1}));
assert_eq!(info.parent.as_deref(), Some("console"));
assert_eq!(info.state, "running");
assert!(!info.detached);
assert!(info.start_time > 0);
let uuid = uuid::Uuid::parse_str(pid.strip_prefix("probe-").unwrap()).unwrap();
assert_eq!(uuid.get_version_num(), 7);
call(&h, "core.stop", json!({"pid":pid})).await;
let entries = call(
&h,
"core.log",
json!({"plugin":"probe","pid":pid,"limit":2}),
)
.await;
assert_eq!(entries.as_array().unwrap().len(), 2);
let entry = &entries[0];
let id = &entry["id"];
let single = call(&h, "core.log", json!({"since":id,"until":id})).await;
assert_eq!(single, json!([entry]));
h.stop().await;
drop(h);
let h = w.start().await;
assert_eq!(
call(&h, "core.log", json!({"since":id,"until":id})).await,
single
);
h.stop().await;
assert!(
std::fs::read(w.0.join("state/core/journal.sqlite"))
.unwrap()
.starts_with(b"SQLite format 3")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn stopping_rejects_calls_but_allows_notifications() {
use std::sync::{Arc, Mutex};
let w = TestWorld::new();
let h = w.start().await;
let (tx, rx) = tokio::sync::oneshot::channel();
let tx = Arc::new(Mutex::new(Some(tx)));
let gate = Arc::new(tokio::sync::Notify::new());
let release = gate.clone();
h.register_native(
"native",
Arc::new(move |ctx, _, _| {
let tx = tx.lock().unwrap().take().unwrap();
let gate = gate.clone();
Box::pin(async move {
tx.send(ctx.clone()).ok();
gate.notified().await;
Ok(json!({}))
})
}),
);
let hc = h.clone();
let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await });
let ctx = rx.await.unwrap();
let hc = h.clone();
let pid = ctx.pid.clone();
let stop = tokio::spawn(async move { hc.call("core.stop", json!({"pid":pid})).await });
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while h.processes()[&ctx.pid].state != "stopping" {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(
ctx.call("probe.echo", json!({})).await.unwrap_err()["code"],
-32600
);
let mut notifications = h.subscribe();
ctx.call("core.notify", json!({"name":"stop-hook","params":{}}))
.await
.unwrap();
assert_eq!(
notifications.recv().await.unwrap()["params"]["name"],
"stop-hook"
);
release.notify_one();
assert_eq!(pending.await.unwrap().unwrap(), json!({}));
assert_eq!(stop.await.unwrap().unwrap(), json!({}));
h.stop().await;
}
-1252
View File
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,7 @@
[package]
name = "toolbox"
name = "probe"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
serde_json = "1"
[workspace]
+47
View File
@@ -0,0 +1,47 @@
//! A raw JSON-RPC guest for host integration tests.
use std::io::{self,BufRead,Write};
use serde_json::{Value,json};
fn send(v:Value) { let mut out=io::stdout().lock();writeln!(out,"\n{v}").unwrap();out.flush().unwrap(); }
fn main() {
let mut lines=io::stdin().lock().lines();
let request:Value=serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();
let id=request.get("id").cloned();let p=&request["params"];
let result=match request["method"].as_str().unwrap() {
"echo"|"rpc.discover"=>p.clone(),
"env"=>json!({"pid":std::env::var("SAUSAGE_PID").unwrap(),"plugin":std::env::var("SAUSAGE_PLUGIN").unwrap(),"parent":std::env::var("SAUSAGE_PARENT").unwrap()}),
"multicall"=>{
send(json!({"jsonrpc":"2.0","id":1,"method":"probe.echo","params":{"n":1}}));
send(json!({"jsonrpc":"2.0","id":"1","method":"probe.echo","params":{"n":2}}));
let mut seen=Vec::new();while seen.len()<2 {let r:Value=serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();if r["id"]==1 {assert_eq!(r["result"]["n"],1);} else {assert_eq!(r["id"],"1");assert_eq!(r["result"]["n"],2);}seen.push(r["id"].clone());}json!({"count":seen.len()})
},
"notification"=>{
send(json!({"jsonrpc":"2.0","method":"probe.write","params":p}));
for _ in 0..200 {if std::fs::exists(p["path"].as_str().unwrap()).unwrap() {break;}std::thread::sleep(std::time::Duration::from_millis(10));}json!({})
},
"unknownid"=>{send(json!({"jsonrpc":"2.0","id":"late","result":7}));return;},
"request_id"=>request["id"].clone(),
"wait_eof"=>{for _ in lines {} std::fs::write("/state/stopped","yes").unwrap(); return;},
"busy"=>loop {std::hint::spin_loop();},
"sleep"=>{std::thread::sleep(std::time::Duration::from_millis(p["ms"].as_u64().unwrap_or(100)));json!({})},
"exit"=>{eprintln!("last line");return;},
"junk"=>{io::stdout().write_all(&[255,10]).unwrap();println!("junk\n{{}}\n[]");eprintln!("stderr line");json!({})},
"notify"=>{send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"name":"progress","params":{"n":1}}}));std::thread::sleep(std::time::Duration::from_millis(30));json!({})},
"serve"=>{send(json!({"jsonrpc":"2.0","id":id,"result":std::env::var("SAUSAGE_PID").unwrap()}));for _ in lines {} return;},
"network"=>{
use std::io::Read;
let mut socket=std::net::TcpStream::connect(p["tcp"].as_str().unwrap()).unwrap();
let mut text=String::new();socket.read_to_string(&mut text).unwrap();
let udp=std::net::UdpSocket::bind("127.0.0.1:0").unwrap();udp.send_to(b"udp",p["udp"].as_str().unwrap()).unwrap();json!(text)
},
"read"=>json!(std::fs::read_to_string(p["path"].as_str().unwrap()).unwrap()),
"write"=>{std::fs::write(p["path"].as_str().unwrap(),p["text"].as_str().unwrap()).unwrap();json!({})},
"call"|"lock"=>{
let method=if request["method"]=="lock" {"core.lock"} else {p["method"].as_str().unwrap()};
let params=if request["method"]=="lock" {p.clone()} else {p["params"].clone()};
send(json!({"jsonrpc":"2.0","id":{"nested":7},"method":method,"params":params}));
loop {let response:Value=serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();if response["id"]==json!({"nested":7}) {break response["result"].clone();}}
},
_=>{send(json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"method not found"}}));return;}
};
if let Some(id)=id {send(json!({"jsonrpc":"2.0","id":id,"result":result}));}
}