core: review fixes, init builds the agent, console without a terminal
Activation path (review T6R): a broken plugin or a stray file in /plugins blocks no activation; candidates replace their own entries; discovery has a 10 s deadline and its process is stopped after the answer; an unchanged hash keeps plugin.prev.wasm; activations run in their own task; the watcher is not recursive; no duplicate services; rpc.retired waits for live processes of its version; reads and hashes on blocking threads. Guest I/O and per-call paths: a stdin write error is EOF, stdout and stderr are drained before the status; stderr is lossy UTF-8 and goes to the journal from its own reader; compiled components are freed; shutdown starts no new processes; lock queues drop stopping processes; compile before the admission gate; CommandPre per component; core.unlock in order; null errors are successes; the journal thread survives SQLite errors. sausage init builds the agent with cargo.build; sausage without a command creates ./world. The console reads plain lines without a terminal. PROTOCOL.md and DECISIONS.md describe the decided rules. The flake tests the whole workspace and checks the packaged cargo.build.
This commit is contained in:
+299
-3
@@ -7,7 +7,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// Builds two genuine guest versions in this executor's target folder.
|
||||
/// Builds genuine guest versions in this executor's target folder.
|
||||
fn fixture(version: usize) -> PathBuf {
|
||||
static FILES: OnceLock<Vec<PathBuf>> = OnceLock::new();
|
||||
FILES.get_or_init(|| {
|
||||
@@ -16,7 +16,7 @@ fn fixture(version: usize) -> PathBuf {
|
||||
"target/compat/activation-guests/{}",
|
||||
std::process::id()
|
||||
));
|
||||
(1..=2)
|
||||
(1..=3)
|
||||
.map(|version| {
|
||||
let status = std::process::Command::new("cargo")
|
||||
.env("FIXTURE_VERSION", version.to_string())
|
||||
@@ -132,6 +132,302 @@ async fn graph(host: &Host) -> Value {
|
||||
call(host, "notes.read", json!({"path":"/proc/core/graph.json"})).await
|
||||
}
|
||||
|
||||
/// H1/H2: broken providers and stray files do not block repair, revert, or force.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_repairs_with_broken_entries() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
world.plugin("broken", document("bad"), document("bad"));
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken").unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(!graph(&host).await["plugins"]["broken"]["error"].is_null());
|
||||
std::fs::copy(fixture(1), world.0.join("plugins/broken/plugin.prev.wasm")).unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"broken"})).await;
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken again").unwrap();
|
||||
activate(&host, "broken").await.unwrap();
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken again").unwrap();
|
||||
std::fs::write(
|
||||
world.0.join("state/notes/2.json"),
|
||||
consumer("broken", document("bad")).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
graph(&host).await["plugins"]["notes"]["imports"]["broken"]["status"],
|
||||
"unavailable"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// H2: a stray file under plugins does not interrupt discovery or activation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_stray_file() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
std::fs::write(world.0.join("plugins/notes.md"), "a note").unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(graph(&host).await["plugins"]["notes"]["error"].is_null());
|
||||
host.stop().await;
|
||||
}
|
||||
/// M4: an answered discovery receives EOF instead of leaking a detached process.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_discovery_answer_stops() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-linger"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
marker(world.0.join("state/notes/discovery-ended-2"), "ended").await;
|
||||
host.stop().await;
|
||||
}
|
||||
/// M4: a rejected candidate's discovery process never becomes a retired installed version.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_candidate_is_not_old_version() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let world = TestWorld::new();
|
||||
let mut rejected = consumer("missing", document("add"));
|
||||
rejected["x-test-linger"] = json!(true);
|
||||
rejected["x-test-stubborn"] = json!(true);
|
||||
world.plugin("notes", document("add"), rejected);
|
||||
std::fs::write(
|
||||
world.0.join("state/notes/3.json"),
|
||||
document("add").to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::copy(fixture(3), world.0.join("state/notes/third.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
assert!(activate(&host, "notes").await.is_err());
|
||||
let candidate = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, process)| process.method == "rpc.discover")
|
||||
.unwrap()
|
||||
.0;
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/state/notes/third.wasm"}),
|
||||
)
|
||||
.await;
|
||||
call(&host, "core.kill", json!({"pid":candidate})).await;
|
||||
let old = format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap()));
|
||||
marker(world.0.join("state/notes/retired"), &old).await;
|
||||
let rejected_hash = format!("{:x}", Sha256::digest(std::fs::read(fixture(2)).unwrap()));
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(300),
|
||||
marker(world.0.join("state/notes/retired"), &rejected_hash)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// M6: shutdown suppresses service restarts and retirement hooks from pending upgrades.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_shutdown_suppresses_hooks() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("serve");
|
||||
old["x-sausage-grace"] = json!(60);
|
||||
world.plugin("notes", old, document("serve"));
|
||||
std::fs::write(
|
||||
world.0.join("config/boot.json"),
|
||||
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
marker(world.0.join("state/notes/service"), "1").await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
for _ in 0..100 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert!(!world.0.join("state/notes/retired").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(world.0.join("state/notes/service")).unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert!(
|
||||
call(&host, "core.log", json!({"grep":"retired start"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
call(&host, "core.log", json!({"grep":"service start"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
/// L15: retirement waits for new admissions of a reverted version, not just its first snapshot.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_retirement_checks_live_version() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("hold");
|
||||
old["x-sausage-grace"] = json!(60);
|
||||
world.plugin("notes", old.clone(), old);
|
||||
let host = world.start().await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
let first = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, p)| p.method == "hold")
|
||||
.unwrap()
|
||||
.0;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
let second = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(pid, p)| p.method == "hold" && *pid != first)
|
||||
.unwrap()
|
||||
.0;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
// The v2 retirement from the revert is unrelated to the pending v1 retirement.
|
||||
let previous = {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap()))
|
||||
};
|
||||
call(&host, "core.stop", json!({"pid":first})).await;
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(300),
|
||||
marker(world.0.join("state/notes/retired"), &previous)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
call(&host, "core.stop", json!({"pid":second})).await;
|
||||
marker(world.0.join("state/notes/retired"), &previous).await;
|
||||
host.stop().await;
|
||||
}
|
||||
/// L13: source subtrees require no watches, even when the host cannot read them.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_watcher_is_shallow() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let source = world.0.join("plugins/notes/source");
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o0)).unwrap();
|
||||
let result = Host::start(&world.0).await;
|
||||
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let host = result.unwrap();
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// L13: a watcher startup error reaches the journal without preventing host startup.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_watcher_failure_is_nonfatal() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let inaccessible = world.0.join("plugins/inaccessible");
|
||||
std::fs::create_dir_all(&inaccessible).unwrap();
|
||||
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o0)).unwrap();
|
||||
let result = Host::start(&world.0).await;
|
||||
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let host = result.unwrap();
|
||||
assert!(
|
||||
!call(&host, "core.log", json!({"grep":"watcher failed"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// L14: rapid service upgrades never overlap singleton instances.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_singleton_restart() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("serve"), document("serve"));
|
||||
std::fs::write(
|
||||
world.0.join("config/boot.json"),
|
||||
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
for _ in 0..128 {
|
||||
activate(&host, "notes").await.unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
}
|
||||
host.stop().await;
|
||||
assert!(!world.0.join("state/notes/duplicate-service").exists());
|
||||
}
|
||||
/// H2: a plugin read error belongs to that entry, not the entire graph.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_read_error_is_local() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
std::fs::create_dir_all(world.0.join("plugins/broken/plugin.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(!graph(&host).await["plugins"]["broken"]["error"].is_null());
|
||||
host.stop().await;
|
||||
}
|
||||
/// L3: cancellation of the caller cannot cancel an activation body.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_dropped_activation_completes() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-wait"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
let caller = host.clone();
|
||||
let task = tokio::spawn(async move { activate(&caller, "notes").await });
|
||||
marker(world.0.join("state/notes/discover-started"), "ready").await;
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
std::fs::write(world.0.join("state/notes/discover-release"), "ready").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap()
|
||||
!= std::fs::read(fixture(2)).unwrap()
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// M4: discovery times out and its process stops after either an answer or a timeout.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_discovery_deadline_and_stop() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-wait"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
let result = tokio::time::timeout(Duration::from_secs(15), activate(&host, "notes")).await;
|
||||
assert!(
|
||||
result.unwrap().unwrap_err()["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("discovery")
|
||||
);
|
||||
assert!(
|
||||
!host
|
||||
.processes()
|
||||
.values()
|
||||
.any(|process| process.method == "rpc.discover" && process.state == "running")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.4: proc graph files contain valid documents before discovery completes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn graph_is_valid_immediately() {
|
||||
@@ -321,7 +617,7 @@ async fn serializes_activations() {
|
||||
second.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(),
|
||||
std::fs::read(fixture(2)).unwrap()
|
||||
std::fs::read(fixture(1)).unwrap()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Initialize the embedded agent and start the resulting world through the CLI.
|
||||
use sausage::Host;
|
||||
use serde_json::{Value, json};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Environment configuration creates a complete world with a callable agent.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn init_builds_agent() {
|
||||
let temporary = std::env::temp_dir().join(format!("sausage-cli-{}", uuid::Uuid::now_v7()));
|
||||
std::fs::create_dir_all(&temporary).unwrap();
|
||||
let world = temporary.join("world");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("init")
|
||||
.arg(&world)
|
||||
.env("SAUSAGE_API", "anthropic-messages")
|
||||
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
|
||||
.env("SAUSAGE_MODEL", "init-test")
|
||||
.env("SAUSAGE_API_TOKEN", "fake-token")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(stdout.contains("first agent build takes a few minutes"));
|
||||
assert!(stdout.contains("sausage run"));
|
||||
let config: Value =
|
||||
serde_json::from_slice(&std::fs::read(world.join("config/agent.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
config,
|
||||
json!({"api":"anthropic-messages","base_url":"http://127.0.0.1:1","model":"init-test","token":"fake-token"})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&std::fs::read(world.join("config/boot.json")).unwrap())
|
||||
.unwrap(),
|
||||
json!([])
|
||||
);
|
||||
for file in [
|
||||
"plugins/cargo/sdk/Cargo.toml",
|
||||
"plugins/cargo/sdk/src/lib.rs",
|
||||
"plugins/agent/source/Cargo.toml",
|
||||
"plugins/agent/source/src/main.rs",
|
||||
"plugins/agent/plugin.wasm",
|
||||
] {
|
||||
assert!(world.join(file).is_file(), "missing {file}");
|
||||
}
|
||||
let cache =
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../target/sdk/cli-cache");
|
||||
let host = Host::start_with_cache(&world, &cache).await.unwrap();
|
||||
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
||||
assert_eq!(doc["x-sausage-grace"], 600);
|
||||
assert!(
|
||||
doc["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|method| method["name"] == "chat")
|
||||
);
|
||||
host.stop().await;
|
||||
|
||||
std::fs::remove_dir_all(temporary).unwrap();
|
||||
}
|
||||
+52
-13
@@ -44,7 +44,10 @@ impl TestWorld {
|
||||
}
|
||||
std::fs::create_dir_all(world.state_dir("probe")).unwrap();
|
||||
std::fs::write(world.state_dir("probe").join("discover.json"),json!({"methods":[{"name":"echo","description":"Echo supplied text.","params":[{"name":"text","description":"The input text.","schema":{"type":"string"}}],"result":{"schema":{"type":"string"}}}]}).to_string()).unwrap();
|
||||
std::fs::copy(fixture(), root.join("candidate.wasm")).unwrap();
|
||||
let mut candidate = std::fs::read(fixture()).unwrap();
|
||||
// A custom section creates a new version without changing probe behavior.
|
||||
candidate.extend_from_slice(&[0, 2, 1, b'c']);
|
||||
std::fs::write(root.join("candidate.wasm"), candidate).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
@@ -152,7 +155,7 @@ async fn ps() {
|
||||
assert!(text.contains("probe serve console"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Log supports plugin filters, process filters, and a row count.
|
||||
/// Log formats local times and short pids, while core.log preserves JSON and filters.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn log() {
|
||||
let world = TestWorld::new();
|
||||
@@ -160,22 +163,58 @@ async fn log() {
|
||||
std::fs::copy(fixture(), world.0.join("plugins/data-logger/plugin.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
host.call("data-logger.echo", json!({})).await.unwrap();
|
||||
let filtered: Value =
|
||||
serde_json::from_str(&command(&host, "log data-logger -n 1").await).unwrap();
|
||||
assert_eq!(filtered[0]["plugin"], "data-logger");
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
for line in [
|
||||
"log".to_owned(),
|
||||
"log probe -n 1".to_owned(),
|
||||
format!("log -n 1 {}", pid.as_str().unwrap()),
|
||||
for (line, filters) in [
|
||||
("log".to_owned(), json!({})),
|
||||
(
|
||||
"log data-logger -n 1".to_owned(),
|
||||
json!({"plugin":"data-logger","limit":1}),
|
||||
),
|
||||
(
|
||||
"log probe -n 1".to_owned(),
|
||||
json!({"plugin":"probe","limit":1}),
|
||||
),
|
||||
(
|
||||
format!("log -n 1 {}", pid.as_str().unwrap()),
|
||||
json!({"pid":pid,"limit":1}),
|
||||
),
|
||||
] {
|
||||
let entries: Value = serde_json::from_str(&command(&host, &line).await).unwrap();
|
||||
assert!(!entries.as_array().unwrap().is_empty());
|
||||
let raw = command(&host, &format!("core.log {filters}")).await;
|
||||
let entries: Value = serde_json::from_str(&raw).unwrap();
|
||||
let entries = entries.as_array().unwrap();
|
||||
assert!(!entries.is_empty());
|
||||
let printed = command(&host, &line).await;
|
||||
let lines: Vec<_> = printed.lines().collect();
|
||||
assert_eq!(lines.len(), entries.len(), "{printed}");
|
||||
if line != "log" {
|
||||
assert_eq!(entries.as_array().unwrap().len(), 1);
|
||||
assert_eq!(entries[0]["plugin"], "probe");
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
for (line, entry) in lines.into_iter().zip(entries) {
|
||||
let time = chrono::DateTime::parse_from_rfc3339(entry["time"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Local)
|
||||
.format("%H:%M:%S%.3f")
|
||||
.to_string();
|
||||
let plugin = entry["plugin"].as_str().unwrap();
|
||||
let pid = entry["pid"].as_str().unwrap();
|
||||
let prefix = format!("{plugin}-");
|
||||
let short = pid
|
||||
.strip_prefix(&prefix)
|
||||
.map(|uuid| format!("{prefix}{}", &uuid[..8]))
|
||||
.unwrap_or_else(|| pid.to_owned());
|
||||
assert_eq!(
|
||||
line,
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
time,
|
||||
short,
|
||||
entry["stream"].as_str().unwrap(),
|
||||
entry["line"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(command(&host, "log absent-plugin").await, "");
|
||||
assert!(execute_line(&host, "log -n").await.is_err());
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
+315
-1
@@ -53,6 +53,314 @@ async fn call(h: &Host, m: &str, p: Value) -> Value {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.3: a closed stdin does not discard a response already on stdout.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn closed_stdin_drains_stdout() {
|
||||
let w = TestWorld::new();
|
||||
std::fs::write(w.0.join("state/early"), "answer").unwrap();
|
||||
let h = w.start().await;
|
||||
assert_eq!(
|
||||
call(&h, "probe.echo", json!({"large":"x".repeat(2*1024*1024)})).await,
|
||||
"early"
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.3: failed imports remain process failures, even with initial input in flight.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_imports_preserve_process_status() {
|
||||
let w = TestWorld::new();
|
||||
let path = w.0.join("plugins/probe/plugin.wasm");
|
||||
let mut bytes = std::fs::read(&path).unwrap();
|
||||
for index in 0..bytes.len() - 5 {
|
||||
if &bytes[index..index + 5] == b"wasi:" {
|
||||
bytes[index..index + 5].copy_from_slice(b"fake:");
|
||||
}
|
||||
}
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
let h = w.start().await;
|
||||
let error = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
h.call("probe.echo", json!({"large":"x".repeat(2*1024*1024)})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32002);
|
||||
assert!(error["data"]["status"].as_str().unwrap().contains("fake:"));
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.3: a closed stdin preserves the actual exit status and final stderr.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn closed_stdin_preserves_failure_details() {
|
||||
let w = TestWorld::new();
|
||||
std::fs::write(w.0.join("state/early"), "fail").unwrap();
|
||||
let h = w.start().await;
|
||||
let error = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
h.call("probe.echo", json!({"large":"x".repeat(2*1024*1024)})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32002);
|
||||
// WASIp2 command.run represents a nonzero exit as its unit error result.
|
||||
assert_eq!(error["data"]["status"], "exit 1");
|
||||
assert!(
|
||||
error["data"]["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|line| line["line"] == "last early log")
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 7: invalid stderr bytes do not stop later log lines or the guest.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stderr_uses_lossy_utf8_and_continues() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let pid = call(&h, "probe.invalid_stderr", json!({})).await;
|
||||
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||
let logs = call(&h, "core.log", json!({"pid":pid})).await;
|
||||
assert!(
|
||||
logs.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|line| line["line"] == "\u{fffd}")
|
||||
);
|
||||
assert!(
|
||||
logs.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|line| line["line"] == "after invalid UTF-8")
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.3 and 7: stderr drains even while a large initial stdin write blocks.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stderr_flood_does_not_block_stdin() {
|
||||
let w = TestWorld::new();
|
||||
std::fs::write(w.0.join("state/early"), "flood").unwrap();
|
||||
let h = w.start().await;
|
||||
let payload = json!({"large":"x".repeat(1024*1024)});
|
||||
assert_eq!(call(&h, "probe.echo", payload.clone()).await, payload);
|
||||
h.stop().await;
|
||||
let db = rusqlite::Connection::open(w.0.join("state/core/journal.sqlite")).unwrap();
|
||||
let count: i64 = db
|
||||
.query_row(
|
||||
"SELECT count(*) FROM journal WHERE line LIKE 'flood %'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(count >= 5000);
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.6: shutdown rejects new processes, including calls after stop returns.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shutdown_rejects_new_processes() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
h.stop().await;
|
||||
assert_eq!(
|
||||
h.call("probe.echo", json!({})).await.unwrap_err()["code"],
|
||||
-32600
|
||||
);
|
||||
assert!(h.processes().is_empty());
|
||||
}
|
||||
|
||||
/// PROTOCOL 6: stdout unlock takes effect before the next result reaches its caller.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stdout_unlock_precedes_result() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
for _ in 0..10 {
|
||||
let pid = call(&h, "probe.unlock_answer", json!({})).await;
|
||||
assert_eq!(
|
||||
call(
|
||||
&h,
|
||||
"core.lock",
|
||||
json!({"path":"ordered-unlock","wait":false})
|
||||
)
|
||||
.await,
|
||||
json!({"held":true})
|
||||
);
|
||||
call(&h, "core.unlock", json!({"path":"ordered-unlock"})).await;
|
||||
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||
}
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.5: a notification-started process has no call to receive progress.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn notification_process_has_no_progress_destination() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
assert_eq!(call(&h, "probe.notification_child", json!({})).await, 0);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.1: an explicit null error does not replace a successful result.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn null_error_is_success() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
assert_eq!(call(&h, "probe.null_error", json!({})).await, 42);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 7: a journal insertion failure does not poison the host or stop later writes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn journal_recovers_after_sqlite_write_failure() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let db = rusqlite::Connection::open(w.0.join("state/core/journal.sqlite")).unwrap();
|
||||
db.busy_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
db.execute_batch("CREATE TRIGGER reject_writes BEFORE INSERT ON journal BEGIN SELECT RAISE(FAIL,'test failure'); END;").unwrap();
|
||||
assert_eq!(
|
||||
call(&h, "probe.echo", json!({"failed_log":true})).await,
|
||||
json!({"failed_log":true})
|
||||
);
|
||||
call(&h, "core.log", json!({})).await;
|
||||
db.execute_batch("DROP TRIGGER reject_writes;").unwrap();
|
||||
assert_eq!(
|
||||
call(&h, "probe.echo", json!({"recovered":true})).await,
|
||||
json!({"recovered":true})
|
||||
);
|
||||
let entries = call(&h, "core.log", json!({"grep":"recovered"})).await;
|
||||
assert!(!entries.as_array().unwrap().is_empty());
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: trailing slashes preserve live proc folders and generated plugin folders.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn proc_directories_accept_trailing_slashes() {
|
||||
use sausage_vfs::{Layer, Open};
|
||||
let w = TestWorld::new();
|
||||
let document = json!({"methods":[]});
|
||||
std::fs::create_dir_all(w.0.join("state/probe")).unwrap();
|
||||
std::fs::write(w.0.join("state/probe/discover.json"), document.to_string()).unwrap();
|
||||
let h = w.start().await;
|
||||
let view = h.filesystem().await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
loop {
|
||||
if view.list("/proc/core/plugins/probe").await.is_ok() {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let pid = call(&h, "probe.serve", json!({})).await;
|
||||
for path in [
|
||||
"/proc/".to_string(),
|
||||
"/proc/core/".into(),
|
||||
"/proc/core/plugins/".into(),
|
||||
"/proc/core/plugins/probe/".into(),
|
||||
format!("/proc/{}/", pid.as_str().unwrap()),
|
||||
] {
|
||||
assert!(view.stat(&path, true).await.unwrap().directory);
|
||||
view.list(&path).await.unwrap();
|
||||
view.open(
|
||||
&path,
|
||||
Open {
|
||||
directory: true,
|
||||
..Open::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.4: Unix replacements with the same size and mtime use the new component.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn component_identity_includes_inode_and_ctime() {
|
||||
let w = TestWorld::new();
|
||||
let path = w.0.join("plugins/probe/plugin.wasm");
|
||||
let mut bytes = std::fs::read(&path).unwrap();
|
||||
bytes.extend_from_slice(&[0, 2, 1, b'a']);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
let h = w.start().await;
|
||||
let old = call(&h, "probe.serve", json!({})).await;
|
||||
*bytes.last_mut().unwrap() = b'b';
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap()
|
||||
.set_times(std::fs::FileTimes::new().set_modified(modified))
|
||||
.unwrap();
|
||||
let new = call(&h, "probe.serve", json!({})).await;
|
||||
let table = h.processes();
|
||||
assert_ne!(
|
||||
table[old.as_str().unwrap()].version,
|
||||
table[new.as_str().unwrap()].version
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.6 and 6: stopping cancels a native process's queued lock request immediately.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stopping_removes_queued_lock_acquisitions() {
|
||||
use sausage_vfs::{Layer, Open};
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
call(&h, "core.lock", json!({"path":"queued-stop"})).await;
|
||||
h.register_native(
|
||||
"queued",
|
||||
std::sync::Arc::new(|ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.notify("ready", json!({"pid":ctx.pid}));
|
||||
ctx.call("core.lock", json!({"path":"queued-stop"})).await
|
||||
})
|
||||
}),
|
||||
);
|
||||
let mut events = h.subscribe();
|
||||
let host = h.clone();
|
||||
let pending = tokio::spawn(async move { host.call("queued.wait", json!({})).await });
|
||||
let event = events.recv().await.unwrap();
|
||||
let pid = event["params"]["params"]["pid"].clone();
|
||||
let view = h.filesystem().await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
loop {
|
||||
let f = view
|
||||
.open("/proc/core/locks.json", Open::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let locks: Value = serde_json::from_slice(&f.read(0, 65536).await.unwrap()).unwrap();
|
||||
if locks["queued-stop"]["waiting"] == json!([pid]) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
h.call("core.stop", json!({"pid":pid})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(pending.await.unwrap().is_err());
|
||||
call(&h, "core.unlock", json!({"path":"queued-stop"})).await;
|
||||
assert_eq!(
|
||||
call(&h, "core.lock", json!({"path":"queued-stop","wait":false})).await,
|
||||
json!({"held":true})
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.1: json rpc lines carry results.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn json_rpc_lines_carry_results() {
|
||||
@@ -608,7 +916,13 @@ async fn shutdown_stops_busy_processes_in_parallel_after_five_seconds() {
|
||||
}));
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
while h.processes().len() != 2 {
|
||||
while h
|
||||
.processes()
|
||||
.values()
|
||||
.filter(|p| p.plugin == "probe" && p.method == "busy")
|
||||
.count()
|
||||
!= 2
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,13 +41,40 @@ fn main() {
|
||||
"serve" | "hold" => {
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":{"version":version}}));
|
||||
if request["method"] == "serve" {
|
||||
let exclusive = format!("/run/{plugin}-service");
|
||||
if std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&exclusive)
|
||||
.is_err()
|
||||
{
|
||||
std::fs::write(format!("/state/{plugin}/duplicate-service"), "duplicate")
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
|
||||
}
|
||||
for _ in lines {}
|
||||
if request["method"] == "serve" {
|
||||
let _ = std::fs::remove_file(format!("/run/{plugin}-service"));
|
||||
}
|
||||
std::fs::write(format!("/state/{plugin}/ended-{version}"), "ended").unwrap();
|
||||
return;
|
||||
}
|
||||
_ => json!({"version":version}),
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":result}));
|
||||
if request["method"] == "rpc.discover" && result["x-test-linger"] == true {
|
||||
if result["x-test-stubborn"] == true {
|
||||
while !std::path::Path::new(&format!("/state/{plugin}/discovery-exit")).exists() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
} else {
|
||||
for _ in lines {}
|
||||
}
|
||||
std::fs::write(
|
||||
format!("/state/{plugin}/discovery-ended-{version}"),
|
||||
"ended",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,9 +292,59 @@ fn locked_write(lines: &mut Lines) -> Value {
|
||||
assert_eq!(std::fs::read_to_string("/state/loop").unwrap(), "new");
|
||||
json!({})
|
||||
}
|
||||
fn unlock_answer(request: &Value, lines: &mut Lines) {
|
||||
lock(&json!({"path":"ordered-unlock"}), lines);
|
||||
let unlock = json!({"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered-unlock"}});
|
||||
let answer =
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()});
|
||||
let mut stdout = io::stdout().lock();
|
||||
writeln!(stdout, "{unlock}\n{answer}").unwrap();
|
||||
stdout.flush().unwrap();
|
||||
drop(stdout);
|
||||
for _ in lines {}
|
||||
}
|
||||
fn notification_child(lines: &mut Lines) -> Value {
|
||||
send(json!({"jsonrpc":"2.0","method":"probe.orphan_notify","params":{}}));
|
||||
let pid = loop {
|
||||
if let Ok(pid) = std::fs::read_to_string("/state/orphan") {
|
||||
break pid;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":7,"method":"core.stop","params":{"pid":pid}}));
|
||||
let mut count = 0;
|
||||
loop {
|
||||
let response = receive(lines);
|
||||
if response["method"] == "core.notify" {
|
||||
count += 1;
|
||||
}
|
||||
if response["id"] == 7 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
json!(count)
|
||||
}
|
||||
fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
let params = &request["params"];
|
||||
let result = match request["method"].as_str().unwrap() {
|
||||
"invalid_stderr" => {
|
||||
io::stderr().write_all(&[255, 10]).unwrap();
|
||||
eprintln!("after invalid UTF-8");
|
||||
json!(std::env::var("SAUSAGE_PID").unwrap())
|
||||
}
|
||||
"unlock_answer" => {
|
||||
unlock_answer(request, lines);
|
||||
return None;
|
||||
}
|
||||
"null_error" => {
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"error":null,"result":42}));
|
||||
return None;
|
||||
}
|
||||
"notification_child" => notification_child(lines),
|
||||
"orphan_notify" => {
|
||||
std::fs::write("/state/orphan", std::env::var("SAUSAGE_PID").unwrap()).unwrap();
|
||||
notify()
|
||||
}
|
||||
"self_serve" => {
|
||||
std::fs::write("/self/endpoint", "endpoint").unwrap();
|
||||
serve(request, lines);
|
||||
@@ -359,6 +409,21 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
Some(result)
|
||||
}
|
||||
fn main() {
|
||||
if let Ok(mode) = std::fs::read_to_string("/state/early") {
|
||||
if mode == "flood" {
|
||||
for index in 0..5000 {
|
||||
eprintln!("flood {index} {}", "x".repeat(128));
|
||||
}
|
||||
} else {
|
||||
eprintln!("last early log");
|
||||
if mode == "answer" {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":std::env::var("SAUSAGE_PID").unwrap(),"result":"early"}),
|
||||
);
|
||||
}
|
||||
std::process::exit(23);
|
||||
}
|
||||
}
|
||||
let mut lines = io::BufReader::new(io::stdin()).lines();
|
||||
let request = receive(&mut lines);
|
||||
if let Some(result) = dispatch(&request, &mut lines)
|
||||
|
||||
Reference in New Issue
Block a user