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:
2026-10-23 00:12:02 +01:00
parent 0a479b5800
commit 4882edf243
23 changed files with 1650 additions and 5377 deletions
Generated
+139 -4
View File
@@ -905,6 +905,8 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
@@ -1151,6 +1153,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "inventory"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c"
dependencies = [
"rustversion",
]
[[package]]
name = "io-extras"
version = "0.19.0"
@@ -1467,6 +1478,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -1656,6 +1677,16 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "regress"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a"
dependencies = [
"hashbrown 0.16.1",
"memchr",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -1809,10 +1840,16 @@ dependencies = [
name = "sausage-interface"
version = "0.1.0"
dependencies = [
"prettyplease",
"proc-macro2",
"proptest",
"schemars",
"quote",
"schemars 0.8.22",
"schemars 1.2.3",
"serde",
"serde_json",
"syn 2.0.119",
"typify",
]
[[package]]
@@ -1820,9 +1857,10 @@ name = "sausage-plugin"
version = "0.1.0"
dependencies = [
"futures",
"inventory",
"sausage-interface",
"sausage-plugin-macros",
"schemars",
"schemars 1.2.3",
"serde",
"serde_json",
"trybuild",
@@ -1835,6 +1873,8 @@ version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"sausage-interface",
"serde_json",
"syn 2.0.119",
]
@@ -1849,10 +1889,23 @@ dependencies = [
"rusqlite",
"tempfile",
"tokio",
"wasip2",
"wasmtime",
"wasmtime-wasi",
]
[[package]]
name = "schemars"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"schemars_derive 0.8.22",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.3"
@@ -1861,11 +1914,23 @@ checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"schemars_derive 1.2.3",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals 0.29.1",
"syn 2.0.119",
]
[[package]]
name = "schemars_derive"
version = "1.2.3"
@@ -1874,7 +1939,7 @@ checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"serde_derive_internals 0.30.0",
"syn 3.0.9",
]
@@ -1924,6 +1989,17 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
@@ -1957,6 +2033,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_tokenstream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.119",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2324,6 +2412,53 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "typify"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0b89f47309feaeb23c4509c15c9a04234f7deccef6f96c3bfe95319819a304"
dependencies = [
"typify-impl",
"typify-macro",
]
[[package]]
name = "typify-impl"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861"
dependencies = [
"heck",
"log",
"proc-macro2",
"quote",
"regress",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"syn 2.0.119",
"thiserror 2.0.21",
"unicode-ident",
]
[[package]]
name = "typify-macro"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ed96c57f06ae0839416b986921a98f18b220da63bbb243a8570a00c8492183"
dependencies = [
"proc-macro2",
"quote",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"serde_tokenstream",
"syn 2.0.119",
"typify-impl",
]
[[package]]
name = "unarray"
version = "0.1.4"
+5 -2
View File
@@ -2,7 +2,7 @@
name = "sausage"
version = "0.1.0"
edition = "2024"
description = "Grow a system of WASI actors by talking to it."
description = "Grow a system of WASI plugins by talking to it."
[[bin]]
name = "sausage"
@@ -15,7 +15,6 @@ clap = { version = "4", features = ["derive"] }
http = "1"
http-body-util = "0.1"
include_dir = "0.7"
notify = "8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
@@ -23,3 +22,7 @@ toml_edit = "0.25"
wasmtime = "49"
wasmtime-wasi = "49"
wasmtime-wasi-http = "49"
uuid = { version = "1", features = ["v7"] }
sha2 = "0.10"
rusqlite = { version = "0.38", features = ["bundled"] }
-667
View File
@@ -1,667 +0,0 @@
//! The broker: message routing, mailboxes, subscriptions, taps, watches, the
//! version store, and the requests to `broker` (spawn, upgrade, rollback,
//! stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch,
//! exit). `supervisor.rs` runs each WASM actor.
//!
//! The broker accepts only `.wasm` files. It does not know any source language.
//! The world folder is the source of truth: at start, the broker boots every
//! `actor/<id>/actor.wasm`; afterwards the file watcher (`watch.rs`) reports
//! changes: a new `actor.wasm` becomes an actor, a changed one an upgrade.
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot};
use wasmtime::component::Component;
use crate::candidate;
use crate::runtime::{InstanceConfig, Mount, Runtime};
use crate::supervisor::{Control, Reply, Supervisor};
use crate::vfs;
use crate::world::{self, ActorMeta, NATIVE, VersionMeta, World};
pub const MAILBOX_SIZE: usize = 1000;
pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5);
#[derive(Clone, Debug)]
pub struct Envelope {
pub id: String,
pub from: String,
pub to: String,
pub reply_to: Option<String>,
pub body: Value,
}
impl Envelope {
/// The NDJSON line that the recipient reads on stdin.
pub fn to_line(&self) -> String {
let mut v = json!({"id": self.id, "from": self.from, "body": self.body});
if let Some(r) = &self.reply_to {
v["reply_to"] = json!(r);
}
v.to_string()
}
}
pub fn shutdown_line() -> String {
json!({"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}).to_string()
}
pub struct Broker {
pub world: World,
pub runtime: Arc<Runtime>,
mailboxes: Mutex<HashMap<String, mpsc::Sender<Envelope>>>,
controls: Mutex<HashMap<String, mpsc::Sender<Control>>>,
/// `actor.json` of every actor, native and WASM.
metas: Mutex<BTreeMap<String, ActorMeta>>,
op_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
seq: AtomicU64,
/// Set by the `exit` request: the host process should stop.
exit: tokio::sync::Notify,
/// Requests of the host from `fs` (see `vfs.rs`) that wait for a reply, by message ID.
asks: Mutex<HashMap<String, oneshot::Sender<Value>>>,
me: std::sync::Weak<Broker>,
}
impl Broker {
pub fn new(world: World, runtime: Arc<Runtime>) -> Result<Arc<Self>> {
let metas = world.actor_ids()?.into_iter().map(|id| (id.clone(), world.read_meta(&id))).collect();
Ok(Arc::new_cyclic(|me| Self {
world,
runtime,
mailboxes: Mutex::new(HashMap::new()),
controls: Mutex::new(HashMap::new()),
metas: Mutex::new(metas),
op_locks: Mutex::new(HashMap::new()),
seq: AtomicU64::new(1),
exit: tokio::sync::Notify::new(),
asks: Mutex::new(HashMap::new()),
me: me.clone(),
}))
}
/// Send `body` from `fs` to `to` now. The future resolves to the reply
/// body, or `None` after `vfs::ASK_TIMEOUT`.
pub fn ask(self: &Arc<Self>, to: &str, body: Value) -> impl Future<Output = Option<Value>> + Send + 'static {
let own = format!("q{}", self.seq.fetch_add(1, Ordering::Relaxed));
let id = format!("{}/{own}", vfs::ID);
let (tx, rx) = oneshot::channel();
self.asks.lock().unwrap().insert(id.clone(), tx);
self.send(vfs::ID, Some(to), Some(&own), None, body);
let this = self.clone();
async move {
let reply = tokio::time::timeout(vfs::ASK_TIMEOUT, rx).await;
this.asks.lock().unwrap().remove(&id);
reply.ok()?.ok()
}
}
/// Wait until an actor sends `{"type": "exit"}` to `broker`. The `sausage`
/// binary then stops all actors and exits, so that a wrapper such as
/// `nix run .#loop` can start a fresh (updated) host.
pub async fn exit_requested(&self) {
self.exit.notified().await;
}
pub fn meta(&self, id: &str) -> Option<ActorMeta> {
self.metas.lock().unwrap().get(id).cloned()
}
/// Change the `actor.json` of `id` (creating it if needed) and save it.
fn update_meta(&self, id: &str, f: impl FnOnce(&mut ActorMeta)) -> Result<()> {
let mut metas = self.metas.lock().unwrap();
let meta = metas.entry(id.to_string()).or_insert_with(|| ActorMeta { running: true, ..Default::default() });
f(meta);
self.world.write_meta(id, meta)
}
/// Create the mailbox of an actor. Messages wait here while the actor is
/// down, during a swap, or while it restarts.
pub fn open_mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
let (tx, rx) = mpsc::channel(MAILBOX_SIZE);
self.mailboxes.lock().unwrap().insert(id.to_string(), tx);
rx
}
/// Send a message. `to: None` publishes to the subscribers of `from`.
/// With `own_id`, the message ID is `"<from>/<own_id>"`. Returns the ID.
pub fn send(&self, from: &str, to: Option<&str>, own_id: Option<&str>, reply_to: Option<&str>, body: Value) -> String {
let id = match own_id {
Some(own) => format!("{from}/{own}"),
None => format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
};
let (recipients, tappers) = {
let metas = self.metas.lock().unwrap();
let recipients: Vec<String> = match to {
Some(to) => vec![to.to_string()],
None => metas
.iter()
.filter(|(_, m)| m.subscriptions.contains(from))
.map(|(id, _)| id.clone())
.collect(),
};
let tappers: Vec<String> = metas.iter().filter(|(_, m)| m.tapped).map(|(id, _)| id.clone()).collect();
(recipients, tappers)
};
if !tappers.is_empty() {
let message = json!({"id": id, "from": from, "to": to, "reply_to": reply_to, "body": body});
self.tap(&tappers, message);
}
for to in recipients {
self.deliver(Envelope {
id: id.clone(),
from: from.to_string(),
to,
reply_to: reply_to.map(str::to_string),
body: body.clone(),
});
}
id
}
/// Give every tapper a copy of one routed message. Copies skip `send`, so
/// they are never tapped themselves; a full mailbox drops the copy.
fn tap(&self, tappers: &[String], message: Value) {
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64);
self.inject(tappers, "broker", json!({"type": "tapped", "message": message, "ts": ts}));
}
/// Put a message of the host straight into the mailboxes of `to`: not
/// routed (so not tapped), never blocking, dropped if a mailbox is full.
fn inject(&self, to: &[String], from: &str, body: Value) {
let mailboxes = self.mailboxes.lock().unwrap();
for id in to {
if let Some(tx) = mailboxes.get(id) {
let _ = tx.try_send(Envelope {
id: format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
from: from.into(),
to: id.clone(),
reply_to: None,
body: body.clone(),
});
}
}
}
/// Route one stdout line of a WASM actor.
pub fn route_line(&self, from: &str, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
}
let Ok(Value::Object(obj)) = serde_json::from_str::<Value>(line) else {
self.log(from, &format!("stdout line is not a JSON object, ignored: {}", truncate(line, 500)));
return;
};
let Some(body) = obj.get("body").cloned() else {
self.log(from, &format!("message without \"body\", ignored: {}", truncate(line, 500)));
return;
};
let own_id = obj.get("id").and_then(Value::as_str);
let to = obj.get("to").and_then(Value::as_str);
let reply_to = obj.get("reply_to").and_then(Value::as_str);
self.send(from, to, own_id, reply_to, body);
}
fn deliver(&self, env: Envelope) {
if env.to == vfs::ID {
let waiting = env.reply_to.as_ref().and_then(|r| self.asks.lock().unwrap().remove(r));
if let Some(tx) = waiting {
let _ = tx.send(env.body);
}
return;
}
let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned();
let error = match tx {
None => format!("no actor with ID {}", env.to),
Some(tx) => match tx.try_send(env.clone()) {
Ok(()) => return,
Err(mpsc::error::TrySendError::Full(_)) => format!("mailbox of {} is full", env.to),
Err(mpsc::error::TrySendError::Closed(_)) => format!("actor {} is gone", env.to),
},
};
if env.from != "broker" {
let body = json!({"type": "undeliverable", "reason": error, "to": env.to});
self.send("broker", Some(&env.from), None, Some(&env.id), body);
}
}
/// Append a host note to the stderr log of an actor.
pub fn log(&self, id: &str, text: &str) {
use std::io::Write;
let path = self.world.actor_dir(id).join("stderr.log");
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "[sausage {}] {text}", world::now());
}
}
fn op_lock(&self, id: &str) -> Arc<tokio::sync::Mutex<()>> {
self.op_locks.lock().unwrap().entry(id.to_string()).or_default().clone()
}
/// What an instance of `id` gets (see PROTOCOL.md). A candidate runs in
/// `scratch`, which holds a copy of the actor's `data/` and `public/`,
/// and has no network.
pub fn instance_config(&self, id: &str, version: u64, scratch: Option<&Path>) -> InstanceConfig {
let own = scratch.map_or_else(|| self.world.actor_dir(id), Path::to_path_buf);
let mut env = vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_VERSION".into(), version.to_string())];
if scratch.is_some() {
env.push(("SAUSAGE_CANDIDATE".into(), "1".into()));
}
InstanceConfig {
mounts: vec![
Mount { guest: "/".into(), host: self.world.root.clone(), writable: true },
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: true },
Mount { guest: "/self".into(), host: own.clone(), writable: true },
Mount { guest: "/public".into(), host: own.join("public"), writable: true },
Mount { guest: "/data".into(), host: own.join("data"), writable: true },
],
network: scratch.is_none(),
stderr_log: own.join("stderr.log"),
env,
actor: id.to_string(),
broker: self.me.clone(),
}
}
// ---------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------
fn start_supervisor(self: &Arc<Self>, id: &str, version: u64, running: bool) {
let mailbox = self.open_mailbox(id);
let (ctl_tx, ctl_rx) = mpsc::channel(16);
self.controls.lock().unwrap().insert(id.to_string(), ctl_tx);
tokio::spawn(Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx).run());
}
fn is_supervised(&self, id: &str) -> bool {
self.controls.lock().unwrap().contains_key(id)
}
async fn control(&self, id: &str, make: impl FnOnce(Reply) -> Control) -> Result<Value> {
let tx = self.controls.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
let (reply, rx) = oneshot::channel();
tx.send(make(reply)).await.map_err(|_| anyhow!("supervisor of {id} is gone"))?;
rx.await.map_err(|_| anyhow!("supervisor of {id} did not answer"))?
}
/// Stop every actor gracefully, in parallel (host shutdown). The
/// `running` flags stay, so the next `sausage run` starts them again.
pub async fn shutdown_all(self: &Arc<Self>) {
let ids: Vec<String> = self.controls.lock().unwrap().keys().cloned().collect();
let mut tasks = tokio::task::JoinSet::new();
for id in ids {
let this = self.clone();
tasks.spawn(async move { this.control(&id, |reply| Control::Shutdown { reply }).await });
}
tasks.join_all().await;
}
/// Compile `versions/<version>/actor.wasm`.
pub fn load_version(&self, id: &str, version: u64) -> Result<Component> {
self.load(id, &self.world.version_dir(id, version).join("actor.wasm"))
}
fn load(&self, id: &str, wasm: &Path) -> Result<Component> {
let started = Instant::now();
let component = self.runtime.load(wasm)?;
self.log(id, &format!("compiled {} in {} ms", wasm.display(), started.elapsed().as_millis()));
Ok(component)
}
/// Test a new version where it lies. Only when the tests pass, copy it
/// to `versions/<n>/`: that folder holds only versions that run(ran).
/// Returns the component and the test report.
async fn try_version(&self, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<(Component, Value)> {
let component = self.load(id, wasm)?;
let tests = candidate::test(self, id, meta.version, &component, source).await;
if let Err(e) = &tests {
self.log(id, &format!("version {} from {} failed its tests: {e:#}", meta.version, meta.wasm));
}
let tests = tests?;
let dir = self.world.version_dir(id, meta.version);
std::fs::create_dir_all(&dir)?;
std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?;
world::replace_source(source.unwrap_or(&dir.join("no-source")), &dir.join("source"))?;
world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?;
Ok((component, tests))
}
/// Make `version` the current `actor.wasm`, `source/`, and `actor.json`,
/// and record whether the actor runs.
pub fn install(&self, id: &str, version: u64, running: bool) -> Result<()> {
self.world.install_files(id, version)?;
self.update_meta(id, |m| {
m.version = Some(version);
m.running = running;
})
}
pub fn set_running(&self, id: &str, running: bool) -> Result<()> {
self.update_meta(id, |m| m.running = running)
}
/// Create a new actor from a `.wasm` file: test it, install it, start it.
pub async fn create_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
if !world::valid_id(id) {
bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'");
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if NATIVE.contains(&id) || self.is_supervised(id) || self.world.wasm_path(id).exists() {
bail!("an actor with ID {id} already exists");
}
let meta = VersionMeta { version: self.world.next_version(id), ..meta };
self.create_version(id, &meta, wasm, source).await
}
async fn create_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
let (_, tests) = self.try_version(id, meta, wasm, source).await?;
self.install(id, meta.version, true)?;
self.start_supervisor(id, meta.version, true);
Ok(json!({"type": "spawned", "actor": id, "version": meta.version, "tests": tests}))
}
pub async fn upgrade_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if !self.is_supervised(id) {
bail!("no WASM actor with ID {id}");
}
let version = self.world.next_version(id);
self.upgrade_version(id, &VersionMeta { version, ..meta }, wasm, source).await
}
async fn upgrade_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
let (component, tests) = self.try_version(id, meta, wasm, source).await?;
let (version, requester) = (meta.version, meta.requested_by.clone());
let mut result = self.control(id, |reply| Control::Upgrade { version, component, requester, reply }).await?;
result["tests"] = tests;
Ok(result)
}
/// Stop an actor (normal shutdown) and delete its folder `actor/<id>/`.
/// Afterwards the ID is unknown: messages to it are undeliverable, and
/// it can be spawned again from scratch.
pub async fn remove_actor(&self, id: &str) -> Result<Value> {
if NATIVE.contains(&id) {
bail!("{id} is part of the host and cannot be removed");
}
if !world::valid_id(id) {
bail!("invalid actor ID {id:?}");
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
let dir = self.world.actor_dir(id);
if !self.is_supervised(id) && !dir.is_dir() {
bail!("no actor with ID {id}");
}
if self.is_supervised(id) {
self.control(id, |reply| Control::Shutdown { reply }).await?;
}
// Without its mailbox, messages to `id` are undeliverable.
self.controls.lock().unwrap().remove(id);
self.mailboxes.lock().unwrap().remove(id);
self.metas.lock().unwrap().remove(id);
if dir.exists() {
std::fs::remove_dir_all(&dir).with_context(|| format!("delete {}", dir.display()))?;
}
Ok(json!({"type": "removed", "actor": id}))
}
/// Boot the actors in `actor/`: at start, and as a full pass when the
/// file watcher dropped events (`overflowed`).
///
/// - An `actor.wasm` equal to the installed version starts as is.
/// - A new or changed `actor.wasm` is tested where it lies. A new actor
/// is installed, a changed one is an upgrade. If its tests fail,
/// nothing changes, and the file stays until it changes again.
pub async fn scan(self: &Arc<Self>) -> Vec<Value> {
let mut report = Vec::new();
for id in self.world.actor_ids().unwrap_or_default() {
if let Some(r) = self.boot_actor(&id).await {
report.push(r);
}
}
report
}
/// The file watcher saw `actor/<id>/actor.wasm` (or the folder) appear or
/// change: start, install or upgrade `id` as at boot, and log the result.
pub async fn check_actor(self: Arc<Self>, id: String) {
if let Some(result) = self.boot_actor(&id).await {
self.log(&id, &format!("actor.wasm changed: {result}"));
eprintln!("sausage: {result}");
}
}
/// The OS file watcher dropped events: tell every watcher with one
/// `fs_changed {path: "/actor", op: "rescan"}`, then run a full pass.
pub async fn overflowed(self: Arc<Self>) -> Vec<Value> {
self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass");
let watchers: Vec<String> =
self.metas.lock().unwrap().iter().filter(|(_, m)| !m.watches.is_empty()).map(|(id, _)| id.clone()).collect();
self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/", "op": "rescan"}));
let report = self.scan().await;
for change in &report {
eprintln!("sausage: {change}");
}
report
}
/// Send `fs_changed` for one coalesced change (from `watch.rs`) to every
/// actor that watches `path` or `to`. Like taps: straight into the
/// mailbox, dropped if it is full.
pub fn notify_watchers(&self, path: &str, op: &str, to: Option<&str>) {
let watchers: Vec<String> = {
let metas = self.metas.lock().unwrap();
metas
.iter()
.filter(|(_, m)| m.watches.iter().any(|w| under(path, w) || to.is_some_and(|t| under(t, w))))
.map(|(id, _)| id.clone())
.collect()
};
if watchers.is_empty() {
return;
}
let mut body = json!({"type": "fs_changed", "path": path, "op": op});
if let Some(to) = to {
body["to"] = json!(to);
}
self.inject(&watchers, vfs::ID, body);
}
/// A problem of the host itself: the broker's `stderr.log` and the terminal.
pub fn host_error(&self, text: &str) {
self.log("broker", text);
eprintln!("sausage: {text}");
}
async fn boot_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
if NATIVE.contains(&id) || !world::valid_id(id) {
return None;
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if !self.world.wasm_path(id).is_file() {
return None;
}
self.scan_actor(id).await
}
async fn scan_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
// The broker's copy wins: actors change theirs (subscribe, ...) without the op lock.
let meta = self.metas.lock().unwrap().entry(id.to_string()).or_insert_with(|| self.world.read_meta(id)).clone();
let installed = meta.version.filter(|v| self.world.version_dir(id, *v).join("actor.wasm").is_file());
if let Some(v) = installed
&& !self.is_supervised(id)
{
self.start_supervisor(id, v, meta.running);
}
let wasm = self.world.wasm_path(id);
if installed.is_some_and(|v| same_file(&wasm, &self.world.version_dir(id, v).join("actor.wasm"))) {
return None;
}
let source = self.world.source_dir(id);
let vmeta = VersionMeta {
version: self.world.next_version(id),
wasm: format!("/actor/{id}/actor.wasm"),
source: Some(format!("/actor/{id}/source")),
built_by: None,
requested_by: "scan".into(),
created_at: world::now(),
};
let source = source.is_dir().then_some(source.as_path());
let result = match installed {
None => self.create_version(id, &vmeta, &wasm, source).await,
Some(_) => self.upgrade_version(id, &vmeta, &wasm, source).await,
};
Some(result.unwrap_or_else(|e| json!({"type": "error", "actor": id, "reason": format!("{e:#}")})))
}
/// Handle one message to `broker`.
async fn handle(self: Arc<Self>, env: Envelope) {
let body = self.clone().command(&env).await.unwrap_or_else(|e| {
json!({"type": "error", "request": env.body.get("type"), "reason": format!("{e:#}")})
});
self.send("broker", Some(&env.from), None, Some(&env.id), body);
}
async fn command(self: Arc<Self>, env: &Envelope) -> Result<Value> {
let b = &env.body;
let ty = b["type"].as_str().unwrap_or("");
let actor = || b["actor"].as_str().map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\""));
let version_meta = || -> Result<(std::path::PathBuf, Option<std::path::PathBuf>, VersionMeta)> {
let wasm_s = b["wasm"].as_str().ok_or_else(|| anyhow!("missing \"wasm\""))?;
let wasm = self.world.resolve(&env.from, wasm_s)?;
if !wasm.is_file() {
bail!("{wasm_s} is not a file");
}
let source_s = b["source"].as_str();
let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?;
if let (Some(s), Some(p)) = (source_s, &source)
&& !p.is_dir()
{
bail!("{s} is not a folder");
}
let meta = VersionMeta {
version: 0,
wasm: wasm_s.to_string(),
source: source_s.map(str::to_string),
built_by: b["built_by"].as_str().map(str::to_string),
requested_by: env.from.clone(),
created_at: world::now(),
};
Ok((wasm, source, meta))
};
match ty {
"spawn" => {
let (wasm, source, meta) = version_meta()?;
self.create_actor(&actor()?, &wasm, source.as_deref(), meta).await
}
"upgrade" => {
let (wasm, source, meta) = version_meta()?;
self.upgrade_actor(&actor()?, &wasm, source.as_deref(), meta).await
}
"rollback" => {
let id = actor()?;
let version = b["version"].as_u64().ok_or_else(|| anyhow!("missing \"version\""))?;
let lock = self.op_lock(&id);
let _guard = lock.lock().await;
self.control(&id, |reply| Control::Rollback { version, reply }).await
}
"stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await,
"start" => self.control(&actor()?, |reply| Control::Start { reply }).await,
"remove" => self.remove_actor(&actor()?).await,
"subscribe" | "unsubscribe" => {
let publisher = actor()?;
let on = ty == "subscribe";
self.update_meta(&env.from, |m| {
if on {
m.subscriptions.insert(publisher.clone());
} else {
m.subscriptions.remove(&publisher);
}
})?;
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
}
"tap" | "untap" => {
let on = ty == "tap";
self.update_meta(&env.from, |m| m.tapped = on)?;
let answer = if on { "tapping" } else { "untapped" };
Ok(json!({"type": answer}))
}
"watch" | "unwatch" => {
let on = ty == "watch";
let path_s = b["path"].as_str().ok_or_else(|| anyhow!("missing \"path\""))?;
let global = self.world.global_path(&self.world.resolve(&env.from, path_s)?);
let path = global.ok_or_else(|| anyhow!("{path_s} is not under the world root"))?;
let path = match path.trim_end_matches('/') {
"" => "/".to_string(),
p => p.to_string(),
};
self.update_meta(&env.from, |m| {
if on {
m.watches.insert(path.clone());
} else {
m.watches.remove(&path);
}
})?;
let answer = if on { "watching" } else { "unwatched" };
Ok(json!({"type": answer, "path": path}))
}
"exit" => {
self.log("broker", &format!("exit requested by {}", env.from));
// notify_one keeps the permit if nobody waits yet.
self.exit.notify_one();
Ok(json!({"type": "exiting"}))
}
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch, exit"),
}
}
/// The native `broker` actor. Requests run concurrently. Aborting this
/// task aborts the requests that still run. Replies (messages with
/// `reply_to`) get no answer, so that two actors never answer each other
/// forever.
pub async fn run_actor(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
let mut requests = tokio::task::JoinSet::new();
loop {
tokio::select! {
env = mailbox.recv() => match env {
Some(env) if env.reply_to.is_none() => { requests.spawn(self.clone().handle(env)); }
Some(_) => {}
None => break,
},
Some(_) = requests.join_next() => {}
}
}
}
}
fn same_file(a: &Path, b: &Path) -> bool {
matches!((std::fs::read(a), std::fs::read(b)), (Ok(x), Ok(y)) if x == y)
}
/// Whether the namespace path `path` is `prefix` or inside it.
fn under(path: &str, prefix: &str) -> bool {
prefix == "/" || path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
pub fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes)", &s[..end], s.len())
}
-156
View File
@@ -1,156 +0,0 @@
//! Tests for a candidate version, before the broker installs it.
//!
//! A test is a JSON file in `source/tests/*.json`, one scenario per file:
//!
//! ```json
//! {
//! "description": "add, then list",
//! "steps": [
//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"}
//! ]
//! }
//! ```
//!
//! Each step sends `send` (optional, from `from` or "test"), then reads one
//! message for each entry of `expect` (an object or a list). An expected body
//! matches if it is a subset of the actual body. With `to`, each message must
//! also go to that recipient.
//!
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
//! `/public` (empty folders for a new actor), without network. A version
//! without test files passes.
use std::path::Path;
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
use serde_json::{Value, json};
use wasmtime::component::Component;
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
use crate::runtime::Instance;
use crate::world;
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
/// `expected` matches `actual` if every key of an expected object exists in
/// the actual object with a matching value. Other values must be equal.
pub fn subset_match(expected: &Value, actual: &Value) -> bool {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => e.iter().all(|(k, v)| a.get(k).is_some_and(|av| subset_match(v, av))),
(Value::Array(e), Value::Array(a)) => e.len() == a.len() && e.iter().zip(a).all(|(x, y)| subset_match(x, y)),
_ => expected == actual,
}
}
struct Scratch {
dir: std::path::PathBuf,
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn stderr_tail(path: &Path) -> String {
let text = std::fs::read_to_string(path).unwrap_or_default();
let start = text.len().saturating_sub(2000);
let mut s = start;
while !text.is_char_boundary(s) {
s += 1;
}
text[s..].to_string()
}
/// The version under test, which starts from a copy of the actor's state.
struct Candidate<'a> {
broker: &'a Broker,
id: &'a str,
version: u64,
component: &'a Component,
}
/// Run every test file of `<source>/tests/`.
pub async fn test(broker: &Broker, id: &str, version: u64, component: &Component, source: Option<&Path>) -> Result<Value> {
let candidate = Candidate { broker, id, version, component };
let Some(source) = source else { return Ok(json!({"passed": []})) };
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
.map(|rd| rd.filter_map(|e| Some(e.ok()?.path())).filter(|p| p.extension().is_some_and(|x| x == "json")).collect())
.unwrap_or_default();
files.sort();
let mut passed = Vec::new();
for file in &files {
let name = file.file_name().unwrap().to_string_lossy().to_string();
candidate.run_file(file).await.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
passed.push(name);
}
Ok(json!({"passed": passed}))
}
impl Candidate<'_> {
/// Start an instance on a fresh copy of the state, without network.
fn start(&self) -> Result<(Instance, Scratch)> {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id()));
let scratch = Scratch { dir: dir.clone() };
let world = &self.broker.world;
for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public")] {
std::fs::create_dir_all(dir.join(name))?;
if from.is_dir() {
world::copy_tree(&from, &dir.join(name))?;
}
}
let cfg = self.broker.instance_config(self.id, self.version, Some(&dir));
Ok((self.broker.runtime.start(self.component, cfg)?, scratch))
}
async fn run_file(&self, file: &Path) -> Result<()> {
let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?;
let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?;
let (mut inst, scratch) = self.start()?;
let stderr = scratch.dir.join("stderr.log");
// Lines that the candidate wrote while a step was sent.
let mut early = std::collections::VecDeque::new();
for (n, step) in steps.iter().enumerate() {
let n = n + 1;
if let Some(body) = step.get("send") {
let from = step["from"].as_str().unwrap_or("test");
let env = json!({"id": format!("test/{n}"), "from": from, "body": body});
inst.send_line(&env.to_string(), |line| early.push_back(line))
.await
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
}
let expected = match step.get("expect") {
None => vec![],
Some(Value::Array(list)) => list.clone(),
Some(one) => vec![one.clone()],
};
for expected in expected {
let next = async { if early.is_empty() { inst.stdout.recv().await } else { early.pop_front() } };
let line = match tokio::time::timeout(EXPECT_TIMEOUT, next).await {
Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"),
Ok(None) => {
let exit = inst.wait().await;
bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr))
}
Ok(Some(l)) => l,
};
let msg: Value = serde_json::from_str(&line)
.map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?;
if !subset_match(&expected, &msg["body"]) {
bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000));
}
if let Some(to) = step.get("to")
&& msg.get("to") != Some(to)
{
bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null));
}
}
}
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |_| {}).await;
Ok(())
}
}
+68 -91
View File
@@ -1,40 +1,22 @@
//! The native `cargo` actor: builds Rust source folders into `.wasm` files.
//!
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
//! optional `"skip_tests": true` skips native `cargo test` (tests run by default).
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "tests": {"passed": 1, "failed": 0, "output": "..."}, ...}`
//! `{"type": "build_failed", "errors": "...", "tests": {"passed": 0, "failed": 1, "output": "..."}}`
//!
//! The broker knows nothing about Rust. This actor is one way to get a
//! `.wasm` file. Other actors can add other languages.
//!
//! Build scripts compile C for WASI with the clang in `CC_wasm32_wasip2`
//! and `AR_wasm32_wasip2`. The Nix package and the dev shell set both.
//!
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
//! published: this actor copies the SDK of this host to `public/sdk/` (so
//! that every actor can read it), and points the dependency there in the
//! build copy of `Cargo.toml`.
//! Rust build helpers for the native cargo plugin.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use serde_json::Value;
use tokio::process::Command;
use tokio::sync::mpsc;
use include_dir::{Dir, include_dir};
use crate::broker::{Broker, Envelope};
use crate::world::{self, World};
pub const ID: &str = "cargo";
pub const TARGET: &str = "wasm32-wasip2";
/// The source of the `sausage-actor` SDK.
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk");
static SDK_SRC: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src");
static SDK_MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src");
#[derive(Clone, Default, Debug)]
pub struct TestResults {
@@ -43,12 +25,6 @@ pub struct TestResults {
pub output: String,
}
impl TestResults {
fn value(&self) -> Value {
json!({"passed": self.passed, "failed": self.failed, "output": self.output})
}
}
pub struct BuildOutput {
/// Host path of the published `.wasm` file.
pub wasm: PathBuf,
@@ -77,7 +53,10 @@ impl std::error::Error for BuildFailure {}
impl From<anyhow::Error> for BuildFailure {
fn from(error: anyhow::Error) -> Self {
Self { reason: format!("{error:#}"), tests: TestResults::default() }
Self {
reason: format!("{error:#}"),
tests: TestResults::default(),
}
}
}
@@ -87,15 +66,29 @@ pub struct Cargo {
}
impl Cargo {
/// Create the actor and install the SDK of this host into `public/sdk/`.
/// Create the plugin and install the SDK of this host into `public/sdk/`.
pub fn new(world: World) -> Result<Self> {
write_dir(&SDK, &world.public_dir(ID).join("sdk"))?;
let sdk = world.plugin_dir(ID).join("sdk");
write_dir(&SDK_SRC, &sdk.join("src"))?;
write_dir(&SDK_MACROS, &sdk.join("macros/src"))?;
world::write_atomic(
&sdk.join("Cargo.toml"),
include_bytes!("../../sdk/Cargo.toml"),
)?;
world::write_atomic(
&sdk.join("macros/Cargo.toml"),
include_bytes!("../../sdk/macros/Cargo.toml"),
)?;
Ok(Self { world })
}
/// Run native `cargo test` unless opted out, then build and publish the
/// crate's `wasm32-wasip2` binary under `/actor/cargo/public/builds/<n>/`.
pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result<BuildOutput, BuildFailure> {
/// crate's `wasm32-wasip2` binary under `/plugin/cargo/public/builds/<n>/`.
pub async fn build(
&self,
source: &Path,
skip_tests: bool,
) -> std::result::Result<BuildOutput, BuildFailure> {
if !source.join("Cargo.toml").is_file() {
return Err(BuildFailure {
reason: format!("{} has no Cargo.toml", source.display()),
@@ -103,17 +96,20 @@ impl Cargo {
});
}
let started = Instant::now();
let data = self.world.data_dir(ID);
let data = self.world.state_dir(ID);
let build_dir = data.join("work");
world::replace_source(source, &build_dir)?;
let manifest = build_dir.join("Cargo.toml");
let original = std::fs::read_to_string(&manifest).context("read build manifest")?;
let prepared = prepare_manifest(&original, Some(&self.world.public_dir(ID).join("sdk")))?;
let prepared = prepare_manifest(&original, Some(&self.world.plugin_dir(ID).join("sdk")))?;
std::fs::write(&manifest, prepared).context("write prepared manifest")?;
let target_dir = data.join("target");
let tests = if skip_tests {
TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() }
TestResults {
output: "skipped (skip_tests=true)".into(),
..TestResults::default()
}
} else {
match run_tests(&build_dir, &target_dir).await {
Ok(tests) => tests,
@@ -121,13 +117,20 @@ impl Cargo {
let output = format!("cannot run cargo tests: {error:#}");
return Err(BuildFailure {
reason: output.clone(),
tests: TestResults { failed: 1, output, ..TestResults::default() },
tests: TestResults {
failed: 1,
output,
..TestResults::default()
},
});
}
}
};
if tests.failed > 0 {
return Err(BuildFailure { reason: "unit tests failed".into(), tests });
return Err(BuildFailure {
reason: "unit tests failed".into(),
tests,
});
}
let build_result: Result<BuildOutput> = async {
@@ -141,7 +144,10 @@ impl Cargo {
.arg(&target_dir)
.current_dir(&build_dir)
.kill_on_drop(true);
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
let out = cmd
.output()
.await
.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if !out.status.success() {
bail!("{}", strip_progress(&stderr));
@@ -155,10 +161,12 @@ impl Cargo {
artifact = Some(PathBuf::from(exe));
}
}
let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?;
let artifact = artifact.ok_or_else(|| {
anyhow!("cargo built no binary. Does the crate have a src/main.rs?")
})?;
let n = next_build_number(&self.world.public_dir(ID).join("builds"))?;
let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string());
let n = next_build_number(&self.world.plugin_dir(ID).join("builds"))?;
let dest_dir = self.world.plugin_dir(ID).join("builds").join(n.to_string());
let name = artifact.file_name().context("artifact has no file name")?;
let dest = dest_dir.join(name);
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
@@ -170,52 +178,10 @@ impl Cargo {
})
}
.await;
build_result.map_err(|error| BuildFailure { reason: format!("{error:#}"), tests })
}
/// The native `cargo` actor. Builds run one at a time, in order.
/// Replies (messages with `reply_to`) get no answer.
pub async fn run_actor(self, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
while let Some(env) = mailbox.recv().await {
if env.reply_to.is_some() {
continue;
}
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}"), "tests": TestResults::default().value()})
});
broker.send(ID, Some(&env.from), None, Some(&env.id), body);
}
}
async fn handle(&self, broker: &Broker, env: &Envelope) -> Result<Value> {
match env.body.get("type").and_then(Value::as_str) {
Some("build") => {
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
let source = broker.world.resolve(&env.from, source_s)?;
let skip_tests = env.body.get("skip_tests").and_then(Value::as_bool).unwrap_or(false);
match self.build(&source, skip_tests).await {
Ok(out) => {
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
Ok(json!({
"type": "built",
"wasm": wasm,
"source": source_s,
"built_by": ID,
"seconds": (out.seconds * 10.0).round() / 10.0,
"warnings": out.warnings,
"tests": out.tests.value(),
}))
}
Err(failure) => Ok(json!({
"type": "build_failed",
"source": source_s,
"errors": failure.reason,
"tests": failure.tests.value(),
})),
}
}
other => bail!("unknown request {other:?}. Known: build"),
}
build_result.map_err(|error| BuildFailure {
reason: format!("{error:#}"),
tests,
})
}
}
@@ -245,7 +211,11 @@ async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result<TestResults> {
if !out.status.success() && failed == 0 {
failed = 1;
}
Ok(TestResults { passed, failed, output })
Ok(TestResults {
passed,
failed,
output,
})
}
/// Count the `passed` or `failed` totals in cargo's per-target test summaries.
@@ -268,7 +238,12 @@ fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
let mut doc: toml_edit::DocumentMut = text.parse()?;
if let Some(sdk) = sdk {
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(dep) = doc.get_mut(section).and_then(|t| t.get_mut("sausage-actor")) else { continue };
let Some(dep) = doc
.get_mut(section)
.and_then(|t| t.get_mut("sausage-actor"))
else {
continue;
};
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
table.remove(key);
@@ -289,7 +264,9 @@ fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
for file in dir.files() {
let dest = to.join(file.path());
let contents = match file.path().file_name() {
Some(n) if n == "Cargo.toml" => prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes(),
Some(n) if n == "Cargo.toml" => {
prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes()
}
_ => file.contents().to_vec(),
};
if std::fs::read(&dest).ok() != Some(contents.clone()) {
-68
View File
@@ -1,68 +0,0 @@
//! The native `console` actor: your terminal.
//!
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
//! `{"type": "text", "text": ...}`.
//! - A line without `@` goes to `agent` as text.
//! - `/help`, `/quit`. EOF on stdin also quits (not with `--headless`, which
//! never reads stdin).
use std::sync::Arc;
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
use crate::broker::{Broker, Envelope};
pub const ID: &str = "console";
const HELP: &str = "\
@<actor> <json> send a JSON body to an actor
@<actor> <text> send {\"type\":\"text\",\"text\":...}
<text> send text to the agent
/quit stop all actors and exit";
/// Print a message: text bodies as text, everything else as JSON.
fn show(env: &Envelope) {
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
match env.body["text"].as_str() {
Some(text) if env.body["type"] == "text" => println!("[{}{reply}] {text}", env.from),
_ => println!("[{}{reply}] {}", env.from, env.body),
}
}
/// Print every message that arrives for the console.
pub async fn run_mailbox(mut mailbox: mpsc::Receiver<Envelope>) {
while let Some(env) = mailbox.recv().await {
show(&env);
}
}
/// Read lines until EOF or `/quit`, and send them as messages.
pub async fn run_input(broker: Arc<Broker>) {
println!("Sausage console. Type /help for commands.");
let mut lines = BufReader::new(tokio::io::stdin()).lines();
while let Ok(Some(line)) = lines.next_line().await {
let line = line.trim();
let (to, body) = match line {
"" => continue,
"/quit" => break,
"/help" => {
println!("{HELP}");
continue;
}
_ => match line.strip_prefix('@') {
Some(rest) => {
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let body = match serde_json::from_str::<Value>(text.trim()) {
Ok(v @ Value::Object(_)) => v,
_ => json!({"type": "text", "text": text.trim()}),
};
(to, body)
}
None => ("agent", json!({"type": "text", "text": line})),
},
};
broker.send(ID, Some(to), None, None, body);
}
}
+42
View File
@@ -0,0 +1,42 @@
//! SQLite journal entries and filters.
use rusqlite::{Connection, params};
use serde_json::{Value, json};
use std::{collections::HashSet, path::Path, sync::Mutex};
pub struct Journal {
db: Mutex<Connection>,
warned: Mutex<HashSet<String>>,
}
impl Journal {
pub fn open(path: &Path) -> anyhow::Result<Self> {
std::fs::create_dir_all(path.parent().unwrap())?;
let db = Connection::open(path)?;
db.execute_batch("CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, pid TEXT, plugin TEXT, stream TEXT, line TEXT)")?;
Ok(Self {
db: Mutex::new(db),
warned: Mutex::new(HashSet::new()),
})
}
pub fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
self.db
.lock()
.unwrap()
.execute(
"INSERT INTO journal VALUES (?1,?2,?3,?4,?5)",
params![uuid::Uuid::now_v7().to_string(), pid, plugin, stream, line],
)
.expect("write journal");
}
pub fn warning(&self, pid: &str, plugin: &str) {
if self.warned.lock().unwrap().insert(pid.into()) {
self.write(pid, plugin, "core", "warning: discarded stdout");
}
}
pub fn query(&self, p: &Value) -> Value {
let db = self.db.lock().unwrap();
let mut stmt=db.prepare("SELECT id,pid,plugin,stream,line FROM journal WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2) AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4) AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6").unwrap();
let rows=stmt.query_map(params![p["plugin"].as_str(),p["pid"].as_str(),p["since"].as_str(),p["until"].as_str(),p["grep"].as_str(),p["limit"].as_i64().unwrap_or(100)],|r|Ok(json!({"id":r.get::<_,String>(0)?,"pid":r.get::<_,String>(1)?,"plugin":r.get::<_,String>(2)?,"stream":r.get::<_,String>(3)?,"line":r.get::<_,String>(4)?}))).unwrap();
let mut entries: Vec<_> = rows.map(Result::unwrap).collect();
entries.reverse();
json!(entries)
}
}
+622 -72
View File
@@ -1,86 +1,636 @@
//! Sausage: grow a system of WASI actors by talking to it.
//!
//! The host runs the broker, the native actors (`broker`, `cargo`, and the
//! console), one supervisor per WASM actor, and the file watcher of the
//! world folder (`watch.rs`). See `PROTOCOL.md`.
pub mod broker;
pub mod candidate;
pub mod cargo_actor;
pub mod console;
//! Process ownership, JSON-RPC routing, native plugins, and host lifetime.
pub mod cargo;
mod journal;
pub mod runtime;
pub mod supervisor;
pub mod vfs;
pub mod watch;
pub mod world;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Result, bail};
use tokio::sync::mpsc;
pub use broker::{Broker, Envelope};
pub use world::World;
/// The contract between the host and the actors. The host publishes it at
/// `/actor/broker/public/PROTOCOL.md`, so that every actor can read it.
const PROTOCOL: &str = include_str!("../../PROTOCOL.md");
/// A running world.
pub struct Host {
pub world: World,
pub broker: Arc<Broker>,
native: Vec<tokio::task::JoinHandle<()>>,
watcher: Option<watch::Watcher>,
_lock: std::fs::File,
use runtime::{InstanceConfig, Runtime};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::{HashMap, VecDeque},
future::Future,
path::Path,
pin::Pin,
sync::{Arc, Mutex},
};
use tokio::sync::{mpsc, oneshot, watch};
use uuid::Uuid;
use world::World;
pub type RpcResult = Result<Value, Value>;
pub type NativeFuture = Pin<Box<dyn Future<Output = RpcResult> + Send>>;
pub type NativePlugin = Arc<dyn Fn(NativeContext, String, Value) -> NativeFuture + Send + Sync>;
#[derive(Clone)]
pub struct NativeContext {
pub host: Host,
pub pid: String,
}
impl NativeContext {
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
let exit = requests_exit(method, &params);
let result = self
.host
.route(
self.pid.clone(),
Some(json!(Uuid::now_v7().to_string())),
method.into(),
params,
false,
)
.await;
if exit && result.is_ok() {
self.host.0.exit.send_replace(true);
}
result
}
pub fn notify(&self, name: &str, params: Value) {
self.host.notify(&self.pid, name, params);
}
}
#[derive(Clone, serde::Serialize)]
pub struct ProcessInfo {
pub plugin: String,
pub version: String,
pub method: String,
pub params: Value,
pub parent: Option<String>,
pub detached: bool,
pub start_time: u128,
pub state: String,
}
struct Process {
info: ProcessInfo,
input: Option<mpsc::UnboundedSender<Value>>,
abort: Option<tokio::task::AbortHandle>,
caller: String,
id: Option<Value>,
answer: Option<oneshot::Sender<RpcResult>>,
ended: watch::Sender<bool>,
}
struct Lock {
owner: String,
queue: VecDeque<(String, oneshot::Sender<RpcResult>)>,
}
struct State {
processes: HashMap<String, Process>,
locks: HashMap<String, Lock>,
components: HashMap<String, wasmtime::component::Component>,
native: HashMap<String, NativePlugin>,
}
struct Inner {
world: World,
runtime: Arc<Runtime>,
state: Mutex<State>,
journal: journal::Journal,
notifications: tokio::sync::broadcast::Sender<Value>,
exit: watch::Sender<bool>,
compilation: tokio::sync::Mutex<()>,
}
#[derive(Clone)]
pub struct Host(Arc<Inner>);
struct ProcessGuard {
host: Host,
pid: String,
}
impl Drop for ProcessGuard {
fn drop(&mut self) {
self.host.finish(&self.pid, "process task ended");
}
}
fn error(code: i64, text: &str) -> Value {
json!({"code":code,"message":text})
}
impl Host {
/// Start the broker, `cargo`, the file watcher, and every registered
/// actor of the world at `root`. Only one host can run a world at a time.
pub async fn start(root: &Path) -> Result<Host> {
let world = World::new(std::path::absolute(root)?);
if !world.exists() {
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
pub async fn start(path: &Path) -> anyhow::Result<Self> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
.join(".cache")
});
Self::start_with_cache(path, &base.join("sausage")).await
}
let lock = std::fs::File::create(world.root.join(".lock"))?;
if lock.try_lock().is_err() {
bail!("another sausage process runs {}", root.display());
pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result<Self> {
let world = World::open(path)?;
let journal = journal::Journal::open(&world.state_dir("core").join("journal.sqlite"))?;
let host = Self(Arc::new(Inner {
world,
runtime: Runtime::new(cache)?,
journal,
state: Mutex::new(State {
processes: HashMap::new(),
locks: HashMap::new(),
components: HashMap::new(),
native: HashMap::new(),
}),
notifications: tokio::sync::broadcast::channel(1024).0,
exit: watch::channel(false).0,
compilation: tokio::sync::Mutex::new(()),
}));
let boot: Vec<Value> =
serde_json::from_slice(&std::fs::read(host.0.world.root.join("config/boot.json"))?)?;
for service in boot {
let result = host
.route(
"core".into(),
Some(json!(Uuid::now_v7().to_string())),
service["method"].as_str().unwrap_or("").into(),
service["params"].clone(),
true,
)
.await;
if let Err(e) = result {
host.0.journal.write("core", "core", "core", &e.to_string());
}
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?;
let cargo = cargo_actor::Cargo::new(world.clone())?;
let native = vec![
tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))),
tokio::spawn(cargo.run_actor(broker.clone(), broker.open_mailbox(cargo_actor::ID))),
];
// Watch first, so that no change during the boot pass is lost.
let watcher = watch::start(broker.clone())
.inspect_err(|e| broker.host_error(&format!("no file watching: {e}")))
.ok();
for change in broker.scan().await {
eprintln!("sausage: {change}");
}
Ok(Host { world, broker, native, watcher, _lock: lock })
Ok(host)
}
/// Register a native actor with the ID `id` and return its mailbox.
pub fn mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
self.broker.open_mailbox(id)
pub fn register_native(&self, name: &str, plugin: NativePlugin) {
self.0
.state
.lock()
.unwrap()
.native
.insert(name.into(), plugin);
}
/// Stop the file watcher, the broker and `cargo` (with the requests they
/// still run), then every actor, gracefully.
pub async fn stop(mut self) {
drop(self.watcher.take());
for task in &self.native {
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Value> {
self.0.notifications.subscribe()
}
pub fn processes(&self) -> HashMap<String, ProcessInfo> {
self.0
.state
.lock()
.unwrap()
.processes
.iter()
.map(|(p, s)| (p.clone(), s.info.clone()))
.collect()
}
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
let exit = requests_exit(method, &params);
let result = self
.route(
"console".into(),
Some(json!(Uuid::now_v7().to_string())),
method.into(),
params,
false,
)
.await;
if exit && result.is_ok() {
self.0.exit.send_replace(true);
}
result
}
pub async fn exit_requested(&self) {
let mut exit = self.0.exit.subscribe();
let _ = exit.wait_for(|requested| *requested).await;
}
pub fn restart_requested(&self) -> bool {
*self.0.exit.borrow()
}
fn send(&self, pid: &str, value: Value) {
if pid == "console" {
let _ = self.0.notifications.send(value);
} else if let Some(tx) = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.and_then(|p| p.input.as_ref())
{
let _ = tx.send(value);
}
}
fn notify(&self, pid: &str, name: &str, params: Value) {
let target = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.and_then(|p| p.answer.as_ref().map(|_| (p.caller.clone(), p.id.clone())));
if let Some((caller, id)) = target {
self.send(&caller,json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":id,"name":name,"params":params}}));
}
}
fn route(
&self,
caller: String,
id: Option<Value>,
method: String,
params: Value,
detached: bool,
) -> NativeFuture {
let host = self.clone();
Box::pin(async move {
if !params.is_object() {
return Err(error(-32602, "params must be an object"));
}
if caller != "console" && caller != "core" {
let state = host.0.state.lock().unwrap();
let Some(process) = state.processes.get(&caller) else {
return Err(error(-32600, "process ended"));
};
if process.info.state != "running" && method != "core.notify" {
return Err(error(-32600, "process is stopping"));
}
}
let (plugin, method) = method
.split_once('.')
.ok_or_else(|| error(-32601, "method not found"))?;
if plugin == "core" {
return host.core(&caller, id, method, params).await;
}
if plugin.is_empty()
|| !plugin
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
{
return Err(error(-32601, "invalid plugin name"));
}
let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
let (component, version) = if native.is_some() {
(None, "native".into())
} else {
let _compilation = host.0.compilation.lock().await;
let bytes = std::fs::read(host.0.world.plugin_dir(plugin).join("plugin.wasm"))
.map_err(|_| error(-32001, "no such plugin"))?;
let hash = format!("{:x}", Sha256::digest(&bytes));
let cached = host.0.state.lock().unwrap().components.get(&hash).cloned();
let component = match cached {
Some(c) => c,
None => {
let rt = host.0.runtime.clone();
let c = tokio::task::spawn_blocking(move || rt.compile(&bytes))
.await
.map_err(|e| error(-32603, &e.to_string()))?
.map_err(|e| error(-32603, &e.to_string()))?;
host.0
.state
.lock()
.unwrap()
.components
.insert(hash.clone(), c.clone());
c
}
};
(Some(component), hash)
};
let pid = format!("{plugin}-{}", Uuid::now_v7());
let (tx, rx) = oneshot::channel();
let (input, mut incoming) = mpsc::unbounded_channel::<Value>();
let info = ProcessInfo {
plugin: plugin.into(),
version,
method: method.into(),
params: params.clone(),
parent: if detached { None } else { Some(caller.clone()) },
detached,
start_time: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis(),
state: "running".into(),
};
let parent_name = info.parent.clone().unwrap_or_else(|| "core".into());
{
let mut state = host.0.state.lock().unwrap();
if caller != "console"
&& caller != "core"
&& !state
.processes
.get(&caller)
.is_some_and(|p| p.info.state == "running")
{
return Err(error(-32600, "caller stopped or ended"));
}
host.0
.journal
.write(&pid, plugin, "core", &format!("start {method} {params}"));
state.processes.insert(
pid.clone(),
Process {
info,
input: Some(input),
abort: None,
caller,
id: id.clone(),
answer: Some(tx),
ended: watch::channel(false).0,
},
);
}
let plugin_name = plugin.to_string();
let h = host.clone();
let p = pid.clone();
let method = method.to_string();
let has_id = id.is_some();
let task = tokio::spawn(async move {
let _guard = ProcessGuard {
host: h.clone(),
pid: p.clone(),
};
if let Some(native) = native {
let result = native(
NativeContext {
host: h.clone(),
pid: p.clone(),
},
method,
params,
)
.await;
h.answer(&p, result);
h.finish(&p, "exit 0");
return;
}
let cfg = InstanceConfig {
world: h.0.world.root.clone(),
network: true,
env: vec![
("SAUSAGE_PID".into(), p.clone()),
("SAUSAGE_PLUGIN".into(), plugin_name.clone()),
("SAUSAGE_PARENT".into(), parent_name),
],
};
let result = async {
let mut instance = h.0.runtime.start(&component.unwrap(),cfg)?;
let initial = if has_id { json!({"jsonrpc":"2.0","id":p,"method":method,"params":params}) } else {json!({"jsonrpc":"2.0","method":method,"params":params})};
instance.send_line(&initial.to_string(),|line|h.output(&p,line)).await?;
let mut stdin_open = true;
let mut stderr_open = true;
loop { tokio::select! {
line = instance.stderr.recv(), if stderr_open => match line { Some(line) => h.0.journal.write(&p,&plugin_name,"stderr",&line), None => stderr_open=false },
line = instance.stdout.recv() => match line { Some(line) => h.output(&p,line), None => break },
value = incoming.recv(), if stdin_open => match value { Some(v) => { instance.send_line(&v.to_string(),|line|h.output(&p,line)).await?; }, None => { instance.stdin.take(); stdin_open=false; } }
}}
while let Some(line) = instance.stderr.recv().await { h.0.journal.write(&p,&plugin_name,"stderr",&line); }
Ok::<_,anyhow::Error>(instance.wait().await.to_string())
}.await;
h.finish(&p, &result.unwrap_or_else(|e| e.to_string()));
});
if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) {
p.abort = Some(task.abort_handle());
} else {
task.abort();
}
for task in self.native {
let _ = task.await;
if id.is_none() {
return Ok(Value::Null);
}
rx.await
.unwrap_or_else(|_| Err(error(-32002, "process ended")))
})
}
fn answer(&self, pid: &str, result: RpcResult) {
if let Some(tx) = self
.0
.state
.lock()
.unwrap()
.processes
.get_mut(pid)
.and_then(|p| p.answer.take())
{
let _ = tx.send(result);
}
}
fn output(&self, pid: &str, line: String) {
let Ok(v) = serde_json::from_str::<Value>(&line) else {
self.discard(pid);
return;
};
if !v.is_object() || v["jsonrpc"] != "2.0" {
self.discard(pid);
return;
}
if let Some(method) = v["method"].as_str() {
let h = self.clone();
let caller = pid.to_string();
let exit = requests_exit(method, &v["params"]);
let method = method.to_string();
tokio::spawn(async move {
let id = v.get("id").cloned();
let result = h
.route(
caller.clone(),
id.clone(),
method,
v.get("params").cloned().unwrap_or(json!({})),
false,
)
.await;
let success = result.is_ok();
if let Some(id) = id {
let value = match result {
Ok(r) => json!({"jsonrpc":"2.0","id":id,"result":r}),
Err(e) => json!({"jsonrpc":"2.0","id":id,"error":e}),
};
h.send(&caller, value);
}
if exit && success {
h.0.exit.send_replace(true);
}
});
} else if v["id"] == pid {
self.answer(
pid,
if let Some(e) = v.get("error") {
Err(e.clone())
} else {
Ok(v["result"].clone())
},
);
}
}
fn discard(&self, pid: &str) {
// The journal deduplicates this warning for each process.
if let Some(info) = self.processes().get(pid) {
self.0.journal.warning(pid, &info.plugin);
}
}
fn finish(&self, pid: &str, status: &str) {
let mut state = self.0.state.lock().unwrap();
let process = state.processes.remove(pid);
if let Some(p) = process {
self.0.journal.write(pid, &p.info.plugin, "core", status);
if let Some(tx) = p.answer {
let _=tx.send(Err(json!({"code":-32002,"message":"process ended without an answer","data":{"status":status,"lines":self.0.journal.query(&json!({"pid":pid,"limit":20}))}})));
}
Self::release_locks(&mut state, pid);
let _ = p.ended.send(true);
let children: Vec<_> = state
.processes
.iter()
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
.map(|(id, _)| id.clone())
.collect();
drop(state);
for child in children {
self.begin_stop(&child);
}
}
}
fn begin_stop(&self, pid: &str) -> Option<watch::Receiver<bool>> {
let mut state = self.0.state.lock().unwrap();
let p = state.processes.get_mut(pid)?;
let ended = p.ended.subscribe();
if p.info.state == "stopping" {
return Some(ended);
}
p.info.state = "stopping".into();
p.input.take();
self.0.journal.write(pid, &p.info.plugin, "core", "stop");
let children: Vec<_> = state
.processes
.iter()
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
.map(|(id, _)| id.clone())
.collect();
drop(state);
for child in children {
self.begin_stop(&child);
}
let h = self.clone();
let pid = pid.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
h.kill(&pid);
});
Some(ended)
}
fn kill(&self, pid: &str) {
if let Some(p) = self.0.state.lock().unwrap().processes.get(pid) {
self.0.journal.write(pid, &p.info.plugin, "core", "kill");
if let Some(a) = &p.abort {
a.abort();
}
}
self.finish(pid, "killed");
}
pub async fn stop(&self) {
let ids: Vec<_> = self.processes().into_keys().collect();
let mut waits = Vec::new();
for pid in ids {
if let Some(n) = self.begin_stop(&pid) {
waits.push(n);
}
}
for mut n in waits {
let _ = n.wait_for(|ended| *ended).await;
}
}
fn release_locks(state: &mut State, pid: &str) {
for lock in state.locks.values_mut() {
lock.queue.retain(|(p, _)| p != pid);
}
let paths: Vec<_> = state
.locks
.iter()
.filter(|(_, l)| l.owner == pid)
.map(|(p, _)| p.clone())
.collect();
for path in paths {
grant(state, &path);
}
}
async fn core(&self, caller: &str, id: Option<Value>, method: &str, p: Value) -> RpcResult {
match method {
"call" => {
self.route(
caller.into(),
id,
required_str(&p, "method")?.into(),
p.get("params").cloned().unwrap_or(json!({})),
p["detach"].as_bool().unwrap_or(false),
)
.await
}
"notify" => {
self.notify(
caller,
required_str(&p, "name")?,
p.get("params").cloned().unwrap_or(json!({})),
);
Ok(Value::Null)
}
"kill" => {
self.kill(required_str(&p, "pid")?);
Ok(json!({}))
}
"stop" => {
if let Some(mut n) = self.begin_stop(required_str(&p, "pid")?) {
let _ = n.wait_for(|ended| *ended).await;
}
Ok(json!({}))
}
"lock" => {
let path = p["path"]
.as_str()
.ok_or_else(|| error(-32602, "missing path"))?;
let rx = {
let mut state = self.0.state.lock().unwrap();
if let Some(lock) = state.locks.get_mut(path) {
if p["wait"] == false {
return Ok(json!({"held":false}));
}
let (tx, rx) = oneshot::channel();
lock.queue.push_back((caller.into(), tx));
Some(rx)
} else {
state.locks.insert(
path.into(),
Lock {
owner: caller.into(),
queue: VecDeque::new(),
},
);
None
}
};
match rx {
Some(rx) => rx
.await
.unwrap_or_else(|_| Err(error(-32603, "lock canceled"))),
None => Ok(json!({"held":true})),
}
}
"unlock" => {
let mut state = self.0.state.lock().unwrap();
let path = required_str(&p, "path")?;
if state.locks.get(path).is_some_and(|l| l.owner == caller) {
grant(&mut state, path);
}
Ok(Value::Null)
}
"log" => Ok(self.0.journal.query(&p)),
"exit" => Ok(json!({})),
_ => Err(error(-32601, "method not found")),
}
self.broker.shutdown_all().await;
}
}
fn grant(state: &mut State, path: &str) {
if let Some(mut lock) = state.locks.remove(path) {
while let Some((owner, tx)) = lock.queue.pop_front() {
if tx.send(Ok(json!({"held":true}))).is_ok() {
lock.owner = owner;
state.locks.insert(path.into(), lock);
break;
}
}
}
}
fn requests_exit(method: &str, params: &Value) -> bool {
method == "core.exit"
|| (method == "core.call"
&& requests_exit(params["method"].as_str().unwrap_or(""), &params["params"]))
}
fn required_str<'a>(params: &'a Value, name: &str) -> Result<&'a str, Value> {
params[name]
.as_str()
.ok_or_else(|| error(-32602, &format!("{name} must be a string")))
}
+72 -134
View File
@@ -1,164 +1,102 @@
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
//! Command parsing and the minimal line console.
use anyhow::{Result, bail};
use clap::{Parser, Subcommand};
use include_dir::{Dir, include_dir};
use sausage::{Host, world::World};
use serde_json::json;
use tokio::signal::unix::Signal;
use sausage::world::{ActorMeta, World};
use sausage::{Host, cargo_actor, console};
/// The source of the agent actor. `init` builds it like any other actor.
static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
// Without a command, `sausage` runs the world in `./world` and creates it
// first (like `init`) if it does not exist yet.
use std::{io::Write, path::PathBuf};
use tokio::io::{AsyncBufReadExt, BufReader};
#[derive(Parser)]
#[command(version, about)]
#[command(version)]
struct Cli {
/// Do not read stdin, so end of input does not stop the world. It runs
/// until SIGTERM (or SIGINT), or until an actor sends
/// `@broker {"type":"exit"}`. Also set by SAUSAGE_HEADLESS=1.
#[arg(long, global = true)]
headless: bool,
#[arg(long, global = true)]
cache_dir: Option<PathBuf>,
#[command(subcommand)]
command: Option<Cmd>,
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Create a new world and build the agent actor.
///
/// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and
/// SAUSAGE_API_TOKEN, and asks for every value that is missing.
Init {
/// Folder of the new world.
#[arg(default_value = "world")]
dir: PathBuf,
},
/// Start the broker, all actors, and the console.
Run {
#[arg(default_value = "world")]
dir: PathBuf,
},
Init { dir: PathBuf },
Run { dir: PathBuf },
}
/// The value of `var`, or the answer to `question`.
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
if let Ok(v) = std::env::var(var)
&& !v.is_empty()
{
return Ok(v);
}
match default {
Some(d) => print!("{question} [{d}]: "),
None => print!("{question}: "),
}
print!("{question}: ");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
match (line.trim(), default) {
("", Some(d)) => Ok(d.to_string()),
("", None) => bail!("{question} is required (or set {var})"),
(v, _) => Ok(v.to_string()),
if !line.trim().is_empty() {
return Ok(line.trim().into());
}
match default {
Some(v) => Ok(v.into()),
None => bail!("{question} is required"),
}
}
async fn init(dir: PathBuf) -> Result<()> {
let api = setting("SAUSAGE_API", "API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?;
if api != "openai-responses" && api != "anthropic-messages" {
bail!("unknown API shape {api:?}");
}
let base_url = setting("SAUSAGE_BASE_URL", "API base URL", None)?;
let model = setting("SAUSAGE_MODEL", "Model", None)?;
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
let world = World::create(&dir)?;
let console = ActorMeta { subscriptions: ["agent".to_string()].into(), ..world.read_meta(console::ID) };
world.write_meta(console::ID, &console)?;
// The agent's configuration, in its /data.
world.ensure_actor_dirs("agent")?;
let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
let config_path = world.data_dir("agent").join("config.json");
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
// The agent is an ordinary actor folder: `run` finds its `actor.wasm`,
// tests it, and installs it as version 1.
let source = world.source_dir("agent");
std::fs::create_dir_all(&source)?;
AGENT_SOURCE.extract(&source).context("extract agent source")?;
println!("Building the agent actor (the first build takes a few minutes) …");
let out = cargo_actor::Cargo::new(world.clone())?.build(&source, false).await.context("build agent")?;
std::fs::copy(&out.wasm, world.wasm_path("agent"))?;
println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display());
Ok(())
}
/// Install the SIGTERM and SIGINT handlers now (not when first polled).
fn termination_signals() -> Result<(Signal, Signal)> {
use tokio::signal::unix::{SignalKind, signal};
let term = signal(SignalKind::terminate()).context("install SIGTERM handler")?;
let int = signal(SignalKind::interrupt()).context("install SIGINT handler")?;
Ok((term, int))
}
/// Resolves on SIGTERM or SIGINT.
async fn termination((mut term, mut int): (Signal, Signal)) {
tokio::select! {
_ = term.recv() => {}
_ = int.recv() => {}
}
}
async fn run(dir: PathBuf, headless: bool) -> Result<()> {
// Register the signal handlers before the host starts, so a signal that
// arrives early is never the default (kill) action.
let signals = if headless { Some(termination_signals()?) } else { None };
let host = Host::start(&dir).await?;
tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
let broker = host.broker.clone();
let exit = match signals {
Some(signals) => {
println!("Sausage headless: running until SIGTERM or an exit request.");
tokio::select! {
_ = termination(signals) => false,
_ = broker.exit_requested() => true,
}
}
None => tokio::select! {
_ = console::run_input(host.broker.clone()) => false,
_ = broker.exit_requested() => true,
},
};
println!("Stopping actors …");
host.stop().await;
if exit {
// The console still blocks on a stdin read that cannot be cancelled,
// so the runtime would not shut down on its own. Exit code 75 tells
// `nix run .#loop` to start the host again (0 = /quit = stop the loop).
println!("Exiting, as an actor asked the broker to.");
std::process::exit(75);
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let headless = cli.headless || std::env::var("SAUSAGE_HEADLESS").is_ok_and(|v| !v.is_empty() && v != "0");
match cli.command {
Some(Cmd::Init { dir }) => init(dir).await,
Some(Cmd::Run { dir }) => run(dir, headless).await,
None => {
let dir = PathBuf::from("world");
if !dir.join("actor").is_dir() {
println!("No world in ./world yet, creating it (same as `sausage init`).");
init(dir.clone()).await?;
Cmd::Init { dir } => {
let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?;
if api != "openai-responses" && api != "anthropic-messages" {
bail!("unknown API shape {api:?}");
}
run(dir, headless).await
let base_url = setting("SAUSAGE_BASE_URL", "Base URL", None)?;
let model = setting("SAUSAGE_MODEL", "Model", None)?;
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
let world = World::create(&dir)?;
std::fs::write(
world.root.join("config/agent.json"),
serde_json::to_vec_pretty(
&json!({"api":api,"base_url":base_url.trim_end_matches('/'),"model":model,"token":token}),
)?,
)?;
}
Cmd::Run { dir } => {
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let host = match cli.cache_dir {
Some(cache) => Host::start_with_cache(&dir, &cache).await?,
None => Host::start(&dir).await?,
};
let mut notifications = host.subscribe();
let notification_task = tokio::spawn(async move {
while let Ok(notification) = notifications.recv().await {
println!("{notification}");
}
});
let h = host.clone();
let console = async move {
let mut lines = BufReader::new(tokio::io::stdin()).lines();
loop {
tokio::select! {
line=lines.next_line()=>{let Some(line)=line? else {break;};if line=="quit" {break;}
let (method,params)=line.split_once(' ').unwrap_or((&line,"{}"));
match serde_json::from_str(params) {Ok(p)=>println!("{}",match h.call(method,p).await {Ok(v)=>v,Err(e)=>e}),Err(e)=>eprintln!("{e}")}
}
}
}
Ok::<_, anyhow::Error>(())
};
let exit = tokio::select! {
_=host.exit_requested()=>true,
_=term.recv()=>false,
_=tokio::signal::ctrl_c()=>false,
r=console,if !cli.headless=>{r?;false},
};
let exit = exit || host.restart_requested();
host.stop().await;
notification_task.abort();
if exit {
std::process::exit(75);
}
}
}
Ok(())
}
+72 -115
View File
@@ -1,36 +1,25 @@
//! Wasmtime engine and actor instances.
//! Wasmtime engine and process instances.
//!
//! 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.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use anyhow::Result;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
use wasmtime_wasi::p2::bindings::Command;
use wasmtime_wasi::p2::bindings::filesystem;
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
use crate::broker::Broker;
use crate::vfs::{Vfs, VfsState, VfsView};
/// A host folder that the guest sees at `guest`.
#[derive(Clone, Debug)]
pub struct Mount {
pub guest: String,
pub host: PathBuf,
pub writable: bool,
}
pub struct HttpHooks {
allow: bool,
}
@@ -65,33 +54,35 @@ impl WasiHttpHooks for HttpHooks {
}
}
pub struct ActorState {
pub struct ProcessState {
ctx: WasiCtx,
table: ResourceTable,
http: WasiHttpCtx,
hooks: HttpHooks,
vfs: VfsState,
}
fn vfs_view(s: &mut ActorState) -> VfsView<'_> {
VfsView { inner: WasiFilesystemCtxView { ctx: s.ctx.filesystem(), table: &mut s.table }, state: &mut s.vfs }
}
impl WasiView for ActorState {
impl WasiView for ProcessState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
WasiCtxView {
ctx: &mut self.ctx,
table: &mut self.table,
}
}
}
impl WasiHttpView for ActorState {
impl WasiHttpView for ProcessState {
fn http(&mut self) -> WasiHttpCtxView<'_> {
WasiHttpCtxView { ctx: &mut self.http, table: &mut self.table, hooks: &mut self.hooks }
WasiHttpCtxView {
ctx: &mut self.http,
table: &mut self.table,
hooks: &mut self.hooks,
}
}
}
pub struct Runtime {
engine: Engine,
linker: Linker<ActorState>,
linker: Linker<ProcessState>,
}
/// How an instance ended.
@@ -116,22 +107,18 @@ impl std::fmt::Display for Exit {
}
pub struct InstanceConfig {
pub mounts: Vec<Mount>,
pub world: PathBuf,
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
pub network: bool,
pub stderr_log: PathBuf,
pub env: Vec<(String, String)>,
/// The actor that runs in the instance.
pub actor: String,
/// Asks the actors that serve `/actor/<id>/fs/` (see `vfs.rs`).
pub broker: Weak<Broker>,
}
/// A running guest. Dropping it kills the guest.
pub struct Instance {
stdin: Option<DuplexStream>,
pub stdin: Option<DuplexStream>,
/// Lines that the guest writes to stdout. Closed when the guest ends.
pub stdout: mpsc::Receiver<String>,
pub stderr: mpsc::Receiver<String>,
task: JoinHandle<Exit>,
}
@@ -145,7 +132,11 @@ impl Instance {
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the
/// lines that the guest writes to `route`: a guest that blocks on a full
/// stdout would never read its stdin.
pub async fn send_line(&mut self, line: &str, mut route: impl FnMut(String)) -> std::io::Result<()> {
pub async fn send_line(
&mut self,
line: &str,
mut route: impl FnMut(String),
) -> std::io::Result<()> {
let stdin = self.stdin.as_mut().ok_or(std::io::ErrorKind::BrokenPipe)?;
let bytes = format!("{line}\n");
let write = async {
@@ -173,49 +164,6 @@ impl Instance {
Err(e) => Exit::Trap(format!("host task failed: {e}")),
}
}
pub fn is_finished(&self) -> bool {
self.task.is_finished()
}
/// Kill the guest. Its stdout closes when it has ended.
pub fn kill(&self) {
self.task.abort();
}
/// Send `shutdown_line`, close stdin, and wait until the guest ends.
/// Pass every line that the guest writes meanwhile to `route`. Kill the
/// guest when `deadline` has passed.
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration, mut route: impl FnMut(String)) -> Exit {
let stdin = self.stdin.take();
let stdout = &mut self.stdout;
let finished = tokio::time::timeout(deadline, async {
let close = async {
if let Some(mut stdin) = stdin {
let _ = stdin.write_all(format!("{shutdown_line}\n").as_bytes()).await;
let _ = stdin.shutdown().await;
}
};
let drain = async {
while let Some(line) = stdout.recv().await {
route(line);
}
};
tokio::join!(close, drain);
})
.await;
match finished {
Ok(()) => self.wait().await,
Err(_) => Exit::Trap("killed after shutdown deadline".into()),
}
}
}
fn log_note(path: &Path, text: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "[sausage] {text}");
}
}
/// The most useful line of a trap: the `wasm trap: ...` reason.
@@ -230,85 +178,91 @@ fn short_trap(full: &str) -> String {
}
impl Runtime {
pub fn new() -> Result<Arc<Self>> {
pub fn new(cache: &Path) -> Result<Arc<Self>> {
let mut config = Config::new();
config.epoch_interruption(true);
std::fs::create_dir_all(cache)?;
let mut cache_config = wasmtime::CacheConfig::new();
cache_config.with_directory(cache.canonicalize()?);
config.cache(Some(wasmtime::Cache::new(cache_config)?));
let engine = Engine::new(&config)?;
let mut linker: Linker<ActorState> = Linker::new(&engine);
let mut linker: Linker<ProcessState> = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
// Replace `wasi:filesystem` with the wrapper that serves `/actor/<id>/fs/`.
linker.allow_shadowing(true);
filesystem::types::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
filesystem::preopens::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
linker.allow_shadowing(false);
// Time slices: every 10 ms each running guest yields to the scheduler.
let ticker = engine.clone();
let runtime = Arc::new(Self { engine, linker });
let ticker = Arc::downgrade(&runtime);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_millis(10));
ticker.increment_epoch();
let Some(runtime) = ticker.upgrade() else {
break;
};
runtime.engine.increment_epoch();
}
});
Ok(Arc::new(Self { engine, linker }))
Ok(runtime)
}
pub fn load(&self, wasm: &Path) -> Result<Component> {
Component::from_file(&self.engine, wasm)
.map_err(anyhow::Error::from)
.with_context(|| format!("load {}", wasm.display()))
pub fn compile(&self, bytes: &[u8]) -> Result<Component> {
Ok(Component::new(&self.engine, bytes)?)
}
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
let stderr = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&cfg.stderr_log)
.with_context(|| format!("open {}", cfg.stderr_log.display()))?;
let (guest_err, host_err) = tokio::io::duplex(64 * 1024);
let mut b = WasiCtx::builder();
if cfg.network {
b.inherit_network().allow_tcp(true).allow_udp(true).allow_ip_name_lookup(true);
b.inherit_network()
.allow_tcp(true)
.allow_udp(true)
.allow_ip_name_lookup(true);
}
b.stdin(AsyncStdinStream::new(guest_in));
b.stdout(AsyncStdoutStream::new(8192, guest_out));
b.stderr(AsyncStdoutStream::new(8192, tokio::fs::File::from_std(stderr)));
b.stderr(AsyncStdoutStream::new(8192, guest_err));
for (k, v) in &cfg.env {
b.env(k, v);
}
for m in &cfg.mounts {
let perms = if m.writable { FsPerms::ReadWrite } else { FsPerms::ReadOnly };
b.preopened_dir(&m.host, &m.guest, perms)
.map_err(anyhow::Error::from)
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
}
let state = ActorState {
// Filesystem setup lives here. T6 replaces this preopen with the VFS.
b.preopened_dir(&cfg.world, "/", FsPerms::ReadWrite)?;
let state = ProcessState {
ctx: b.build(),
table: ResourceTable::new(),
http: WasiHttpCtx::new(),
hooks: HttpHooks { allow: cfg.network },
vfs: VfsState::new(cfg.broker.clone(), cfg.actor.clone()),
};
let (line_tx, line_rx) = mpsc::channel(1024);
tokio::spawn(async move {
let mut lines = BufReader::new(host_out).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut reader = BufReader::new(host_out);
let mut bytes = Vec::new();
while let Ok(n) = reader.read_until(b'\n', &mut bytes).await {
if n == 0 {
break;
}
let line = String::from_utf8(std::mem::take(&mut bytes)).unwrap_or_default();
if line_tx.send(line).await.is_err() {
break;
}
}
});
let (err_tx, err_rx) = mpsc::channel(1024);
tokio::spawn(async move {
let mut lines = BufReader::new(host_err).lines();
while let Ok(Some(line)) = lines.next_line().await {
if err_tx.send(line).await.is_err() {
break;
}
}
});
let rt = self.clone();
let component = component.clone();
let stderr_log = cfg.stderr_log.clone();
let task = tokio::spawn(async move {
let mut store = Store::new(&rt.engine, state);
store.epoch_deadline_async_yield_and_update(1);
@@ -324,15 +278,18 @@ impl Runtime {
Some(I32Exit(0)) => Exit::Clean,
Some(I32Exit(c)) => Exit::Code(*c),
None => {
// Keep the full backtrace in the log, and a short reason in the status.
let full = format!("{e:?}");
log_note(&stderr_log, &format!("trap details:\n{full}"));
Exit::Trap(short_trap(&full))
}
},
}
});
Ok(Instance { stdin: Some(host_in), stdout: line_rx, task })
Ok(Instance {
stdin: Some(host_in),
stdout: line_rx,
stderr: err_rx,
task,
})
}
}
-292
View File
@@ -1,292 +0,0 @@
//! The supervisor of one WASM actor: it runs the instance, feeds it the
//! mailbox, routes its output, restarts it after a crash, and swaps versions.
//!
//! The broker talks to a supervisor only through `Control` requests.
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot};
use wasmtime::component::Component;
use crate::broker::{Broker, Envelope, SHUTDOWN_DEADLINE, shutdown_line};
use crate::runtime::{Exit, Instance};
/// A new version that crashes this often within `CRASH_WINDOW` after an
/// upgrade is rolled back automatically.
const CRASH_LIMIT: usize = 3;
const CRASH_WINDOW: Duration = Duration::from_secs(30);
pub type Reply = oneshot::Sender<Result<Value>>;
pub enum Control {
/// Swap to `version` (already in `versions/`, compiled as `component`).
Upgrade { version: u64, component: Component, requester: String, reply: Reply },
Rollback { version: u64, reply: Reply },
Stop { reply: Reply },
Start { reply: Reply },
Shutdown { reply: Reply },
}
/// The time after an upgrade in which crashes count toward a rollback.
struct Probation {
previous: u64,
since: Instant,
crashes: usize,
requester: String,
}
pub struct Supervisor {
broker: Arc<Broker>,
id: String,
version: u64,
running: bool,
mailbox: mpsc::Receiver<Envelope>,
control: mpsc::Receiver<Control>,
component: Option<Component>,
instance: Option<Instance>,
started_at: Instant,
restarts: u32,
restart_at: Option<Instant>,
probation: Option<Probation>,
/// A control request that arrived during a blocked delivery.
next_control: Option<Control>,
}
async fn next_line(instance: &mut Option<Instance>) -> Option<String> {
match instance {
Some(i) => i.stdout.recv().await,
None => std::future::pending().await,
}
}
async fn sleep_until(t: Option<Instant>) {
match t {
Some(t) => tokio::time::sleep_until(t.into()).await,
None => std::future::pending().await,
}
}
impl Supervisor {
pub fn new(
broker: Arc<Broker>,
id: String,
version: u64,
running: bool,
mailbox: mpsc::Receiver<Envelope>,
control: mpsc::Receiver<Control>,
) -> Self {
Self {
broker,
id,
version,
running,
mailbox,
control,
component: None,
instance: None,
started_at: Instant::now(),
restarts: 0,
restart_at: None,
probation: None,
next_control: None,
}
}
pub async fn run(mut self) {
loop {
if let Some(ctl) = self.next_control.take() {
if self.handle(ctl).await {
break;
}
continue;
}
if self.running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
self.restart_at = None;
let _ = self.start().await;
}
let has_instance = self.instance.is_some();
tokio::select! {
ctl = self.control.recv() => {
let Some(ctl) = ctl else { break };
if self.handle(ctl).await {
break;
}
}
msg = self.mailbox.recv(), if has_instance => {
let Some(msg) = msg else { break };
self.deliver(msg).await;
}
line = next_line(&mut self.instance) => match line {
Some(line) => self.broker.route_line(&self.id, &line),
None => {
let exit = self.instance.as_mut().unwrap().wait().await;
self.instance = None;
self.on_exit(exit).await;
}
},
_ = sleep_until(self.restart_at), if !has_instance && self.running => {}
}
}
}
/// Write a message to the instance's stdin, and route its output
/// meanwhile. If a control request arrives before the actor reads the
/// message, the instance is killed (a partly written line would corrupt
/// the next message) and the request runs.
async fn deliver(&mut self, msg: Envelope) {
let Some(inst) = self.instance.as_mut() else { return };
let (broker, id, line) = (self.broker.clone(), self.id.clone(), msg.to_line());
let stuck = tokio::select! {
written = inst.send_line(&line, |l| broker.route_line(&id, &l)) => match written {
Ok(()) => return,
// The instance is ending. The main loop sees its closed stdout.
Err(_) => false,
},
Some(ctl) = self.control.recv() => {
self.next_control = Some(ctl);
true
}
};
if msg.from != "broker" {
let body = json!({"type": "undeliverable", "reason": "the actor stopped", "to": self.id});
self.broker.send("broker", Some(&msg.from), None, Some(&msg.id), body);
}
if stuck && let Some(inst) = &self.instance {
self.broker.log(&self.id, "killed: it did not read stdin when the broker needed it");
inst.kill();
}
}
/// Start an instance of the current version. If that fails, the actor
/// stays stopped until the next start, upgrade, or rollback.
async fn start(&mut self) -> Result<()> {
let started = self.try_start();
match &started {
Ok(()) => self.started_at = Instant::now(),
Err(e) => {
self.broker.log(&self.id, &format!("cannot start version {}: {e:#}", self.version));
self.running = false;
}
}
started
}
fn try_start(&mut self) -> Result<()> {
if self.component.is_none() {
self.component = Some(self.broker.load_version(&self.id, self.version)?);
}
let cfg = self.broker.instance_config(&self.id, self.version, None);
self.instance = Some(self.broker.runtime.start(self.component.as_ref().unwrap(), cfg)?);
Ok(())
}
/// Stop the instance gracefully. Its last messages still get routed.
async fn stop(&mut self) {
let Some(inst) = self.instance.take() else { return };
let (broker, id) = (self.broker.clone(), self.id.clone());
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |line| broker.route_line(&id, &line)).await;
self.broker.log(&self.id, &format!("stopped: {exit}"));
}
async fn on_exit(&mut self, exit: Exit) {
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
if let Some(p) = &mut self.probation
&& p.since.elapsed() < CRASH_WINDOW
{
p.crashes += 1;
if p.crashes >= CRASH_LIMIT {
let (from, to, requester) = (self.version, p.previous, p.requester.clone());
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {to}"));
let body = match self.rollback(to).await {
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": to,
"reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
};
self.broker.send("broker", Some(&requester), None, None, body);
return;
}
}
// Restart after 0.5 s, doubling up to 30 s; a minute of uptime resets it.
if self.started_at.elapsed() > Duration::from_secs(60) {
self.restarts = 0;
}
let delay = Duration::from_millis(500 << self.restarts.min(6)).min(Duration::from_secs(30));
self.restarts += 1;
self.restart_at = Some(Instant::now() + delay);
}
/// Make `version` current. It is compiled first (unless `component` is
/// given): if that fails, nothing changes. `data/` and `public/` stay as
/// they are. A stopped actor stays stopped.
async fn install(&mut self, version: u64, component: Option<Component>) -> Result<()> {
let component = match component {
Some(c) => c,
None => self.broker.load_version(&self.id, version)?,
};
self.stop().await;
self.broker.install(&self.id, version, self.running)?;
self.version = version;
self.component = Some(component);
self.restarts = 0;
self.restart_at = None;
Ok(())
}
async fn upgrade(&mut self, version: u64, component: Component, requester: String) -> Result<Value> {
let previous = self.version;
self.install(version, Some(component)).await?;
if !self.running {
// Installed, not started: main runs and mail is read at the next `start`.
return Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous, "running": false}));
}
self.probation = Some(Probation { previous, since: Instant::now(), crashes: 0, requester });
if let Err(e) = self.start().await {
self.rollback(previous).await?;
bail!("version {version} did not start ({e:#}); rolled back to {previous}");
}
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous, "running": true}))
}
async fn rollback(&mut self, version: u64) -> Result<Value> {
let from = self.version;
self.install(version, None).await?;
self.probation = None;
if self.running {
self.start().await?;
}
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version, "running": self.running}))
}
/// Handle a control request. Returns `true` when the supervisor ends.
async fn handle(&mut self, ctl: Control) -> bool {
match ctl {
Control::Upgrade { version, component, requester, reply } => {
let _ = reply.send(self.upgrade(version, component, requester).await);
}
Control::Rollback { version, reply } => {
let _ = reply.send(self.rollback(version).await);
}
Control::Stop { reply } => {
self.running = false;
self.stop().await;
let r = self.broker.set_running(&self.id, false);
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
}
Control::Start { reply } => {
self.running = true;
self.restart_at = None;
let r = self.broker.set_running(&self.id, true);
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
}
Control::Shutdown { reply } => {
self.stop().await;
let _ = reply.send(Ok(json!({"type": "shut_down"})));
return true;
}
}
false
}
}
-796
View File
@@ -1,796 +0,0 @@
//! Actor-served files: `/actor/<id>/fs/...` (see PROTOCOL.md, "Actor-served
//! files").
//!
//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`.
//! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
//! the reply. Other paths go to `wasmtime-wasi` unchanged except that an
//! actor directory listing advertises `fs/` when its source docs declare it.
//!
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
//! `fs_rename {path, to}` -> `fs_done`
//! - any of them -> `fs_error {errno}` (a `wasi:filesystem` error-code name)
//!
//! A file is read once when it is opened. After a change, it is written back
//! in one `fs_write` when it is synced or closed.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
use bytes::Bytes;
use serde_json::{Value, json};
use wasmtime::component::{HasData, Resource};
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
use wasmtime_wasi::p2::bindings::filesystem::types::{
self, DescriptorFlags, DescriptorType, DirectoryEntry, ErrorCode, HostDescriptor, HostDirectoryEntryStream, OpenFlags,
};
use wasmtime_wasi::p2::bindings::filesystem::preopens;
use wasmtime_wasi::p2::pipe::MemoryInputPipe;
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult, OutputStream, Pollable, StreamResult};
use crate::broker::Broker;
/// The `from` of the host's requests. Replies to it go back to the waiting call.
pub const ID: &str = "fs";
pub const ASK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Per-instance state.
pub struct VfsState {
broker: Weak<Broker>,
/// The actor of this instance, for notes in its `stderr.log`.
me: String,
/// Native directory descriptors and their absolute paths. Keeping every
/// `/actor` child (not only the preopen) lets `fs/` work after traversal.
dirs: HashMap<u32, String>,
}
impl VfsState {
pub fn new(broker: Weak<Broker>, me: String) -> Self {
Self { broker, me, dirs: HashMap::new() }
}
}
/// [`HasData`] for the linker: the filesystem functions get a [`VfsView`].
pub struct Vfs;
impl HasData for Vfs {
type Data<'a> = VfsView<'a>;
}
pub struct VfsView<'a> {
pub inner: WasiFilesystemCtxView<'a>,
pub state: &'a mut VfsState,
}
/// A descriptor of an actor-served file or folder (in the resource table in
/// place of a `wasmtime-wasi` descriptor).
#[derive(Clone)]
enum Virt {
Dir { actor: String, path: String },
File(VFile),
}
#[derive(Clone)]
struct VFile {
actor: String,
path: String,
data: Arc<Mutex<Vec<u8>>>,
dirty: Arc<AtomicBool>,
write: bool,
}
/// A directory-entry-stream of an actor-served folder.
struct VDir(std::vec::IntoIter<DirectoryEntry>);
enum Node {
File(Vec<u8>),
Dir(Vec<DirectoryEntry>),
}
/// Sends the requests. Owned, so that no borrow of the view lives across an await.
struct Asker {
broker: Option<Arc<Broker>>,
me: String,
}
impl Asker {
async fn ask(&self, actor: &str, body: Value) -> Result<Value, ErrorCode> {
let broker = self.broker.as_ref().ok_or(ErrorCode::Io)?;
let what = format!("{} {} to {actor}", body["type"].as_str().unwrap_or(""), body["path"].as_str().unwrap_or(""));
match broker.ask(actor, body).await {
None => {
broker.log(&self.me, &format!("{what}: no reply within {} s", ASK_TIMEOUT.as_secs()));
Err(ErrorCode::Io)
}
Some(reply) => match reply["type"].as_str() {
Some("fs_error") => Err(errno(&reply["errno"])),
Some("undeliverable") => Err(ErrorCode::NoEntry),
_ => Ok(reply),
},
}
}
async fn read(&self, actor: &str, path: &str) -> Result<Node, ErrorCode> {
let reply = self.ask(actor, json!({"type": "fs_read", "path": path})).await?;
match reply["type"].as_str() {
Some("fs_file") => content(&reply).map(Node::File),
Some("fs_dir") => Ok(Node::Dir(
reply["entries"]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let type_ = if e["type"] == "dir" { DescriptorType::Directory } else { DescriptorType::RegularFile };
Some(DirectoryEntry { type_, name: e["name"].as_str()?.to_string() })
})
.collect(),
)),
_ => {
if let Some(b) = &self.broker {
b.log(&self.me, &format!("fs_read {path} to {actor}: unexpected reply {reply}"));
}
Err(ErrorCode::Io)
}
}
}
async fn change(&self, actor: &str, body: Value) -> FsResult<()> {
self.ask(actor, body).await.map(|_| ()).map_err(FsError::from)
}
async fn flush(&self, f: &VFile) -> FsResult<()> {
if f.dirty.swap(false, Ordering::SeqCst)
&& let Err(e) = self.ask(&f.actor, write_body(f)).await
{
f.dirty.store(true, Ordering::SeqCst);
return Err(e.into());
}
Ok(())
}
}
fn errno(v: &Value) -> ErrorCode {
match v.as_str().unwrap_or("") {
"no-entry" => ErrorCode::NoEntry,
"exist" => ErrorCode::Exist,
"not-directory" => ErrorCode::NotDirectory,
"is-directory" => ErrorCode::IsDirectory,
"not-empty" => ErrorCode::NotEmpty,
"access" => ErrorCode::Access,
"not-permitted" => ErrorCode::NotPermitted,
"invalid" => ErrorCode::Invalid,
"unsupported" => ErrorCode::Unsupported,
"busy" => ErrorCode::Busy,
_ => ErrorCode::Io,
}
}
fn content(v: &Value) -> Result<Vec<u8>, ErrorCode> {
if let Some(text) = v["text"].as_str() {
return Ok(text.as_bytes().to_vec());
}
match v["base64"].as_str() {
Some(b) => base64_decode(b).ok_or(ErrorCode::IllegalByteSequence),
None => Ok(Vec::new()),
}
}
fn write_body(f: &VFile) -> Value {
let data = f.data.lock().unwrap().clone();
match String::from_utf8(data) {
Ok(text) => json!({"type": "fs_write", "path": f.path, "text": text}),
Err(e) => json!({"type": "fs_write", "path": f.path, "base64": base64_encode(e.as_bytes())}),
}
}
/// `rel` resolved against the folder `base`, as an absolute path.
fn join(base: &str, rel: &str) -> String {
let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect();
for p in rel.split('/') {
match p {
"" | "." => {}
".." => {
parts.pop();
}
p => parts.push(p),
}
}
format!("/{}", parts.join("/"))
}
/// Split a global path under an actor's virtual `fs/` into its owner and
/// actor-relative path.
fn actor_fs_path(full: &str) -> Option<(String, String)> {
let rest = full.strip_prefix("/actor/")?;
let (actor, rest) = rest.split_once('/')?;
let (fs, path) = rest.split_once('/').unwrap_or((rest, ""));
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{path}")))
}
fn actor_root(path: &str) -> Option<&str> {
let actor = path.strip_prefix("/actor/")?;
(!actor.is_empty() && !actor.contains('/')).then_some(actor)
}
/// An actor advertises its virtual filesystem in its leading `//!` docs.
/// This is discovery metadata; actual reads still go through `fs_read`.
fn declares_actor_fs(source: &str) -> bool {
let docs = source
.lines()
.take_while(|line| line.trim().starts_with("//!"))
.map(|line| line.trim().trim_start_matches("//!").trim())
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
docs.contains("actor-served files") && docs.contains("fs_read")
}
fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat {
types::DescriptorStat {
type_,
link_count: 1,
size,
data_access_timestamp: None,
data_modification_timestamp: None,
status_change_timestamp: None,
}
}
fn node_stat(node: &Node) -> types::DescriptorStat {
match node {
Node::File(data) => stat(DescriptorType::RegularFile, data.len() as u64),
Node::Dir(_) => stat(DescriptorType::Directory, 0),
}
}
fn hash(actor: &str, path: &str) -> types::MetadataHashValue {
let mut h = std::collections::hash_map::DefaultHasher::new();
(actor, path).hash(&mut h);
types::MetadataHashValue { lower: h.finish(), upper: 0 }
}
fn write_at(data: &mut Vec<u8>, at: usize, bytes: &[u8]) {
if data.len() < at + bytes.len() {
data.resize(at + bytes.len(), 0);
}
data[at..at + bytes.len()].copy_from_slice(bytes);
}
/// An output stream into an actor-served file (`pos: None` appends).
struct VWriter {
file: VFile,
pos: Option<usize>,
}
#[wasmtime_wasi::async_trait]
impl Pollable for VWriter {
async fn ready(&mut self) {}
}
#[wasmtime_wasi::async_trait]
impl OutputStream for VWriter {
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
let mut data = self.file.data.lock().unwrap();
let at = self.pos.unwrap_or(data.len());
write_at(&mut data, at, &bytes);
if let Some(p) = &mut self.pos {
*p += bytes.len();
}
self.file.dirty.store(true, Ordering::SeqCst);
Ok(())
}
fn flush(&mut self) -> StreamResult<()> {
Ok(())
}
fn check_write(&mut self) -> StreamResult<usize> {
Ok(64 * 1024)
}
}
impl VfsView<'_> {
fn asker(&self) -> Asker {
Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() }
}
fn actor_serves_fs(&self, actor: &str) -> bool {
self.state
.broker
.upgrade()
.and_then(|broker| std::fs::read_to_string(broker.world.source_dir(actor).join("src/main.rs")).ok())
.is_some_and(|source| declares_actor_fs(&source))
}
fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> {
self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned()
}
fn file(&mut self, fd: &Resource<types::Descriptor>) -> Option<FsResult<VFile>> {
match self.virt(fd)? {
Virt::File(f) => Some(Ok(f)),
Virt::Dir { .. } => Some(Err(ErrorCode::IsDirectory.into())),
}
}
/// The actor and its path, if `path` relative to `fd` is actor-served.
fn target(&mut self, fd: &Resource<types::Descriptor>, path: &str) -> Option<(String, String)> {
match self.virt(fd) {
Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))),
Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))),
None => {
let base = self.state.dirs.get(&fd.rep())?;
actor_fs_path(&join(base, path))
}
}
}
async fn native_directory_entries(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Vec<DirectoryEntry>> {
let stream = HostDescriptor::read_directory(&mut self.inner, fd).await?;
let stream_rep = stream.rep();
let mut entries = Vec::new();
loop {
match HostDirectoryEntryStream::read_directory_entry(&mut self.inner, Resource::new_borrow(stream_rep)).await? {
Some(entry) => entries.push(entry),
None => break,
}
}
HostDirectoryEntryStream::drop(&mut self.inner, stream).map_err(|_| ErrorCode::Io)?;
Ok(entries)
}
fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> {
let r = self.inner.table.push(v)?;
Ok(Resource::new_own(r.rep()))
}
async fn open(&mut self, actor: String, path: String, oflags: OpenFlags, flags: DescriptorFlags) -> FsResult<Resource<types::Descriptor>> {
let write = flags.contains(DescriptorFlags::WRITE);
let create = oflags.contains(OpenFlags::CREATE);
let exclusive = create && oflags.contains(OpenFlags::EXCLUSIVE);
let virt = match self.asker().read(&actor, &path).await {
Ok(_) if exclusive => return Err(ErrorCode::Exist.into()),
Ok(Node::Dir(_)) if write => return Err(ErrorCode::IsDirectory.into()),
Ok(Node::Dir(_)) => Virt::Dir { actor, path },
Ok(Node::File(_)) if oflags.contains(OpenFlags::DIRECTORY) => return Err(ErrorCode::NotDirectory.into()),
Ok(Node::File(data)) => {
let truncate = oflags.contains(OpenFlags::TRUNCATE);
let data = if truncate { Vec::new() } else { data };
Virt::File(VFile { actor, path, data: Arc::new(Mutex::new(data)), dirty: Arc::new(AtomicBool::new(truncate)), write })
}
Err(ErrorCode::NoEntry) if create && !oflags.contains(OpenFlags::DIRECTORY) => Virt::File(VFile {
actor,
path,
data: Arc::default(),
dirty: Arc::new(AtomicBool::new(true)),
write,
}),
Err(e) => return Err(e.into()),
};
self.push(virt)
}
/// Send the changed file without waiting (the descriptor is gone). The
/// request leaves now, so later requests of this instance come after it.
fn write_back_later(&self, f: &VFile) {
let Some(broker) = self.state.broker.upgrade() else { return };
let reply = broker.ask(&f.actor, write_body(f));
let (me, what) = (self.state.me.clone(), format!("fs_write {} to {}", f.path, f.actor));
tokio::spawn(async move {
match reply.await {
Some(r) if r["type"] != "fs_error" && r["type"] != "undeliverable" => {}
r => broker.log(&me, &format!("{what} failed: {}", r.map_or("no reply".to_string(), |r| r.to_string()))),
}
});
}
}
impl preopens::Host for VfsView<'_> {
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
let dirs = preopens::Host::get_directories(&mut self.inner)?;
for (fd, name) in &dirs {
if name == "/actor" || name == "/" {
self.state.dirs.insert(fd.rep(), name.clone());
}
}
Ok(dirs)
}
}
impl types::Host for VfsView<'_> {
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
types::Host::convert_error_code(&mut self.inner, err)
}
fn filesystem_error_code(&mut self, err: Resource<wasmtime::Error>) -> wasmtime::Result<Option<ErrorCode>> {
types::Host::filesystem_error_code(&mut self.inner, err)
}
}
impl HostDescriptor for VfsView<'_> {
async fn advise(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize, len: types::Filesize, advice: types::Advice) -> FsResult<()> {
if self.virt(&fd).is_some() {
return Ok(());
}
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
}
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
match self.virt(&fd) {
Some(Virt::File(f)) => self.asker().flush(&f).await,
Some(Virt::Dir { .. }) => Ok(()),
None => HostDescriptor::sync_data(&mut self.inner, fd).await,
}
}
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorFlags> {
match self.virt(&fd) {
Some(Virt::File(f)) if f.write => Ok(DescriptorFlags::READ | DescriptorFlags::WRITE),
Some(Virt::File(_)) => Ok(DescriptorFlags::READ),
Some(Virt::Dir { .. }) => Ok(DescriptorFlags::READ | DescriptorFlags::MUTATE_DIRECTORY),
None => HostDescriptor::get_flags(&mut self.inner, fd).await,
}
}
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorType> {
match self.virt(&fd) {
Some(Virt::File(_)) => Ok(DescriptorType::RegularFile),
Some(Virt::Dir { .. }) => Ok(DescriptorType::Directory),
None => HostDescriptor::get_type(&mut self.inner, fd).await,
}
}
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
f.data.lock().unwrap().resize(size as usize, 0);
f.dirty.store(true, Ordering::SeqCst);
Ok(())
}
None => HostDescriptor::set_size(&mut self.inner, fd, size).await,
}
}
async fn set_times(&mut self, fd: Resource<types::Descriptor>, atim: types::NewTimestamp, mtim: types::NewTimestamp) -> FsResult<()> {
if self.virt(&fd).is_some() {
return Ok(());
}
HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await
}
async fn read(&mut self, fd: Resource<types::Descriptor>, len: types::Filesize, offset: types::Filesize) -> FsResult<(Vec<u8>, bool)> {
match self.file(&fd) {
Some(f) => {
let f = f?;
let data = f.data.lock().unwrap();
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(data.len());
Ok((data[start..end].to_vec(), start == end))
}
None => HostDescriptor::read(&mut self.inner, fd, len, offset).await,
}
}
async fn write(&mut self, fd: Resource<types::Descriptor>, buf: Vec<u8>, offset: types::Filesize) -> FsResult<types::Filesize> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
write_at(&mut f.data.lock().unwrap(), offset as usize, &buf);
f.dirty.store(true, Ordering::SeqCst);
Ok(buf.len() as u64)
}
None => HostDescriptor::write(&mut self.inner, fd, buf, offset).await,
}
}
async fn read_directory(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<types::DirectoryEntryStream>> {
match self.virt(&fd) {
Some(Virt::Dir { actor, path }) => match self.asker().read(&actor, &path).await? {
Node::Dir(entries) => {
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
}
Node::File(_) => Err(ErrorCode::NotDirectory.into()),
},
Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()),
None => {
let actor = self.state.dirs.get(&fd.rep()).and_then(|path| actor_root(path)).map(str::to_string);
if actor.as_deref().is_some_and(|id| self.actor_serves_fs(id)) {
let mut entries = self.native_directory_entries(fd).await?;
if !entries.iter().any(|entry| entry.name == "fs") {
entries.push(DirectoryEntry { type_: DescriptorType::Directory, name: "fs".to_string() });
}
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
} else {
HostDescriptor::read_directory(&mut self.inner, fd).await
}
}
}
}
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
match self.virt(&fd) {
Some(Virt::File(f)) => self.asker().flush(&f).await,
Some(Virt::Dir { .. }) => Ok(()),
None => HostDescriptor::sync(&mut self.inner, fd).await,
}
}
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_mkdir", "path": path})).await,
None => HostDescriptor::create_directory_at(&mut self.inner, fd, path).await,
}
}
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
match self.virt(&fd) {
Some(Virt::File(f)) => Ok(stat(DescriptorType::RegularFile, f.data.lock().unwrap().len() as u64)),
Some(Virt::Dir { .. }) => Ok(stat(DescriptorType::Directory, 0)),
None => HostDescriptor::stat(&mut self.inner, fd).await,
}
}
async fn stat_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::DescriptorStat> {
match self.target(&fd, &path) {
Some((actor, path)) => Ok(node_stat(&self.asker().read(&actor, &path).await?)),
None => HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await,
}
}
async fn set_times_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
atim: types::NewTimestamp,
mtim: types::NewTimestamp,
) -> FsResult<()> {
if self.target(&fd, &path).is_some() {
return Ok(());
}
HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await
}
async fn link_at(
&mut self,
fd: Resource<types::Descriptor>,
old_path_flags: types::PathFlags,
old_path: String,
new_descriptor: Resource<types::Descriptor>,
new_path: String,
) -> FsResult<()> {
if self.target(&fd, &old_path).is_some() || self.target(&new_descriptor, &new_path).is_some() {
return Err(ErrorCode::Unsupported.into());
}
HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await
}
async fn open_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
oflags: OpenFlags,
flags: DescriptorFlags,
) -> FsResult<Resource<types::Descriptor>> {
match self.target(&fd, &path) {
Some((actor, path)) => self.open(actor, path, oflags, flags).await,
None => {
let child_path = self.state.dirs.get(&fd.rep()).map(|base| join(base, &path));
let opened = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await?;
if let Some(path) = child_path {
if HostDescriptor::get_type(&mut self.inner, Resource::new_borrow(opened.rep())).await? == DescriptorType::Directory {
self.state.dirs.insert(opened.rep(), path);
}
}
Ok(opened)
}
}
}
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
if self.virt(&fd).is_some() {
if let Virt::File(f) = self.inner.table.delete(Resource::<Virt>::new_own(fd.rep()))?
&& f.dirty.load(Ordering::SeqCst)
{
self.write_back_later(&f);
}
return Ok(());
}
self.state.dirs.remove(&fd.rep());
HostDescriptor::drop(&mut self.inner, fd)
}
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
if self.target(&fd, &path).is_some() {
return Err(ErrorCode::Invalid.into());
}
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
}
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
None => HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await,
}
}
async fn rename_at(&mut self, fd: Resource<types::Descriptor>, old_path: String, new_fd: Resource<types::Descriptor>, new_path: String) -> FsResult<()> {
match (self.target(&fd, &old_path), self.target(&new_fd, &new_path)) {
(None, None) => HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await,
(Some((a, from)), Some((b, to))) if a == b => {
self.asker().change(&a, json!({"type": "fs_rename", "path": from, "to": to})).await
}
_ => Err(ErrorCode::Unsupported.into()),
}
}
async fn symlink_at(&mut self, fd: Resource<types::Descriptor>, src_path: String, dest_path: String) -> FsResult<()> {
if self.target(&fd, &dest_path).is_some() {
return Err(ErrorCode::Unsupported.into());
}
HostDescriptor::symlink_at(&mut self.inner, fd, src_path, dest_path).await
}
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
None => HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await,
}
}
fn read_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynInputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
let data = f.data.lock().unwrap();
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
let stream: DynInputStream = Box::new(MemoryInputPipe::new(data[start..].to_vec()));
std::mem::drop(data);
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::read_via_stream(&mut self.inner, fd, offset),
}
}
fn write_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynOutputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: Some(offset as usize) });
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::write_via_stream(&mut self.inner, fd, offset),
}
}
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: None });
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::append_via_stream(&mut self.inner, fd),
}
}
async fn is_same_object(&mut self, a: Resource<types::Descriptor>, b: Resource<types::Descriptor>) -> wasmtime::Result<bool> {
let key = |v: Virt| match v {
Virt::Dir { actor, path } => (actor, path),
Virt::File(f) => (f.actor, f.path),
};
match (self.virt(&a), self.virt(&b)) {
(None, None) => HostDescriptor::is_same_object(&mut self.inner, a, b).await,
(Some(x), Some(y)) => Ok(key(x) == key(y)),
_ => Ok(false),
}
}
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
match self.virt(&fd) {
Some(Virt::Dir { actor, path }) => Ok(hash(&actor, &path)),
Some(Virt::File(f)) => Ok(hash(&f.actor, &f.path)),
None => HostDescriptor::metadata_hash(&mut self.inner, fd).await,
}
}
async fn metadata_hash_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::MetadataHashValue> {
match self.target(&fd, &path) {
Some((actor, path)) => Ok(hash(&actor, &path)),
None => HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await,
}
}
}
impl HostDirectoryEntryStream for VfsView<'_> {
async fn read_directory_entry(&mut self, stream: Resource<types::DirectoryEntryStream>) -> FsResult<Option<DirectoryEntry>> {
if let Some(dir) = self.inner.table.get_any_mut(stream.rep()).ok().and_then(|a| a.downcast_mut::<VDir>()) {
return Ok(dir.0.next());
}
HostDirectoryEntryStream::read_directory_entry(&mut self.inner, stream).await
}
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
let ours = self.inner.table.get_any_mut(stream.rep()).ok().is_some_and(|a| a.is::<VDir>());
if ours {
self.inner.table.delete(Resource::<VDir>::new_own(stream.rep()))?;
return Ok(());
}
HostDirectoryEntryStream::drop(&mut self.inner, stream)
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
for i in 0..4 {
out.push(if i <= chunk.len() { B64[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
}
}
out
}
fn base64_decode(text: &str) -> Option<Vec<u8>> {
let (mut out, mut n, mut bits) = (Vec::new(), 0u32, 0);
for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
n = (n << 6 | B64.iter().position(|b| *b == c)? as u32) & 0xFF_FFFF;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((n >> bits) as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn actor_fs_paths_and_discovery_docs() {
assert_eq!(actor_fs_path("/actor/brain/fs"), Some(("brain".to_string(), "/".to_string())));
assert_eq!(actor_fs_path("/actor/brain/fs/docs/readme.md"), Some(("brain".to_string(), "/docs/readme.md".to_string())));
assert_eq!(actor_fs_path("/actor/brain/public/fs/file"), None);
assert_eq!(actor_root("/actor/brain"), Some("brain"));
assert_eq!(actor_root("/actor/brain/fs"), None);
assert!(declares_actor_fs("//! brain\n//! Actor-served files include fs_read.\nfn main() {}"));
assert!(!declares_actor_fs("//! actor without fs support\nfn main() {}"));
}
#[test]
fn base64_round_trip() {
for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] {
let enc = base64_encode(s.as_bytes());
assert_eq!(base64_decode(&enc).unwrap(), s.as_bytes());
}
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
}
#[test]
fn join_resolves_dots() {
assert_eq!(join("/a/b", "../c/./d"), "/a/c/d");
assert_eq!(join("/", "x/fs"), "/x/fs");
assert_eq!(join("/", ".."), "/");
}
}
-258
View File
@@ -1,258 +0,0 @@
//! File watching: the host watches the whole world folder with the OS file
//! watcher (`notify`, inotify on Linux), whoever writes: actors, a 9P mount,
//! a shell or `git` on the host. Nothing outside the folder counts.
//!
//! Events are coalesced per path: once a path is quiet for `QUIET` (or has
//! kept changing for `MAX_DELAY`), it becomes one change
//! `{path, op: write | create | remove | rename, to?}` with a namespace path
//! (relative to the world root, such as `/actor/...` or `/config/...`). Then the broker
//!
//! - sends `fs_changed` to the actors that watch the path (`watch` request),
//! - starts or upgrades an actor whose `actor/<id>/actor.wasm` (or folder
//! `actor/<id>`) changed, tested first, as at boot (`Broker::check_actor`).
//!
//! The host's temporary files (`.sausage-tmp-*`) are skipped: a rename of one
//! onto a file is a `write` of that file. If the OS drops events, the broker
//! runs a full pass and tells every watcher (`Broker::overflowed`).
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use notify::event::{EventKind, ModifyKind, RenameMode};
use notify::{RecursiveMode, Watcher as _};
use tokio::sync::mpsc;
use tokio::time::Instant;
use crate::broker::Broker;
use crate::world::TMP_PREFIX;
/// A path that is quiet this long becomes one change.
pub const QUIET: Duration = Duration::from_millis(100);
/// A path that keeps changing becomes a change at least this often.
pub const MAX_DELAY: Duration = Duration::from_secs(1);
/// The running watcher. Dropping it stops watching.
pub struct Watcher {
_os: notify::RecommendedWatcher,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Watcher {
fn drop(&mut self) {
self.task.abort();
}
}
/// Watch the world folder of `broker` recursively. A failure to watch part
/// of it (for example too few inotify watches) is logged, and the rest is
/// watched.
pub fn start(broker: Arc<Broker>) -> notify::Result<Watcher> {
let (tx, rx) = mpsc::unbounded_channel();
let mut os = notify::recommended_watcher(move |event| {
let _ = tx.send(event);
})?;
if let Err(e) = os.watch(&broker.world.root, RecursiveMode::Recursive) {
broker.host_error(&format!("cannot watch all of {}: {e}", broker.world.root.display()));
}
let task = tokio::spawn(run(broker, rx));
Ok(Watcher { _os: os, task })
}
/// A change waiting for its path to be quiet.
struct Pending {
op: &'static str,
to: Option<String>,
first: Instant,
last: Instant,
}
impl Pending {
fn due(&self) -> Instant {
(self.last + QUIET).min(self.first + MAX_DELAY)
}
}
async fn run(broker: Arc<Broker>, mut events: mpsc::UnboundedReceiver<notify::Result<notify::Event>>) {
let root = broker.world.root.clone();
let mut pending: HashMap<String, Pending> = HashMap::new();
// Actor checks and full passes. Dropping the set (when the host stops) aborts them.
let mut tasks = tokio::task::JoinSet::new();
loop {
let due = pending.values().map(Pending::due).min();
tokio::select! {
event = events.recv() => match event {
Some(Ok(event)) => {
if event.need_rescan() {
let broker = broker.clone();
tasks.spawn(async move {
broker.overflowed().await;
});
}
let now = Instant::now();
for (path, op, to) in changes(&root, &event) {
add(&mut pending, path, op, to, now);
}
}
Some(Err(e)) => broker.host_error(&format!("file watcher: {e}")),
None => break,
},
_ = sleep_until(due) => {
let now = Instant::now();
let ready: Vec<String> = pending.iter().filter(|(_, p)| p.due() <= now).map(|(k, _)| k.clone()).collect();
for path in ready {
let p = pending.remove(&path).unwrap();
let op = if p.op == "moved-in" { "create" } else { p.op };
broker.notify_watchers(&path, op, p.to.as_deref());
if let Some(id) = new_wasm(&path, op, p.to.as_deref()) {
tasks.spawn(broker.clone().check_actor(id));
}
}
}
Some(_) = tasks.join_next() => {}
}
}
}
async fn sleep_until(t: Option<Instant>) {
match t {
Some(t) => tokio::time::sleep_until(t).await,
None => std::future::pending().await,
}
}
/// The world namespace path of a host path, unless it is outside the
/// world or a temporary file of the host.
fn namespace(root: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(root).ok()?;
let mut out = String::new();
for part in rel.components() {
let name = part.as_os_str().to_str()?;
if name.starts_with(TMP_PREFIX) {
return None;
}
out.push('/');
out.push_str(name);
}
Some(if out.is_empty() { "/".to_string() } else { out })
}
/// One change: namespace path, op, and the new path of a rename.
type Change = (String, &'static str, Option<String>);
/// The changes of one OS event. inotify reports a rename three times:
/// `From` (the old name), `To` (the new name) and `Both`. `To` becomes the
/// internal op `moved-in`, which `Both` replaces.
fn changes(root: &Path, event: &notify::Event) -> Vec<Change> {
let path = |i: usize| event.paths.get(i).and_then(|p| namespace(root, p));
let one = |op: &'static str| -> Vec<Change> { path(0).map(|p| (p, op, None)).into_iter().collect() };
match event.kind {
EventKind::Create(_) => one("create"),
EventKind::Remove(_) => one("remove"),
EventKind::Modify(ModifyKind::Metadata(_)) => vec![],
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => one("remove"),
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => one("moved-in"),
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => match (path(0), path(1)) {
(Some(from), Some(to)) => vec![(from, "rename", Some(to))],
// Renamed to a temporary name: gone.
(Some(from), None) => vec![(from, "remove", None)],
// A temporary file renamed onto `to`: an atomic write.
(None, Some(to)) => vec![(to, "write", None)],
(None, None) => vec![],
},
EventKind::Modify(_) => one("write"),
_ => vec![],
}
}
/// Coalesce a change into `pending`. The last op wins, except that a write
/// after a create stays a create. A rename replaces the `moved-in` of its
/// new name.
fn add(pending: &mut HashMap<String, Pending>, path: String, op: &'static str, to: Option<String>, now: Instant) {
if let Some(to) = &to
&& pending.get(to).is_some_and(|p| p.op == "moved-in")
{
pending.remove(to);
}
match pending.get_mut(&path) {
Some(p) => {
if !(p.op == "create" && op == "write") {
p.op = op;
p.to = to;
}
p.last = now;
}
None => {
pending.insert(path, Pending { op, to, first: now, last: now });
}
}
}
/// The actor to check when `actor/<id>/actor.wasm` or the folder `actor/<id>`
/// appeared or changed.
fn new_wasm(path: &str, op: &str, to: Option<&str>) -> Option<String> {
let target = match op {
"remove" => return None,
"rename" => to?,
_ => path,
};
let rest = target.strip_prefix("/actor/")?;
let id = rest.strip_suffix("/actor.wasm").unwrap_or(rest);
(!id.is_empty() && !id.contains('/')).then(|| id.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wasm_and_folder_changes_name_the_actor() {
assert_eq!(new_wasm("/actor/a/actor.wasm", "write", None).as_deref(), Some("a"));
assert_eq!(new_wasm("/actor/a", "create", None).as_deref(), Some("a"));
assert_eq!(new_wasm("/actor/x/y", "rename", Some("/actor/b/actor.wasm")).as_deref(), Some("b"));
assert_eq!(new_wasm("/actor/a/actor.wasm", "remove", None), None);
assert_eq!(new_wasm("/actor/a/versions/1/actor.wasm", "write", None), None);
assert_eq!(new_wasm("/actor/a/public/x", "write", None), None);
assert_eq!(new_wasm("/actor", "write", None), None);
}
#[test]
fn a_rename_is_one_change_and_temp_files_are_skipped() {
let root = Path::new("/w");
let now = Instant::now();
let mut pending = HashMap::new();
let ev = |kind, paths: &[&str]| {
let mut e = notify::Event::new(kind);
for p in paths {
e = e.add_path(std::path::PathBuf::from(*p));
}
e
};
let name = |mode| EventKind::Modify(ModifyKind::Name(mode));
for e in [
ev(name(RenameMode::From), &["/w/actor/a/public/x"]),
ev(name(RenameMode::To), &["/w/actor/a/public/y"]),
ev(name(RenameMode::Both), &["/w/actor/a/public/x", "/w/actor/a/public/y"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/actor/a/.sausage-tmp-host-1-actor.json"]),
ev(name(RenameMode::Both), &["/w/actor/a/.sausage-tmp-host-1-actor.json", "/w/actor/a/actor.json"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/outside/.lock"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/config/x"]),
] {
for (path, op, to) in changes(root, &e) {
add(&mut pending, path, op, to, now);
}
}
let mut got: Vec<(String, &str, Option<String>)> =
pending.into_iter().map(|(k, p)| (k, p.op, p.to)).collect();
got.sort();
assert_eq!(
got,
vec![
("/actor/a/actor.json".to_string(), "write", None),
("/actor/a/public/x".to_string(), "rename", Some("/actor/a/public/y".to_string())),
("/config/x".to_string(), "create", None),
]
);
}
}
+35 -284
View File
@@ -1,312 +1,63 @@
//! On-disk layout of a Sausage world. The folder is the source of truth:
//! `sausage run` boots every actor that it finds in `actor/`, and then
//! watches the folder (`watch.rs`). `/self` aliases the running actor's folder;
//! `/public` and `/data` remain aliases for its `public/` and `data/`.
//!
//! ```text
//! <world>/ mounted at /, readable and writable by every actor
//! config/ global configuration by convention (no secrets)
//! actor/<id>/
//! actor.wasm the current version
//! actor.json version, running, subscriptions, tapped, watches (broker)
//! source/ source of the current version
//! public/ published files, readable and writable by everyone
//! data/ state, readable and writable by everyone
//! stderr.log stderr of the actor and notes of the broker
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
//! ```
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
/// Name prefix of the host's temporary files and folders. Copies skip them.
pub const TMP_PREFIX: &str = ".sausage-tmp-";
/// IDs of the actors that the host implements natively. `fs` sends the
/// requests for actor-served files (`vfs.rs`).
pub const NATIVE: &[&str] = &["broker", "console", "cargo", "fs"];
//! World folders and atomic build output helpers.
use anyhow::{Result, bail};
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct World {
pub root: PathBuf,
}
/// `actor/<id>/actor.json`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ActorMeta {
/// The installed version. `None` for native actors and for a new
/// `actor.wasm` that the broker has not installed yet.
#[serde(default)]
pub version: Option<u64>,
#[serde(default = "yes")]
pub running: bool,
/// The actors whose published messages this actor receives.
#[serde(default)]
pub subscriptions: BTreeSet<String>,
/// Whether this actor gets a copy of every routed message (`tap`).
#[serde(default)]
pub tapped: bool,
/// Namespace paths whose changes this actor gets as `fs_changed` (`watch`).
#[serde(default)]
pub watches: BTreeSet<String>,
}
fn yes() -> bool {
true
}
/// `versions/<n>/meta.json`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct VersionMeta {
pub version: u64,
/// Path of the `.wasm` file, as the requester gave it.
pub wasm: String,
/// Path of the source folder, as the requester gave it.
pub source: Option<String>,
/// The actor that built the `.wasm` file.
pub built_by: Option<String>,
/// The actor that requested the version ("scan" for a file found on disk).
pub requested_by: String,
pub created_at: u64,
}
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
pub fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl World {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn actors_root(&self) -> PathBuf {
self.root.join("actor")
}
pub fn actor_dir(&self, id: &str) -> PathBuf {
self.actors_root().join(id)
}
pub fn wasm_path(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("actor.wasm")
}
pub fn public_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("public")
}
pub fn source_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("source")
}
pub fn data_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("data")
}
pub fn versions_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("versions")
}
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
self.versions_dir(id).join(v.to_string())
}
/// Private scratch space of the broker (candidate copies, seeds).
pub fn scratch_dir(&self) -> PathBuf {
self.data_dir("broker").join("scratch")
}
pub fn exists(&self) -> bool {
self.actors_root().is_dir()
}
/// Create an empty world with the folders of the native actors.
pub fn create(root: &Path) -> Result<World> {
let world = World::new(std::path::absolute(root)?);
if world.exists() {
bail!("{} is already a Sausage world", root.display());
}
for id in NATIVE {
world.ensure_actor_dirs(id)?;
}
std::fs::create_dir_all(world.root.join("config"))?;
Ok(world)
}
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
std::fs::create_dir_all(self.public_dir(id))?;
std::fs::create_dir_all(self.data_dir(id))?;
Ok(())
}
/// IDs of every folder in `actor/`, sorted.
pub fn actor_ids(&self) -> Result<Vec<String>> {
let mut ids: Vec<String> = std::fs::read_dir(self.actors_root())?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|id| valid_id(id))
.collect();
ids.sort();
Ok(ids)
}
pub fn read_meta(&self, id: &str) -> ActorMeta {
std::fs::read_to_string(self.actor_dir(id).join("actor.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(ActorMeta { running: true, ..Default::default() })
}
pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> {
write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes())
}
/// Make `actor.wasm` and `source/` a copy of `versions/<version>/`,
/// unless `actor.wasm` already is that version (it came from there).
pub fn install_files(&self, id: &str, version: u64) -> Result<()> {
let dir = self.version_dir(id, version);
self.ensure_actor_dirs(id)?;
let wasm = std::fs::read(dir.join("actor.wasm"))?;
if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) {
return Ok(());
}
write_atomic(&self.wasm_path(id), &wasm)?;
replace_tree(&dir.join("source"), &self.source_dir(id))
}
/// Version numbers in `versions/`, ascending.
pub fn versions(&self, id: &str) -> Vec<u64> {
let mut v: Vec<u64> = std::fs::read_dir(self.versions_dir(id))
.map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect())
.unwrap_or_default();
v.sort();
v
}
pub fn next_version(&self, id: &str) -> u64 {
self.versions(id).last().map_or(1, |v| v + 1)
}
/// Map a path in an actor's namespace to a host path.
///
/// - Absolute paths are relative to the world root.
/// - `/self/...` aliases `sender`'s folder; `/public/...` and `/data/...`
/// alias its corresponding children.
/// - Native senders (the console) can also give absolute host paths.
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
let mut out = base;
for c in Path::new(rest).components() {
match c {
Component::Normal(n) => out.push(n),
Component::CurDir | Component::RootDir => {}
_ => bail!("path must not contain '..': {path}"),
pub fn open(path: &Path) -> Result<Self> {
for name in ["proc", "run", "self"] {
if path.join(name).symlink_metadata().is_ok() {
bail!("reserved world path: {name}");
}
}
Ok(out)
};
let prefixed = |prefix: &str| {
path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/'))
};
if let Some(rest) = prefixed("/actor") {
return under(self.actors_root(), rest);
Ok(Self {
root: path.canonicalize()?,
})
}
if let Some(rest) = prefixed("/self") {
return under(self.actor_dir(sender), rest);
pub fn create(path: &Path) -> Result<Self> {
if path.exists() {
bail!("world already exists: {}", path.display());
}
if let Some(rest) = prefixed("/public") {
return under(self.public_dir(sender), rest);
for dir in ["plugins", "state/core", "config"] {
std::fs::create_dir_all(path.join(dir))?;
}
if let Some(rest) = prefixed("/data") {
return under(self.data_dir(sender), rest);
std::fs::write(path.join("config/boot.json"), "[]\n")?;
Self::open(path)
}
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
return Ok(PathBuf::from(path));
pub fn plugin_dir(&self, name: &str) -> PathBuf {
self.root.join("plugins").join(name)
}
if Path::new(path).is_absolute() {
return under(self.root.clone(), path);
}
bail!("cannot resolve path {path} for {sender}: use an absolute world path")
}
/// The path of a host file in the global filesystem, if it has one.
pub fn global_path(&self, host: &Path) -> Option<String> {
let rel = host.strip_prefix(&self.root).ok()?;
Some(format!("/{}", rel.display()))
pub fn state_dir(&self, name: &str) -> PathBuf {
self.root.join("state").join(name)
}
}
/// Write a file so that readers never see a partial version.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path.parent().context("path has no parent")?;
std::fs::create_dir_all(dir)?;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let tmp = dir.join(format!("{TMP_PREFIX}host-{}-{name}", std::process::id()));
std::fs::create_dir_all(path.parent().unwrap())?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)?;
std::fs::rename(tmp, path)?;
Ok(())
}
/// Folders that a copy of a source folder leaves out.
const BUILD_DIRS: &[&str] = &["target", ".git"];
/// Copy a directory tree, except uncommitted files.
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
copy_filtered(from, to, &[])
pub fn replace_source(from: &Path, to: &Path) -> Result<()> {
if to.exists() {
std::fs::remove_dir_all(to)?;
}
copy_tree(from, to)
}
fn copy_filtered(from: &Path, to: &Path, skip: &[&str]) -> Result<()> {
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(TMP_PREFIX) {
if entry.file_name() == "target" {
continue;
}
let dst = to.join(&*name);
let ty = entry.file_type()?;
if ty.is_dir() && !skip.contains(&&*name) {
copy_filtered(&entry.path(), &dst, skip)?;
} else if ty.is_file() {
std::fs::copy(entry.path(), &dst)?;
}
}
Ok(())
}
/// Replace the directory `dst` with a copy of `src` (an empty folder if `src`
/// does not exist): copy to a sibling, then swap with two renames.
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
replace_filtered(src, dst, &[])
}
/// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`.
pub fn replace_source(src: &Path, dst: &Path) -> Result<()> {
replace_filtered(src, dst, BUILD_DIRS)
}
fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> {
let parent = dst.parent().context("path has no parent")?;
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
let old = parent.join(format!("{TMP_PREFIX}old-{name}"));
let _ = std::fs::remove_dir_all(&fresh);
let _ = std::fs::remove_dir_all(&old);
if src.is_dir() {
copy_filtered(src, &fresh, skip)?;
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
} else {
std::fs::create_dir_all(&fresh)?;
std::fs::copy(entry.path(), to.join(entry.file_name()))?;
}
if dst.exists() {
std::fs::rename(dst, &old)?;
}
std::fs::rename(&fresh, dst)?;
let _ = std::fs::remove_dir_all(&old);
Ok(())
}
-105
View File
@@ -1,105 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toolbox"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-180
View File
@@ -1,180 +0,0 @@
use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
fn send(out: &mut impl Write, message: Value) {
writeln!(out, "{message}").unwrap();
out.flush().unwrap();
}
fn reply(out: &mut impl Write, message: &Value, body: Value) {
send(
out,
json!({"to": message["from"], "reply_to": message["id"], "body": body}),
);
}
/// Connect to `address`, send one line, and return the line that comes back.
fn tcp_line(address: &str, line: &str) -> io::Result<String> {
let mut stream = std::net::TcpStream::connect(address)?;
writeln!(stream, "{line}")?;
let mut answer = String::new();
io::BufReader::new(stream).read_line(&mut answer)?;
Ok(answer.trim_end().to_string())
}
fn io_error(error: io::Error) -> Value {
json!({"type": "error", "error": error.to_string()})
}
fn main() {
let actor = std::env::var("SAUSAGE_ACTOR").unwrap_or_default();
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_else(|_| "candidate".to_string());
let candidate = std::env::var_os("SAUSAGE_CANDIDATE").is_some();
if actor == "crashloop" && version == "2" && !candidate {
std::fs::write("/data/state", "bad-v2").unwrap();
panic!("version 2 crashes on startup");
}
let mut open = HashMap::<String, std::fs::File>::new();
// Files that this actor serves at /actor/<id>/fs/ (path -> text).
let mut served = std::collections::BTreeMap::<String, String>::new();
let stdin = io::stdin();
let mut out = io::stdout().lock();
let mut emit_shutdown = false;
if actor == "spawn-race" && !candidate {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::fs::write(format!("/data/start-{stamp}"), "started").unwrap();
}
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
let Ok(message) = serde_json::from_str::<Value>(&line) else {
continue;
};
let body = &message["body"];
let path = body["path"].as_str().unwrap_or_default().to_string();
let text = body["text"].as_str().unwrap_or_default();
let response = match body["type"].as_str() {
Some("shutdown") => {
if actor == "queue" {
let _ = std::fs::write("/data/shutdown", "stopping");
std::thread::sleep(Duration::from_millis(800));
}
if emit_shutdown {
send(
&mut out,
json!({"to": "console", "reply_to": message["id"], "body": {"type": "shutdown-reply"}}),
);
send(&mut out, json!({"body": {"type": "shutdown-publish"}}));
}
break;
}
Some("echo") => json!({"type": "echo", "value": body["value"]}),
Some("fs_read") => {
let dir = if path == "/" { "/".to_string() } else { format!("{path}/") };
let mut names: Vec<String> = served
.keys()
.filter_map(|k| k.strip_prefix(&dir))
.map(|rest| match rest.split_once('/') {
Some((first, _)) => format!("{first}/"),
None => rest.to_string(),
})
.collect();
names.dedup();
if let Some(text) = served.get(&path) {
json!({"type": "fs_file", "text": text})
} else if path == "/" || !names.is_empty() {
let entries: Vec<Value> = names
.iter()
.map(|n| match n.strip_suffix('/') {
Some(d) => json!({"name": d, "type": "dir"}),
None => json!({"name": n, "type": "file"}),
})
.collect();
json!({"type": "fs_dir", "entries": entries})
} else {
json!({"type": "fs_error", "errno": "no-entry"})
}
}
Some("fs_write") => {
served.insert(path.clone(), text.to_string());
json!({"type": "fs_done"})
}
Some("fs_remove") => match served.remove(&path) {
Some(_) => json!({"type": "fs_done"}),
None => json!({"type": "fs_error", "errno": "no-entry"}),
},
Some("publish") => {
send(
&mut out,
json!({"body": {"type": "published", "value": body["value"]}}),
);
continue;
}
Some("version") => json!({"type": "version", "version": version}),
Some("ordered") => json!({"type": "ordered", "n": body["n"], "version": version}),
Some("write") => match std::fs::write(&path, text) {
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("mkdir") => match std::fs::create_dir_all(&path) {
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("rename") => match std::fs::rename(&path, body["to"].as_str().unwrap_or_default())
{
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("open") => {
let result = (|| -> io::Result<()> {
let mut file = std::fs::File::create(&path)?;
file.write_all(text.as_bytes())?;
open.insert(path.clone(), file);
Ok(())
})();
match result {
Ok(()) => json!({"type": "opened"}),
Err(error) => io_error(error),
}
}
Some("close") => json!({"type": "closed", "closed": open.remove(&path).is_some()}),
Some("arm-shutdown") => {
emit_shutdown = true;
json!({"type": "armed"})
}
Some("ignore-stdin") => json!({"type": "ignoring"}),
Some("tcp") => match tcp_line(body["address"].as_str().unwrap_or_default(), text) {
Ok(line) => json!({"type": "tcp", "line": line}),
Err(error) => io_error(error),
},
Some("read") => match std::fs::read_to_string(&path) {
Ok(text) => json!({"type": "content", "text": text}),
Err(error) => io_error(error),
},
Some("list") => match std::fs::read_dir(&path) {
Ok(entries) => {
let mut names: Vec<String> = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
names.sort();
json!({"type": "entries", "names": names})
}
Err(error) => io_error(error),
},
_ => json!({"type": "error", "error": "unknown message"}),
};
reply(&mut out, &message, response);
if body["type"] == "ignore-stdin" {
loop {
std::thread::sleep(Duration::from_secs(60));
}
}
}
}
-555
View File
@@ -1,555 +0,0 @@
//! Integration tests for the agent's inference and restart behavior.
#[allow(dead_code)]
mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
use common::{create_world, receive_message, receive_reply, request, source_with_test, start};
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
use tokio::task::{JoinHandle, JoinSet};
const APIS: [Api; 2] = [Api::OpenAi, Api::Anthropic];
const TEST_TOKEN: &str = "fake-token";
#[derive(Clone, Copy)]
enum Api {
OpenAi,
Anthropic,
}
impl Api {
fn name(self) -> &'static str {
match self {
Self::OpenAi => "openai-responses",
Self::Anthropic => "anthropic-messages",
}
}
fn path(self) -> &'static str {
match self {
Self::OpenAi => "/v1/responses",
Self::Anthropic => "/v1/messages",
}
}
fn text_response(self, text: &str) -> Value {
match self {
Self::OpenAi => {
json!({"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]})
}
Self::Anthropic => json!({"content": [{"type": "text", "text": text}]}),
}
}
fn tool_response(self, id: &str, name: &str, arguments: &Value) -> Value {
match self {
Self::OpenAi => {
json!({"output": [{"type": "function_call", "call_id": id, "name": name, "arguments": arguments.to_string()}]})
}
Self::Anthropic => {
json!({"content": [{"type": "tool_use", "id": id, "name": name, "input": arguments}]})
}
}
}
fn check_request(self, request: &HttpRequest) {
assert_eq!(request.method, "POST");
assert_eq!(request.path, self.path());
assert_eq!(request.headers["content-type"], "application/json");
assert_eq!(request.body["model"], "fake-model");
match self {
Self::OpenAi => assert_eq!(request.headers["authorization"], "Bearer fake-token"),
Self::Anthropic => {
assert_eq!(request.headers["x-api-key"], TEST_TOKEN);
assert_eq!(request.headers["anthropic-version"], "2023-06-01");
}
}
}
}
struct HttpRequest {
method: String,
path: String,
headers: HashMap<String, String>,
body: Value,
}
enum ReplyPlan {
Send(Value),
Hold(Value, oneshot::Receiver<()>),
}
struct FakeServer {
base_url: String,
plans: mpsc::Sender<ReplyPlan>,
requests: mpsc::Receiver<HttpRequest>,
task: JoinHandle<()>,
}
impl FakeServer {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (plans, mut plan_rx) = mpsc::channel(16);
let (request_tx, requests) = mpsc::channel(16);
let task = tokio::spawn(async move {
let mut handlers = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let (mut stream, _) = accepted.expect("fake listener accept failed");
let request = read_http_request(&mut stream).await;
let Some(plan) = plan_rx.recv().await else { break };
if request_tx.send(request).await.is_err() {
break;
}
handlers.spawn(async move {
let (body, release) = match plan {
ReplyPlan::Send(body) => (body, None),
ReplyPlan::Hold(body, release) => (body, Some(release)),
};
if let Some(release) = release {
let _ = release.await;
}
write_http_response(stream, body).await;
});
}
Some(_) = handlers.join_next(), if !handlers.is_empty() => {}
}
}
});
Self {
base_url: format!("http://127.0.0.1:{}", address.port()),
plans,
requests,
task,
}
}
async fn answer(&self, body: Value) {
self.plans.send(ReplyPlan::Send(body)).await.unwrap();
}
async fn hold(&self, body: Value) -> oneshot::Sender<()> {
let (release, wait) = oneshot::channel();
self.plans.send(ReplyPlan::Hold(body, wait)).await.unwrap();
release
}
async fn next_request(&mut self) -> HttpRequest {
tokio::time::timeout(Duration::from_secs(20), self.requests.recv())
.await
.expect("timed out waiting for fake inference request")
.expect("fake server request channel closed")
}
}
impl Drop for FakeServer {
fn drop(&mut self) {
self.task.abort();
}
}
async fn read_http_request(stream: &mut TcpStream) -> HttpRequest {
let mut received = Vec::new();
let mut chunk = [0; 4096];
let header_end = loop {
let count = stream.read(&mut chunk).await.expect("read HTTP request");
assert_ne!(count, 0, "client closed before HTTP headers arrived");
received.extend_from_slice(&chunk[..count]);
if let Some(end) = received.windows(4).position(|part| part == b"\r\n\r\n") {
break end;
}
};
let header_text = String::from_utf8_lossy(&received[..header_end]);
let mut lines = header_text.split("\r\n");
let mut request_line = lines.next().unwrap().split_whitespace();
let method = request_line.next().unwrap().to_string();
let path = request_line.next().unwrap().to_string();
let mut headers = HashMap::new();
for line in lines {
if let Some((name, value)) = line.split_once(':') {
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
let body_start = header_end + 4;
let request_body = if let Some(length) = headers.get("content-length") {
let body_length = length.parse::<usize>().unwrap();
while received.len() < body_start + body_length {
let count = stream.read(&mut chunk).await.expect("read HTTP body");
assert_ne!(count, 0, "client closed before HTTP body arrived");
received.extend_from_slice(&chunk[..count]);
}
received[body_start..body_start + body_length].to_vec()
} else {
assert!(
headers
.get("transfer-encoding")
.is_some_and(|value| value.eq_ignore_ascii_case("chunked")),
"request has neither Content-Length nor chunked transfer encoding: {headers:?}"
);
read_chunked_body(stream, &mut received, body_start).await
};
let body = serde_json::from_slice(&request_body).unwrap();
HttpRequest {
method,
path,
headers,
body,
}
}
async fn read_chunked_body(
stream: &mut TcpStream,
received: &mut Vec<u8>,
mut offset: usize,
) -> Vec<u8> {
let mut body = Vec::new();
loop {
let line_end = loop {
if let Some(end) = received[offset..]
.windows(2)
.position(|part| part == b"\r\n")
{
break offset + end;
}
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await.expect("read chunk size");
assert_ne!(count, 0, "client closed before chunk size arrived");
received.extend_from_slice(&chunk[..count]);
};
let size = String::from_utf8_lossy(&received[offset..line_end]);
let size = size.split(';').next().unwrap().trim();
let size = usize::from_str_radix(size, 16).unwrap();
offset = line_end + 2;
if size == 0 {
return body;
}
while received.len() < offset + size + 2 {
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await.expect("read chunk data");
assert_ne!(count, 0, "client closed before chunk data arrived");
received.extend_from_slice(&chunk[..count]);
}
body.extend_from_slice(&received[offset..offset + size]);
assert_eq!(&received[offset + size..offset + size + 2], b"\r\n");
offset += size + 2;
}
}
async fn write_http_response(mut stream: TcpStream, body: Value) {
let body = serde_json::to_vec(&body).unwrap();
let head = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
let mut response = head.into_bytes();
response.extend_from_slice(&body);
let _ = stream.write_all(&response).await;
let _ = stream.shutdown().await;
}
fn agent_wasm() -> &'static PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| {
let root = common::repo_root();
let output = Command::new("cargo")
.args([
"build",
"--release",
"-p",
"agent",
"--target",
"wasm32-wasip2",
"--target-dir",
"target/test-actors",
])
.current_dir(&root)
.output()
.expect("run cargo to build the agent actor");
assert!(
output.status.success(),
"building agent failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let wasm = root.join("target/test-actors/wasm32-wasip2/release/agent.wasm");
assert!(
wasm.is_file(),
"agent build did not create {}",
wasm.display()
);
wasm
})
}
async fn start_agent(
api: Api,
fake: &FakeServer,
label: &str,
) -> (World, Host, mpsc::Receiver<Envelope>) {
let world = create_world(label);
let data = world.data_dir("agent");
std::fs::create_dir_all(&data).unwrap();
let config = json!({"api": api.name(), "base_url": fake.base_url, "model": "fake-model", "token": TEST_TOKEN});
std::fs::write(
data.join("config.json"),
serde_json::to_vec(&config).unwrap(),
)
.unwrap();
let source = source_with_test(
&world,
"agent-candidate",
json!({"steps": [{"send": {"type": "text", "text": "candidate"}, "expect": {"type": "text"}}]}),
);
let (host, mut mailbox) = start(&world).await;
let spawned = common::spawn_actor(&host, &mut mailbox, "agent", agent_wasm(), &source).await;
assert_eq!(
spawned.body["type"], "spawned",
"agent spawn failed: {spawned:?}"
);
let subscribed = request(
&host,
&mut mailbox,
json!({"type": "subscribe", "actor": "agent"}),
)
.await;
assert_eq!(subscribed.body["type"], "subscribed");
(world, host, mailbox)
}
fn send_text(host: &Host, text: &str) -> String {
host.broker.send(
"console",
Some("agent"),
None,
None,
json!({"type": "text", "text": text}),
)
}
async fn assert_published_text(mailbox: &mut mpsc::Receiver<Envelope>, text: &str) {
let message = receive_message(mailbox, Duration::from_secs(20)).await;
assert_eq!(message.from, "agent");
assert_eq!(message.to, "console");
assert_eq!(message.reply_to, None);
assert_eq!(message.body, json!({"type": "text", "text": text}));
}
fn history(world: &World) -> Vec<Value> {
std::fs::read_to_string(world.data_dir("agent").join("history.jsonl"))
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
fn assert_history_tools_have_results(history: &[Value]) {
let calls: Vec<_> = history
.iter()
.filter(|entry| entry["kind"] == "tool_call")
.collect();
let results: Vec<_> = history
.iter()
.filter(|entry| entry["kind"] == "tool_result")
.collect();
assert_eq!(calls.len(), results.len());
for call in calls {
assert!(
results.iter().any(|result| result["id"] == call["id"]),
"tool call has no result: {call}"
);
}
}
fn assert_tool_pair(
request: &HttpRequest,
api: Api,
id: &str,
name: &str,
arguments: &Value,
) -> String {
match api {
Api::OpenAi => {
let input = request.body["input"].as_array().unwrap();
let call_index = input
.iter()
.position(|item| item["type"] == "function_call")
.unwrap();
let result_index = input
.iter()
.position(|item| item["type"] == "function_call_output")
.unwrap();
assert!(call_index < result_index);
let call = &input[call_index];
assert_eq!(call["call_id"], id);
assert_eq!(call["name"], name);
assert_eq!(
serde_json::from_str::<Value>(call["arguments"].as_str().unwrap()).unwrap(),
*arguments
);
let result = &input[result_index];
assert_eq!(result["call_id"], id);
result["output"].as_str().unwrap().to_string()
}
Api::Anthropic => {
let messages = request.body["messages"].as_array().unwrap();
let mut call_position = None;
let mut result_position = None;
let mut call = None;
let mut result = None;
for (index, message) in messages.iter().enumerate() {
let Some(blocks) = message["content"].as_array() else {
continue;
};
for block in blocks {
match block["type"].as_str() {
Some("tool_use") => {
call_position = Some(index);
call = Some(block);
}
Some("tool_result") => {
result_position = Some(index);
result = Some(block);
}
_ => {}
}
}
}
assert!(call_position.unwrap() < result_position.unwrap());
let call = call.unwrap();
assert_eq!(call["id"], id);
assert_eq!(call["name"], name);
assert_eq!(call["input"], *arguments);
let result = result.unwrap();
assert_eq!(result["tool_use_id"], id);
result["content"].as_str().unwrap().to_string()
}
}
}
#[tokio::test]
async fn text_answers_use_both_inference_apis() {
for api in APIS {
let mut fake = FakeServer::start().await;
fake.answer(api.text_response("hello from the fake model"))
.await;
let (_, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-text-{}", api.name())).await;
send_text(&host, "say hello");
api.check_request(&fake.next_request().await);
assert_published_text(&mut mailbox, "hello from the fake model").await;
host.stop().await;
}
}
#[tokio::test]
async fn tool_calls_pair_with_results_in_both_inference_apis() {
let args = json!({"path": "/actor"});
for api in APIS {
let mut fake = FakeServer::start().await;
fake.answer(api.tool_response("list-1", "list_dir", &args))
.await;
let (world, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-tools-{}", api.name())).await;
send_text(&host, "list actors");
api.check_request(&fake.next_request().await);
fake.answer(api.text_response("the actor list is ready"))
.await;
let second = fake.next_request().await;
api.check_request(&second);
let output = assert_tool_pair(&second, api, "list-1", "list_dir", &args);
let result: Value = serde_json::from_str(&output).unwrap();
assert!(
result["entries"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["name"] == "agent")
);
assert_published_text(&mut mailbox, "the actor list is ready").await;
host.stop().await;
let history = history(&world);
assert_eq!(
history
.iter()
.map(|entry| entry["kind"].as_str().unwrap())
.collect::<Vec<_>>(),
["user", "tool_call", "tool_result", "assistant"]
);
assert_eq!(history[1]["id"], "list-1");
assert_eq!(history[2]["id"], "list-1");
}
}
#[tokio::test]
async fn restart_resumes_a_turn_with_completed_tool_history() {
let args = json!({"path": "/actor"});
for api in [Api::OpenAi] {
let mut fake = FakeServer::start().await;
fake.answer(api.tool_response("list-1", "list_dir", &args))
.await;
let (world, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-resume-{}", api.name())).await;
let id = send_text(&host, "list actors before the restart");
api.check_request(&fake.next_request().await);
let release = fake.hold(api.text_response("discarded response")).await;
let second = fake.next_request().await;
api.check_request(&second);
assert_tool_pair(&second, api, "list-1", "list_dir", &args);
let stopped = request(
&host,
&mut mailbox,
json!({"type": "stop", "actor": "agent"}),
)
.await;
assert_eq!(stopped.body["type"], "stopped");
assert_eq!(
serde_json::from_slice::<Value>(
&std::fs::read(world.data_dir("agent").join("turn.json")).unwrap()
)
.unwrap(),
json!(true)
);
let _ = release.send(());
fake.answer(api.text_response("resumed answer")).await;
let start_id = host.broker.send(
"console",
Some("broker"),
None,
None,
json!({"type": "start", "actor": "agent"}),
);
let resumed = fake.next_request().await;
api.check_request(&resumed);
assert_tool_pair(&resumed, api, "list-1", "list_dir", &args);
let started = receive_reply(&mut mailbox, &start_id, Duration::from_secs(20)).await;
assert_eq!(started.body["type"], "started");
assert_published_text(&mut mailbox, "resumed answer").await;
let history = history(&world);
assert_history_tools_have_results(&history);
assert_eq!(
history
.iter()
.filter(|entry| entry["kind"] == "user"
&& entry["text"].as_str().unwrap_or_default().contains(&id))
.count(),
1
);
host.stop().await;
}
}
-85
View File
@@ -1,85 +0,0 @@
//! Tests of the `sausage` command line.
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
fn empty_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("target/test-cli")
.join(format!("{label}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Bare `sausage` (as in `nix run <flake>`) without a world starts `init`
/// for ./world instead of printing a usage error.
#[test]
fn bare_command_without_a_world_starts_init() {
let dir = empty_dir("bare");
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
.current_dir(&dir)
.env_remove("SAUSAGE_API")
.env_remove("SAUSAGE_BASE_URL")
.env_remove("SAUSAGE_MODEL")
.env_remove("SAUSAGE_API_TOKEN")
.stdin(Stdio::null())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// With no answers on stdin, init stops at the first required value.
assert!(!output.status.success(), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(stdout.contains("No world in ./world yet"), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(stderr.contains("SAUSAGE_BASE_URL"), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
// It asks before it creates anything.
assert!(!dir.join("world").exists());
}
/// Start a headless host on a fresh world with stdin at EOF; returns once it
/// printed its ready line (printed after the signal handlers are installed).
fn start_headless(label: &str) -> (std::process::Child, std::io::Lines<std::io::BufReader<std::process::ChildStdout>>) {
use std::io::{BufRead, BufReader};
let dir = empty_dir(label);
let world = dir.join("world");
sausage::World::create(&world).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
.args(["run", "--headless"])
.arg(&world)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let line = lines.next().expect("host exited before it started").unwrap();
assert!(line.contains("headless"), "unexpected first line: {line}");
(child, lines)
}
/// With stdin at EOF (as soon as the host starts), a headless host stays up.
#[test]
fn headless_ignores_stdin_eof() {
let (mut child, _lines) = start_headless("headless-eof");
std::thread::sleep(std::time::Duration::from_millis(500));
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "headless host exited on stdin EOF");
}
/// SIGTERM right after the ready line stops the actors and exits with 0.
#[test]
fn headless_stops_cleanly_on_sigterm_right_after_ready() {
let (mut child, lines) = start_headless("headless-term");
let pid = child.id().to_string();
assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success());
let rest: Vec<String> = lines.map(|l| l.unwrap()).collect();
let status = child.wait().unwrap();
assert!(status.success(), "exit status {status:?}, output {rest:?}");
assert!(rest.iter().any(|l| l.contains("Stopping actors")), "output {rest:?}");
}
-248
View File
@@ -1,248 +0,0 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::sync::mpsc;
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
static WORLD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf()
}
pub fn create_world(label: &str) -> World {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let sequence = WORLD_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = repo_root()
.join("target/test-worlds")
.join(format!("{label}-{}-{nonce}-{sequence}", std::process::id()));
World::create(&path).unwrap()
}
pub fn actor_fixture_source(name: &str) -> PathBuf {
repo_root().join("cli/tests/actors").join(name)
}
pub fn cargo_source(name: &str) -> PathBuf {
repo_root().join("cli/tests/crates").join(name)
}
pub fn toolbox_wasm() -> &'static PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| build_fixture("toolbox"))
}
fn build_fixture(name: &str) -> PathBuf {
let root = repo_root();
let manifest = actor_fixture_source(name).join("Cargo.toml");
let target_dir = root.join("target/test-actors");
let output = Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"--target-dir",
])
.arg(&target_dir)
.arg("--manifest-path")
.arg(&manifest)
.current_dir(&root)
.output()
.expect("run cargo to build the test actor");
assert!(
output.status.success(),
"building fixture {name} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let wasm = target_dir
.join("wasm32-wasip2/release")
.join(format!("{name}.wasm"));
assert!(
wasm.is_file(),
"fixture build did not create {}",
wasm.display()
);
wasm
}
pub fn passing_source(world: &World, name: &str) -> PathBuf {
source_with_test(
world,
name,
json!({
"steps": [{
"send": {"type": "echo", "value": "candidate"},
"expect": {"type": "echo", "value": "candidate"}
}]
}),
)
}
pub fn source_with_test(world: &World, name: &str, test: Value) -> PathBuf {
let source = world.source_dir("console").join("test-fixtures").join(name);
std::fs::create_dir_all(source.join("tests")).unwrap();
std::fs::write(
source.join("tests/candidate.json"),
serde_json::to_vec(&test).unwrap(),
)
.unwrap();
source
}
pub async fn start(world: &World) -> (Host, mpsc::Receiver<Envelope>) {
let host = Host::start(&world.root).await.unwrap();
let mailbox = host.mailbox("console");
(host, mailbox)
}
pub async fn request(host: &Host, mailbox: &mut mpsc::Receiver<Envelope>, body: Value) -> Envelope {
let id = host
.broker
.send("console", Some("broker"), None, None, body);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn receive_message(
mailbox: &mut mpsc::Receiver<Envelope>,
timeout: Duration,
) -> Envelope {
tokio::time::timeout(timeout, mailbox.recv())
.await
.expect("timed out waiting for a message")
.expect("console mailbox closed")
}
pub async fn receive_reply(
mailbox: &mut mpsc::Receiver<Envelope>,
id: &str,
timeout: Duration,
) -> Envelope {
tokio::time::timeout(timeout, async {
loop {
let envelope = mailbox.recv().await.expect("console mailbox closed");
if envelope.reply_to.as_deref() == Some(id) {
return envelope;
}
}
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for reply to {id}"))
}
pub async fn receive_replies(
mailbox: &mut mpsc::Receiver<Envelope>,
ids: &[String],
timeout: Duration,
) -> Vec<Envelope> {
let mut replies = Vec::with_capacity(ids.len());
tokio::time::timeout(timeout, async {
while replies.len() < ids.len() {
let message = mailbox.recv().await.expect("console mailbox closed");
assert!(
ids.iter()
.any(|id| message.reply_to.as_deref() == Some(id.as_str())),
"unexpected message while waiting for replies: {message:?}"
);
replies.push(message);
}
})
.await
.expect("timed out waiting for replies");
replies
}
pub async fn ask(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
) -> Envelope {
let id = host.broker.send("console", Some(actor), None, None, body);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn ask_type(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
expected: &str,
) -> Envelope {
let reply = ask(host, mailbox, actor, body).await;
assert_eq!(reply.body["type"], expected, "unexpected reply: {reply:?}");
reply
}
pub fn spawn_request(host: &Host, actor: &str, wasm: &Path, source: &Path) -> String {
host.broker.send(
"console",
Some("broker"),
None,
None,
json!({
"type": "spawn",
"actor": actor,
"wasm": wasm.display().to_string(),
"source": source.display().to_string()
}),
)
}
pub async fn spawn_actor(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
let id = spawn_request(host, actor, wasm, source);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn spawn_type(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
let reply = spawn_actor(host, mailbox, actor, wasm, source).await;
assert_eq!(
reply.body["type"], "spawned",
"unexpected spawn reply: {reply:?}"
);
reply
}
pub async fn upgrade_actor(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
request(
host,
mailbox,
json!({
"type": "upgrade",
"actor": actor,
"wasm": wasm.display().to_string(),
"source": source.display().to_string()
}),
)
.await
}
+537
View File
@@ -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 = &notification["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;
}
-1252
View File
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,7 @@
[package]
name = "toolbox"
name = "probe"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
serde_json = "1"
[workspace]
+47
View File
@@ -0,0 +1,47 @@
//! A raw JSON-RPC guest for host integration tests.
use std::io::{self,BufRead,Write};
use serde_json::{Value,json};
fn send(v:Value) { let mut out=io::stdout().lock();writeln!(out,"\n{v}").unwrap();out.flush().unwrap(); }
fn main() {
let mut lines=io::stdin().lock().lines();
let request:Value=serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();
let id=request.get("id").cloned();let p=&request["params"];
let result=match request["method"].as_str().unwrap() {
"echo"|"rpc.discover"=>p.clone(),
"env"=>json!({"pid":std::env::var("SAUSAGE_PID").unwrap(),"plugin":std::env::var("SAUSAGE_PLUGIN").unwrap(),"parent":std::env::var("SAUSAGE_PARENT").unwrap()}),
"multicall"=>{
send(json!({"jsonrpc":"2.0","id":1,"method":"probe.echo","params":{"n":1}}));
send(json!({"jsonrpc":"2.0","id":"1","method":"probe.echo","params":{"n":2}}));
let mut seen=Vec::new();while seen.len()<2 {let r:Value=serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();if r["id"]==1 {assert_eq!(r["result"]["n"],1);} else {assert_eq!(r["id"],"1");assert_eq!(r["result"]["n"],2);}seen.push(r["id"].clone());}json!({"count":seen.len()})
},
"notification"=>{
send(json!({"jsonrpc":"2.0","method":"probe.write","params":p}));
for _ in 0..200 {if std::fs::exists(p["path"].as_str().unwrap()).unwrap() {break;}std::thread::sleep(std::time::Duration::from_millis(10));}json!({})
},
"unknownid"=>{send(json!({"jsonrpc":"2.0","id":"late","result":7}));return;},
"request_id"=>request["id"].clone(),
"wait_eof"=>{for _ in lines {} std::fs::write("/state/stopped","yes").unwrap(); return;},
"busy"=>loop {std::hint::spin_loop();},
"sleep"=>{std::thread::sleep(std::time::Duration::from_millis(p["ms"].as_u64().unwrap_or(100)));json!({})},
"exit"=>{eprintln!("last line");return;},
"junk"=>{io::stdout().write_all(&[255,10]).unwrap();println!("junk\n{{}}\n[]");eprintln!("stderr line");json!({})},
"notify"=>{send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"name":"progress","params":{"n":1}}}));std::thread::sleep(std::time::Duration::from_millis(30));json!({})},
"serve"=>{send(json!({"jsonrpc":"2.0","id":id,"result":std::env::var("SAUSAGE_PID").unwrap()}));for _ in lines {} return;},
"network"=>{
use std::io::Read;
let mut socket=std::net::TcpStream::connect(p["tcp"].as_str().unwrap()).unwrap();
let mut text=String::new();socket.read_to_string(&mut text).unwrap();
let udp=std::net::UdpSocket::bind("127.0.0.1:0").unwrap();udp.send_to(b"udp",p["udp"].as_str().unwrap()).unwrap();json!(text)
},
"read"=>json!(std::fs::read_to_string(p["path"].as_str().unwrap()).unwrap()),
"write"=>{std::fs::write(p["path"].as_str().unwrap(),p["text"].as_str().unwrap()).unwrap();json!({})},
"call"|"lock"=>{
let method=if request["method"]=="lock" {"core.lock"} else {p["method"].as_str().unwrap()};
let params=if request["method"]=="lock" {p.clone()} else {p["params"].clone()};
send(json!({"jsonrpc":"2.0","id":{"nested":7},"method":method,"params":params}));
loop {let response:Value=serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();if response["id"]==json!({"nested":7}) {break response["result"].clone();}}
},
_=>{send(json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"method not found"}}));return;}
};
if let Some(id)=id {send(json!({"jsonrpc":"2.0","id":id,"result":result}));}
}