sausage core part 1: a process per call, routing, tree, stop and kill, locks, journal, boot
Checkpoint. Removes the old host. A quality rework follows (modules, journal writer thread, hash cache, finer tests).
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
//! End-to-end host behavior with a raw WASI guest.
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::PathBuf, sync::OnceLock};
|
||||
fn fixture() -> PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let target = root.join("target/core/guests");
|
||||
let status = std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
target.join("wasm32-wasip2/debug/probe.wasm")
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("sausage-{}", uuid::Uuid::now_v7()));
|
||||
let w = World::create(&p).unwrap();
|
||||
std::fs::create_dir_all(w.plugin_dir("probe")).unwrap();
|
||||
std::fs::copy(fixture(), w.plugin_dir("probe").join("plugin.wasm")).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
Host::start(&self.0).await.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
async fn call(h: &Host, m: &str, p: Value) -> Value {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), h.call(m, p))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn routing_environment_files_and_errors() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
assert_eq!(call(&h, "probe.echo", json!({"x":1})).await, json!({"x":1}));
|
||||
assert_eq!(
|
||||
call(&h, "probe.rpc.discover", json!({"x":2})).await,
|
||||
json!({"x":2})
|
||||
);
|
||||
assert_eq!(
|
||||
call(
|
||||
&h,
|
||||
"probe.call",
|
||||
json!({"method":"probe.echo","params":{"nested":true}})
|
||||
)
|
||||
.await,
|
||||
json!({"nested":true})
|
||||
);
|
||||
assert_eq!(
|
||||
call(&h, "probe.multicall", json!({})).await,
|
||||
json!({"count":2})
|
||||
);
|
||||
call(
|
||||
&h,
|
||||
"probe.notification",
|
||||
json!({"path":"/state/notification","text":"delivered"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(w.0.join("state/notification")).unwrap(),
|
||||
"delivered"
|
||||
);
|
||||
assert_eq!(
|
||||
h.call("probe.unknownid", json!({})).await.unwrap_err()["code"],
|
||||
-32002
|
||||
);
|
||||
let env = call(&h, "probe.env", json!({})).await;
|
||||
assert_eq!(env["plugin"], "probe");
|
||||
assert_eq!(env["parent"], "console");
|
||||
assert!(env["pid"].as_str().unwrap().starts_with("probe-"));
|
||||
call(
|
||||
&h,
|
||||
"probe.write",
|
||||
json!({"path":"/state/test","text":"hello"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
call(&h, "probe.read", json!({"path":"/state/test"})).await,
|
||||
"hello"
|
||||
);
|
||||
assert_eq!(
|
||||
h.call("absent.run", json!({})).await.unwrap_err()["code"],
|
||||
-32001
|
||||
);
|
||||
assert_eq!(
|
||||
h.call("core.activate", json!({})).await.unwrap_err()["code"],
|
||||
-32601
|
||||
);
|
||||
assert_eq!(
|
||||
h.call("probe.echo", json!([])).await.unwrap_err()["code"],
|
||||
-32602
|
||||
);
|
||||
let e = h.call("probe.exit", json!({})).await.unwrap_err();
|
||||
assert_eq!(e["code"], -32002);
|
||||
assert!(
|
||||
e["data"]["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|l| l["line"] == "last line")
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn journal_notifications_locks_and_kill() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let mut notifications = h.subscribe();
|
||||
call(&h, "probe.notify", json!({})).await;
|
||||
assert_eq!(
|
||||
notifications.recv().await.unwrap()["params"]["name"],
|
||||
"progress"
|
||||
);
|
||||
call(&h, "probe.junk", json!({})).await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while !h.processes().is_empty() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let starts = call(&h, "core.log", json!({"grep":"start junk"})).await;
|
||||
let pid = &starts[0]["pid"];
|
||||
let warnings = call(&h, "core.log", json!({"pid":pid,"grep":"discarded stdout"})).await;
|
||||
assert_eq!(warnings.as_array().unwrap().len(), 1);
|
||||
assert!(!warnings.to_string().contains("junk"));
|
||||
let log = call(&h, "core.log", json!({"grep":"stderr line"})).await;
|
||||
assert_eq!(log.as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
call(&h, "core.lock", json!({"path":"x"})).await,
|
||||
json!({"held":true})
|
||||
);
|
||||
assert_eq!(
|
||||
call(&h, "core.lock", json!({"path":"x","wait":false})).await,
|
||||
json!({"held":false})
|
||||
);
|
||||
call(&h, "core.unlock", json!({"path":"x"})).await;
|
||||
assert_eq!(
|
||||
call(&h, "probe.lock", json!({"path":"x"})).await,
|
||||
json!({"held":true})
|
||||
);
|
||||
let pid = call(&h, "probe.serve", json!({})).await;
|
||||
call(&h, "core.kill", json!({"pid":pid})).await;
|
||||
assert!(!h.processes().contains_key(pid.as_str().unwrap()));
|
||||
h.stop().await;
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_and_reserved_world_paths() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
h.register_native(
|
||||
"native",
|
||||
std::sync::Arc::new(|ctx, method, params| {
|
||||
Box::pin(async move {
|
||||
ctx.notify("native", json!({}));
|
||||
Ok(json!({"pid":ctx.pid,"method":method,"params":params}))
|
||||
})
|
||||
}),
|
||||
);
|
||||
let result = call(&h, "native.echo", json!({"x":1})).await;
|
||||
assert_eq!(result["method"], "echo");
|
||||
assert!(result["pid"].as_str().unwrap().starts_with("native-"));
|
||||
h.stop().await;
|
||||
for name in ["proc", "run", "self"] {
|
||||
std::fs::create_dir(w.0.join(name)).unwrap();
|
||||
assert!(World::open(&w.0).is_err());
|
||||
std::fs::remove_dir(w.0.join(name)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tree_detach_stop_boot_and_scheduler() {
|
||||
let w = TestWorld::new();
|
||||
std::fs::write(
|
||||
w.0.join("config/boot.json"),
|
||||
serde_json::to_vec(&json!([
|
||||
{"method":"absent.start","params":{}},
|
||||
{"method":"probe.write","params":{"path":"/state/boot","text":"ready"}},
|
||||
{"method":"probe.read","params":{"path":"/state/boot"}}
|
||||
]))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let h = w.start().await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(w.0.join("state/boot")).unwrap(),
|
||||
"ready"
|
||||
);
|
||||
let attached = call(
|
||||
&h,
|
||||
"probe.call",
|
||||
json!({"method":"probe.serve","params":{}}),
|
||||
)
|
||||
.await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(7), async {
|
||||
while h.processes().contains_key(attached.as_str().unwrap()) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let detached = call(
|
||||
&h,
|
||||
"probe.call",
|
||||
json!({"method":"core.call","params":{"method":"probe.serve","params":{},"detach":true}}),
|
||||
)
|
||||
.await;
|
||||
assert!(h.processes()[detached.as_str().unwrap()].detached);
|
||||
call(&h, "core.stop", json!({"pid":detached})).await;
|
||||
let hc = h.clone();
|
||||
let busy = tokio::spawn(async move { hc.call("probe.busy", json!({})).await });
|
||||
let pid = tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
loop {
|
||||
if let Some((pid, _)) = h.processes().into_iter().find(|(_, p)| p.method == "busy") {
|
||||
break pid;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
call(&h, "probe.echo", json!({"works":true})).await,
|
||||
json!({"works":true})
|
||||
);
|
||||
call(&h, "core.kill", json!({"pid":pid})).await;
|
||||
assert_eq!(busy.await.unwrap().unwrap_err()["code"], -32002);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn same_process_lock_acquisitions_queue() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
h.register_native(
|
||||
"locks",
|
||||
std::sync::Arc::new(|ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
assert_eq!(
|
||||
ctx.call("core.lock", json!({"path":"fifo"})).await.unwrap(),
|
||||
json!({"held":true})
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.call("core.lock", json!({"path":"fifo","wait":false}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"held":false})
|
||||
);
|
||||
let mut second = Box::pin(ctx.call("core.lock", json!({"path":"fifo"})));
|
||||
std::future::poll_fn(|cx| {
|
||||
assert!(second.as_mut().poll(cx).is_pending());
|
||||
std::task::Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
ctx.call("core.unlock", json!({"path":"fifo"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.await.unwrap(), json!({"held":true}));
|
||||
Ok(json!({}))
|
||||
})
|
||||
}),
|
||||
);
|
||||
call(&h, "locks.test", json!({})).await;
|
||||
h.stop().await;
|
||||
assert_eq!(
|
||||
call(&h, "core.lock", json!({"path":"fifo","wait":false})).await,
|
||||
json!({"held":true})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn stop_closes_stdin_deadline_and_exit() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let hc = h.clone();
|
||||
let pending = tokio::spawn(async move { hc.call("probe.wait_eof", json!({})).await });
|
||||
let pid = tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
loop {
|
||||
if let Some((pid, _)) = h
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, p)| p.method == "wait_eof")
|
||||
{
|
||||
break pid;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(w.0.join("state/stopped")).unwrap(),
|
||||
"yes"
|
||||
);
|
||||
assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002);
|
||||
assert_eq!(call(&h, "core.exit", json!({})).await, json!({}));
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), h.exit_requested())
|
||||
.await
|
||||
.unwrap();
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tcp_udp_and_dns() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let tcp = format!("localhost:{}", listener.local_addr().unwrap().port());
|
||||
let udp = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let udp_addr = udp.local_addr().unwrap().to_string();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
stream.write_all(b"tcp").await.unwrap();
|
||||
});
|
||||
assert_eq!(
|
||||
call(&h, "probe.network", json!({"tcp":tcp,"udp":udp_addr})).await,
|
||||
"tcp"
|
||||
);
|
||||
let mut buf = [0; 3];
|
||||
udp.recv_from(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"udp");
|
||||
server.await.unwrap();
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_kill_drops_future_and_releases_locks() {
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
struct Dropped(Arc<AtomicBool>);
|
||||
impl Drop for Dropped {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let flag = dropped.clone();
|
||||
h.register_native(
|
||||
"native",
|
||||
Arc::new(move |ctx, _, _| {
|
||||
let flag = flag.clone();
|
||||
Box::pin(async move {
|
||||
let _guard = Dropped(flag);
|
||||
ctx.call("core.lock", json!({"path":"native-lock"}))
|
||||
.await
|
||||
.unwrap();
|
||||
ctx.notify("ready", json!({"pid":ctx.pid}));
|
||||
std::future::pending().await
|
||||
})
|
||||
}),
|
||||
);
|
||||
let mut notifications = h.subscribe();
|
||||
let hc = h.clone();
|
||||
let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await });
|
||||
let notification =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), notifications.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let pid = ¬ification["params"]["params"]["pid"];
|
||||
assert_eq!(
|
||||
call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await,
|
||||
json!({"held":false})
|
||||
);
|
||||
call(&h, "core.kill", json!({"pid":pid})).await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
while !dropped.load(Ordering::SeqCst) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await,
|
||||
json!({"held":true})
|
||||
);
|
||||
assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shutdown_stops_busy_processes_in_parallel_after_five_seconds() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let hc = h.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
hc.call("probe.busy", json!({})).await
|
||||
}));
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
while h.processes().len() != 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let before = std::time::Instant::now();
|
||||
h.stop().await;
|
||||
assert!(before.elapsed() >= std::time::Duration::from_millis(4900));
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(7));
|
||||
for task in tasks {
|
||||
assert_eq!(task.await.unwrap().unwrap_err()["code"], -32002);
|
||||
}
|
||||
assert!(h.processes().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn journal_filters_persist_and_process_versions_are_hashes() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let pid = call(&h, "probe.serve", json!({"version":1})).await;
|
||||
let pid = pid.as_str().unwrap();
|
||||
let info = h.processes()[pid].clone();
|
||||
assert_eq!(
|
||||
info.version,
|
||||
format!("{:x}", Sha256::digest(std::fs::read(fixture()).unwrap()))
|
||||
);
|
||||
assert_eq!(info.plugin, "probe");
|
||||
assert_eq!(info.method, "serve");
|
||||
assert_eq!(info.params, json!({"version":1}));
|
||||
assert_eq!(info.parent.as_deref(), Some("console"));
|
||||
assert_eq!(info.state, "running");
|
||||
assert!(!info.detached);
|
||||
assert!(info.start_time > 0);
|
||||
let uuid = uuid::Uuid::parse_str(pid.strip_prefix("probe-").unwrap()).unwrap();
|
||||
assert_eq!(uuid.get_version_num(), 7);
|
||||
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||
let entries = call(
|
||||
&h,
|
||||
"core.log",
|
||||
json!({"plugin":"probe","pid":pid,"limit":2}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(entries.as_array().unwrap().len(), 2);
|
||||
let entry = &entries[0];
|
||||
let id = &entry["id"];
|
||||
let single = call(&h, "core.log", json!({"since":id,"until":id})).await;
|
||||
assert_eq!(single, json!([entry]));
|
||||
h.stop().await;
|
||||
drop(h);
|
||||
let h = w.start().await;
|
||||
assert_eq!(
|
||||
call(&h, "core.log", json!({"since":id,"until":id})).await,
|
||||
single
|
||||
);
|
||||
h.stop().await;
|
||||
assert!(
|
||||
std::fs::read(w.0.join("state/core/journal.sqlite"))
|
||||
.unwrap()
|
||||
.starts_with(b"SQLite format 3")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn stopping_rejects_calls_but_allows_notifications() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let tx = Arc::new(Mutex::new(Some(tx)));
|
||||
let gate = Arc::new(tokio::sync::Notify::new());
|
||||
let release = gate.clone();
|
||||
h.register_native(
|
||||
"native",
|
||||
Arc::new(move |ctx, _, _| {
|
||||
let tx = tx.lock().unwrap().take().unwrap();
|
||||
let gate = gate.clone();
|
||||
Box::pin(async move {
|
||||
tx.send(ctx.clone()).ok();
|
||||
gate.notified().await;
|
||||
Ok(json!({}))
|
||||
})
|
||||
}),
|
||||
);
|
||||
let hc = h.clone();
|
||||
let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await });
|
||||
let ctx = rx.await.unwrap();
|
||||
let hc = h.clone();
|
||||
let pid = ctx.pid.clone();
|
||||
let stop = tokio::spawn(async move { hc.call("core.stop", json!({"pid":pid})).await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while h.processes()[&ctx.pid].state != "stopping" {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ctx.call("probe.echo", json!({})).await.unwrap_err()["code"],
|
||||
-32600
|
||||
);
|
||||
let mut notifications = h.subscribe();
|
||||
ctx.call("core.notify", json!({"name":"stop-hook","params":{}}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
notifications.recv().await.unwrap()["params"]["name"],
|
||||
"stop-hook"
|
||||
);
|
||||
release.notify_one();
|
||||
assert_eq!(pending.await.unwrap().unwrap(), json!({}));
|
||||
assert_eq!(stop.await.unwrap().unwrap(), json!({}));
|
||||
h.stop().await;
|
||||
}
|
||||
Reference in New Issue
Block a user