Send rpc.superseded with the newly active hash before activation returns. Add an opt-in SDK hook and drain helper that closes SDK listeners and idle connections without cancelling active handlers or background work. Document the additive notice and preserve existing grace, stop and restart rules. Cover immediate notices, ignored notices, early service restarts, SDK draining and duplicate-hook diagnostics. Card: SAU-352
1135 lines
43 KiB
Rust
1135 lines
43 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 = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/activation-guests"));
|
|
(1..=3)
|
|
.map(|version| {
|
|
let target = target.join(version.to_string());
|
|
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()
|
|
}
|
|
fn fixture_hash(version: usize) -> String {
|
|
use sha2::{Digest, Sha256};
|
|
format!(
|
|
"{:x}",
|
|
Sha256::digest(std::fs::read(fixture(version)).unwrap())
|
|
)
|
|
}
|
|
|
|
/// PROTOCOL 10.3: summaries omit even large schemas and report sorted main-method diffs.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn summarizes_method_changes() {
|
|
let mut old = document("unchanged");
|
|
old["methods"] = json!([
|
|
{"name":"z_removed"}, {"name":"a_removed"}, {"name":"direct", "params":[]},
|
|
{"name":"referenced", "result":{"schema":{"$ref":"#/components/schemas/Node"}}},
|
|
{"name":"unrelated", "result":{"schema":{"type":"string"}}}
|
|
]);
|
|
old["components"]["schemas"] = json!({
|
|
"Node":{"properties":{"next":{"$ref":"#/components/schemas/Node"}, "value":{"$ref":"#/components/schemas/Value"}}},
|
|
"Value":{"type":"string"}, "Unused":{"type":"string"}
|
|
});
|
|
let mut next = old.clone();
|
|
next["methods"].as_array_mut().unwrap().drain(..2);
|
|
next["methods"][0]["params"] = json!([{"name":"limit","schema":{"type":"integer"}}]);
|
|
next["methods"].as_array_mut().unwrap().extend(
|
|
json!([
|
|
{"name":"z_added"}, {"name":"a_added"}
|
|
])
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.cloned(),
|
|
);
|
|
next["components"]["schemas"]["Value"]["type"] = json!("integer");
|
|
next["components"]["schemas"]["Unused"]["description"] = json!("x".repeat(220_000));
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", old, next.clone());
|
|
let host = world.start().await;
|
|
let result = activate(&host, "notes").await.unwrap();
|
|
assert_eq!(result["notes"]["method_count"], 5);
|
|
assert_eq!(result["notes"]["added"], json!(["a_added", "z_added"]));
|
|
assert_eq!(
|
|
result["notes"]["removed"],
|
|
json!(["a_removed", "z_removed"])
|
|
);
|
|
assert_eq!(result["notes"]["changed"], json!(["direct", "referenced"]));
|
|
assert!(result.to_string().len() < 1024);
|
|
assert_eq!(call(&host, "notes.rpc.discover", json!({})).await, next);
|
|
assert_eq!(
|
|
call(
|
|
&host,
|
|
"notes.read",
|
|
json!({"path":"/proc/core/plugins/notes/discover.json"})
|
|
)
|
|
.await,
|
|
next
|
|
);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 10.3: first installation has no previous build; a no-op has no method changes.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn summarizes_first_install_and_noop() {
|
|
let world = TestWorld::new();
|
|
world.plugin("notes", document("add"), document("add"));
|
|
std::fs::remove_file(world.0.join("plugins/notes/plugin.wasm")).unwrap();
|
|
let host = world.start().await;
|
|
let result = activate(&host, "notes").await.unwrap();
|
|
assert_eq!(result["notes"]["previous"], Value::Null);
|
|
assert_eq!(result["notes"]["added"], json!(["add"]));
|
|
assert_eq!(result["notes"]["hash"], fixture_hash(2));
|
|
let result = activate(&host, "notes").await.unwrap();
|
|
assert_eq!(result["notes"]["previous"], fixture_hash(2));
|
|
for field in ["added", "removed", "changed"] {
|
|
assert_eq!(result["notes"][field], json!([]));
|
|
}
|
|
assert!(!world.0.join("plugins/notes/plugin.prev.wasm").exists());
|
|
host.stop().await;
|
|
}
|
|
|
|
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 a persistent compile cache shared by the test suites.
|
|
async fn start(&self) -> Host {
|
|
Host::start_with_cache(
|
|
&self.0,
|
|
Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
}
|
|
impl Drop for TestWorld {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
/// Builds a method-only document without an upgrade delay.
|
|
fn document(name: &str) -> Value {
|
|
json!({"methods":[{"name":name,"params":[],"result":{"schema":{"type":"string"}}}],"x-sausage-grace":0})
|
|
}
|
|
/// 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;
|
|
}
|
|
/// Checks that a rejected discovery never receives a retirement hook.
|
|
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.yaml"),
|
|
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;
|
|
assert!(host.processes().is_empty());
|
|
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
|
|
);
|
|
}
|
|
/// Checks retirement against live admissions after a revert.
|
|
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,
|
|
Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/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,
|
|
Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/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;
|
|
}
|
|
/// 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;
|
|
}
|
|
/// PROTOCOL 10.1: discovery fails after its fixed ten-second deadline and receives EOF.
|
|
/// PROTOCOL 3.7: old processes retain their declared grace while new calls use new code.
|
|
/// PROTOCOL 3.7: retirement waits for all live admissions, including reverted versions.
|
|
/// PROTOCOL 3.7: rejected discovery candidates never receive retirement hooks.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn upgrade_and_discovery_deadlines() {
|
|
tokio::join!(
|
|
review_discovery_deadline_and_stop(),
|
|
old_process_grace_and_new_calls(),
|
|
review_retirement_checks_live_version(),
|
|
review_candidate_is_not_old_version(),
|
|
);
|
|
}
|
|
|
|
/// Checks the fixed discovery deadline and subsequent process stop.
|
|
async fn review_discovery_deadline_and_stop() {
|
|
let world = TestWorld::new();
|
|
let mut next = document("add");
|
|
next["x-test-no-answer"] = json!(true);
|
|
world.plugin("notes", document("add"), next);
|
|
let host = world.start().await;
|
|
let before = std::time::Instant::now();
|
|
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!(before.elapsed() >= Duration::from_millis(9900));
|
|
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_eq!(
|
|
result,
|
|
json!({"notes": {
|
|
"plugin":"notes", "wasm":"/plugins/notes/plugin.wasm",
|
|
"hash":fixture_hash(2), "previous":fixture_hash(1),
|
|
"method_count":1, "added":[], "removed":[], "changed":[],
|
|
"interface_check":"passed"
|
|
}})
|
|
);
|
|
assert_eq!(
|
|
call(&host, "notes.rpc.discover", json!({})).await,
|
|
document("add")
|
|
);
|
|
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;
|
|
let result = call(&host,"core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await;
|
|
assert_eq!(result.as_object().unwrap().len(), 2);
|
|
assert_eq!(result["notes"]["added"], json!(["other"]));
|
|
assert_eq!(result["notes"]["removed"], json!(["add"]));
|
|
assert_eq!(result["agent"]["method_count"], 1);
|
|
assert_eq!(result["agent"]["interface_check"], "passed");
|
|
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;
|
|
let result = call(
|
|
&host,
|
|
"core.activate",
|
|
json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}),
|
|
)
|
|
.await;
|
|
assert_eq!(result["notes"]["interface_check"], "skipped");
|
|
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();
|
|
let result = call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
|
assert_eq!(result["notes"]["hash"], fixture_hash(1));
|
|
assert_eq!(result["notes"]["previous"], fixture_hash(2));
|
|
assert_eq!(result["notes"]["interface_check"], "passed");
|
|
assert!(result["notes"].get("methods").is_none());
|
|
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;
|
|
}
|
|
/// Checks the declared upgrade grace and dispatch to the new version.
|
|
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;
|
|
let notice: Value = serde_json::from_slice(
|
|
&std::fs::read(world.0.join(format!("state/notes/superseded-{pid}"))).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(notice["params"]["current"], fixture_hash(2));
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 3.7: every old process gets the new hash while still running, before grace ends.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn superseded_notifies_each_old_process() {
|
|
let world = TestWorld::new();
|
|
let mut old = document("hold");
|
|
old["x-sausage-grace"] = json!(60);
|
|
world.plugin("notes", old, document("hold"));
|
|
let host = world.start().await;
|
|
call(&host, "notes.hold", json!({})).await;
|
|
call(&host, "notes.hold", json!({})).await;
|
|
let pids: Vec<_> = host
|
|
.processes()
|
|
.into_iter()
|
|
.filter(|(_, process)| process.method == "hold")
|
|
.map(|(pid, _)| pid)
|
|
.collect();
|
|
assert_eq!(pids.len(), 2);
|
|
activate(&host, "notes").await.unwrap();
|
|
let expected =
|
|
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":fixture_hash(2)}})
|
|
.to_string();
|
|
for pid in pids {
|
|
// marker's existing 30-second bound is shorter than the old 60-second grace.
|
|
marker(
|
|
world.0.join(format!("state/notes/superseded-{pid}")),
|
|
&expected,
|
|
)
|
|
.await;
|
|
assert_eq!(host.processes()[&pid].state, "running");
|
|
}
|
|
assert_eq!(
|
|
call(&host, "notes.version", json!({})).await["version"],
|
|
"2"
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// PROTOCOL 3.7: a service that exits on the notice restarts before its grace deadline.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn superseded_service_exits_early() {
|
|
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.yaml"),
|
|
json!([{"method":"notes.serve","params":{"exit_on_superseded":true}}]).to_string(),
|
|
)
|
|
.unwrap();
|
|
let host = world.start().await;
|
|
marker(world.0.join("state/notes/service"), "1").await;
|
|
let pid = host
|
|
.processes()
|
|
.into_iter()
|
|
.find(|(_, p)| p.method == "serve")
|
|
.unwrap()
|
|
.0;
|
|
activate(&host, "notes").await.unwrap();
|
|
marker(world.0.join("state/notes/service"), "2").await;
|
|
assert!(!host.processes().contains_key(&pid));
|
|
assert!(
|
|
!world.0.join("state/notes/ended-1").exists(),
|
|
"old service must exit on the notice, not EOF"
|
|
);
|
|
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.yaml"),
|
|
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;
|
|
}
|