diff --git a/Cargo.lock b/Cargo.lock index 3fa281a..2e9c8b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 033c2ee..15c86e1 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -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"] } diff --git a/cli/src/broker.rs b/cli/src/broker.rs deleted file mode 100644 index f8587be..0000000 --- a/cli/src/broker.rs +++ /dev/null @@ -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//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, - 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, - mailboxes: Mutex>>, - controls: Mutex>>, - /// `actor.json` of every actor, native and WASM. - metas: Mutex>, - op_locks: 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>>, - me: std::sync::Weak, -} - -impl Broker { - pub fn new(world: World, runtime: Arc) -> Result> { - 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, to: &str, body: Value) -> impl Future> + 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 { - 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 { - 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 `"/"`. 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 = 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 = 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::(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> { - 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, 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 { - 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) { - let ids: Vec = 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//actor.wasm`. - pub fn load_version(&self, id: &str, version: u64) -> Result { - self.load(id, &self.world.version_dir(id, version).join("actor.wasm")) - } - - fn load(&self, id: &str, wasm: &Path) -> Result { - 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//`: 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, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result { - 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, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result { - 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, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result { - 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, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result { - 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//`. - /// 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 { - 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) -> Vec { - 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//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, 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) -> Vec { - self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass"); - let watchers: Vec = - 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 = { - 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, id: &str) -> Option { - 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, id: &str) -> Option { - // 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, 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, env: &Envelope) -> Result { - 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, 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, mut mailbox: mpsc::Receiver) { - 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()) -} - diff --git a/cli/src/candidate.rs b/cli/src/candidate.rs deleted file mode 100644 index 703414f..0000000 --- a/cli/src/candidate.rs +++ /dev/null @@ -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 `/tests/`. -pub async fn test(broker: &Broker, id: &str, version: u64, component: &Component, source: Option<&Path>) -> Result { - 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(()) - } -} diff --git a/cli/src/cargo_actor.rs b/cli/src/cargo.rs similarity index 62% rename from cli/src/cargo_actor.rs rename to cli/src/cargo.rs index 0c51d01..e3f93ae 100644 --- a/cli/src/cargo_actor.rs +++ b/cli/src/cargo.rs @@ -1,40 +1,22 @@ -//! The native `cargo` actor: builds Rust source folders into `.wasm` files. -//! -//! Request: `{"type": "build", "source": "/actor//public/"}` -//! optional `"skip_tests": true` skips native `cargo test` (tests run by default). -//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds//.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 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 { - 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//`. - pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result { + /// crate's `wasm32-wasip2` binary under `/plugin/cargo/public/builds//`. + pub async fn build( + &self, + source: &Path, + skip_tests: bool, + ) -> std::result::Result { 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 = 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, mut mailbox: mpsc::Receiver) { - 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 { - 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 { 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 { 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()) { diff --git a/cli/src/console.rs b/cli/src/console.rs deleted file mode 100644 index 70ff338..0000000 --- a/cli/src/console.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! The native `console` actor: your terminal. -//! -//! - `@ ` sends a JSON body. `@ ` 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 = "\ -@ send a JSON body to an actor -@ send {\"type\":\"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) { - 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) { - 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::(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); - } -} diff --git a/cli/src/journal.rs b/cli/src/journal.rs new file mode 100644 index 0000000..3704d00 --- /dev/null +++ b/cli/src/journal.rs @@ -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, + warned: Mutex>, +} +impl Journal { + pub fn open(path: &Path) -> anyhow::Result { + 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) + } +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 042f4c8..fe82f87 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -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, - native: Vec>, - watcher: Option, - _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; +pub type NativeFuture = Pin + Send>>; +pub type NativePlugin = Arc 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, ¶ms); + 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, + pub detached: bool, + pub start_time: u128, + pub state: String, +} +struct Process { + info: ProcessInfo, + input: Option>, + abort: Option, + caller: String, + id: Option, + answer: Option>, + ended: watch::Sender, +} +struct Lock { + owner: String, + queue: VecDeque<(String, oneshot::Sender)>, +} +struct State { + processes: HashMap, + locks: HashMap, + components: HashMap, + native: HashMap, +} +struct Inner { + world: World, + runtime: Arc, + state: Mutex, + journal: journal::Journal, + notifications: tokio::sync::broadcast::Sender, + exit: watch::Sender, + compilation: tokio::sync::Mutex<()>, +} +#[derive(Clone)] +pub struct Host(Arc); +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 { - let world = World::new(std::path::absolute(root)?); - if !world.exists() { - bail!("{} is not a Sausage world. Create one with: sausage init", root.display()); - } - let lock = std::fs::File::create(world.root.join(".lock"))?; - if lock.try_lock().is_err() { - bail!("another sausage process runs {}", root.display()); - } - 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 }) + pub async fn start(path: &Path) -> anyhow::Result { + 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 } - - /// Register a native actor with the ID `id` and return its mailbox. - pub fn mailbox(&self, id: &str) -> mpsc::Receiver { - self.broker.open_mailbox(id) + pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result { + 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 = + 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()); + } + } + Ok(host) } - - /// 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 { - task.abort(); + pub fn register_native(&self, name: &str, plugin: NativePlugin) { + self.0 + .state + .lock() + .unwrap() + .native + .insert(name.into(), plugin); + } + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.0.notifications.subscribe() + } + pub fn processes(&self) -> HashMap { + 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, ¶ms); + 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); } - for task in self.native { - let _ = task.await; + 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, + 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::(); + 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(); + } + 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::(&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> { + 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, 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(""), ¶ms["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"))) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 03d874c..b88d799 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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, #[command(subcommand)] - command: Option, + 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 { 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:?}"); + } + 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); } - run(dir, headless).await } } + Ok(()) } diff --git a/cli/src/runtime.rs b/cli/src/runtime.rs index ce0c9ad..40d23da 100644 --- a/cli/src/runtime.rs +++ b/cli/src/runtime.rs @@ -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, + linker: Linker, } /// How an instance ended. @@ -116,22 +107,18 @@ impl std::fmt::Display for Exit { } pub struct InstanceConfig { - pub mounts: Vec, + 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//fs/` (see `vfs.rs`). - pub broker: Weak, } /// A running guest. Dropping it kills the guest. pub struct Instance { - stdin: Option, + pub stdin: Option, /// Lines that the guest writes to stdout. Closed when the guest ends. pub stdout: mpsc::Receiver, + pub stderr: mpsc::Receiver, task: JoinHandle, } @@ -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> { + pub fn new(cache: &Path) -> Result> { 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 = Linker::new(&engine); + let mut linker: Linker = 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//fs/`. - linker.allow_shadowing(true); - filesystem::types::add_to_linker::(&mut linker, vfs_view)?; - filesystem::preopens::add_to_linker::(&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::from_file(&self.engine, wasm) - .map_err(anyhow::Error::from) - .with_context(|| format!("load {}", wasm.display())) + pub fn compile(&self, bytes: &[u8]) -> Result { + Ok(Component::new(&self.engine, bytes)?) } pub fn start(self: &Arc, component: &Component, cfg: InstanceConfig) -> Result { 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, + }) } } diff --git a/cli/src/supervisor.rs b/cli/src/supervisor.rs deleted file mode 100644 index ca77456..0000000 --- a/cli/src/supervisor.rs +++ /dev/null @@ -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>; - -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, - id: String, - version: u64, - running: bool, - mailbox: mpsc::Receiver, - control: mpsc::Receiver, - component: Option, - instance: Option, - started_at: Instant, - restarts: u32, - restart_at: Option, - probation: Option, - /// A control request that arrived during a blocked delivery. - next_control: Option, -} - -async fn next_line(instance: &mut Option) -> Option { - match instance { - Some(i) => i.stdout.recv().await, - None => std::future::pending().await, - } -} - -async fn sleep_until(t: Option) { - match t { - Some(t) => tokio::time::sleep_until(t.into()).await, - None => std::future::pending().await, - } -} - -impl Supervisor { - pub fn new( - broker: Arc, - id: String, - version: u64, - running: bool, - mailbox: mpsc::Receiver, - control: mpsc::Receiver, - ) -> 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) -> 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 { - 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 { - 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 - } -} diff --git a/cli/src/vfs.rs b/cli/src/vfs.rs deleted file mode 100644 index e2db552..0000000 --- a/cli/src/vfs.rs +++ /dev/null @@ -1,796 +0,0 @@ -//! Actor-served files: `/actor//fs/...` (see PROTOCOL.md, "Actor-served -//! files"). -//! -//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`. -//! A path under `/actor//fs/` does not reach the disk: the host asks the -//! actor `` 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, - /// 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, -} - -impl VfsState { - pub fn new(broker: Weak, 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>>, - dirty: Arc, - write: bool, -} - -/// A directory-entry-stream of an actor-served folder. -struct VDir(std::vec::IntoIter); - -enum Node { - File(Vec), - Dir(Vec), -} - -/// Sends the requests. Owned, so that no borrow of the view lives across an await. -struct Asker { - broker: Option>, - me: String, -} - -impl Asker { - async fn ask(&self, actor: &str, body: Value) -> Result { - 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 { - 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, 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::>() - .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, 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, -} - -#[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 { - 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) -> Option { - self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::().cloned() - } - - fn file(&mut self, fd: &Resource) -> Option> { - 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, 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) -> FsResult> { - 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> { - 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> { - 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, 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 { - types::Host::convert_error_code(&mut self.inner, err) - } - - fn filesystem_error_code(&mut self, err: Resource) -> wasmtime::Result> { - types::Host::filesystem_error_code(&mut self.inner, err) - } -} - -impl HostDescriptor for VfsView<'_> { - async fn advise(&mut self, fd: Resource, 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) -> 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) -> FsResult { - 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) -> FsResult { - 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, 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, 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, len: types::Filesize, offset: types::Filesize) -> FsResult<(Vec, 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, buf: Vec, offset: types::Filesize) -> FsResult { - 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) -> FsResult> { - 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) -> 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, 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) -> FsResult { - 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, path_flags: types::PathFlags, path: String) -> FsResult { - 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, - 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, - old_path_flags: types::PathFlags, - old_path: String, - new_descriptor: Resource, - 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, - path_flags: types::PathFlags, - path: String, - oflags: OpenFlags, - flags: DescriptorFlags, - ) -> FsResult> { - 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) -> wasmtime::Result<()> { - if self.virt(&fd).is_some() { - if let Virt::File(f) = self.inner.table.delete(Resource::::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, path: String) -> FsResult { - 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, 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, old_path: String, new_fd: Resource, 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, 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, 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, offset: types::Filesize) -> FsResult> { - 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, offset: types::Filesize) -> FsResult> { - 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) -> FsResult> { - 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, b: Resource) -> wasmtime::Result { - 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) -> FsResult { - 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, path_flags: types::PathFlags, path: String) -> FsResult { - 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) -> FsResult> { - if let Some(dir) = self.inner.table.get_any_mut(stream.rep()).ok().and_then(|a| a.downcast_mut::()) { - return Ok(dir.0.next()); - } - HostDirectoryEntryStream::read_directory_entry(&mut self.inner, stream).await - } - - fn drop(&mut self, stream: Resource) -> wasmtime::Result<()> { - let ours = self.inner.table.get_any_mut(stream.rep()).ok().is_some_and(|a| a.is::()); - if ours { - self.inner.table.delete(Resource::::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> { - 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("/", ".."), "/"); - } -} diff --git a/cli/src/watch.rs b/cli/src/watch.rs deleted file mode 100644 index 6f6bc0e..0000000 --- a/cli/src/watch.rs +++ /dev/null @@ -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//actor.wasm` (or folder -//! `actor/`) 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) -> notify::Result { - 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, - first: Instant, - last: Instant, -} - -impl Pending { - fn due(&self) -> Instant { - (self.last + QUIET).min(self.first + MAX_DELAY) - } -} - -async fn run(broker: Arc, mut events: mpsc::UnboundedReceiver>) { - let root = broker.world.root.clone(); - let mut pending: HashMap = 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 = 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) { - 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 { - 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); - -/// 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: ¬ify::Event) -> Vec { - let path = |i: usize| event.paths.get(i).and_then(|p| namespace(root, p)); - let one = |op: &'static str| -> Vec { 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, path: String, op: &'static str, to: Option, 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//actor.wasm` or the folder `actor/` -/// appeared or changed. -fn new_wasm(path: &str, op: &str, to: Option<&str>) -> Option { - 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)> = - 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), - ] - ); - } -} diff --git a/cli/src/world.rs b/cli/src/world.rs index 6035a7e..997ccf7 100644 --- a/cli/src/world.rs +++ b/cli/src/world.rs @@ -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 -//! / mounted at /, readable and writable by every actor -//! config/ global configuration by convention (no secrets) -//! actor// -//! 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// 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//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, - #[serde(default = "yes")] - pub running: bool, - /// The actors whose published messages this actor receives. - #[serde(default)] - pub subscriptions: BTreeSet, - /// 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, -} - -fn yes() -> bool { - true -} - -/// `versions//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, - /// The actor that built the `.wasm` file. - pub built_by: Option, - /// 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) -> 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 { - 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> { - let mut ids: Vec = 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//`, - /// 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 { - let mut v: Vec = 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 { - let under = |base: PathBuf, rest: &str| -> Result { - 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 { + 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); } - if let Some(rest) = prefixed("/self") { - return under(self.actor_dir(sender), rest); - } - if let Some(rest) = prefixed("/public") { - return under(self.public_dir(sender), rest); - } - if let Some(rest) = prefixed("/data") { - return under(self.data_dir(sender), rest); - } - if NATIVE.contains(&sender) && Path::new(path).is_absolute() { - return Ok(PathBuf::from(path)); - } - if Path::new(path).is_absolute() { - return under(self.root.clone(), path); - } - bail!("cannot resolve path {path} for {sender}: use an absolute world path") + Ok(Self { + root: path.canonicalize()?, + }) } - - /// The path of a host file in the global filesystem, if it has one. - pub fn global_path(&self, host: &Path) -> Option { - let rel = host.strip_prefix(&self.root).ok()?; - Some(format!("/{}", rel.display())) + pub fn create(path: &Path) -> Result { + if path.exists() { + bail!("world already exists: {}", path.display()); + } + for dir in ["plugins", "state/core", "config"] { + std::fs::create_dir_all(path.join(dir))?; + } + std::fs::write(path.join("config/boot.json"), "[]\n")?; + Self::open(path) + } + pub fn plugin_dir(&self, name: &str) -> PathBuf { + self.root.join("plugins").join(name) + } + 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)?; + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &to.join(entry.file_name()))?; + } else { + std::fs::copy(entry.path(), to.join(entry.file_name()))?; } } 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)?; - } else { - std::fs::create_dir_all(&fresh)?; - } - if dst.exists() { - std::fs::rename(dst, &old)?; - } - std::fs::rename(&fresh, dst)?; - let _ = std::fs::remove_dir_all(&old); - Ok(()) -} diff --git a/cli/tests/actors/toolbox/Cargo.lock b/cli/tests/actors/toolbox/Cargo.lock deleted file mode 100644 index 7b62d62..0000000 --- a/cli/tests/actors/toolbox/Cargo.lock +++ /dev/null @@ -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" diff --git a/cli/tests/actors/toolbox/src/main.rs b/cli/tests/actors/toolbox/src/main.rs deleted file mode 100644 index 6672db7..0000000 --- a/cli/tests/actors/toolbox/src/main.rs +++ /dev/null @@ -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 { - 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::::new(); - // Files that this actor serves at /actor//fs/ (path -> text). - let mut served = std::collections::BTreeMap::::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::(&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 = 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 = 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 = 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)); - } - } - } -} diff --git a/cli/tests/agent.rs b/cli/tests/agent.rs deleted file mode 100644 index 2bc3499..0000000 --- a/cli/tests/agent.rs +++ /dev/null @@ -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, - body: Value, -} - -enum ReplyPlan { - Send(Value), - Hold(Value, oneshot::Receiver<()>), -} - -struct FakeServer { - base_url: String, - plans: mpsc::Sender, - requests: mpsc::Receiver, - 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::().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, - mut offset: usize, -) -> Vec { - 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 = 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) { - 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, 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 { - 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::(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::>(), - ["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::( - &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; - } -} diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs deleted file mode 100644 index f1e966e..0000000 --- a/cli/tests/cli.rs +++ /dev/null @@ -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 `) 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>) { - 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 = 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:?}"); -} diff --git a/cli/tests/common/mod.rs b/cli/tests/common/mod.rs deleted file mode 100644 index c79932f..0000000 --- a/cli/tests/common/mod.rs +++ /dev/null @@ -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 = 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) { - 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, 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, - 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, - 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, - ids: &[String], - timeout: Duration, -) -> Vec { - 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, - 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, - 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, - 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, - 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, - 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 -} diff --git a/cli/tests/core.rs b/cli/tests/core.rs new file mode 100644 index 0000000..b709d00 --- /dev/null +++ b/cli/tests/core.rs @@ -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 = 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); + impl Drop for Dropped { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + let w = TestWorld::new(); + let h = w.start().await; + let dropped = Arc::new(AtomicBool::new(false)); + let flag = dropped.clone(); + h.register_native( + "native", + Arc::new(move |ctx, _, _| { + let flag = flag.clone(); + Box::pin(async move { + let _guard = Dropped(flag); + ctx.call("core.lock", json!({"path":"native-lock"})) + .await + .unwrap(); + ctx.notify("ready", json!({"pid":ctx.pid})); + std::future::pending().await + }) + }), + ); + let mut notifications = h.subscribe(); + let hc = h.clone(); + let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await }); + let notification = + tokio::time::timeout(std::time::Duration::from_secs(10), notifications.recv()) + .await + .unwrap() + .unwrap(); + let pid = ¬ification["params"]["params"]["pid"]; + assert_eq!( + call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await, + json!({"held":false}) + ); + call(&h, "core.kill", json!({"pid":pid})).await; + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !dropped.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await, + json!({"held":true}) + ); + assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002); + h.stop().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_stops_busy_processes_in_parallel_after_five_seconds() { + let w = TestWorld::new(); + let h = w.start().await; + let mut tasks = Vec::new(); + for _ in 0..2 { + let hc = h.clone(); + tasks.push(tokio::spawn(async move { + hc.call("probe.busy", json!({})).await + })); + } + tokio::time::timeout(std::time::Duration::from_secs(20), async { + while h.processes().len() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let before = std::time::Instant::now(); + h.stop().await; + assert!(before.elapsed() >= std::time::Duration::from_millis(4900)); + assert!(before.elapsed() < std::time::Duration::from_secs(7)); + for task in tasks { + assert_eq!(task.await.unwrap().unwrap_err()["code"], -32002); + } + assert!(h.processes().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn journal_filters_persist_and_process_versions_are_hashes() { + use sha2::{Digest, Sha256}; + let w = TestWorld::new(); + let h = w.start().await; + let pid = call(&h, "probe.serve", json!({"version":1})).await; + let pid = pid.as_str().unwrap(); + let info = h.processes()[pid].clone(); + assert_eq!( + info.version, + format!("{:x}", Sha256::digest(std::fs::read(fixture()).unwrap())) + ); + assert_eq!(info.plugin, "probe"); + assert_eq!(info.method, "serve"); + assert_eq!(info.params, json!({"version":1})); + assert_eq!(info.parent.as_deref(), Some("console")); + assert_eq!(info.state, "running"); + assert!(!info.detached); + assert!(info.start_time > 0); + let uuid = uuid::Uuid::parse_str(pid.strip_prefix("probe-").unwrap()).unwrap(); + assert_eq!(uuid.get_version_num(), 7); + call(&h, "core.stop", json!({"pid":pid})).await; + let entries = call( + &h, + "core.log", + json!({"plugin":"probe","pid":pid,"limit":2}), + ) + .await; + assert_eq!(entries.as_array().unwrap().len(), 2); + let entry = &entries[0]; + let id = &entry["id"]; + let single = call(&h, "core.log", json!({"since":id,"until":id})).await; + assert_eq!(single, json!([entry])); + h.stop().await; + drop(h); + let h = w.start().await; + assert_eq!( + call(&h, "core.log", json!({"since":id,"until":id})).await, + single + ); + h.stop().await; + assert!( + std::fs::read(w.0.join("state/core/journal.sqlite")) + .unwrap() + .starts_with(b"SQLite format 3") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stopping_rejects_calls_but_allows_notifications() { + use std::sync::{Arc, Mutex}; + let w = TestWorld::new(); + let h = w.start().await; + let (tx, rx) = tokio::sync::oneshot::channel(); + let tx = Arc::new(Mutex::new(Some(tx))); + let gate = Arc::new(tokio::sync::Notify::new()); + let release = gate.clone(); + h.register_native( + "native", + Arc::new(move |ctx, _, _| { + let tx = tx.lock().unwrap().take().unwrap(); + let gate = gate.clone(); + Box::pin(async move { + tx.send(ctx.clone()).ok(); + gate.notified().await; + Ok(json!({})) + }) + }), + ); + let hc = h.clone(); + let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await }); + let ctx = rx.await.unwrap(); + let hc = h.clone(); + let pid = ctx.pid.clone(); + let stop = tokio::spawn(async move { hc.call("core.stop", json!({"pid":pid})).await }); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while h.processes()[&ctx.pid].state != "stopping" { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + ctx.call("probe.echo", json!({})).await.unwrap_err()["code"], + -32600 + ); + let mut notifications = h.subscribe(); + ctx.call("core.notify", json!({"name":"stop-hook","params":{}})) + .await + .unwrap(); + assert_eq!( + notifications.recv().await.unwrap()["params"]["name"], + "stop-hook" + ); + release.notify_one(); + assert_eq!(pending.await.unwrap().unwrap(), json!({})); + assert_eq!(stop.await.unwrap().unwrap(), json!({})); + h.stop().await; +} diff --git a/cli/tests/host.rs b/cli/tests/host.rs deleted file mode 100644 index 668e805..0000000 --- a/cli/tests/host.rs +++ /dev/null @@ -1,1252 +0,0 @@ -mod common; - -use std::time::Duration; - -use common::*; -use sausage::{Envelope, Host, World}; -use serde_json::{Value, json}; -use tokio::sync::mpsc; - -async fn filesystem_world(label: &str) -> (World, Host, mpsc::Receiver) { - let world = create_world(label); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - for actor in ["writer", "reader"] { - let source = passing_source(&world, actor); - let spawned = spawn_actor(&host, &mut mailbox, actor, &wasm, &source).await; - assert_eq!(spawned.body["type"], "spawned"); - } - (world, host, mailbox) -} - -async fn receive_shutdown_messages( - mailbox: &mut mpsc::Receiver, - id: &str, - expected: &str, -) { - let (mut result, mut reply, mut publish) = (false, false, false); - tokio::time::timeout(Duration::from_secs(10), async { - while !result || !reply || !publish { - let message = mailbox.recv().await.expect("console mailbox closed"); - if message.reply_to.as_deref() == Some(id) { - assert_eq!(message.body["type"], expected); - result = true; - } else if message.reply_to.as_deref() == Some("broker/shutdown") { - assert_eq!(message.from, "shutdown-output"); - assert_eq!(message.body["type"], "shutdown-reply"); - reply = true; - } else { - assert_eq!(message.from, "shutdown-output"); - assert_eq!(message.reply_to, None); - assert_eq!(message.body["type"], "shutdown-publish"); - publish = true; - } - } - }) - .await - .expect("shutdown output or lifecycle reply did not arrive"); -} - -#[tokio::test] -async fn routes_replies_publishes_and_reports_undeliverable() { - let world = create_world("routing"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - - let missing_id = host.broker.send( - "console", - Some("missing"), - None, - None, - json!({"type": "hello"}), - ); - let missing = receive_reply(&mut mailbox, &missing_id, Duration::from_secs(10)).await; - assert_eq!(missing.body["type"], "undeliverable"); - assert_eq!(missing.body["to"], "missing"); - - let source = passing_source(&world, "router"); - let spawned = spawn_actor(&host, &mut mailbox, "router", &wasm, &source).await; - assert_eq!(spawned.body["type"], "spawned"); - - let echo = ask( - &host, - &mut mailbox, - "router", - json!({"type": "echo", "value": "hello"}), - ) - .await; - assert_eq!(echo.from, "router"); - assert_eq!(echo.body, json!({"type": "echo", "value": "hello"})); - - let subscribed = request( - &host, - &mut mailbox, - json!({"type": "subscribe", "actor": "router"}), - ) - .await; - assert_eq!(subscribed.body["type"], "subscribed"); - - host.broker.send( - "console", - Some("router"), - None, - None, - json!({"type": "publish", "value": "news"}), - ); - let published = receive_message(&mut mailbox, Duration::from_secs(10)).await; - assert_eq!(published.from, "router"); - assert_eq!( - published.body, - json!({"type": "published", "value": "news"}) - ); - assert_eq!(published.reply_to, None); - - host.stop().await; -} - -#[tokio::test] -async fn broker_does_not_answer_messages_with_reply_to() { - let world = create_world("broker-reply-to"); - let (host, mut mailbox) = start(&world).await; - let untapped = request(&host, &mut mailbox, json!({"type": "untap"})).await; - assert_eq!(untapped.body["type"], "untapped"); - - host.broker.send( - "console", - Some("broker"), - None, - Some("upstream-message"), - json!({"type": "untap"}), - ); - assert!( - tokio::time::timeout(Duration::from_secs(1), mailbox.recv()) - .await - .is_err(), - "broker replied to a message that already had reply_to" - ); - host.stop().await; -} - -#[tokio::test] -async fn exit_request_is_answered_and_signals_the_host() { - let world = create_world("broker-exit"); - let (host, mut mailbox) = start(&world).await; - let exiting = request(&host, &mut mailbox, json!({"type": "exit"})).await; - assert_eq!(exiting.body["type"], "exiting"); - tokio::time::timeout(Duration::from_secs(1), host.broker.exit_requested()) - .await - .expect("exit request did not reach the host"); - host.stop().await; -} - -#[tokio::test] -async fn filesystem_is_writable_and_public_aliases_share_files() { - let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await; - for path in [ - "/actor/reader/public/shared.txt", - "/actor/writer/public/shared.txt", - ] { - let written = ask( - &host, - &mut mailbox, - "writer", - json!({"type": "write", "path": path, "text": "shared"}), - ) - .await; - assert_eq!(written.body["type"], "ok", "write to {path} failed"); - } - - let written = ask( - &host, - &mut mailbox, - "writer", - json!({"type": "write", "path": "/public/shared.txt", "text": "public"}), - ) - .await; - assert_eq!(written.body["type"], "ok"); - let shared = ask( - &host, - &mut mailbox, - "reader", - json!({"type": "read", "path": "/actor/writer/public/shared.txt"}), - ) - .await; - assert_eq!(shared.body, json!({"type": "content", "text": "public"})); - host.stop().await; -} - -#[tokio::test] -async fn actors_can_read_and_write_other_actor_data() { - let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await; - ask_type( - &host, - &mut mailbox, - "writer", - json!({"type": "write", "path": "/data/x", "text": "shared"}), - "ok", - ) - .await; - let shared = ask_type( - &host, - &mut mailbox, - "reader", - json!({"type": "read", "path": "/actor/writer/data/x"}), - "content", - ) - .await; - assert_eq!(shared.body["text"], "shared"); - - ask_type( - &host, - &mut mailbox, - "reader", - json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}), - "ok", - ) - .await; - let changed = ask_type( - &host, - &mut mailbox, - "writer", - json!({"type": "read", "path": "/data/x"}), - "content", - ) - .await; - assert_eq!(changed.body["text"], "changed"); - host.stop().await; -} - -#[tokio::test] -async fn world_root_is_shared_writable_and_survives_restart() { - let (world, host, mut mailbox) = filesystem_world("world-root").await; - assert!(world.root.join("config").is_dir()); - ask_type(&host, &mut mailbox, "writer", json!({"type": "write", "path": "/self/data/x", "text": "self-alias"}), "ok").await; - assert_eq!(ask_type(&host, &mut mailbox, "writer", json!({"type": "read", "path": "/data/x"}), "content").await.body["text"], "self-alias"); - let file = "/config/x"; - ask_type(&host, &mut mailbox, "writer", json!({"type": "write", "path": file, "text": "first"}), "ok").await; - let read = json!({"type": "read", "path": file}); - assert_eq!(ask_type(&host, &mut mailbox, "reader", read.clone(), "content").await.body["text"], "first"); - ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": file, "text": "second"}), "ok").await; - assert_eq!(std::fs::read_to_string(world.root.join("config/x")).unwrap(), "second"); - for actor in ["writer", "reader"] { - assert_eq!(world.resolve(actor, file).unwrap(), world.root.join("config/x")); - } - assert_eq!(world.global_path(&world.root.join("config/x")).as_deref(), Some(file)); - - // Watches use the same world namespace, not only /actor paths. - let watching = request(&host, &mut mailbox, json!({"type": "watch", "path": world.root.join("config").display().to_string()})).await; - assert_eq!(watching.body, json!({"type": "watching", "path": "/config"})); - drain(&mut mailbox, Duration::from_millis(300)).await; - std::fs::write(world.root.join("config/event"), "watched").unwrap(); - assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await), - vec![json!({"type": "fs_changed", "path": "/config/event", "op": "create"})]); - request(&host, &mut mailbox, json!({"type": "unwatch", "path": world.root.join("config").display().to_string()})).await; - - // A new top-level folder appears in already running instances, without a mount change. - std::fs::create_dir(world.root.join("docs")).unwrap(); - std::fs::write(world.root.join("docs/new"), "host-created").unwrap(); - let docs = json!({"type": "read", "path": "/docs/new"}); - assert_eq!(ask_type(&host, &mut mailbox, "reader", docs, "content").await.body["text"], "host-created"); - ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": "/docs/new", "text": "actor-updated"}), "ok").await; - assert_eq!(std::fs::read_to_string(world.root.join("docs/new")).unwrap(), "actor-updated"); - host.stop().await; - - let (host, mut mailbox) = start(&world).await; - for actor in ["writer", "reader"] { - assert_eq!(ask_type(&host, &mut mailbox, actor, read.clone(), "content").await.body["text"], "second"); - } - host.stop().await; -} - -#[tokio::test] -async fn actors_serve_files_under_fs() { - // "writer" serves its fs/ from memory (toolbox fs_read / fs_write / fs_remove). - let (_, host, mut mailbox) = filesystem_world("actor-fs").await; - let file = "/actor/writer/fs/notes/a.txt"; - let write = json!({"type": "write", "path": file, "text": "hello"}); - ask_type(&host, &mut mailbox, "reader", write, "ok").await; - // The write reached the owner as one fs_write message. - let direct = json!({"type": "fs_read", "path": "/notes/a.txt"}); - let served = ask_type(&host, &mut mailbox, "writer", direct, "fs_file").await; - assert_eq!(served.body["text"], "hello"); - - let read = json!({"type": "read", "path": file}); - assert_eq!(ask_type(&host, &mut mailbox, "reader", read, "content").await.body["text"], "hello"); - let list = json!({"type": "list", "path": "/actor/writer/fs/notes"}); - assert_eq!(ask_type(&host, &mut mailbox, "reader", list, "entries").await.body["names"], json!(["a.txt"])); - let root = json!({"type": "list", "path": "/actor/writer/fs"}); - assert_eq!(ask_type(&host, &mut mailbox, "reader", root, "entries").await.body["names"], json!(["notes"])); - - // fs_error and undeliverable become errors of the file operation. - for path in ["/actor/writer/fs/missing.txt", "/actor/nobody/fs/x"] { - ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": path}), "error").await; - } - // An actor that does not answer (here: it is busy reading itself) fails after the timeout. - let id = host.broker.send("console", Some("reader"), None, None, json!({"type": "read", "path": "/actor/reader/fs/x"})); - let timed_out = receive_reply(&mut mailbox, &id, Duration::from_secs(20)).await; - assert_eq!(timed_out.body["type"], "error"); - host.stop().await; -} - -#[tokio::test] -async fn actor_fs_is_discoverable_only_when_its_source_docs_advertise_it() { - let world = create_world("actor-fs-discovery"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - - let advertised = passing_source(&world, "advertised-fs"); - std::fs::create_dir_all(advertised.join("src")).unwrap(); - std::fs::write( - advertised.join("src/main.rs"), - "//! test actor\n//! Actor-served files: implements fs_read and fs_dir.\n", - ) - .unwrap(); - spawn_type(&host, &mut mailbox, "served", &wasm, &advertised).await; - - let undocumented = passing_source(&world, "undocumented-fs"); - std::fs::create_dir_all(undocumented.join("src")).unwrap(); - std::fs::write(undocumented.join("src/main.rs"), "//! test actor without a virtual filesystem\n").unwrap(); - spawn_type(&host, &mut mailbox, "plain", &wasm, &undocumented).await; - - let reader_source = passing_source(&world, "fs-reader"); - spawn_type(&host, &mut mailbox, "reader", &wasm, &reader_source).await; - - let served_entries = ask_type( - &host, - &mut mailbox, - "reader", - json!({"type": "list", "path": "/actor/served"}), - "entries", - ) - .await; - assert!(served_entries.body["names"].as_array().unwrap().iter().any(|name| name.as_str() == Some("fs"))); - - let plain_entries = ask_type( - &host, - &mut mailbox, - "reader", - json!({"type": "list", "path": "/actor/plain"}), - "entries", - ) - .await; - assert!(!plain_entries.body["names"].as_array().unwrap().iter().any(|name| name.as_str() == Some("fs"))); - - let path = "/actor/served/fs/readme.txt"; - ask_type( - &host, - &mut mailbox, - "reader", - json!({"type": "write", "path": path, "text": "virtual document"}), - "ok", - ) - .await; - let document = ask_type( - &host, - &mut mailbox, - "reader", - json!({"type": "read", "path": path}), - "content", - ) - .await; - assert_eq!(document.body["text"], "virtual document"); - - host.stop().await; -} - -#[tokio::test] -async fn files_are_plain_posix_files() { - let (_, host, mut mailbox) = filesystem_world("posix").await; - // An open file is visible by path, to the writer and to every reader. - let open = json!({"type": "open", "path": "/public/new.txt", "text": "new"}); - ask_type(&host, &mut mailbox, "writer", open, "opened").await; - let own = json!({"type": "read", "path": "/public/new.txt"}); - assert_eq!(ask_type(&host, &mut mailbox, "writer", own, "content").await.body["text"], "new"); - let other = json!({"type": "read", "path": "/actor/writer/public/new.txt"}); - assert_eq!(ask_type(&host, &mut mailbox, "reader", other, "content").await.body["text"], "new"); - // It can be renamed while open. - let rename = json!({"type": "rename", "path": "/public/new.txt", "to": "/public/moved.txt"}); - ask_type(&host, &mut mailbox, "writer", rename, "ok").await; - ask_type(&host, &mut mailbox, "writer", json!({"type": "close", "path": "/public/new.txt"}), "closed").await; - let moved = json!({"type": "read", "path": "/actor/writer/public/moved.txt"}); - assert_eq!(ask_type(&host, &mut mailbox, "reader", moved, "content").await.body["text"], "new"); - host.stop().await; -} - -#[tokio::test] -async fn filesystem_rejects_paths_that_escape_data() { - let (_, host, mut mailbox) = filesystem_world("filesystem-path-escape").await; - let escaped = ask( - &host, - &mut mailbox, - "writer", - json!({"type": "write", "path": "/data/../../..", "text": "outside"}), - ) - .await; - assert_eq!(escaped.body["type"], "error"); - host.stop().await; -} - -#[tokio::test] -async fn upgrades_test_candidates_keep_old_versions_and_queue_messages_in_order() { - let world = create_world("upgrade"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let passing = passing_source(&world, "passing"); - let failing = source_with_test( - &world, - "failing", - json!({ - "steps": [{ - "send": {"type": "echo", "value": "candidate"}, - "expect": {"type": "wrong"} - }] - }), - ); - assert_eq!( - spawn_actor(&host, &mut mailbox, "queue", &wasm, &passing) - .await - .body["type"], - "spawned" - ); - - let failed = upgrade_actor(&host, &mut mailbox, "queue", &wasm, &failing).await; - assert_eq!(failed.body["type"], "error"); - let old_version = ask(&host, &mut mailbox, "queue", json!({"type": "version"})).await; - assert_eq!(old_version.body["version"], "1"); - // A version that fails its tests never enters versions/. - assert!(!world.actor_dir("queue").join("versions/2").exists()); - - let upgrade_id = host.broker.send( - "console", - Some("broker"), - None, - None, - json!({"type": "upgrade", "actor": "queue", "wasm": wasm.display().to_string(), "source": passing.display().to_string()}), - ); - tokio::time::timeout(Duration::from_secs(10), async { - while !world.data_dir("queue").join("shutdown").is_file() { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("old actor did not enter shutdown during upgrade"); - - let mut message_ids = Vec::new(); - for n in 1..=3 { - message_ids.push(host.broker.send( - "console", - Some("queue"), - None, - None, - json!({"type": "ordered", "n": n}), - )); - } - - let mut upgrade_result = None; - let mut actor_replies = Vec::new(); - tokio::time::timeout(Duration::from_secs(15), async { - while upgrade_result.is_none() || actor_replies.len() < message_ids.len() { - let envelope = mailbox.recv().await.expect("console mailbox closed"); - if envelope.reply_to.as_deref() == Some(upgrade_id.as_str()) { - upgrade_result = Some(envelope); - } else if message_ids - .iter() - .any(|id| envelope.reply_to.as_deref() == Some(id.as_str())) - { - actor_replies.push(envelope); - } else { - panic!("unexpected message during queued upgrade: {envelope:?}"); - } - } - }) - .await - .expect("upgrade did not deliver queued messages"); - - assert_eq!(upgrade_result.unwrap().body["type"], "upgraded"); - let actual_ids: Vec = actor_replies - .iter() - .map(|message| message.reply_to.clone().unwrap()) - .collect(); - assert_eq!(actual_ids, message_ids); - for (index, envelope) in actor_replies.iter().enumerate() { - assert_eq!(envelope.from, "queue"); - assert_eq!(envelope.body["type"], "ordered"); - assert_eq!(envelope.body["n"], json!(index + 1)); - assert_eq!(envelope.body["version"], "2"); - } - - host.stop().await; -} - -#[tokio::test] -async fn rolls_back_a_crash_loop_to_the_previous_version_without_restoring_data() { - let world = create_world("crash-loop"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - assert_eq!( - spawn_actor(&host, &mut mailbox, "crashloop", &wasm, &source) - .await - .body["type"], - "spawned" - ); - assert_eq!( - ask( - &host, - &mut mailbox, - "crashloop", - json!({"type": "write", "path": "/data/state", "text": "stable"}) - ) - .await - .body["type"], - "ok" - ); - - let upgrade_id = host.broker.send( - "console", - Some("broker"), - None, - None, - json!({"type": "upgrade", "actor": "crashloop", "wasm": wasm.display().to_string(), "source": source.display().to_string()}), - ); - let mut upgraded: Option = None; - let mut rolled_back: Option = None; - tokio::time::timeout(Duration::from_secs(35), async { - while upgraded.is_none() || rolled_back.is_none() { - let envelope = mailbox.recv().await.expect("console mailbox closed"); - let kind = envelope.body["type"].as_str().map(str::to_string); - match kind.as_deref() { - Some("upgraded") if envelope.reply_to.as_deref() == Some(upgrade_id.as_str()) => { - upgraded = Some(envelope.body); - } - Some("rolled_back") => rolled_back = Some(envelope.body), - other => panic!("unexpected automatic rollback message: {other:?}, {envelope:?}"), - } - } - }) - .await - .expect("crash loop did not trigger automatic rollback"); - - assert_eq!(upgraded.unwrap()["version"], 2); - let rollback = rolled_back.unwrap(); - assert_eq!(rollback["actor"], "crashloop"); - assert_eq!(rollback["from"], 2); - assert_eq!(rollback["to"], 1); - assert_eq!(rollback["reason"], "crash loop after upgrade"); - - let version = ask(&host, &mut mailbox, "crashloop", json!({"type": "version"})).await; - assert_eq!(version.body["version"], "1"); - let current = ask( - &host, - &mut mailbox, - "crashloop", - json!({"type": "read", "path": "/data/state"}), - ) - .await; - assert_eq!(current.body["text"], "bad-v2"); - - host.stop().await; -} - -#[tokio::test] -async fn upgrade_and_rollback_keep_a_stopped_actor_stopped() { - let world = create_world("upgrade-stopped"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - spawn_type(&host, &mut mailbox, "sleeper", &wasm, &source).await; - let stopped = request(&host, &mut mailbox, json!({"type": "stop", "actor": "sleeper"})).await; - assert_eq!(stopped.body["type"], "stopped"); - - let upgraded = upgrade_actor(&host, &mut mailbox, "sleeper", &wasm, &source).await; - assert_eq!(upgraded.body["type"], "upgraded", "{upgraded:?}"); - assert_eq!(upgraded.body["version"], 2); - assert_eq!(upgraded.body["running"], false); - let meta = world.read_meta("sleeper"); - assert_eq!((meta.version, meta.running), (Some(2), false)); - - // Mail waits in the mailbox: the stopped actor reads nothing. - let waiting = host.broker.send("console", Some("sleeper"), None, None, json!({"type": "version"})); - assert!( - tokio::time::timeout(Duration::from_millis(500), receive_reply(&mut mailbox, &waiting, Duration::from_secs(10))) - .await - .is_err(), - "a stopped actor must not answer" - ); - - let rolled_back = request(&host, &mut mailbox, json!({"type": "rollback", "actor": "sleeper", "version": 1})).await; - assert_eq!(rolled_back.body["type"], "rolled_back", "{rolled_back:?}"); - assert_eq!(rolled_back.body["running"], false); - assert!(!world.read_meta("sleeper").running); - - // `start` runs the installed version and delivers the waiting message. - let started = request(&host, &mut mailbox, json!({"type": "start", "actor": "sleeper"})).await; - assert_eq!(started.body["type"], "started"); - let answer = receive_reply(&mut mailbox, &waiting, Duration::from_secs(10)).await; - assert_eq!(answer.body["version"], "1"); - - // A running actor still restarts on the new version. - let running = upgrade_actor(&host, &mut mailbox, "sleeper", &wasm, &source).await; - assert_eq!(running.body["running"], true); - assert_eq!(ask(&host, &mut mailbox, "sleeper", json!({"type": "version"})).await.body["version"], "3"); - host.stop().await; -} - -#[tokio::test] -async fn explicit_rollback_keeps_current_data() { - let world = create_world("explicit-rollback"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - assert_eq!( - spawn_actor(&host, &mut mailbox, "recover", &wasm, &source) - .await - .body["type"], - "spawned" - ); - ask( - &host, - &mut mailbox, - "recover", - json!({"type": "write", "path": "/data/state", "text": "version-one"}), - ) - .await; - - let upgraded = upgrade_actor(&host, &mut mailbox, "recover", &wasm, &source).await; - assert_eq!(upgraded.body["type"], "upgraded"); - ask( - &host, - &mut mailbox, - "recover", - json!({"type": "write", "path": "/data/state", "text": "version-two"}), - ) - .await; - - let rolled_back = request( - &host, - &mut mailbox, - json!({"type": "rollback", "actor": "recover", "version": 1}), - ) - .await; - assert_eq!(rolled_back.body["type"], "rolled_back"); - assert_eq!(rolled_back.body["from"], 2); - assert_eq!(rolled_back.body["to"], 1); - let version = ask(&host, &mut mailbox, "recover", json!({"type": "version"})).await; - assert_eq!(version.body["version"], "1"); - let current = ask( - &host, - &mut mailbox, - "recover", - json!({"type": "read", "path": "/data/state"}), - ) - .await; - assert_eq!(current.body["text"], "version-two"); - - host.stop().await; -} - -#[tokio::test] -async fn remove_shuts_an_actor_down_and_deletes_its_folder() { - let world = create_world("remove"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await; - let write = json!({"type": "write", "path": "/data/state", "text": "x"}); - ask_type(&host, &mut mailbox, "doomed", write, "ok").await; - ask_type(&host, &mut mailbox, "doomed", json!({"type": "arm-shutdown"}), "armed").await; - - // Normal shutdown: the actor gets "shutdown" and its last message is routed. - let id = host.broker.send("console", Some("broker"), None, None, json!({"type": "remove", "actor": "doomed"})); - let (mut removed, mut last_words) = (false, false); - tokio::time::timeout(Duration::from_secs(10), async { - while !removed || !last_words { - let message = mailbox.recv().await.expect("console mailbox closed"); - if message.reply_to.as_deref() == Some(id.as_str()) { - assert_eq!(message.body, json!({"type": "removed", "actor": "doomed"})); - removed = true; - } else if message.reply_to.as_deref() == Some("broker/shutdown") { - assert_eq!(message.body["type"], "shutdown-reply"); - last_words = true; - } - } - }) - .await - .expect("remove did not answer or did not shut the actor down"); - assert!(!world.actor_dir("doomed").exists()); - - let gone = ask(&host, &mut mailbox, "doomed", json!({"type": "echo", "value": "hi"})).await; - assert_eq!(gone.body["type"], "undeliverable"); - let again = request(&host, &mut mailbox, json!({"type": "remove", "actor": "doomed"})).await; - assert_eq!(again.body["type"], "error"); - let broker = request(&host, &mut mailbox, json!({"type": "remove", "actor": "broker"})).await; - assert_eq!(broker.body["type"], "error"); - assert!(world.actor_dir("broker").exists()); - - // The ID is free again: a new spawn starts at version 1 with empty data. - assert_eq!(spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await.body["version"], 1); - let read = json!({"type": "read", "path": "/data/state"}); - ask_type(&host, &mut mailbox, "doomed", read, "error").await; - host.stop().await; -} - -#[tokio::test] -async fn unknown_broker_request_lists_the_known_ones() { - let world = create_world("unknown-request"); - let (host, mut mailbox) = start(&world).await; - let unknown = request(&host, &mut mailbox, json!({"type": "frobnicate"})).await; - assert_eq!(unknown.body["type"], "error"); - let reason = unknown.body["reason"].as_str().unwrap(); - assert!(reason.contains("remove") && reason.contains("tap") && reason.contains("watch"), "{reason}"); - // `scan` is retired: the host watches the world folder instead. - let scan = request(&host, &mut mailbox, json!({"type": "scan"})).await; - assert_eq!(scan.body["type"], "error"); - host.stop().await; -} - -#[tokio::test] -async fn cargo_actor_runs_unit_tests_by_default_and_can_skip_them() { - let world = create_world("cargo-actor"); - let (host, mut mailbox) = start(&world).await; - - let valid_source = cargo_source("cargo-valid"); - let valid_id = host.broker.send( - "console", - Some("cargo"), - None, - None, - json!({"type": "build", "source": valid_source.display().to_string()}), - ); - let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(180)).await; - assert_eq!(built.body["type"], "built", "{built:?}"); - assert_eq!(built.body["tests"]["passed"], 1); - assert_eq!(built.body["tests"]["failed"], 0); - assert!(built.body["tests"]["output"].as_str().unwrap().contains("unit_tests_run_on_the_host_target")); - let wasm = built.body["wasm"].as_str().unwrap(); - assert!(wasm.ends_with(".wasm")); - assert!(host.world.resolve("console", wasm).unwrap().is_file()); - - let failing_test_source = cargo_source("cargo-test-fail"); - let failed_test_id = host.broker.send( - "console", - Some("cargo"), - None, - None, - json!({"type": "build", "source": failing_test_source.display().to_string()}), - ); - let failed_test = receive_reply(&mut mailbox, &failed_test_id, Duration::from_secs(120)).await; - assert_eq!(failed_test.body["type"], "build_failed", "{failed_test:?}"); - assert_eq!(failed_test.body["tests"]["passed"], 0); - assert_eq!(failed_test.body["tests"]["failed"], 1); - assert!(failed_test.body["tests"]["output"].as_str().unwrap().contains("deliberate_unit_test_failure")); - - let skipped_id = host.broker.send( - "console", - Some("cargo"), - None, - None, - json!({"type": "build", "source": failing_test_source.display().to_string(), "skip_tests": true}), - ); - let skipped = receive_reply(&mut mailbox, &skipped_id, Duration::from_secs(120)).await; - assert_eq!(skipped.body["type"], "built", "{skipped:?}"); - assert_eq!(skipped.body["tests"]["passed"], 0); - assert_eq!(skipped.body["tests"]["failed"], 0); - assert_eq!(skipped.body["tests"]["output"], "skipped (skip_tests=true)"); - - let invalid_source = cargo_source("cargo-fail"); - let invalid_id = host.broker.send( - "console", - Some("cargo"), - None, - None, - json!({"type": "build", "source": invalid_source.display().to_string()}), - ); - let invalid = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await; - assert_eq!(invalid.body["type"], "build_failed", "{invalid:?}"); - assert_eq!(invalid.body["tests"]["failed"], 1); - assert!(invalid.body["tests"]["output"].as_str().unwrap().contains("missing_function")); - - host.stop().await; -} - -#[tokio::test] -async fn cargo_actor_compiles_c_code_for_wasi() { - let world = create_world("cargo-c"); - let (host, mut mailbox) = start(&world).await; - let source = cargo_source("cargo-c"); - let id = host.broker.send("console", Some("cargo"), None, None, json!({"type": "build", "source": source.display().to_string()})); - let built = receive_reply(&mut mailbox, &id, Duration::from_secs(300)).await; - assert_eq!(built.body["type"], "built", "{built:?}"); - assert_eq!(built.body["tests"]["passed"], 1, "{built:?}"); - let wasm = host.world.resolve("console", built.body["wasm"].as_str().unwrap()).unwrap(); - spawn_type(&host, &mut mailbox, "c-add", &wasm, &source).await; - let sum = ask_type(&host, &mut mailbox, "c-add", json!({"type": "add", "a": 2, "b": 3}), "sum").await; - assert_eq!(sum.body["value"], 5); - host.stop().await; -} - -#[tokio::test] -async fn rejects_invalid_actor_ids() { - let world = create_world("invalid-id"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let invalid = request( - &host, - &mut mailbox, - json!({"type": "spawn", "actor": "bad/actor", "wasm": wasm.display().to_string()}), - ) - .await; - assert_eq!(invalid.body["type"], "error"); - assert!( - invalid.body["reason"] - .as_str() - .unwrap() - .contains("invalid actor ID") - ); - host.stop().await; -} - -#[tokio::test] -async fn rejects_parent_paths() { - let world = create_world("parent-path"); - let (host, mut mailbox) = start(&world).await; - let parent_path = request( - &host, - &mut mailbox, - json!({"type": "spawn", "actor": "safe", "wasm": "/public/../escape.wasm"}), - ) - .await; - assert_eq!(parent_path.body["type"], "error"); - assert!( - parent_path.body["reason"] - .as_str() - .unwrap() - .contains("must not contain '..'") - ); - host.stop().await; -} - -#[tokio::test] -async fn concurrent_spawns_of_one_id_leave_one_live_instance() { - let world = create_world("concurrent-spawn"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - let ids = [ - spawn_request(&host, "spawn-race", &wasm, &source), - spawn_request(&host, "spawn-race", &wasm, &source), - ]; - let replies = receive_replies(&mut mailbox, &ids, Duration::from_secs(20)).await; - let spawned = replies - .iter() - .filter(|r| r.body["type"] == "spawned") - .count(); - let failed = replies.iter().filter(|r| r.body["type"] == "error").count(); - assert_eq!((spawned, failed), (1, 1)); - - let echo = ask_type( - &host, - &mut mailbox, - "spawn-race", - json!({"type": "echo", "value": "live"}), - "echo", - ) - .await; - assert_eq!(echo.body["value"], "live"); - tokio::time::sleep(Duration::from_millis(100)).await; - let list = ask_type( - &host, - &mut mailbox, - "spawn-race", - json!({"type": "list", "path": "/data"}), - "entries", - ) - .await; - let starts = list.body["names"] - .as_array() - .unwrap() - .iter() - .filter(|n| n.as_str().unwrap().starts_with("start-")) - .count(); - assert_eq!(starts, 1); - host.stop().await; -} - -#[tokio::test] -async fn stop_finishes_when_actor_stdin_is_full() { - let world = create_world("full-stdin-stop"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - spawn_type(&host, &mut mailbox, "full-stdin", &wasm, &source).await; - let ignore = json!({"type": "ignore-stdin"}); - ask_type(&host, &mut mailbox, "full-stdin", ignore, "ignoring").await; - let large = json!({"type": "echo", "value": "x".repeat(256 * 1024)}); - host.broker - .send("console", Some("full-stdin"), None, None, large); - tokio::time::sleep(Duration::from_millis(100)).await; - let stop = json!({"type": "stop", "actor": "full-stdin"}); - let id = host - .broker - .send("console", Some("broker"), None, None, stop); - let stopped = tokio::time::timeout( - Duration::from_secs(6), - receive_reply(&mut mailbox, &id, Duration::from_secs(10)), - ) - .await - .expect("stop exceeded the five-second shutdown deadline"); - assert_eq!(stopped.body["type"], "stopped"); - host.stop().await; -} - -#[tokio::test] -async fn shutdown_reply_and_publish_survive_stop_and_upgrade() { - let world = create_world("shutdown-output"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - spawn_type(&host, &mut mailbox, "shutdown-output", &wasm, &source).await; - let subscribe = json!({"type": "subscribe", "actor": "shutdown-output"}); - ask_type(&host, &mut mailbox, "broker", subscribe, "subscribed").await; - - for operation in ["stop", "upgrade"] { - let arm = json!({"type": "arm-shutdown"}); - ask_type(&host, &mut mailbox, "shutdown-output", arm, "armed").await; - let body = match operation { - "stop" => json!({"type": "stop", "actor": "shutdown-output"}), - _ => { - json!({"type": "upgrade", "actor": "shutdown-output", "wasm": wasm.display().to_string(), "source": source.display().to_string()}) - } - }; - let id = host - .broker - .send("console", Some("broker"), None, None, body); - receive_shutdown_messages( - &mut mailbox, - &id, - if operation == "stop" { - "stopped" - } else { - "upgraded" - }, - ) - .await; - if operation == "stop" { - let start = json!({"type": "start", "actor": "shutdown-output"}); - ask_type(&host, &mut mailbox, "broker", start, "started").await; - } - } - host.stop().await; -} - -#[tokio::test] -async fn candidates_receive_version_and_preexisting_spawn_state() { - let world = create_world("candidate-context"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let version_step = - json!({"send": {"type": "version"}, "expect": {"type": "version", "version": "1"}}); - let version_source = source_with_test(&world, "version", json!({"steps": [version_step]})); - let state_step = json!({"send": {"type": "read", "path": "/data/state.json"}, "expect": {"type": "content", "text": "seeded"}}); - let state_source = source_with_test(&world, "state", json!({"steps": [state_step]})); - let state_dir = world.data_dir("stateful"); - std::fs::create_dir_all(&state_dir).unwrap(); - std::fs::write(state_dir.join("state.json"), "seeded").unwrap(); - let ids = [ - spawn_request(&host, "versioned", &wasm, &version_source), - spawn_request(&host, "stateful", &wasm, &state_source), - ]; - let replies = receive_replies(&mut mailbox, &ids, Duration::from_secs(20)).await; - let successful = replies - .iter() - .filter(|reply| reply.body["type"] == "spawned") - .count(); - assert_eq!(successful, 2); - host.stop().await; -} - -#[tokio::test] -async fn running_actors_have_sockets_and_candidates_do_not() { - // A TCP server on the host that answers each line with "echo: ". - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap().to_string(); - tokio::spawn(async move { - while let Ok((stream, _)) = listener.accept().await { - tokio::spawn(async move { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - let (read, mut write) = stream.into_split(); - let mut lines = tokio::io::BufReader::new(read).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let _ = write.write_all(format!("echo: {line}\n").as_bytes()).await; - } - }); - } - }); - - let world = create_world("sockets"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let tcp = json!({"type": "tcp", "address": address, "text": "hi"}); - // The candidate test expects the connection to fail. - let offline = source_with_test(&world, "offline", json!({"steps": [{"send": tcp, "expect": {"type": "error"}}]})); - assert_eq!(spawn_actor(&host, &mut mailbox, "net", &wasm, &offline).await.body["type"], "spawned"); - - let answer = ask(&host, &mut mailbox, "net", tcp).await; - assert_eq!(answer.body, json!({"type": "tcp", "line": "echo: hi"})); - host.stop().await; -} - -/// Every message that arrives until the mailbox is quiet for `quiet`. -async fn drain(mailbox: &mut mpsc::Receiver, quiet: Duration) -> Vec { - let mut messages = Vec::new(); - while let Ok(Some(message)) = tokio::time::timeout(quiet, mailbox.recv()).await { - messages.push(message); - } - messages -} - -#[tokio::test] -async fn tap_copies_every_routed_message_once() { - let world = create_world("tap"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - spawn_type(&host, &mut mailbox, "tapped", &wasm, &source).await; - let subscribe = json!({"type": "subscribe", "actor": "tapped"}); - assert_eq!(request(&host, &mut mailbox, subscribe).await.body["type"], "subscribed"); - assert_eq!(request(&host, &mut mailbox, json!({"type": "tap"})).await.body["type"], "tapping"); - - let echo = json!({"type": "echo", "value": "hi"}); - let echo_id = host.broker.send("console", Some("tapped"), None, None, echo.clone()); - let publish = json!({"type": "publish", "value": "news"}); - host.broker.send("console", Some("tapped"), None, None, publish); - let messages = drain(&mut mailbox, Duration::from_secs(2)).await; - let copies: Vec<&Value> = messages - .iter() - .filter(|m| m.from == "broker" && m.body["type"] == "tapped") - .map(|m| &m.body) - .collect(); - // 4 copies (2 requests, 1 reply, 1 publish) + the reply + the publish itself. - assert_eq!((copies.len(), messages.len()), (4, 6), "{messages:?}"); - for copy in &copies { - assert!(copy["ts"].as_u64().unwrap() > 0, "{copy}"); - } - let count = |want: Value| copies.iter().filter(|c| c["message"] == want).count(); - let request_copy = json!({"id": echo_id, "from": "console", "to": "tapped", "reply_to": null, "body": echo}); - assert_eq!(count(request_copy), 1, "{copies:?}"); - let reply_copies = copies - .iter() - .filter(|c| c["message"]["reply_to"] == echo_id.as_str() && c["message"]["from"] == "tapped" && c["message"]["to"] == "console") - .count(); - assert_eq!(reply_copies, 1, "{copies:?}"); - let publish_copy = copies.iter().find(|c| c["message"]["body"]["type"] == "published").expect("publish not tapped"); - assert_eq!(publish_copy["message"]["from"], "tapped"); - assert_eq!(publish_copy["message"]["to"], Value::Null); - - assert_eq!(request(&host, &mut mailbox, json!({"type": "untap"})).await.body["type"], "untapped"); - let echo_id = host.broker.send("console", Some("tapped"), None, None, json!({"type": "echo", "value": "quiet"})); - let messages = drain(&mut mailbox, Duration::from_secs(1)).await; - assert_eq!(messages.len(), 1, "{messages:?}"); - assert_eq!(messages[0].reply_to.as_deref(), Some(echo_id.as_str())); - host.stop().await; -} - -#[tokio::test] -async fn tap_survives_a_restart() { - let world = create_world("tap-restart"); - let (host, mut mailbox) = start(&world).await; - assert_eq!(request(&host, &mut mailbox, json!({"type": "tap"})).await.body["type"], "tapping"); - host.stop().await; - assert!(world.read_meta("console").tapped); - - let (host, mut mailbox) = start(&world).await; - let id = host.broker.send("console", Some("nobody"), None, None, json!({"type": "hello"})); - let copy = tokio::time::timeout(Duration::from_secs(10), async { - loop { - let m = mailbox.recv().await.expect("console mailbox closed"); - if m.body["type"] == "tapped" && m.body["message"]["id"] == id.as_str() { - return m; - } - } - }) - .await - .expect("no tapped copy after the restart"); - assert_eq!(copy.body["message"]["to"], "nobody"); - host.stop().await; -} - -#[tokio::test] -async fn a_full_tapper_mailbox_drops_copies_without_blocking() { - let world = create_world("tap-full"); - let (host, mut mailbox) = start(&world).await; - // "ghost" taps and never reads its mailbox. - let mut ghost = host.mailbox("ghost"); - let mut sink = host.mailbox("sink"); - host.broker.send("ghost", Some("broker"), None, None, json!({"type": "tap"})); - tokio::time::timeout(Duration::from_secs(10), async { - while !world.read_meta("ghost").tapped { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("tap was not saved"); - - let total = sausage::broker::MAILBOX_SIZE + 500; - for n in 0..total { - host.broker.send("console", Some("sink"), None, None, json!({"type": "n", "n": n})); - assert_eq!(sink.try_recv().unwrap().body["n"], n); - } - assert!(mailbox.try_recv().is_err(), "a dropped copy reported something to the sender"); - let mut held = 0; - while ghost.try_recv().is_ok() { - held += 1; - } - assert_eq!(held, sausage::broker::MAILBOX_SIZE); - host.broker.send("console", Some("sink"), None, None, json!({"type": "after"})); - assert_eq!(ghost.try_recv().unwrap().body["type"], "tapped"); - host.stop().await; -} - -/// Ask `actor` again and again (it may not exist yet) until `want` holds for -/// the answer. -async fn ask_until( - host: &Host, - mailbox: &mut mpsc::Receiver, - actor: &str, - body: Value, - want: impl Fn(&Value) -> bool, -) -> Envelope { - tokio::time::timeout(Duration::from_secs(30), async { - loop { - let reply = ask(host, mailbox, actor, body.clone()).await; - if want(&reply.body) { - return reply; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .unwrap_or_else(|_| panic!("{actor} never gave the wanted answer")) -} - -/// The `fs_changed` bodies among `messages` (all from `fs`). -fn fs_events(messages: Vec) -> Vec { - messages - .into_iter() - .filter(|m| m.body["type"] == "fs_changed") - .map(|m| { - assert_eq!(m.from, "fs"); - m.body - }) - .collect() -} - -#[tokio::test] -async fn a_new_actor_wasm_on_disk_becomes_an_actor() { - let world = create_world("watch-spawn"); - let (host, mut mailbox) = start(&world).await; - // Any writer counts: here the test itself, like a shell on the host. - let tests = world.source_dir("dropped").join("tests"); - std::fs::create_dir_all(&tests).unwrap(); - let test = json!({"steps": [{"send": {"type": "echo", "value": "candidate"}, "expect": {"type": "echo", "value": "candidate"}}]}); - std::fs::write(tests.join("candidate.json"), test.to_string()).unwrap(); - std::fs::copy(toolbox_wasm(), world.wasm_path("dropped")).unwrap(); - - let echo = json!({"type": "echo", "value": "hi"}); - let reply = ask_until(&host, &mut mailbox, "dropped", echo, |b| b["type"] == "echo").await; - assert_eq!(reply.from, "dropped"); - assert_eq!(world.read_meta("dropped").version, Some(1)); - let meta = std::fs::read_to_string(world.version_dir("dropped", 1).join("meta.json")).unwrap(); - assert_eq!(serde_json::from_str::(&meta).unwrap()["requested_by"], "scan"); - host.stop().await; -} - -#[tokio::test] -async fn a_changed_actor_wasm_on_disk_is_an_upgrade() { - let world = create_world("watch-upgrade"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let source = passing_source(&world, "source"); - spawn_type(&host, &mut mailbox, "changing", &wasm, &source).await; - - // The same program plus a custom section: a different file. - let mut changed = std::fs::read(&wasm).unwrap(); - changed.extend_from_slice(&[0, 4, 1, b'x', b'v', b'2']); - std::fs::write(world.wasm_path("changing"), changed).unwrap(); - - let version = json!({"type": "version"}); - ask_until(&host, &mut mailbox, "changing", version, |b| b["version"] == "2").await; - assert_eq!(world.read_meta("changing").version, Some(2)); - assert_eq!(world.versions("changing"), vec![1, 2]); - host.stop().await; -} - -#[tokio::test] -async fn watchers_get_one_event_per_change_from_any_writer() { - let (world, host, mut mailbox) = filesystem_world("watch-events").await; - let watch = json!({"type": "watch", "path": "/actor/writer/public/"}); - let watching = request(&host, &mut mailbox, watch).await; - assert_eq!(watching.body, json!({"type": "watching", "path": "/actor/writer/public"})); - assert!(world.read_meta("console").watches.contains("/actor/writer/public")); - drain(&mut mailbox, Duration::from_millis(300)).await; - - let quiet = Duration::from_millis(600); - let event = |path: &str, op: &str| json!({"type": "fs_changed", "path": format!("/actor/writer/public/{path}"), "op": op}); - - // An actor creates a file: one create, not create + write. - let write = json!({"type": "write", "path": "/public/a.txt", "text": "a"}); - ask_type(&host, &mut mailbox, "writer", write, "ok").await; - assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("a.txt", "create")]); - - // The host (a shell, git, ...) changes it. - std::fs::write(world.public_dir("writer").join("a.txt"), "b").unwrap(); - assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("a.txt", "write")]); - - // A rename is one event with `to`. - let rename = json!({"type": "rename", "path": "/public/a.txt", "to": "/public/b.txt"}); - ask_type(&host, &mut mailbox, "writer", rename, "ok").await; - let mut renamed = event("a.txt", "rename"); - renamed["to"] = json!("/actor/writer/public/b.txt"); - assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![renamed]); - - std::fs::remove_file(world.public_dir("writer").join("b.txt")).unwrap(); - assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("b.txt", "remove")]); - - // Nothing outside the watched path. - let outside = json!({"type": "write", "path": "/data/x", "text": "x"}); - ask_type(&host, &mut mailbox, "writer", outside, "ok").await; - assert_eq!(fs_events(drain(&mut mailbox, quiet).await), Vec::::new()); - - let unwatch = json!({"type": "unwatch", "path": "/actor/writer/public"}); - let unwatched = request(&host, &mut mailbox, unwatch).await; - assert_eq!(unwatched.body, json!({"type": "unwatched", "path": "/actor/writer/public"})); - assert!(world.read_meta("console").watches.is_empty()); - let write = json!({"type": "write", "path": "/public/c.txt", "text": "c"}); - ask_type(&host, &mut mailbox, "writer", write, "ok").await; - assert_eq!(fs_events(drain(&mut mailbox, quiet).await), Vec::::new()); - host.stop().await; -} - -#[tokio::test] -async fn a_watcher_overflow_sends_one_rescan_and_runs_a_full_pass() { - let world = create_world("watch-overflow"); - let wasm = toolbox_wasm().clone(); - let (host, mut mailbox) = start(&world).await; - let mut bystander = host.mailbox("bystander"); - let watch = json!({"type": "watch", "path": "/actor/elsewhere"}); - assert_eq!(request(&host, &mut mailbox, watch).await.body["type"], "watching"); - - // A dropped actor that the full pass finds (before the watcher's quiet time). - let tests = world.source_dir("planted").join("tests"); - std::fs::create_dir_all(&tests).unwrap(); - let test = json!({"steps": [{"send": {"type": "echo", "value": "candidate"}, "expect": {"type": "echo", "value": "candidate"}}]}); - std::fs::write(tests.join("candidate.json"), test.to_string()).unwrap(); - std::fs::copy(&wasm, world.wasm_path("planted")).unwrap(); - let report = host.broker.clone().overflowed().await; - assert!(report.iter().any(|r| r["type"] == "spawned" && r["actor"] == "planted"), "{report:?}"); - - let rescan = json!({"type": "fs_changed", "path": "/", "op": "rescan"}); - assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await), vec![rescan]); - assert!(bystander.try_recv().is_err(), "an actor without watches got the rescan"); - host.stop().await; -} diff --git a/cli/tests/actors/toolbox/Cargo.toml b/cli/tests/plugins/probe/Cargo.toml similarity index 82% rename from cli/tests/actors/toolbox/Cargo.toml rename to cli/tests/plugins/probe/Cargo.toml index 5a9a35e..b2499d8 100644 --- a/cli/tests/actors/toolbox/Cargo.toml +++ b/cli/tests/plugins/probe/Cargo.toml @@ -1,9 +1,7 @@ [package] -name = "toolbox" +name = "probe" version = "0.1.0" edition = "2024" - +[workspace] [dependencies] serde_json = "1" - -[workspace] diff --git a/cli/tests/plugins/probe/src/main.rs b/cli/tests/plugins/probe/src/main.rs new file mode 100644 index 0000000..1b0620f --- /dev/null +++ b/cli/tests/plugins/probe/src/main.rs @@ -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}));} +}