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
+6
View File
@@ -26,3 +26,9 @@ wasmtime-wasi-http = "49"
uuid = { version = "1", features = ["v7"] } uuid = { version = "1", features = ["v7"] }
sha2 = "0.10" sha2 = "0.10"
rusqlite = { version = "0.38", features = ["bundled"] } rusqlite = { version = "0.38", features = ["bundled"] }
sausage-vfs = { version = "0.1.0", path = "../vfs" }
sausage-interface = { version = "0.1.0", path = "../interface" }
notify = "8.2.0"
[dev-dependencies]
sausage-interface = { path = "../interface" }
+204 -9
View File
@@ -1,15 +1,210 @@
//! Activation and revert of plugin versions (PROTOCOL.md section 10.3). //! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
use crate::{Host, RpcResult, error}; use crate::{
use serde_json::Value; Host, RpcResult, error,
graph::{Interface, io_error, problems},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, path::PathBuf};
struct Candidate {
plugin: String,
copy: PathBuf,
interface: Interface,
}
impl Host { impl Host {
/// `core.activate` and `core.revert`. /// Copies first, discovers that copy, checks the combined graph, then installs it.
pub(crate) async fn activation( pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult {
let _activation = self.0.activations.lock().await;
let mut candidates = Vec::new();
let result = self
.activate_candidates(method, &params, &mut candidates)
.await;
for candidate in candidates {
let _ = tokio::fs::remove_file(candidate.copy).await;
}
result
}
/// Prepares every member before graph checks or changes to installed files.
async fn activate_candidates(
&self, &self,
_caller: &str, method: &str,
_method: &str, params: &Value,
_params: Value, candidates: &mut Vec<Candidate>,
) -> RpcResult { ) -> RpcResult {
Err(error(-32601, "method not found")) let requests = params
.get("plugins")
.and_then(Value::as_array)
.cloned()
.unwrap_or_else(|| vec![params.clone()]);
if requests.is_empty() {
return Err(error(-32602, "plugins must not be empty"));
}
for request in requests {
let plugin = request["plugin"]
.as_str()
.ok_or_else(|| error(-32602, "plugin must be a string"))?;
if plugin.is_empty()
|| !plugin.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| byte == b'_'
|| byte == b'-'
})
|| candidates
.iter()
.any(|candidate| candidate.plugin == plugin)
{
return Err(error(-32602, "invalid or duplicate plugin"));
}
let directory = self.0.world.plugin_dir(plugin);
tokio::fs::create_dir_all(&directory)
.await
.map_err(io_error)?;
let source = if method == "revert" {
directory.join("plugin.prev.wasm")
} else {
let source = PathBuf::from(
request["wasm"]
.as_str()
.ok_or_else(|| error(-32602, "wasm must be a string"))?,
);
if source.starts_with(&self.0.world.root) {
source
} else {
self.0
.world
.root
.join(source.strip_prefix("/").unwrap_or(&source))
}
};
let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7()));
// Read once. Discovery and installation both use the same private bytes.
let bytes = tokio::fs::read(source).await.map_err(io_error)?;
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
self.0
.journal
.write("core", plugin, "core", "activation copied candidate");
let discovery = self.discover_copy(plugin, &copy, &bytes).await;
let interface = match discovery {
Ok(interface) => interface,
Err(error) => {
let _ = tokio::fs::remove_file(copy).await;
return Err(error);
}
};
self.0
.journal
.write("core", plugin, "core", "activation discovered candidate");
candidates.push(Candidate {
plugin: plugin.into(),
copy,
interface,
});
}
self.refresh_graph().await?;
let previous = self.0.graph.lock().unwrap().plugins.clone();
if previous.values().any(|entry| entry.error.is_some()) {
return Err(error(
-32003,
"activation waits for successful discovery of every plugin",
));
}
let mut proposed = previous.clone();
let names: Vec<_> = candidates
.iter()
.map(|candidate| candidate.plugin.clone())
.collect();
for candidate in candidates.iter() {
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
}
if params["force"] != true {
let failures = problems(&proposed, Some(&names));
if !failures.is_empty() {
self.0.journal.write(
"core",
"core",
"core",
&format!("activation rejected {}", json!(failures)),
);
let details = failures
.iter()
.map(|problem| {
format!(
"- {} imports {}: {}",
problem["consumer"],
problem["provider"],
problem["display"]
.as_str()
.unwrap_or_else(|| problem["message"]
.as_str()
.unwrap_or("incompatible import"))
)
})
.collect::<Vec<_>>()
.join("\n");
return Err(
json!({"code":-32003,"message":format!("{} was not activated: consumers break.\n{details}\nUpdate consumers first, or activate both together.",names.join(", ")),"data":failures}),
);
}
}
self.install_candidates(candidates, previous, proposed)
.await
}
/// Installs staged files and snapshots all old admissions under the shared write gate.
async fn install_candidates(
&self,
candidates: &[Candidate],
previous: BTreeMap<String, Interface>,
proposed: BTreeMap<String, Interface>,
) -> RpcResult {
let admission = self.0.admission.write().await;
// Every candidate already has a temporary name before any installed file changes.
for candidate in candidates {
let directory = self.0.world.plugin_dir(&candidate.plugin);
let current = directory.join("plugin.wasm");
if tokio::fs::try_exists(&current).await.map_err(io_error)? {
tokio::fs::rename(&current, directory.join("plugin.prev.wasm"))
.await
.map_err(io_error)?;
}
tokio::fs::rename(&candidate.copy, &current)
.await
.map_err(io_error)?;
}
let mut upgrades = Vec::new();
let mut documents = serde_json::Map::new();
for candidate in candidates {
documents.insert(
candidate.plugin.clone(),
candidate.interface.document.clone(),
);
if let Some(old) = previous
.get(&candidate.plugin)
.filter(|old| old.hash != candidate.interface.hash)
{
let grace = old.document["x-sausage-grace"]
.as_f64()
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(60.0);
upgrades.push(self.snapshot_upgrade(
&candidate.plugin,
old.hash.clone(),
&candidate.interface.hash,
grace,
));
}
self.0.journal.write(
"core",
&candidate.plugin,
"core",
&format!("activation installed {}", candidate.interface.hash),
);
}
self.publish_graph(proposed);
drop(admission);
for upgrade in upgrades {
self.begin_upgrade(upgrade);
}
Ok(Value::Object(documents))
} }
} }
+13 -1
View File
@@ -31,7 +31,19 @@ impl Host {
Ok(Value::Null) Ok(Value::Null)
} }
"kill" => { "kill" => {
self.kill(required_str(&p, "pid")?); let pid = required_str(&p, "pid")?;
let ended = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.ended.subscribe());
self.kill(pid);
if let Some(mut ended) = ended {
let _ = ended.wait_for(|ended| *ended).await;
}
Ok(json!({})) Ok(json!({}))
} }
"stop" => { "stop" => {
+11 -10
View File
@@ -1,7 +1,7 @@
//! Execution of native futures and WASI processes, including transport drainage. //! Execution of native futures and WASI processes, including transport drainage.
use crate::{ use crate::{
Host, NativeContext, NativePlugin, Host, NativeContext, NativePlugin,
process::{ProcessGuard, ProcessInfo}, process::ProcessInfo,
runtime::{Instance, InstanceConfig}, runtime::{Instance, InstanceConfig},
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -17,11 +17,7 @@ impl Host {
native: Option<NativePlugin>, native: Option<NativePlugin>,
incoming: mpsc::UnboundedReceiver<Value>, incoming: mpsc::UnboundedReceiver<Value>,
has_id: bool, has_id: bool,
) { ) -> String {
let _guard = ProcessGuard {
host: self.clone(),
pid: pid.clone(),
};
if let Some(native) = native { if let Some(native) = native {
let context = NativeContext { let context = NativeContext {
host: self.clone(), host: self.clone(),
@@ -29,13 +25,12 @@ impl Host {
}; };
let result = native(context, info.method, info.params).await; let result = native(context, info.method, info.params).await;
self.answer(&pid, result); self.answer(&pid, result);
self.finish(&pid, "exit 0"); return "exit 0".into();
return;
} }
let result = self let result = self
.run_guest(&pid, &info, &component.unwrap(), incoming, has_id) .run_guest(&pid, &info, &component.unwrap(), incoming, has_id)
.await; .await;
self.finish(&pid, &result.unwrap_or_else(|error| error.to_string())); result.unwrap_or_else(|error| error.to_string())
} }
/// Drain stdout before deciding whether the guest returned an answer. /// Drain stdout before deciding whether the guest returned an answer.
@@ -47,8 +42,14 @@ impl Host {
mut incoming: mpsc::UnboundedReceiver<Value>, mut incoming: mpsc::UnboundedReceiver<Value>,
has_id: bool, has_id: bool,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let (memory, tracker, store_ended) = {
let state = self.0.state.lock().unwrap();
let p = state.processes.get(pid).unwrap();
(p.memory.clone(), p.files.clone(), p.store_ended.clone())
};
let config = InstanceConfig { let config = InstanceConfig {
world: self.0.world.root.clone(), filesystem: self.mounts(Some(pid.into()), memory, tracker).await?,
store_ended,
network: true, network: true,
env: vec![ env: vec![
("SAUSAGE_PID".into(), pid.into()), ("SAUSAGE_PID".into(), pid.into()),
+301
View File
@@ -0,0 +1,301 @@
//! Per-process mount tables, reserved self files, and the live process namespace.
use crate::Host;
use sausage_vfs::{
Entry, File, Generated, Layer, Memory, Mounts, Open, Process, Result, Stat, async_trait,
};
use std::{io, sync::Arc};
fn denied<T>() -> Result<T> {
Err(io::ErrorKind::ReadOnlyFilesystem.into())
}
fn missing<T>() -> Result<T> {
Err(io::ErrorKind::NotFound.into())
}
fn directory() -> Stat {
Stat {
directory: true,
symlink: false,
size: 0,
accessed: None,
modified: None,
}
}
fn entry(name: String, directory: bool) -> Entry {
Entry {
name,
directory,
symlink: false,
}
}
fn reserved(path: &str) -> bool {
matches!(
path.trim_start_matches('/').split('/').next(),
Some("info.json" | "log" | "fs")
)
}
impl Host {
/// Create a console filesystem view without adding a process. Its `/self` is private and ephemeral.
pub async fn filesystem(&self) -> anyhow::Result<Mounts> {
self.mounts(None, Memory::default(), Process::default())
.await
}
pub(crate) async fn mounts(
&self,
pid: Option<String>,
memory: Memory,
tracker: Process,
) -> anyhow::Result<Mounts> {
let root = self.0.world.root.clone();
let host = tracker
.clone()
.blocking(move || sausage_vfs::Host::new(root, tracker))
.await?;
let mut mounts = Mounts::default();
mounts.mount("/", Arc::new(host), false);
mounts.mount("/proc", Arc::new(Proc(self.clone())), true);
mounts.mount(
"/self",
Arc::new(SelfFolder {
host: self.clone(),
pid,
memory,
}),
false,
);
mounts.mount("/run", Arc::new(self.0.run.clone()), false);
Ok(mounts)
}
fn core_files(&self) -> Generated {
let mut files = Generated::default();
let protocol = include_bytes!("../../PROTOCOL.md");
files.insert("/PROTOCOL.md", move || Ok(protocol.to_vec()));
let graph = self.graph_json();
files.insert("/graph.json", move || Ok(graph.clone()));
let dot = self.graph_dot();
files.insert("/graph.dot", move || Ok(dot.clone()));
let locks = {
let state = self.0.state.lock().unwrap();
let locks: std::collections::BTreeMap<_, _> = state
.locks
.iter()
.map(|(path, lock)| {
let waiting: Vec<_> = lock.queue.iter().map(|(pid, _)| pid).collect();
let info = serde_json::json!({
"owner": lock.owner,
"waiting": waiting,
});
(path.clone(), info)
})
.collect();
serde_json::to_vec(&locks).unwrap()
};
files.insert("/locks.json", move || Ok(locks.clone()));
for name in self.discovered_plugins() {
if let Some(doc) = self.discover_document(&name) {
let bytes = serde_json::to_vec(&doc).unwrap();
files.insert(&format!("/plugins/{name}/discover.json"), move || {
Ok(bytes.clone())
});
files.insert(&format!("/plugins/{name}/client.rs"), move || {
sausage_interface::client_rs(&name, &doc)
.map(String::into_bytes)
.map_err(io::Error::other)
});
}
}
files
}
}
struct Proc(Host);
impl Proc {
fn folder(&self, path: &str) -> Result<(SelfFolder, String)> {
let (pid, rest) = path
.trim_start_matches('/')
.split_once('/')
.unwrap_or((path.trim_start_matches('/'), ""));
let memory = self
.0
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.memory.clone())
.ok_or(io::ErrorKind::NotFound)?;
Ok((
SelfFolder {
host: self.0.clone(),
pid: Some(pid.into()),
memory,
},
format!("/{rest}"),
))
}
}
#[async_trait]
impl Layer for Proc {
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
return Ok(directory());
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().stat(&format!("/{path}"), follow).await;
}
let (folder, path) = self.folder(path)?;
folder.stat(&path, follow).await
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
if path == "/" {
let mut entries = vec![entry("core".into(), true)];
entries.extend(self.0.processes().into_keys().map(|pid| entry(pid, true)));
entries.sort_by(|a, b| a.name.cmp(&b.name));
return Ok(entries);
}
if path.trim_end_matches('/') == "/core/plugins" {
return Ok(self
.0
.discovered_plugins()
.into_iter()
.filter(|name| self.0.discover_document(name).is_some())
.map(|name| entry(name, true))
.collect());
}
if path.trim_end_matches('/') == "/core" {
let mut entries = self.0.core_files().list("/").await?;
if !entries.iter().any(|e| e.name == "plugins") {
entries.push(entry("plugins".into(), true));
}
return Ok(entries);
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().list(&format!("/{path}")).await;
}
let (folder, path) = self.folder(path)?;
folder.list(&path).await
}
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
if options.write || options.create || options.truncate {
return denied();
}
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
return Memory::default().open("/", options).await;
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().open(&format!("/{path}"), options).await;
}
let (folder, path) = self.folder(path)?;
folder.open(&path, options).await
}
async fn mkdir(&self, _: &str) -> Result<()> {
denied()
}
async fn remove_file(&self, _: &str) -> Result<()> {
denied()
}
async fn remove_dir(&self, _: &str) -> Result<()> {
denied()
}
async fn rename(&self, _: &str, _: &str) -> Result<()> {
denied()
}
}
struct SelfFolder {
host: Host,
pid: Option<String>,
memory: Memory,
}
impl SelfFolder {
async fn generated(&self, path: &str) -> Result<Generated> {
let pid = self.pid.as_ref().ok_or(io::ErrorKind::NotFound)?;
let bytes = match path {
"/info.json" => {
let info = self
.host
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.info.clone())
.ok_or(io::ErrorKind::NotFound)?;
serde_json::to_vec(&info).unwrap()
}
"/log" => {
let lines = self
.host
.0
.journal
.query(serde_json::json!({"pid":pid,"limit":i64::MAX}))
.await;
let mut bytes = Vec::new();
for line in lines.as_array().unwrap() {
serde_json::to_writer(&mut bytes, line).unwrap();
bytes.push(b'\n');
}
bytes
}
_ => return missing(),
};
let mut files = Generated::default();
files.insert(path, move || Ok(bytes.clone()));
Ok(files)
}
}
#[async_trait]
impl Layer for SelfFolder {
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
if reserved(path) {
self.generated(path).await?.stat(path, follow).await
} else {
self.memory.stat(path, follow).await
}
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let mut entries = self.memory.list(path).await?;
if path == "/" && self.pid.is_some() {
entries.extend([entry("info.json".into(), false), entry("log".into(), false)]);
}
Ok(entries)
}
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
if reserved(path) {
if options.write || options.create || options.truncate {
return denied();
}
self.generated(path).await?.open(path, options).await
} else {
self.memory.open(path, options).await
}
}
async fn mkdir(&self, path: &str) -> Result<()> {
if reserved(path) {
denied()
} else {
self.memory.mkdir(path).await
}
}
async fn remove_file(&self, path: &str) -> Result<()> {
if reserved(path) {
denied()
} else {
self.memory.remove_file(path).await
}
}
async fn remove_dir(&self, path: &str) -> Result<()> {
if reserved(path) || path == "/" {
denied()
} else {
self.memory.remove_dir(path).await
}
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
if reserved(from) || reserved(to) || from == "/" || to == "/" {
denied()
} else {
self.memory.rename(from, to).await
}
}
}
+253 -27
View File
@@ -1,31 +1,257 @@
//! The interface graph (PROTOCOL.md section 10.4): the `rpc.discover` //! Cached discovery documents and import checks under PROTOCOL.md section 10.4.
//! documents of all plugins, their imports, and the result of each check. //! Refreshes use immutable copies. The proc layer only reads serialized cache entries.
//! use crate::{Host, RpcResult};
//! The `/proc` layer reads the files of `/proc/core/` through these use serde_json::{Value, json};
//! functions. They must be cheap and must not block: they return cached data. use sha2::{Digest, Sha256};
use crate::Host; use std::{
use serde_json::Value; collections::{BTreeMap, HashMap},
path::Path,
};
// The /proc layer (T6) uses these functions. Remove this when it does. #[derive(Clone)]
#[allow(dead_code)] pub(crate) struct Interface {
impl Host { pub(crate) hash: String,
/// The content of `/proc/core/graph.json`. pub(crate) document: Value,
pub(crate) fn graph_json(&self) -> Vec<u8> { pub(crate) error: Option<Value>,
b"{}\n".to_vec() }
} pub(crate) struct Graph {
pub(crate) plugins: BTreeMap<String, Interface>,
pub(crate) versions: HashMap<String, Value>,
json: Vec<u8>,
dot: Vec<u8>,
}
/// The content of `/proc/core/graph.dot`. impl Default for Graph {
pub(crate) fn graph_dot(&self) -> Vec<u8> { /// Exposes a valid empty graph before background discovery completes.
b"digraph plugins {}\n".to_vec() fn default() -> Self {
} Self {
plugins: BTreeMap::new(),
/// The plugins that have an `rpc.discover` document, for `/proc/core/plugins/`. versions: HashMap::new(),
pub(crate) fn discovered_plugins(&self) -> Vec<String> { json: b"{\"plugins\":{}}".to_vec(),
Vec::new() dot: b"digraph plugins {}\n".to_vec(),
} }
/// The `rpc.discover` document of the current `plugin.wasm` of `plugin`.
pub(crate) fn discover_document(&self, _plugin: &str) -> Option<Value> {
None
} }
} }
/// Hashes exactly the bytes copied for discovery or installation.
pub(crate) fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// Lists broken imports, optionally restricted to changed consumers or providers.
pub(crate) fn problems(
plugins: &BTreeMap<String, Interface>,
changed: Option<&[String]>,
) -> Vec<Value> {
let mut failures = Vec::new();
for (consumer, interface) in plugins {
if let Some(imports) = interface.document["x-sausage-imports"].as_object() {
for (provider, expected) in imports {
if changed
.is_some_and(|names| !names.contains(consumer) && !names.contains(provider))
{
continue;
}
let Some(actual) = plugins.get(provider) else {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"missing","message":"the provider does not exist"}));
continue;
};
if let Some(error) = &actual.error {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"unavailable","message":"discovery failed","error":error}));
continue;
}
for problem in sausage_interface::compat::check(expected, &actual.document) {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()}));
}
}
}
}
failures
}
impl Host {
/// The cached JSON graph. This does not perform filesystem or RPC operations.
pub(crate) fn graph_json(&self) -> Vec<u8> {
self.0.graph.lock().unwrap().json.clone()
}
/// The cached DOT graph, with an edge for every import.
pub(crate) fn graph_dot(&self) -> Vec<u8> {
self.0.graph.lock().unwrap().dot.clone()
}
/// Plugin names currently present in the discovery cache.
pub(crate) fn discovered_plugins(&self) -> Vec<String> {
self.0
.graph
.lock()
.unwrap()
.plugins
.keys()
.cloned()
.collect()
}
/// The current document, if discovery succeeded.
pub(crate) fn discover_document(&self, plugin: &str) -> Option<Value> {
self.0
.graph
.lock()
.unwrap()
.plugins
.get(plugin)
.filter(|entry| entry.error.is_none())
.map(|entry| entry.document.clone())
}
/// Discovers a private copy and caches successful documents by file hash.
pub(crate) async fn discover_copy(
&self,
plugin: &str,
path: &Path,
bytes: &[u8],
) -> Result<Interface, Value> {
let hash = hash(bytes);
let cache_key = format!("{plugin}/{hash}");
let document = self
.route_from(
"core".into(),
Some(json!(uuid::Uuid::now_v7().to_string())),
format!("{plugin}.rpc.discover"),
json!({}),
true,
Some(path.to_path_buf()),
)
.await?;
if !document["methods"].is_array() {
return Err(crate::error(
-32003,
"rpc.discover did not return an OpenRPC document",
));
}
self.0
.graph
.lock()
.unwrap()
.versions
.insert(cache_key, document.clone());
Ok(Interface {
hash,
document,
error: None,
})
}
/// Refreshes every installed plugin while the caller holds the activation mutex.
pub(crate) async fn refresh_graph(&self) -> RpcResult {
let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins"))
.await
.map_err(io_error)?;
let mut plugins = BTreeMap::new();
while let Some(entry) = directory.next_entry().await.map_err(io_error)? {
let name = entry.file_name().to_string_lossy().into_owned();
let source = entry.path().join("plugin.wasm");
let bytes = match tokio::fs::read(&source).await {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_error(error)),
};
let hash = hash(&bytes);
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
let interface = if let Some(previous) =
previous.filter(|entry| entry.hash == hash && entry.error.is_none())
{
previous
} else {
let copy = entry
.path()
.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
let result = self.discover_copy(&name, &copy, &bytes).await;
let _ = tokio::fs::remove_file(&copy).await;
match result {
Ok(interface) => interface,
Err(error) => Interface {
hash,
document: Value::Null,
error: Some(error),
},
}
};
plugins.insert(name, interface);
}
let admission = self.0.admission.write().await;
let old_plugins = self.0.graph.lock().unwrap().plugins.clone();
let mut upgrades = Vec::new();
for (plugin, current) in &plugins {
if let Some(old) = old_plugins
.get(plugin)
.filter(|old| old.hash != current.hash && current.error.is_none())
{
let grace = old.document["x-sausage-grace"]
.as_f64()
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(60.0);
upgrades.push(self.snapshot_upgrade(
plugin,
old.hash.clone(),
&current.hash,
grace,
));
}
}
self.publish_graph(plugins);
drop(admission);
for upgrade in upgrades {
self.begin_upgrade(upgrade);
}
Ok(json!({}))
}
/// Rebuilds serialized snapshots after a watcher refresh or an activation.
pub(crate) fn publish_graph(&self, plugins: BTreeMap<String, Interface>) {
let failures = problems(&plugins, None);
for failure in &failures {
self.0
.journal
.write("core", "core", "core", &format!("broken import {failure}"));
}
let mut entries = serde_json::Map::new();
let mut dot = String::from("digraph plugins {\n");
for (name, interface) in &plugins {
dot.push_str(&format!(" {};\n", json!(name)));
let mut imports = serde_json::Map::new();
for provider in interface.document["x-sausage-imports"]
.as_object()
.into_iter()
.flat_map(|imports| imports.keys())
{
let issues: Vec<_> = failures
.iter()
.filter(|problem| {
problem["consumer"] == *name && problem["provider"] == *provider
})
.cloned()
.collect();
let status = issues
.first()
.and_then(|problem| problem["status"].as_str())
.unwrap_or("compatible");
imports.insert(provider.clone(), json!({"status":status,"problems":issues}));
dot.push_str(&format!(
" {} -> {} [label={}];\n",
json!(name),
json!(provider),
json!(status)
));
}
entries.insert(
name.clone(),
json!({"hash":interface.hash,"imports":imports,"error":interface.error}),
);
}
dot.push_str("}\n");
let mut graph = self.0.graph.lock().unwrap();
graph.json = serde_json::to_vec(&json!({"plugins":entries})).unwrap();
graph.dot = dot.into_bytes();
graph.plugins = plugins;
}
}
/// Converts a filesystem failure into a JSON-RPC internal error.
pub(crate) fn io_error(error: std::io::Error) -> Value {
crate::error(-32603, &error.to_string())
}
+17
View File
@@ -17,6 +17,7 @@ pub(crate) struct State {
pub(crate) processes: HashMap<String, Process>, pub(crate) processes: HashMap<String, Process>,
pub(crate) locks: HashMap<String, Lock>, pub(crate) locks: HashMap<String, Lock>,
pub(crate) native: HashMap<String, NativePlugin>, pub(crate) native: HashMap<String, NativePlugin>,
pub(crate) services: Vec<crate::services::Service>,
} }
pub(crate) struct Inner { pub(crate) struct Inner {
pub(crate) world: World, pub(crate) world: World,
@@ -26,6 +27,15 @@ pub(crate) struct Inner {
pub(crate) notifications: tokio::sync::broadcast::Sender<Value>, pub(crate) notifications: tokio::sync::broadcast::Sender<Value>,
pub(crate) exit: watch::Sender<bool>, pub(crate) exit: watch::Sender<bool>,
pub(crate) components: components::Components, pub(crate) components: components::Components,
/// Calls hold it for reading from the choice of a version until their
/// process is in the table. Activation holds it for writing while it
/// installs a version, so that it sees every process of the old version.
pub(crate) admission: tokio::sync::RwLock<()>,
pub(crate) run: sausage_vfs::Memory,
pub(crate) graph: Mutex<crate::graph::Graph>,
pub(crate) activations: tokio::sync::Mutex<()>,
pub(crate) watcher: Mutex<Option<notify::RecommendedWatcher>>,
pub(crate) retirements: Mutex<std::collections::HashSet<(String, String)>>,
} }
#[derive(Clone)] #[derive(Clone)]
/// A cloneable handle to a world and its running processes. /// A cloneable handle to a world and its running processes.
@@ -60,10 +70,17 @@ impl Host {
processes: HashMap::new(), processes: HashMap::new(),
locks: HashMap::new(), locks: HashMap::new(),
native: HashMap::new(), native: HashMap::new(),
services: Vec::new(),
}), }),
notifications: tokio::sync::broadcast::channel(1024).0, notifications: tokio::sync::broadcast::channel(1024).0,
exit: watch::channel(false).0, exit: watch::channel(false).0,
components: components::Components::default(), components: components::Components::default(),
admission: tokio::sync::RwLock::new(()),
run: sausage_vfs::Memory::default(),
graph: Mutex::new(crate::graph::Graph::default()),
activations: tokio::sync::Mutex::new(()),
watcher: Mutex::new(None),
retirements: Mutex::new(std::collections::HashSet::new()),
})); }));
host.boot().await?; host.boot().await?;
Ok(host) Ok(host)
+3
View File
@@ -5,6 +5,7 @@ mod components;
pub mod console; pub mod console;
mod core; mod core;
mod execution; mod execution;
mod filesystem;
mod graph; mod graph;
mod host; mod host;
mod journal; mod journal;
@@ -14,6 +15,8 @@ mod process;
mod routing; mod routing;
pub mod runtime; pub mod runtime;
mod services; mod services;
mod upgrades;
mod watcher;
pub mod world; pub mod world;
pub use host::Host; pub use host::Host;
pub use native::{NativeContext, NativeFuture, NativePlugin}; pub use native::{NativeContext, NativeFuture, NativePlugin};
+18 -14
View File
@@ -24,6 +24,9 @@ pub struct ProcessInfo {
} }
pub(crate) struct Process { pub(crate) struct Process {
pub(crate) info: ProcessInfo, pub(crate) info: ProcessInfo,
pub(crate) memory: sausage_vfs::Memory,
pub(crate) files: sausage_vfs::Process,
pub(crate) store_ended: watch::Sender<bool>,
pub(crate) input: Option<mpsc::UnboundedSender<Value>>, pub(crate) input: Option<mpsc::UnboundedSender<Value>>,
pub(crate) abort: Option<tokio::task::AbortHandle>, pub(crate) abort: Option<tokio::task::AbortHandle>,
pub(crate) caller: String, pub(crate) caller: String,
@@ -31,19 +34,19 @@ pub(crate) struct Process {
pub(crate) answer: Option<oneshot::Sender<RpcResult>>, pub(crate) answer: Option<oneshot::Sender<RpcResult>>,
pub(crate) ended: watch::Sender<bool>, pub(crate) ended: watch::Sender<bool>,
} }
pub(crate) struct ProcessGuard {
pub(crate) host: Host,
pub(crate) pid: String,
}
impl Drop for ProcessGuard {
fn drop(&mut self) {
self.host.finish(&self.pid, "process task ended");
}
}
impl Host { impl Host {
/// Remove a process and release its locks before asynchronous journal completion. /// Called only after the execution task ends. Wait for the store and file operations before releasing locks.
pub(crate) fn finish(&self, pid: &str, status: &str) { pub(crate) async fn finish(&self, pid: &str, status: &str) {
let (files, mut store_ended) = {
let mut state = self.0.state.lock().unwrap();
let Some(p) = state.processes.get_mut(pid) else {
return;
};
p.info.state = "stopping".into();
(p.files.clone(), p.store_ended.subscribe())
};
let _ = store_ended.wait_for(|ended| *ended).await;
files.idle().await;
let mut state = self.0.state.lock().unwrap(); let mut state = self.0.state.lock().unwrap();
let process = state.processes.remove(pid); let process = state.processes.remove(pid);
if let Some(p) = process { if let Some(p) = process {
@@ -106,13 +109,14 @@ impl Host {
} }
/// Abort a process task and perform its ended-state cleanup. /// Abort a process task and perform its ended-state cleanup.
pub(crate) fn kill(&self, pid: &str) { pub(crate) fn kill(&self, pid: &str) {
if let Some(p) = self.0.state.lock().unwrap().processes.get(pid) { if let Some(p) = self.0.state.lock().unwrap().processes.get_mut(pid) {
p.info.state = "stopping".into();
p.input.take();
self.0.journal.write(pid, &p.info.plugin, "core", "kill"); self.0.journal.write(pid, &p.info.plugin, "core", "kill");
if let Some(a) = &p.abort { if let Some(a) = &p.abort {
a.abort(); a.abort();
} }
} }
self.finish(pid, "killed");
} }
/// Stop every process in parallel and wait for journal writes to finish. /// Stop every process in parallel and wait for journal writes to finish.
pub async fn stop(&self) { pub async fn stop(&self) {
+24 -1
View File
@@ -93,6 +93,7 @@ impl Host {
{ {
return Err(error(-32601, "invalid plugin name")); return Err(error(-32601, "invalid plugin name"));
} }
let admission = host.0.admission.read().await;
let native = host.0.state.lock().unwrap().native.get(plugin).cloned(); let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
let (component, version) = if native.is_some() { let (component, version) = if native.is_some() {
(None, "native".into()) (None, "native".into())
@@ -141,6 +142,9 @@ impl Host {
pid.clone(), pid.clone(),
Process { Process {
info: info.clone(), info: info.clone(),
memory: sausage_vfs::Memory::default(),
files: sausage_vfs::Process::default(),
store_ended: watch::channel(true).0,
input: Some(input), input: Some(input),
abort: None, abort: None,
caller, caller,
@@ -153,15 +157,34 @@ impl Host {
let h = host.clone(); let h = host.clone();
let p = pid.clone(); let p = pid.clone();
let has_id = id.is_some(); let has_id = id.is_some();
let (start, ready) = oneshot::channel();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let _ = ready.await;
h.run_process(p, info, component, native, incoming, has_id) h.run_process(p, info, component, native, incoming, has_id)
.await; .await
}); });
if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) { if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) {
p.abort = Some(task.abort_handle()); p.abort = Some(task.abort_handle());
if p.info.state == "stopping" {
task.abort();
}
} else { } else {
task.abort(); task.abort();
} }
drop(admission);
let cleanup = host.clone();
let finished_pid = pid.clone();
tokio::spawn(async move {
let status = task.await.unwrap_or_else(|error| {
if error.is_cancelled() {
"killed".into()
} else {
format!("host task failed: {error}")
}
});
cleanup.finish(&finished_pid, &status).await;
});
let _ = start.send(());
if id.is_none() { if id.is_none() {
return Ok(Value::Null); return Ok(Value::Null);
} }
+31 -16
View File
@@ -3,7 +3,7 @@
//! An instance is one run of a `wasi:cli/command` component. The host writes //! An instance is one run of a `wasi:cli/command` component. The host writes
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout. //! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
use std::path::{Path, PathBuf}; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -17,7 +17,7 @@ use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
use wasmtime_wasi::p2::bindings::Command; use wasmtime_wasi::p2::bindings::Command;
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView}; use wasmtime_wasi::{I32Exit, WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView}; use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
/// The HTTP transport hooks supplied to WASI processes. /// The HTTP transport hooks supplied to WASI processes.
@@ -61,6 +61,7 @@ pub struct ProcessState {
table: ResourceTable, table: ResourceTable,
http: WasiHttpCtx, http: WasiHttpCtx,
hooks: HttpHooks, hooks: HttpHooks,
fs: sausage_vfs::adapter::Adapter,
} }
impl WasiView for ProcessState { impl WasiView for ProcessState {
@@ -111,8 +112,10 @@ impl std::fmt::Display for Exit {
/// The filesystem, network, and environment supplied to one process. /// The filesystem, network, and environment supplied to one process.
pub struct InstanceConfig { pub struct InstanceConfig {
/// The world folder to preopen as the guest root. /// The virtual namespace exposed through the single root preopen.
pub world: PathBuf, pub filesystem: sausage_vfs::Mounts,
/// The store completion barrier used by process cleanup.
pub store_ended: tokio::sync::watch::Sender<bool>,
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`. /// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
pub network: bool, pub network: bool,
/// The environment variables supplied to the guest. /// The environment variables supplied to the guest.
@@ -127,12 +130,20 @@ pub struct Instance {
pub stdout: mpsc::Receiver<String>, pub stdout: mpsc::Receiver<String>,
/// The stderr lines that the process writes. /// The stderr lines that the process writes.
pub stderr: mpsc::Receiver<String>, pub stderr: mpsc::Receiver<String>,
task: JoinHandle<Exit>, task: Option<JoinHandle<Exit>>,
store_ended: tokio::sync::watch::Sender<bool>,
} }
impl Drop for Instance { impl Drop for Instance {
fn drop(&mut self) { fn drop(&mut self) {
self.task.abort(); if let Some(task) = self.task.take() {
task.abort();
let ended = self.store_ended.clone();
tokio::spawn(async move {
let _ = task.await;
ended.send_replace(true);
});
}
} }
} }
@@ -166,7 +177,10 @@ impl Instance {
/// Wait until the guest ends. /// Wait until the guest ends.
pub async fn wait(&mut self) -> Exit { pub async fn wait(&mut self) -> Exit {
match (&mut self.task).await { let result = self.task.as_mut().unwrap().await;
self.task.take();
self.store_ended.send_replace(true);
match result {
Ok(exit) => exit, Ok(exit) => exit,
Err(e) if e.is_cancelled() => Exit::Trap("killed".into()), Err(e) if e.is_cancelled() => Exit::Trap("killed".into()),
Err(e) => Exit::Trap(format!("host task failed: {e}")), Err(e) => Exit::Trap(format!("host task failed: {e}")),
@@ -198,6 +212,10 @@ impl Runtime {
let mut linker: Linker<ProcessState> = Linker::new(&engine); let mut linker: Linker<ProcessState> = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
sausage_vfs::adapter::add_to_linker(&mut linker, |state| sausage_vfs::adapter::View {
adapter: &mut state.fs,
table: &mut state.table,
})?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
// Time slices: every 10 ms each running guest yields to the scheduler. // Time slices: every 10 ms each running guest yields to the scheduler.
let runtime = Arc::new(Self { engine, linker }); let runtime = Arc::new(Self { engine, linker });
@@ -220,7 +238,7 @@ impl Runtime {
Ok(Component::new(&self.engine, bytes)?) Ok(Component::new(&self.engine, bytes)?)
} }
/// Prepare a world preopen off the async workers, then start the WASI task. /// Start a WASI task with the supplied virtual namespace.
pub async fn start( pub async fn start(
self: &Arc<Self>, self: &Arc<Self>,
component: &Component, component: &Component,
@@ -243,18 +261,13 @@ impl Runtime {
for (k, v) in &cfg.env { for (k, v) in &cfg.env {
b.env(k, v); b.env(k, v);
} }
// Filesystem setup lives here. T6 replaces this preopen with the VFS.
let world = cfg.world;
let mut b = tokio::task::spawn_blocking(move || -> Result<_> {
b.preopened_dir(world, "/", FsPerms::ReadWrite)?;
Ok(b)
})
.await??;
let state = ProcessState { let state = ProcessState {
ctx: b.build(), ctx: b.build(),
table: ResourceTable::new(), table: ResourceTable::new(),
http: WasiHttpCtx::new(), http: WasiHttpCtx::new(),
hooks: HttpHooks { allow: cfg.network }, hooks: HttpHooks { allow: cfg.network },
fs: sausage_vfs::adapter::Adapter::new(cfg.filesystem),
}; };
let (line_tx, line_rx) = mpsc::channel(1024); let (line_tx, line_rx) = mpsc::channel(1024);
@@ -285,6 +298,7 @@ impl Runtime {
}); });
let rt = self.clone(); let rt = self.clone();
let component = component.clone(); let component = component.clone();
cfg.store_ended.send_replace(false);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut store = Store::new(&rt.engine, state); let mut store = Store::new(&rt.engine, state);
store.epoch_deadline_async_yield_and_update(1); store.epoch_deadline_async_yield_and_update(1);
@@ -311,7 +325,8 @@ impl Runtime {
stdin: Some(host_in), stdin: Some(host_in),
stdout: line_rx, stdout: line_rx,
stderr: err_rx, stderr: err_rx,
task, task: Some(task),
store_ended: cfg.store_ended,
}) })
} }
} }
+34 -19
View File
@@ -1,31 +1,46 @@
//! Services: the calls in `/config/boot.json` (PROTOCOL.md section 8). The //! Ordered boot services and their identities for upgrade restarts (PROTOCOL.md section 8).
//! core starts them in order at boot.
use crate::Host; use crate::Host;
use serde_json::{Value, json}; use serde_json::{Value, json};
use uuid::Uuid; #[derive(Clone)]
pub(crate) struct Service {
pub(crate) method: String,
pub(crate) params: Value,
pub(crate) request: Value,
}
impl Host { impl Host {
/// Start each service of `/config/boot.json` as a detached call with the /// Starts discovery in the background, then waits for each boot service in order.
/// parent `core`, in order. A failure goes to the journal, and boot
/// continues.
pub(crate) async fn boot(&self) -> anyhow::Result<()> { pub(crate) async fn boot(&self) -> anyhow::Result<()> {
self.watch_plugins()?;
let boot: Vec<Value> = serde_json::from_slice( let boot: Vec<Value> = serde_json::from_slice(
&tokio::fs::read(self.0.world.root.join("config/boot.json")).await?, &tokio::fs::read(self.0.world.root.join("config/boot.json")).await?,
)?; )?;
for service in boot { for service in boot {
let result = self self.start_service(
.route( service["method"].as_str().unwrap_or("").into(),
"core".into(), service["params"].clone(),
Some(json!(Uuid::now_v7().to_string())), )
service["method"].as_str().unwrap_or("").into(), .await;
service["params"].clone(),
true,
)
.await;
if let Err(e) = result {
self.0.journal.write("core", "core", "core", &e.to_string());
}
} }
Ok(()) Ok(())
} }
/// Records a unique request identity before the service can answer or exit.
pub(crate) async fn start_service(&self, method: String, params: Value) {
let request = json!(uuid::Uuid::now_v7().to_string());
self.0.state.lock().unwrap().services.push(Service {
method: method.clone(),
params: params.clone(),
request: request.clone(),
});
self.0
.journal
.write("core", "core", "core", &format!("service start {method}"));
if let Err(error) = self
.route("core".into(), Some(request), method, params, true)
.await
{
self.0
.journal
.write("core", "core", "core", &format!("service failed {error}"));
}
}
} }
+157
View File
@@ -0,0 +1,157 @@
//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7).
use crate::{Host, services::Service};
use serde_json::json;
use std::{collections::BTreeMap, time::Duration};
use tokio::sync::watch;
pub(crate) struct OldProcess {
pid: String,
version: String,
ended: watch::Receiver<bool>,
}
pub(crate) struct Upgrade {
plugin: String,
previous: String,
grace: Duration,
processes: Vec<OldProcess>,
services: Vec<(Service, Option<watch::Receiver<bool>>)>,
}
impl Host {
/// Snapshots old admissions while activation holds the admission write guard.
pub(crate) fn snapshot_upgrade(
&self,
plugin: &str,
previous: String,
current: &str,
grace: f64,
) -> Upgrade {
let mut state = self.0.state.lock().unwrap();
let processes = state
.processes
.iter()
.filter(|(_, process)| process.info.plugin == plugin && process.info.version != current)
.map(|(pid, process)| OldProcess {
pid: pid.clone(),
version: process.info.version.clone(),
ended: process.ended.subscribe(),
})
.collect();
let mut services = Vec::new();
let mut retained = Vec::new();
for service in std::mem::take(&mut state.services) {
if service
.method
.split_once('.')
.is_some_and(|(name, _)| name == plugin)
{
let ended = state
.processes
.values()
.find(|process| process.id.as_ref() == Some(&service.request))
.map(|process| process.ended.subscribe());
services.push((service, ended));
} else {
retained.push(service);
}
}
state.services = retained;
Upgrade {
plugin: plugin.into(),
previous,
grace: Duration::from_secs_f64(grace),
processes,
services,
}
}
/// Lets old calls finish, stops them after grace, and starts each lifecycle hook once.
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
let host = self.clone();
tokio::spawn(async move {
host.0.journal.write(
"core",
&upgrade.plugin,
"core",
&format!("upgrade grace {} seconds", upgrade.grace.as_secs_f64()),
);
let mut versions: BTreeMap<String, Vec<watch::Receiver<bool>>> = BTreeMap::new();
versions.entry(upgrade.previous).or_default();
for process in upgrade.processes {
versions
.entry(process.version)
.or_default()
.push(process.ended.clone());
let host = host.clone();
let grace = upgrade.grace;
tokio::spawn(async move {
let mut ended = process.ended;
tokio::select! {
_ = ended.wait_for(|ended|*ended) => (),
_ = tokio::time::sleep(grace) => { host.begin_stop(&process.pid); }
}
});
}
for (service, ended) in upgrade.services {
let host = host.clone();
tokio::spawn(async move {
if let Some(mut ended) = ended {
let _ = ended.wait_for(|ended| *ended).await;
}
host.start_service(service.method, service.params).await;
});
}
for (version, processes) in versions {
let host = host.clone();
let plugin = upgrade.plugin.clone();
let retirement = (plugin.clone(), version.clone());
if !host
.0
.retirements
.lock()
.unwrap()
.insert(retirement.clone())
{
continue;
}
tokio::spawn(async move {
for mut ended in processes {
let _ = ended.wait_for(|ended| *ended).await;
}
if host
.0
.graph
.lock()
.unwrap()
.plugins
.get(&plugin)
.is_some_and(|current| current.hash == version)
{
host.0.retirements.lock().unwrap().remove(&retirement);
return;
}
host.0.journal.write(
"core",
&plugin,
"core",
&format!("retired start {version}"),
);
let result = host
.route(
"core".into(),
Some(json!(uuid::Uuid::now_v7().to_string())),
format!("{plugin}.rpc.retired"),
json!({"previous":version}),
true,
)
.await;
host.0.journal.write(
"core",
&plugin,
"core",
&format!("retired result {result:?}"),
);
host.0.retirements.lock().unwrap().remove(&retirement);
});
}
});
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Background discovery and file notifications for installed plugin versions.
use crate::Host;
use notify::{RecursiveMode, Watcher};
impl Host {
/// Starts background discovery and retains the watcher for this host's lifetime.
pub(crate) fn watch_plugins(&self) -> anyhow::Result<()> {
let (events, mut incoming) = tokio::sync::mpsc::unbounded_channel();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event
&& !matches!(event.kind, notify::EventKind::Access(_))
&& event
.paths
.iter()
.any(|path| path.file_name().is_some_and(|name| name == "plugin.wasm"))
{
let _ = events.send(());
}
})?;
watcher.watch(&self.0.world.root.join("plugins"), RecursiveMode::Recursive)?;
*self.0.watcher.lock().unwrap() = Some(watcher);
let weak = std::sync::Arc::downgrade(&self.0);
tokio::spawn(async move {
loop {
let Some(inner) = weak.upgrade() else {
break;
};
let host = Host(inner);
let _activation = host.0.activations.lock().await;
if let Err(error) = host.refresh_graph().await {
host.0.journal.write(
"core",
"core",
"core",
&format!("discovery failed {error}"),
);
}
drop(_activation);
drop(host);
if incoming.recv().await.is_none() {
break;
}
while incoming.try_recv().is_ok() {}
}
});
Ok(())
}
}
+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;
}
+257 -3
View File
@@ -190,7 +190,7 @@ async fn unimplemented_core_methods_return_method_not_found() {
let w = TestWorld::new(); let w = TestWorld::new();
let h = w.start().await; let h = w.start().await;
assert_eq!( assert_eq!(
h.call("core.activate", json!({})).await.unwrap_err()["code"], h.call("core.unknown", json!({})).await.unwrap_err()["code"],
-32601 -32601
); );
@@ -1083,8 +1083,9 @@ async fn boot_services_are_detached() {
.unwrap(); .unwrap();
let h = w.start().await; let h = w.start().await;
let processes = h.processes(); let processes = h.processes();
assert_eq!(processes.len(), 1); let services: Vec<_> = processes.values().filter(|p| p.method == "serve").collect();
assert!(processes.values().next().unwrap().detached); assert_eq!(services.len(), 1);
assert!(services[0].detached);
h.stop().await; h.stop().await;
} }
@@ -1110,3 +1111,256 @@ async fn core_lifetime_events_enter_the_journal() {
} }
h.stop().await; 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;
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "activation-guest"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
serde_json = "1"
+53
View File
@@ -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}));
}
+1
View File
@@ -5,3 +5,4 @@ edition = "2024"
[workspace] [workspace]
[dependencies] [dependencies]
serde_json = "1" serde_json = "1"
wasip2 = "1"
+124 -1
View File
@@ -14,6 +14,13 @@ fn receive(lines: &mut Lines) -> Value {
fn echo(params: &Value) -> Value { fn echo(params: &Value) -> Value {
params.clone() 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 { fn environment() -> Value {
json!({ json!({
"pid": std::env::var("SAUSAGE_PID").unwrap(), "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 { fn lock(params: &Value, lines: &mut Lines) -> Value {
outgoing_call(&json!({"method":"core.lock", "params":params}), lines) 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> { fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
let params = &request["params"]; let params = &request["params"];
let result = match request["method"].as_str().unwrap() { 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(), "env" => environment(),
"multicall" => multicall(lines), "multicall" => multicall(lines),
"notification" => notification(params), "notification" => notification(params),
+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;
}