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:
@@ -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"),
|
||||
©,
|
||||
)
|
||||
.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;
|
||||
}
|
||||
+257
-3
@@ -190,7 +190,7 @@ async fn unimplemented_core_methods_return_method_not_found() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
assert_eq!(
|
||||
h.call("core.activate", json!({})).await.unwrap_err()["code"],
|
||||
h.call("core.unknown", json!({})).await.unwrap_err()["code"],
|
||||
-32601
|
||||
);
|
||||
|
||||
@@ -1083,8 +1083,9 @@ async fn boot_services_are_detached() {
|
||||
.unwrap();
|
||||
let h = w.start().await;
|
||||
let processes = h.processes();
|
||||
assert_eq!(processes.len(), 1);
|
||||
assert!(processes.values().next().unwrap().detached);
|
||||
let services: Vec<_> = processes.values().filter(|p| p.method == "serve").collect();
|
||||
assert_eq!(services.len(), 1);
|
||||
assert!(services[0].detached);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
@@ -1110,3 +1111,256 @@ async fn core_lifetime_events_enter_the_journal() {
|
||||
}
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: the root merges world files and mounts, and cross-mount rename fails.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_root_and_mount_boundaries() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
call(&h, "probe.filesystem", json!({"topic":"root"})).await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(w.0.join("state/root")).unwrap(),
|
||||
"world"
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: a process publishes self files read-only under its own live proc folder.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_self_and_proc_lifetime() {
|
||||
use sausage_vfs::Layer;
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let view = h.filesystem().await.unwrap();
|
||||
let pid = call(&h, "probe.filesystem", json!({"topic":"self"})).await;
|
||||
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||
assert!(
|
||||
view.stat(&format!("/proc/{}", pid.as_str().unwrap()), true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let live = call(&h, "probe.self_serve", json!({})).await;
|
||||
call(&h, "probe.filesystem", json!({"topic":"isolated"})).await;
|
||||
assert_eq!(
|
||||
call(
|
||||
&h,
|
||||
"probe.read",
|
||||
json!({"path":format!("/proc/{}/endpoint",live.as_str().unwrap())})
|
||||
)
|
||||
.await,
|
||||
"endpoint"
|
||||
);
|
||||
call(&h, "core.stop", json!({"pid":live})).await;
|
||||
assert!(
|
||||
view.stat(&format!("/proc/{}", live.as_str().unwrap()), true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: core-owned self names reject creation and replacement.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_reserved_self_names() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
call(&h, "probe.filesystem", json!({"topic":"reserved"})).await;
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: generated logs capture a finite snapshot at open.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_log_snapshot_ends() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
call(&h, "probe.filesystem", json!({"topic":"snapshot"})).await;
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: proc/core provides protocol, graph, lock, and plugin inspection files.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_core_inspection_files() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
call(&h, "probe.filesystem", json!({"topic":"core"})).await;
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: proc/core exposes each discovered document and its generated Rust client.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_discovery_document_and_client() {
|
||||
use sausage_vfs::{Layer, Open};
|
||||
let w = TestWorld::new();
|
||||
let document =
|
||||
json!({"methods":[{"name":"echo","params":[],"result":{"schema":{"type":"string"}}}]});
|
||||
std::fs::create_dir_all(w.0.join("state/probe")).unwrap();
|
||||
std::fs::write(w.0.join("state/probe/discover.json"), document.to_string()).unwrap();
|
||||
let h = w.start().await;
|
||||
let view = h.filesystem().await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
loop {
|
||||
if view
|
||||
.open("/proc/core/plugins/probe/discover.json", Open::default())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let published = call(
|
||||
&h,
|
||||
"probe.read",
|
||||
json!({"path":"/proc/core/plugins/probe/discover.json"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(published.as_str().unwrap()).unwrap(),
|
||||
document
|
||||
);
|
||||
let client = call(
|
||||
&h,
|
||||
"probe.read",
|
||||
json!({"path":"/proc/core/plugins/probe/client.rs"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
client,
|
||||
sausage_interface::client_rs("probe", &document).unwrap()
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: run is shared across processes but empty after a host restart.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_run_is_shared_and_ephemeral() {
|
||||
use sausage_vfs::Layer;
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
call(
|
||||
&h,
|
||||
"probe.write",
|
||||
json!({"path":"/run/shared","text":"yes"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
call(&h, "probe.read", json!({"path":"/run/shared"})).await,
|
||||
"yes"
|
||||
);
|
||||
h.stop().await;
|
||||
let h = w.start().await;
|
||||
assert!(
|
||||
h.filesystem()
|
||||
.await
|
||||
.unwrap()
|
||||
.list("/run")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 6: no write from a killed process lands after the next holder acquires its lock.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_kill_drains_writes_before_lock_transfer() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let pid = call(&h, "probe.write_loop", json!({})).await;
|
||||
let next = h.clone();
|
||||
let waiter = tokio::spawn(async move { call(&next, "probe.locked_write", json!({})).await });
|
||||
use sausage_vfs::{Layer, Open};
|
||||
let view = h.filesystem().await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
loop {
|
||||
let file = view
|
||||
.open("/proc/core/locks.json", Open::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let locks: Value = serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
|
||||
if locks["/state/loop"]["waiting"]
|
||||
.as_array()
|
||||
.is_some_and(|queue| !queue.is_empty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
call(&h, "core.kill", json!({"pid":pid})).await;
|
||||
waiter.await.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(w.0.join("state/loop")).unwrap(),
|
||||
"new"
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: native processes appear in proc and locks.json identifies holders and waiters.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn filesystem_native_processes_and_lock_snapshot() {
|
||||
use sausage_vfs::{Layer, Open};
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
h.register_native(
|
||||
"native",
|
||||
std::sync::Arc::new(|ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.call("core.lock", json!({"path":"live"})).await.unwrap();
|
||||
ctx.notify("ready", json!({"pid":ctx.pid}));
|
||||
std::future::pending().await
|
||||
})
|
||||
}),
|
||||
);
|
||||
let mut events = h.subscribe();
|
||||
let host = h.clone();
|
||||
let task = tokio::spawn(async move { host.call("native.wait", json!({})).await });
|
||||
let event = events.recv().await.unwrap();
|
||||
let pid = event["params"]["params"]["pid"].as_str().unwrap();
|
||||
let view = h.filesystem().await.unwrap();
|
||||
assert!(
|
||||
view.list("/proc")
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e.name == pid)
|
||||
);
|
||||
let info = call(
|
||||
&h,
|
||||
"probe.read",
|
||||
json!({"path":format!("/proc/{pid}/info.json")}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(info.as_str().unwrap()).unwrap()["version"],
|
||||
"native"
|
||||
);
|
||||
let host = h.clone();
|
||||
let waiter = tokio::spawn(async move { host.call("core.lock", json!({"path":"live"})).await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let file = view
|
||||
.open("/proc/core/locks.json", Open::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let locks: Value = serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
|
||||
if locks["live"]["waiting"] == json!(["console"]) {
|
||||
assert_eq!(locks["live"]["owner"], pid);
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
call(&h, "core.kill", json!({"pid":pid})).await;
|
||||
assert_eq!(waiter.await.unwrap().unwrap(), json!({"held":true}));
|
||||
assert!(task.await.unwrap().is_err());
|
||||
assert!(view.stat(&format!("/proc/{pid}"), true).await.is_err());
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "activation-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Raw discovery and lifecycle guest for activation protocol tests.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, BufRead, Write};
|
||||
/// Emits one flushed JSON-RPC response.
|
||||
fn send(value: Value) {
|
||||
println!("{value}");
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
/// Reads a version-specific discovery document without the SDK.
|
||||
fn main() {
|
||||
let version = env!("FIXTURE_VERSION");
|
||||
let plugin = std::env::var("SAUSAGE_PLUGIN").unwrap();
|
||||
let mut lines = io::stdin().lock().lines();
|
||||
let request: Value = serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();
|
||||
let document: Value =
|
||||
serde_json::from_slice(&std::fs::read(format!("/state/{plugin}/{version}.json")).unwrap())
|
||||
.unwrap();
|
||||
let result = match request["method"].as_str().unwrap() {
|
||||
"rpc.discover" => {
|
||||
if document["x-test-wait"] == true {
|
||||
std::fs::write(format!("/state/{plugin}/discover-started"), "ready").unwrap();
|
||||
while !std::path::Path::new(&format!("/state/{plugin}/discover-release")).exists() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
document
|
||||
}
|
||||
"read" => {
|
||||
let text =
|
||||
std::fs::read_to_string(request["params"]["path"].as_str().unwrap()).unwrap();
|
||||
serde_json::from_str(&text).unwrap_or(json!(text))
|
||||
}
|
||||
"rpc.retired" => {
|
||||
std::fs::write(
|
||||
format!("/state/{plugin}/retired"),
|
||||
request["params"]["previous"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
json!({"retired":version})
|
||||
}
|
||||
"serve" | "hold" => {
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":{"version":version}}));
|
||||
if request["method"] == "serve" {
|
||||
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
|
||||
}
|
||||
for _ in lines {}
|
||||
std::fs::write(format!("/state/{plugin}/ended-{version}"), "ended").unwrap();
|
||||
return;
|
||||
}
|
||||
_ => json!({"version":version}),
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":result}));
|
||||
}
|
||||
@@ -5,3 +5,4 @@ edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
wasip2 = "1"
|
||||
|
||||
@@ -14,6 +14,13 @@ fn receive(lines: &mut Lines) -> Value {
|
||||
fn echo(params: &Value) -> Value {
|
||||
params.clone()
|
||||
}
|
||||
fn discover(params: &Value) -> Value {
|
||||
if let Ok(bytes) = std::fs::read("/state/probe/discover.json") {
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
} else {
|
||||
params.clone()
|
||||
}
|
||||
}
|
||||
fn environment() -> Value {
|
||||
json!({
|
||||
"pid": std::env::var("SAUSAGE_PID").unwrap(),
|
||||
@@ -155,10 +162,126 @@ fn outgoing_call(params: &Value, lines: &mut Lines) -> Value {
|
||||
fn lock(params: &Value, lines: &mut Lines) -> Value {
|
||||
outgoing_call(&json!({"method":"core.lock", "params":params}), lines)
|
||||
}
|
||||
fn filesystem(params: &Value) -> Value {
|
||||
use std::fs;
|
||||
let pid = std::env::var("SAUSAGE_PID").unwrap();
|
||||
match params["topic"].as_str().unwrap() {
|
||||
"root" => {
|
||||
let preopens = wasip2::filesystem::preopens::get_directories();
|
||||
assert_eq!(preopens.len(), 1);
|
||||
assert_eq!(preopens[0].1, "/");
|
||||
let names: Vec<_> = fs::read_dir("/")
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
for name in ["proc", "self", "run"] {
|
||||
assert!(names.iter().any(|n| n == name));
|
||||
}
|
||||
fs::write("/state/root", "world").unwrap();
|
||||
fs::write("/run/shared", "shared").unwrap();
|
||||
assert!(fs::rename("/run/shared", "/state/moved").is_err());
|
||||
assert!(fs::rename("/state/root", "/self/moved").is_err());
|
||||
assert_eq!(fs::read_to_string("/run/../state/root").unwrap(), "world");
|
||||
}
|
||||
"reserved" => {
|
||||
for name in ["info.json", "log", "fs"] {
|
||||
assert!(fs::write(format!("/self/{name}"), "bad").is_err());
|
||||
assert!(fs::create_dir(format!("/self/{name}")).is_err());
|
||||
fs::write("/self/temp", "ok").unwrap();
|
||||
assert!(fs::rename("/self/temp", format!("/self/{name}")).is_err());
|
||||
fs::remove_file("/self/temp").unwrap();
|
||||
}
|
||||
}
|
||||
"self" => {
|
||||
fs::write("/self/endpoint.tmp", "endpoint").unwrap();
|
||||
fs::rename("/self/endpoint.tmp", "/self/endpoint").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(format!("/proc/{pid}/endpoint")).unwrap(),
|
||||
"endpoint"
|
||||
);
|
||||
assert!(fs::write(format!("/proc/{pid}/endpoint"), "bad").is_err());
|
||||
assert!(fs::remove_file(format!("/proc/{pid}/endpoint")).is_err());
|
||||
let info: Value =
|
||||
serde_json::from_slice(&fs::read("/self/info.json").unwrap()).unwrap();
|
||||
assert_eq!(info["plugin"], "probe");
|
||||
assert_eq!(info["method"], "filesystem");
|
||||
return json!(pid);
|
||||
}
|
||||
"isolated" => {
|
||||
assert!(!fs::exists("/self/endpoint").unwrap());
|
||||
}
|
||||
"snapshot" => {
|
||||
eprintln!("before snapshot");
|
||||
let mut file = fs::File::open("/self/log").unwrap();
|
||||
eprintln!("after snapshot");
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents).unwrap();
|
||||
assert!(contents.contains("start filesystem"));
|
||||
assert!(!contents.contains("after snapshot"));
|
||||
assert_eq!(file.read(&mut [0; 1]).unwrap(), 0);
|
||||
}
|
||||
"core" => {
|
||||
assert!(
|
||||
fs::read_to_string("/proc/core/PROTOCOL.md")
|
||||
.unwrap()
|
||||
.contains("## 4. The filesystem")
|
||||
);
|
||||
for name in ["graph.json", "locks.json"] {
|
||||
let _: Value =
|
||||
serde_json::from_slice(&fs::read(format!("/proc/core/{name}")).unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
fs::read_to_string("/proc/core/graph.dot")
|
||||
.unwrap()
|
||||
.contains("digraph")
|
||||
);
|
||||
assert!(fs::read_dir("/proc/core/plugins").is_ok());
|
||||
assert!(fs::write("/proc/core/graph.json", "bad").is_err());
|
||||
}
|
||||
_ => panic!("unknown filesystem topic"),
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
fn write_loop(request: &Value, lines: &mut Lines) {
|
||||
assert_eq!(
|
||||
lock(&json!({"path":"/state/loop"}), lines),
|
||||
json!({"held":true})
|
||||
);
|
||||
std::fs::write("/state/loop", "old").unwrap();
|
||||
send(
|
||||
json!({"jsonrpc":"2.0", "id":request["id"], "result":std::env::var("SAUSAGE_PID").unwrap()}),
|
||||
);
|
||||
loop {
|
||||
std::fs::write("/state/loop", "old").unwrap();
|
||||
}
|
||||
}
|
||||
fn locked_write(lines: &mut Lines) -> Value {
|
||||
assert_eq!(
|
||||
lock(&json!({"path":"/state/loop"}), lines),
|
||||
json!({"held":true})
|
||||
);
|
||||
std::fs::write("/state/loop", "new").unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
assert_eq!(std::fs::read_to_string("/state/loop").unwrap(), "new");
|
||||
json!({})
|
||||
}
|
||||
fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
let params = &request["params"];
|
||||
let result = match request["method"].as_str().unwrap() {
|
||||
"echo" | "rpc.discover" => echo(params),
|
||||
"self_serve" => {
|
||||
std::fs::write("/self/endpoint", "endpoint").unwrap();
|
||||
serve(request, lines);
|
||||
return None;
|
||||
}
|
||||
"filesystem" => filesystem(params),
|
||||
"write_loop" => {
|
||||
write_loop(request, lines);
|
||||
return None;
|
||||
}
|
||||
"locked_write" => locked_write(lines),
|
||||
"echo" => echo(params),
|
||||
"rpc.discover" => discover(params),
|
||||
"env" => environment(),
|
||||
"multicall" => multicall(lines),
|
||||
"notification" => notification(params),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user