core: sausage-vfs per process (/, /proc, /self, /run), activation, graph, watcher, upgrades

- /proc is a core layer: core/ (PROTOCOL.md, graph.json, graph.dot,
  locks.json, plugins/<name>/discover.json and client.rs) and one folder
  per process (info.json, log, and its /self files).
- Locks are released only after the task ended, the store dropped, and
  in-flight file operations returned.
- core.activate and core.revert with copies, discovery, compatibility
  checks of imports and consumers, groups, force. An admission gate
  makes the snapshot of old processes complete.
- Graph of discover documents by hash, refreshed by a file watcher.
- Upgrades: grace periods (x-sausage-grace), stop, service restarts,
  rpc.retired.
- SDK plugins in the real core (cli/tests/sdk.rs).
This commit is contained in:
2026-10-23 01:05:19 +01:00
parent 151550242b
commit edc2d1a63b
21 changed files with 2306 additions and 101 deletions
+491
View File
@@ -0,0 +1,491 @@
//! 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 two 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..=2)
.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"),
&copy,
)
.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
}
/// 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(2)).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;
}