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:
Generated
-105
@@ -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"
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ¬ification["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
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]
|
||||
@@ -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}));}
|
||||
}
|
||||
Reference in New Issue
Block a user