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
+251
View File
@@ -0,0 +1,251 @@
//! Exercise SDK plugins through the real host, without a fake core.
use sausage::{Host, world::World};
use serde_json::{Value, json};
use std::{path::PathBuf, sync::OnceLock, time::Duration};
fn fixtures() -> &'static PathBuf {
static TARGET: OnceLock<PathBuf> = OnceLock::new();
TARGET.get_or_init(|| {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_owned();
let target = root.join("target/sdk/real-guests");
for package in [
"examples/notes",
"examples/consumer",
"examples/www",
"examples/secrets",
"sdk/tests/plugins/probe",
"sdk/tests/plugins/consumer",
] {
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--offline",
"--target",
"wasm32-wasip2",
"--manifest-path"
])
.arg(root.join(package).join("Cargo.toml"))
.env("CARGO_TARGET_DIR", &target)
.status()
.unwrap()
.success()
);
}
target.join("wasm32-wasip2/debug")
})
}
struct TestWorld(PathBuf);
impl TestWorld {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("sausage-sdk-{}", uuid::Uuid::now_v7()));
let world = World::create(&path).unwrap();
for (plugin, binary) in [
("notes", "notes"),
("consumer", "consumer"),
("www", "www"),
("secrets", "secrets"),
("probe", "sdk-probe"),
("typed", "sdk-consumer"),
] {
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
std::fs::copy(
fixtures().join(format!("{binary}.wasm")),
world.plugin_dir(plugin).join("plugin.wasm"),
)
.unwrap();
}
Self(path)
}
async fn start(&self) -> Host {
Host::start_with_cache(
&self.0,
&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../target/sdk/real-cache"),
)
.await
.unwrap()
}
}
impl Drop for TestWorld {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
async fn call(host: &Host, method: &str, params: Value) -> Value {
tokio::time::timeout(Duration::from_secs(60), host.call(method, params))
.await
.unwrap()
.unwrap()
}
/// Concurrent read-modify-write calls retain every note under the SDK lock.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn notes_lock_preserves_parallel_additions() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(
call(&host, "notes.add", json!({"text":"first"})).await["count"],
1
);
let calls = (0..12)
.map(|n| {
let h = host.clone();
tokio::spawn(
async move { call(&h, "notes.add", json!({"text":format!("note-{n}")})).await },
)
})
.collect::<Vec<_>>();
for task in calls {
task.await.unwrap();
}
let notes = call(&host, "notes.list", json!({})).await;
assert_eq!(notes.as_array().unwrap().len(), 13);
assert_eq!(notes[0], "first");
for n in 0..12 {
assert!(
notes
.as_array()
.unwrap()
.contains(&json!(format!("note-{n}")))
);
}
host.stop().await;
}
/// The example consumer invokes notes through its generated import.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn generated_notes_import() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(
call(&host, "consumer.note", json!({"text":"typed"})).await,
1
);
assert_eq!(call(&host, "notes.list", json!({})).await, json!(["typed"]));
host.stop().await;
}
/// Discovery retains the complete document and satisfies the consumer contract.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn discovery_is_compatible() {
let world = TestWorld::new();
let host = world.start().await;
let actual = call(&host, "notes.rpc.discover", json!({})).await;
let consumer = call(&host, "consumer.rpc.discover", json!({})).await;
let expected: Value =
serde_json::from_str(include_str!("../../examples/consumer/imports/notes.json")).unwrap();
assert_eq!(actual, expected);
assert_eq!(actual["openrpc"], "1.2.6");
assert!(actual["components"]["schemas"]["AddParams"].is_object());
assert!(
actual["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "list")
.unwrap()
.get("x-sausage-params")
.is_none()
);
assert!(actual["components"]["schemas"].get("Empty").is_none());
assert!(
sausage_interface::compat::check(&consumer["x-sausage-imports"]["notes"], &actual)
.is_empty()
);
host.stop().await;
}
/// Detached SDK calls receive progress and forward it through core.notify.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "core: stdout response can overtake a preceding core.notify"]
async fn detached_call_notifications() {
let world = TestWorld::new();
let host = world.start().await;
let mut events = host.subscribe();
assert_eq!(call(&host, "probe.relay", json!({})).await, 42);
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
.await
.unwrap()
.unwrap();
assert_eq!(event["params"]["name"], "step");
host.stop().await;
}
/// Detached typed calls complete and detached services survive their original caller.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_service_survives_parent() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(call(&host, "typed.run", json!({})).await, 5);
let child = call(&host, "probe.launch", json!({})).await;
tokio::time::timeout(Duration::from_secs(5), async {
while host.processes().values().any(|p| p.method == "launch") {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(host.processes().contains_key(child.as_str().unwrap()));
host.stop().await;
}
/// A core.stop request closes stdin and executes the SDK stop hook.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stop_runs_hook() {
let world = TestWorld::new();
let host = world.start().await;
let pid = call(&host, "probe.service", json!({})).await;
call(&host, "core.stop", json!({"pid":pid})).await;
assert_eq!(std::fs::read(world.0.join("stopped")).unwrap(), b"yes");
host.stop().await;
}
/// SDK framing carries payloads above 64 KiB in both directions.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn large_messages() {
let world = TestWorld::new();
let host = world.start().await;
let text = "z".repeat(192 * 1024);
assert_eq!(call(&host, "probe.echo", json!({"text":text})).await, text);
host.stop().await;
}
/// Normal SDK stdout never produces the discarded-output journal warning.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn no_discarded_stdout() {
let world = TestWorld::new();
let host = world.start().await;
call(&host, "probe.echo", json!({"text":"framing"})).await;
host.stop().await;
assert_eq!(
call(&host, "core.log", json!({"grep":"discarded stdout"})).await,
json!([])
);
}
/// Two SDK processes exchange endpoint requests and progress at a world path.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn world_endpoint() {
let world = TestWorld::new();
let host = world.start().await;
call(&host, "probe.service", json!({})).await;
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
host.stop().await;
}
/// The HTTP example exposes its self endpoint through the process namespace.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "needs /proc, /self, and /run (T6)"]
async fn www_control_through_proc() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(
call(&host, "consumer.demo", json!({"text":"web","port":0})).await,
0
);
host.stop().await;
}
/// The vault example publishes a fixed endpoint in the shared run namespace.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "needs /proc, /self, and /run (T6)"]
async fn secrets_fixed_endpoint() {
let world = TestWorld::new();
let host = world.start().await;
call(&host, "secrets.serve", json!({})).await;
assert_eq!(call(&host, "consumer.vault", json!({})).await, "hello");
host.stop().await;
}