cargo.build and the runner (review T22R): builds use the network again; core.unlock and core.notify in order under the runner; quiet test output; manifest preparation keeps [workspace], target tables, renamed and optional entries; cargo.rpc.discover; -32004 with data.output; retention of 10 builds; light, empty test worlds with an in-memory journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner through --config. A compile cache that cannot be created is not fatal. Activation replaces plugin.wasm without a gap. Console and main: init ships the workspace Cargo.lock and creates the world atomically; each console command runs in its own task, so ps, stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal mode is restored; signals during boot; chat text is not taken as a command. Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests choose the libtest binary from cargo's JSON output and time out.
968 lines
37 KiB
Rust
968 lines
37 KiB
Rust
//! Activation, graph, and upgrade integration tests with raw WASI guests.
|
|
use sausage::{Host, world::World};
|
|
use serde_json::{Value, json};
|
|
use std::{
|
|
path::{Path, PathBuf},
|
|
sync::OnceLock,
|
|
time::Duration,
|
|
};
|
|
|
|
/// 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(|| {
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let target = root.join(format!(
|
|
"target/compat/activation-guests/{}",
|
|
std::process::id()
|
|
));
|
|
(1..=3)
|
|
.map(|version| {
|
|
let status = std::process::Command::new("cargo")
|
|
.env("FIXTURE_VERSION", version.to_string())
|
|
.args([
|
|
"build",
|
|
"--offline",
|
|
"--target",
|
|
"wasm32-wasip2",
|
|
"--manifest-path",
|
|
])
|
|
.arg(root.join("cli/tests/plugins/activation/Cargo.toml"))
|
|
.arg("--target-dir")
|
|
.arg(&target)
|
|
.status()
|
|
.unwrap();
|
|
assert!(status.success());
|
|
let copy = target.join(format!("v{version}.wasm"));
|
|
std::fs::copy(
|
|
target.join("wasm32-wasip2/debug/activation-guest.wasm"),
|
|
©,
|
|
)
|
|
.unwrap();
|
|
copy
|
|
})
|
|
.collect()
|
|
})[version - 1]
|
|
.clone()
|
|
}
|
|
struct TestWorld(PathBuf);
|
|
impl TestWorld {
|
|
/// Creates an isolated world with two version documents per plugin.
|
|
fn new() -> Self {
|
|
let path =
|
|
std::env::temp_dir().join(format!("sausage-activation-{}", uuid::Uuid::now_v7()));
|
|
World::create(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
/// Installs version one and stages version two under the world state.
|
|
fn plugin(&self, name: &str, old: Value, new: Value) {
|
|
std::fs::create_dir_all(self.0.join(format!("plugins/{name}"))).unwrap();
|
|
std::fs::create_dir_all(self.0.join(format!("state/{name}"))).unwrap();
|
|
std::fs::copy(
|
|
fixture(1),
|
|
self.0.join(format!("plugins/{name}/plugin.wasm")),
|
|
)
|
|
.unwrap();
|
|
std::fs::copy(fixture(2), self.0.join(format!("state/{name}/next.wasm"))).unwrap();
|
|
std::fs::write(self.0.join(format!("state/{name}/1.json")), old.to_string()).unwrap();
|
|
std::fs::write(self.0.join(format!("state/{name}/2.json")), new.to_string()).unwrap();
|
|
}
|
|
/// Starts a host with an independent compile cache shared by these tests.
|
|
async fn start(&self) -> Host {
|
|
Host::start_with_cache(
|
|
&self.0,
|
|
&Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.unwrap()
|
|
.join("target/compat/runtime-cache"),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
}
|
|
impl Drop for TestWorld {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
/// Builds a method-only document with a short upgrade grace period.
|
|
fn document(name: &str) -> Value {
|
|
json!({"methods":[{"name":name,"params":[],"result":{"schema":{"type":"string"}}}],"x-sausage-grace":0.05})
|
|
}
|
|
/// Adds an import of a provider's method list.
|
|
fn consumer(provider: &str, expected: Value) -> Value {
|
|
let mut document = document("consume");
|
|
document["x-sausage-imports"] = json!({provider:expected});
|
|
document
|
|
}
|
|
/// Performs a bounded test call without imposing a host execution limit.
|
|
async fn call(host: &Host, method: &str, params: Value) -> Value {
|
|
tokio::time::timeout(Duration::from_secs(60), host.call(method, params))
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
}
|
|
/// Activates the staged candidate.
|
|
async fn activate(host: &Host, name: &str) -> Result<Value, Value> {
|
|
tokio::time::timeout(
|
|
Duration::from_secs(60),
|
|
host.call(
|
|
"core.activate",
|
|
json!({"plugin":name,"wasm":format!("/state/{name}/next.wasm")}),
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
/// Waits for a lifecycle marker without a fixed delay.
|
|
async fn marker(path: PathBuf, expected: &str) {
|
|
tokio::time::timeout(Duration::from_secs(30), async {
|
|
loop {
|
|
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected) {
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
/// Reads the cached graph through the actual proc adapter.
|
|
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;
|
|
}
|
|
/// T23: an unwritable compile cache does not prevent a world from running.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn unwritable_compile_cache_is_nonfatal() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("add"));
|
|
let parent = world.0.join("unwritable");
|
|
std::fs::create_dir(&parent).unwrap();
|
|
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap();
|
|
let result = Host::start_with_cache(&world.0, &parent.join("cache")).await;
|
|
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let host = result.unwrap();
|
|
assert_eq!(
|
|
call(&host, "notes.version", json!({})).await["version"],
|
|
"1"
|
|
);
|
|
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_with_cache(&world.0, &world.0.join("compile-cache")).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_with_cache(&world.0, &world.0.join("compile-cache")).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() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("add"));
|
|
let host = world.start().await;
|
|
assert!(graph(&host).await["plugins"].is_object());
|
|
let dot = call(&host, "notes.read", json!({"path":"/proc/core/graph.dot"})).await;
|
|
assert!(dot.as_str().unwrap().starts_with("digraph plugins"));
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: a rejected group leaves every installed file unchanged.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn rejects_group_without_changes() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("other"));
|
|
world.plugin(
|
|
"agent",
|
|
consumer("notes", document("add")),
|
|
consumer("notes", document("missing")),
|
|
);
|
|
let host = world.start().await;
|
|
let result = host.call("core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await;
|
|
assert_eq!(result.unwrap_err()["code"], -32003);
|
|
for name in ["notes", "agent"] {
|
|
assert_eq!(
|
|
std::fs::read(world.0.join(format!("plugins/{name}/plugin.wasm"))).unwrap(),
|
|
std::fs::read(fixture(1)).unwrap()
|
|
);
|
|
}
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: activation discovers and installs a copied candidate.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn activates_copy() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("add"));
|
|
let host = world.start().await;
|
|
let result = activate(&host, "notes").await.unwrap();
|
|
assert!(result["notes"]["methods"].is_array());
|
|
assert_eq!(
|
|
call(&host, "notes.version", json!({})).await["version"],
|
|
"2"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(),
|
|
std::fs::read(fixture(1)).unwrap()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: changes to the original after discovery starts cannot change the installed copy.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn source_changes_do_not_change_candidate() {
|
|
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 activation_host = host.clone();
|
|
let activation = tokio::spawn(async move { activate(&activation_host, "notes").await });
|
|
marker(world.0.join("state/notes/discover-started"), "ready").await;
|
|
std::fs::copy(fixture(1), world.0.join("state/notes/next.wasm")).unwrap();
|
|
std::fs::write(world.0.join("state/notes/discover-release"), "ready").unwrap();
|
|
activation.await.unwrap().unwrap();
|
|
assert_eq!(
|
|
std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(),
|
|
std::fs::read(fixture(2)).unwrap()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 3.7: plugins without a declared grace period retain old calls before the default deadline.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn default_grace_retains_old_process() {
|
|
let world = TestWorld::new();
|
|
let mut old = document("hold");
|
|
old.as_object_mut().unwrap().remove("x-sausage-grace");
|
|
world.plugin("notes", old, document("hold"));
|
|
let host = world.start().await;
|
|
call(&host, "notes.hold", json!({})).await;
|
|
let pid = host
|
|
.processes()
|
|
.into_iter()
|
|
.find(|(_, process)| process.method == "hold")
|
|
.unwrap()
|
|
.0;
|
|
activate(&host, "notes").await.unwrap();
|
|
assert_eq!(host.processes()[&pid].state, "running");
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: candidate imports must fit installed providers.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn checks_candidate_imports() {
|
|
let world = TestWorld::new();
|
|
world.plugin(
|
|
"notes",
|
|
document("add"),
|
|
consumer("absent", document("add")),
|
|
);
|
|
let host = world.start().await;
|
|
let failure = activate(&host, "notes").await.unwrap_err();
|
|
assert_eq!(failure["code"], -32003);
|
|
assert_eq!(failure["data"][0]["status"], "missing");
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: current consumers block an incompatible provider upgrade.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn checks_current_consumers() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("other"));
|
|
world.plugin(
|
|
"agent",
|
|
consumer("notes", document("add")),
|
|
document("consume"),
|
|
);
|
|
let host = world.start().await;
|
|
let failure = activate(&host, "notes").await.unwrap_err();
|
|
assert_eq!(failure["code"], -32003);
|
|
assert_eq!(failure["data"][0]["method"], "add");
|
|
assert_eq!(
|
|
std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(),
|
|
std::fs::read(fixture(1)).unwrap()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: a group checks the proposed set rather than intermediate versions.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn activates_group() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("other"));
|
|
world.plugin(
|
|
"agent",
|
|
consumer("notes", document("add")),
|
|
consumer("notes", document("other")),
|
|
);
|
|
let host = world.start().await;
|
|
call(&host,"core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await;
|
|
assert_eq!(
|
|
call(&host, "agent.version", json!({})).await["version"],
|
|
"2"
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: force skips compatibility checks.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn force_skips_checks() {
|
|
let world = TestWorld::new();
|
|
world.plugin(
|
|
"notes",
|
|
document("add"),
|
|
consumer("absent", document("add")),
|
|
);
|
|
let host = world.start().await;
|
|
call(
|
|
&host,
|
|
"core.activate",
|
|
json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}),
|
|
)
|
|
.await;
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: revert activates the previous copy and preserves the replaced version.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn reverts_previous_version() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("add"));
|
|
let host = world.start().await;
|
|
activate(&host, "notes").await.unwrap();
|
|
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
|
assert_eq!(
|
|
call(&host, "notes.version", json!({})).await["version"],
|
|
"1"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(),
|
|
std::fs::read(fixture(2)).unwrap()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.3: concurrent activations serialize their installation and revert state.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn serializes_activations() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("add"));
|
|
let host = world.start().await;
|
|
let (first, second) = tokio::join!(activate(&host, "notes"), activate(&host, "notes"));
|
|
first.unwrap();
|
|
second.unwrap();
|
|
assert_eq!(
|
|
std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(),
|
|
std::fs::read(fixture(1)).unwrap()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.4: startup fetches every installed document before activation checks consumers.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn discovers_all_before_checks() {
|
|
checks_current_consumers_body().await;
|
|
}
|
|
/// Exercises a consumer whose discovery must finish before the provider can change.
|
|
async fn checks_current_consumers_body() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("other"));
|
|
world.plugin(
|
|
"agent",
|
|
consumer("notes", document("add")),
|
|
document("consume"),
|
|
);
|
|
let host = world.start().await;
|
|
assert_eq!(activate(&host, "notes").await.unwrap_err()["code"], -32003);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.4: manual copies update cached hashes and expose broken imports in the journal.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn watches_manual_copy() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("other"));
|
|
world.plugin(
|
|
"agent",
|
|
consumer("notes", document("add")),
|
|
document("consume"),
|
|
);
|
|
let host = world.start().await;
|
|
// A same-version activation waits for initial discovery without changing the provider.
|
|
call(
|
|
&host,
|
|
"core.activate",
|
|
json!({"plugin":"notes","wasm":"/plugins/notes/plugin.wasm"}),
|
|
)
|
|
.await;
|
|
std::fs::copy(fixture(2), world.0.join("plugins/notes/plugin.wasm")).unwrap();
|
|
tokio::time::timeout(Duration::from_secs(30), async {
|
|
loop {
|
|
let graph = graph(&host).await;
|
|
if graph["plugins"]["agent"]["imports"]["notes"]["status"] == "broken" {
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
!call(&host, "core.log", json!({"grep":"broken import"}))
|
|
.await
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 10.4: nonexistent providers appear as missing imports.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn graph_missing_import() {
|
|
let world = TestWorld::new();
|
|
world.plugin(
|
|
"notes",
|
|
consumer("absent", document("add")),
|
|
document("add"),
|
|
);
|
|
let host = world.start().await;
|
|
call(
|
|
&host,
|
|
"core.activate",
|
|
json!({"plugin":"notes","wasm":"/plugins/notes/plugin.wasm","force":true}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
graph(&host).await["plugins"]["notes"]["imports"]["absent"]["status"],
|
|
"missing"
|
|
);
|
|
let dot = call(&host, "notes.read", json!({"path":"/proc/core/graph.dot"})).await;
|
|
assert!(dot.as_str().unwrap().contains("absent"));
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 3.7: new calls use new code while old calls retain their grace period.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn old_process_grace_and_new_calls() {
|
|
let world = TestWorld::new();
|
|
let mut old = document("add");
|
|
old["x-sausage-grace"] = json!(0.5);
|
|
world.plugin("notes", old, document("add"));
|
|
let host = world.start().await;
|
|
call(&host, "notes.hold", json!({})).await;
|
|
let pid = host
|
|
.processes()
|
|
.into_iter()
|
|
.find(|(_, process)| process.method == "hold")
|
|
.unwrap()
|
|
.0;
|
|
activate(&host, "notes").await.unwrap();
|
|
assert!(host.processes().contains_key(&pid));
|
|
assert_eq!(
|
|
call(&host, "notes.version", json!({})).await["version"],
|
|
"2"
|
|
);
|
|
marker(world.0.join("state/notes/ended-1"), "ended").await;
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 3.7: retirement receives the previous hash only after old processes end.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn retires_old_version() {
|
|
use sha2::{Digest, Sha256};
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("add"));
|
|
let host = world.start().await;
|
|
call(&host, "notes.hold", json!({})).await;
|
|
activate(&host, "notes").await.unwrap();
|
|
let previous = format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap()));
|
|
marker(world.0.join("state/notes/retired"), &previous).await;
|
|
marker(world.0.join("state/notes/ended-1"), "ended").await;
|
|
assert!(
|
|
!call(&host, "core.log", json!({"grep":"retired start"}))
|
|
.await
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 8 and 3.7: services restart after their old process ends.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn restarts_service() {
|
|
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;
|
|
marker(world.0.join("state/notes/service"), "1").await;
|
|
activate(&host, "notes").await.unwrap();
|
|
marker(world.0.join("state/notes/service"), "2").await;
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 8: ordinary detached processes do not become services after an upgrade.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn does_not_restart_detached_call() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("hold"), document("hold"));
|
|
let host = world.start().await;
|
|
call(
|
|
&host,
|
|
"core.call",
|
|
json!({"method":"notes.hold","params":{},"detach":true}),
|
|
)
|
|
.await;
|
|
activate(&host, "notes").await.unwrap();
|
|
marker(world.0.join("state/notes/ended-1"), "ended").await;
|
|
assert!(
|
|
!host
|
|
.processes()
|
|
.values()
|
|
.any(|process| process.method == "hold" && process.state == "running")
|
|
);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 7: activation records the plugin's upgrade grace period in the journal.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn upgrades_enter_the_journal() {
|
|
let world = TestWorld::new();
|
|
let mut old = document("hold");
|
|
old.as_object_mut().unwrap().remove("x-sausage-grace");
|
|
world.plugin("notes", old, document("hold"));
|
|
let host = world.start().await;
|
|
call(&host, "notes.hold", json!({})).await;
|
|
activate(&host, "notes").await.unwrap();
|
|
let entries = tokio::time::timeout(Duration::from_secs(10), async {
|
|
loop {
|
|
let entries = call(
|
|
&host,
|
|
"core.log",
|
|
json!({"plugin":"notes","grep":"upgrade grace"}),
|
|
)
|
|
.await;
|
|
if entries
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|e| e["line"] == "upgrade grace 60 seconds")
|
|
{
|
|
break entries;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("the upgrade journal entry must arrive within 10 seconds");
|
|
assert!(
|
|
entries
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|e| e["stream"] == "core")
|
|
);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 10.2 and 10.3: breaking endpoint methods reject activation with counterexamples where possible.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn endpoint_contracts_and_activation_counterexamples() {
|
|
let method = json!({"name":"stats","params":[{"name":"value","required":true,"schema":{"type":"integer","minimum":0}}],"result":{"schema":{"type":"string"}}});
|
|
let expected = json!({"methods":[],"x-sausage-endpoints":{"control":{"methods":[method]}}});
|
|
for case in ["missing", "params", "result", "unsupported"] {
|
|
let world = TestWorld::new();
|
|
let mut next = expected.clone();
|
|
match case {
|
|
"missing" => next["x-sausage-endpoints"]["control"]["methods"] = json!([]),
|
|
"params" => {
|
|
next["x-sausage-endpoints"]["control"]["methods"][0]["params"][0]["schema"]["minimum"] =
|
|
json!(1)
|
|
}
|
|
"result" => {
|
|
next["x-sausage-endpoints"]["control"]["methods"][0]["result"]["schema"] =
|
|
json!({"type":"integer"})
|
|
}
|
|
_ => {
|
|
next["x-sausage-endpoints"]["control"]["methods"][0]["result"]["schema"]["pattern"] =
|
|
json!("[a-z]+")
|
|
}
|
|
}
|
|
world.plugin("notes", expected.clone(), next);
|
|
world.plugin(
|
|
"agent",
|
|
consumer("notes", expected.clone()),
|
|
document("consume"),
|
|
);
|
|
let host = world.start().await;
|
|
let error = activate(&host, "notes").await.unwrap_err();
|
|
assert_eq!(error["code"], -32003, "{case}: {error}");
|
|
assert!(!error["data"].as_array().unwrap().is_empty());
|
|
if ["params", "result"].contains(&case) {
|
|
assert!(
|
|
error["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|p| !p["counterexample"].is_null()),
|
|
"{error}"
|
|
);
|
|
}
|
|
if case == "unsupported" {
|
|
assert!(error["data"].to_string().contains("unsupported"), "{error}");
|
|
}
|
|
assert_eq!(
|
|
std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(),
|
|
std::fs::read(fixture(1)).unwrap()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
}
|
|
|
|
/// PROTOCOL 10.2: providers can add methods, optional params, and result fields without breaking consumers.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn additive_contract_changes_activate() {
|
|
let old = json!({"methods":[{"name":"read","params":[],"result":{"schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}}]});
|
|
let mut next = old.clone();
|
|
next["methods"][0]["params"] =
|
|
json!([{"name":"limit","required":false,"schema":{"type":"integer"}}]);
|
|
next["methods"][0]["result"]["schema"]["properties"]["extra"] = json!({"type":"boolean"});
|
|
next["methods"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.push(json!({"name":"new","params":[],"result":{"schema":{"type":"null"}}}));
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", old.clone(), next);
|
|
world.plugin("agent", consumer("notes", old), document("consume"));
|
|
let host = world.start().await;
|
|
activate(&host, "notes").await.unwrap();
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 3.7: an old consumer can still send its old import during grace, even after the provider removes it.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn old_consumer_can_call_removed_provider_method() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("legacy"), document("new"));
|
|
let mut old = consumer("notes", document("legacy"));
|
|
old["x-sausage-grace"] = json!(60);
|
|
world.plugin("agent", old, document("consume"));
|
|
let host = world.start().await;
|
|
let pid = call(&host, "agent.deferred", json!({})).await;
|
|
activate(&host, "agent").await.unwrap();
|
|
activate(&host, "notes").await.unwrap();
|
|
assert_eq!(host.processes()[pid.as_str().unwrap()].state, "running");
|
|
std::fs::write(world.0.join("state/agent/release-call"), "release").unwrap();
|
|
let response: Value = tokio::time::timeout(Duration::from_secs(10), async {
|
|
loop {
|
|
if let Ok(bytes) = std::fs::read(world.0.join("state/agent/call-result"))
|
|
&& let Ok(value) = serde_json::from_slice(&bytes)
|
|
{
|
|
break value;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response["error"]["code"], -32601);
|
|
assert_eq!(response["error"]["data"]["version"], "2");
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 10.2: activation and revert check interfaces but leave persistent state unchanged.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn state_is_outside_interface_checks() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("add"));
|
|
let state = world.0.join("state/notes/user-state");
|
|
let contents = b"a state format neither version understands\0";
|
|
std::fs::write(&state, contents).unwrap();
|
|
let host = world.start().await;
|
|
activate(&host, "notes").await.unwrap();
|
|
assert_eq!(std::fs::read(&state).unwrap(), contents);
|
|
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
|
assert_eq!(std::fs::read(state).unwrap(), contents);
|
|
host.stop().await;
|
|
}
|