//! 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> = 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() } 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 { 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.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; 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; } /// PROTOCOL 8 (L14): rapid service upgrades never overlap singleton instances. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_singleton_restart() { singleton_restart_cycles(4).await; } /// PROTOCOL 8 (L14): 128 upgrade/revert cycles stress the singleton restart race. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "stress: run by nix flake check"] async fn singleton_restart_stress() { singleton_restart_cycles(128).await; } /// Exercises service restarts without duplicating the normal and stress setup. async fn singleton_restart_cycles(cycles: usize) { 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..cycles { activate(&host, "notes").await.unwrap(); call(&host, "core.revert", json!({"plugin":"notes"})).await; } activate(&host, "notes").await.unwrap(); marker(world.0.join("state/notes/service"), "2").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; } /// 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!(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; } /// 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; 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; }