sausage core part 1: a process per call, routing, tree, stop and kill, locks, journal, boot
Checkpoint. Removes the old host. A quality rework follows (modules, journal writer thread, hash cache, finer tests).
This commit is contained in:
Generated
+139
-4
@@ -905,6 +905,8 @@ version = "0.16.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1151,6 +1153,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inventory"
|
||||||
|
version = "0.3.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-extras"
|
name = "io-extras"
|
||||||
version = "0.19.0"
|
version = "0.19.0"
|
||||||
@@ -1467,6 +1478,16 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
@@ -1656,6 +1677,16 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -1809,10 +1840,16 @@ dependencies = [
|
|||||||
name = "sausage-interface"
|
name = "sausage-interface"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
"proptest",
|
"proptest",
|
||||||
"schemars",
|
"quote",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"schemars 1.2.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"typify",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1820,9 +1857,10 @@ name = "sausage-plugin"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
|
"inventory",
|
||||||
"sausage-interface",
|
"sausage-interface",
|
||||||
"sausage-plugin-macros",
|
"sausage-plugin-macros",
|
||||||
"schemars",
|
"schemars 1.2.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"trybuild",
|
"trybuild",
|
||||||
@@ -1835,6 +1873,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
"sausage-interface",
|
||||||
|
"serde_json",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1849,10 +1889,23 @@ dependencies = [
|
|||||||
"rusqlite",
|
"rusqlite",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"wasip2",
|
||||||
"wasmtime",
|
"wasmtime",
|
||||||
"wasmtime-wasi",
|
"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]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -1861,11 +1914,23 @@ checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"ref-cast",
|
"ref-cast",
|
||||||
"schemars_derive",
|
"schemars_derive 1.2.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "schemars_derive"
|
name = "schemars_derive"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -1874,7 +1939,7 @@ checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde_derive_internals",
|
"serde_derive_internals 0.30.0",
|
||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1924,6 +1989,17 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"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]]
|
[[package]]
|
||||||
name = "serde_derive_internals"
|
name = "serde_derive_internals"
|
||||||
version = "0.30.0"
|
version = "0.30.0"
|
||||||
@@ -1957,6 +2033,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -2324,6 +2412,53 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
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]]
|
[[package]]
|
||||||
name = "unarray"
|
name = "unarray"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
|||||||
+5
-2
@@ -2,7 +2,7 @@
|
|||||||
name = "sausage"
|
name = "sausage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
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]]
|
[[bin]]
|
||||||
name = "sausage"
|
name = "sausage"
|
||||||
@@ -15,7 +15,6 @@ clap = { version = "4", features = ["derive"] }
|
|||||||
http = "1"
|
http = "1"
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
notify = "8"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
@@ -23,3 +22,7 @@ toml_edit = "0.25"
|
|||||||
wasmtime = "49"
|
wasmtime = "49"
|
||||||
wasmtime-wasi = "49"
|
wasmtime-wasi = "49"
|
||||||
wasmtime-wasi-http = "49"
|
wasmtime-wasi-http = "49"
|
||||||
|
|
||||||
|
uuid = { version = "1", features = ["v7"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||||
|
|||||||
@@ -1,667 +0,0 @@
|
|||||||
//! The broker: message routing, mailboxes, subscriptions, taps, watches, the
|
|
||||||
//! version store, and the requests to `broker` (spawn, upgrade, rollback,
|
|
||||||
//! stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch,
|
|
||||||
//! exit). `supervisor.rs` runs each WASM actor.
|
|
||||||
//!
|
|
||||||
//! The broker accepts only `.wasm` files. It does not know any source language.
|
|
||||||
//! The world folder is the source of truth: at start, the broker boots every
|
|
||||||
//! `actor/<id>/actor.wasm`; afterwards the file watcher (`watch.rs`) reports
|
|
||||||
//! changes: a new `actor.wasm` becomes an actor, a changed one an upgrade.
|
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use tokio::sync::{mpsc, oneshot};
|
|
||||||
use wasmtime::component::Component;
|
|
||||||
|
|
||||||
use crate::candidate;
|
|
||||||
use crate::runtime::{InstanceConfig, Mount, Runtime};
|
|
||||||
use crate::supervisor::{Control, Reply, Supervisor};
|
|
||||||
use crate::vfs;
|
|
||||||
use crate::world::{self, ActorMeta, NATIVE, VersionMeta, World};
|
|
||||||
|
|
||||||
pub const MAILBOX_SIZE: usize = 1000;
|
|
||||||
pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct Envelope {
|
|
||||||
pub id: String,
|
|
||||||
pub from: String,
|
|
||||||
pub to: String,
|
|
||||||
pub reply_to: Option<String>,
|
|
||||||
pub body: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Envelope {
|
|
||||||
/// The NDJSON line that the recipient reads on stdin.
|
|
||||||
pub fn to_line(&self) -> String {
|
|
||||||
let mut v = json!({"id": self.id, "from": self.from, "body": self.body});
|
|
||||||
if let Some(r) = &self.reply_to {
|
|
||||||
v["reply_to"] = json!(r);
|
|
||||||
}
|
|
||||||
v.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shutdown_line() -> String {
|
|
||||||
json!({"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Broker {
|
|
||||||
pub world: World,
|
|
||||||
pub runtime: Arc<Runtime>,
|
|
||||||
mailboxes: Mutex<HashMap<String, mpsc::Sender<Envelope>>>,
|
|
||||||
controls: Mutex<HashMap<String, mpsc::Sender<Control>>>,
|
|
||||||
/// `actor.json` of every actor, native and WASM.
|
|
||||||
metas: Mutex<BTreeMap<String, ActorMeta>>,
|
|
||||||
op_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
|
||||||
seq: AtomicU64,
|
|
||||||
/// Set by the `exit` request: the host process should stop.
|
|
||||||
exit: tokio::sync::Notify,
|
|
||||||
/// Requests of the host from `fs` (see `vfs.rs`) that wait for a reply, by message ID.
|
|
||||||
asks: Mutex<HashMap<String, oneshot::Sender<Value>>>,
|
|
||||||
me: std::sync::Weak<Broker>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Broker {
|
|
||||||
pub fn new(world: World, runtime: Arc<Runtime>) -> Result<Arc<Self>> {
|
|
||||||
let metas = world.actor_ids()?.into_iter().map(|id| (id.clone(), world.read_meta(&id))).collect();
|
|
||||||
Ok(Arc::new_cyclic(|me| Self {
|
|
||||||
world,
|
|
||||||
runtime,
|
|
||||||
mailboxes: Mutex::new(HashMap::new()),
|
|
||||||
controls: Mutex::new(HashMap::new()),
|
|
||||||
metas: Mutex::new(metas),
|
|
||||||
op_locks: Mutex::new(HashMap::new()),
|
|
||||||
seq: AtomicU64::new(1),
|
|
||||||
exit: tokio::sync::Notify::new(),
|
|
||||||
asks: Mutex::new(HashMap::new()),
|
|
||||||
me: me.clone(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send `body` from `fs` to `to` now. The future resolves to the reply
|
|
||||||
/// body, or `None` after `vfs::ASK_TIMEOUT`.
|
|
||||||
pub fn ask(self: &Arc<Self>, to: &str, body: Value) -> impl Future<Output = Option<Value>> + Send + 'static {
|
|
||||||
let own = format!("q{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
|
||||||
let id = format!("{}/{own}", vfs::ID);
|
|
||||||
let (tx, rx) = oneshot::channel();
|
|
||||||
self.asks.lock().unwrap().insert(id.clone(), tx);
|
|
||||||
self.send(vfs::ID, Some(to), Some(&own), None, body);
|
|
||||||
let this = self.clone();
|
|
||||||
async move {
|
|
||||||
let reply = tokio::time::timeout(vfs::ASK_TIMEOUT, rx).await;
|
|
||||||
this.asks.lock().unwrap().remove(&id);
|
|
||||||
reply.ok()?.ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wait until an actor sends `{"type": "exit"}` to `broker`. The `sausage`
|
|
||||||
/// binary then stops all actors and exits, so that a wrapper such as
|
|
||||||
/// `nix run .#loop` can start a fresh (updated) host.
|
|
||||||
pub async fn exit_requested(&self) {
|
|
||||||
self.exit.notified().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn meta(&self, id: &str) -> Option<ActorMeta> {
|
|
||||||
self.metas.lock().unwrap().get(id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Change the `actor.json` of `id` (creating it if needed) and save it.
|
|
||||||
fn update_meta(&self, id: &str, f: impl FnOnce(&mut ActorMeta)) -> Result<()> {
|
|
||||||
let mut metas = self.metas.lock().unwrap();
|
|
||||||
let meta = metas.entry(id.to_string()).or_insert_with(|| ActorMeta { running: true, ..Default::default() });
|
|
||||||
f(meta);
|
|
||||||
self.world.write_meta(id, meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the mailbox of an actor. Messages wait here while the actor is
|
|
||||||
/// down, during a swap, or while it restarts.
|
|
||||||
pub fn open_mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
|
|
||||||
let (tx, rx) = mpsc::channel(MAILBOX_SIZE);
|
|
||||||
self.mailboxes.lock().unwrap().insert(id.to_string(), tx);
|
|
||||||
rx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message. `to: None` publishes to the subscribers of `from`.
|
|
||||||
/// With `own_id`, the message ID is `"<from>/<own_id>"`. Returns the ID.
|
|
||||||
pub fn send(&self, from: &str, to: Option<&str>, own_id: Option<&str>, reply_to: Option<&str>, body: Value) -> String {
|
|
||||||
let id = match own_id {
|
|
||||||
Some(own) => format!("{from}/{own}"),
|
|
||||||
None => format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
|
|
||||||
};
|
|
||||||
let (recipients, tappers) = {
|
|
||||||
let metas = self.metas.lock().unwrap();
|
|
||||||
let recipients: Vec<String> = match to {
|
|
||||||
Some(to) => vec![to.to_string()],
|
|
||||||
None => metas
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, m)| m.subscriptions.contains(from))
|
|
||||||
.map(|(id, _)| id.clone())
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
let tappers: Vec<String> = metas.iter().filter(|(_, m)| m.tapped).map(|(id, _)| id.clone()).collect();
|
|
||||||
(recipients, tappers)
|
|
||||||
};
|
|
||||||
if !tappers.is_empty() {
|
|
||||||
let message = json!({"id": id, "from": from, "to": to, "reply_to": reply_to, "body": body});
|
|
||||||
self.tap(&tappers, message);
|
|
||||||
}
|
|
||||||
for to in recipients {
|
|
||||||
self.deliver(Envelope {
|
|
||||||
id: id.clone(),
|
|
||||||
from: from.to_string(),
|
|
||||||
to,
|
|
||||||
reply_to: reply_to.map(str::to_string),
|
|
||||||
body: body.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Give every tapper a copy of one routed message. Copies skip `send`, so
|
|
||||||
/// they are never tapped themselves; a full mailbox drops the copy.
|
|
||||||
fn tap(&self, tappers: &[String], message: Value) {
|
|
||||||
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64);
|
|
||||||
self.inject(tappers, "broker", json!({"type": "tapped", "message": message, "ts": ts}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Put a message of the host straight into the mailboxes of `to`: not
|
|
||||||
/// routed (so not tapped), never blocking, dropped if a mailbox is full.
|
|
||||||
fn inject(&self, to: &[String], from: &str, body: Value) {
|
|
||||||
let mailboxes = self.mailboxes.lock().unwrap();
|
|
||||||
for id in to {
|
|
||||||
if let Some(tx) = mailboxes.get(id) {
|
|
||||||
let _ = tx.try_send(Envelope {
|
|
||||||
id: format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
|
|
||||||
from: from.into(),
|
|
||||||
to: id.clone(),
|
|
||||||
reply_to: None,
|
|
||||||
body: body.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route one stdout line of a WASM actor.
|
|
||||||
pub fn route_line(&self, from: &str, line: &str) {
|
|
||||||
let line = line.trim();
|
|
||||||
if line.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Ok(Value::Object(obj)) = serde_json::from_str::<Value>(line) else {
|
|
||||||
self.log(from, &format!("stdout line is not a JSON object, ignored: {}", truncate(line, 500)));
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(body) = obj.get("body").cloned() else {
|
|
||||||
self.log(from, &format!("message without \"body\", ignored: {}", truncate(line, 500)));
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let own_id = obj.get("id").and_then(Value::as_str);
|
|
||||||
let to = obj.get("to").and_then(Value::as_str);
|
|
||||||
let reply_to = obj.get("reply_to").and_then(Value::as_str);
|
|
||||||
self.send(from, to, own_id, reply_to, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deliver(&self, env: Envelope) {
|
|
||||||
if env.to == vfs::ID {
|
|
||||||
let waiting = env.reply_to.as_ref().and_then(|r| self.asks.lock().unwrap().remove(r));
|
|
||||||
if let Some(tx) = waiting {
|
|
||||||
let _ = tx.send(env.body);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned();
|
|
||||||
let error = match tx {
|
|
||||||
None => format!("no actor with ID {}", env.to),
|
|
||||||
Some(tx) => match tx.try_send(env.clone()) {
|
|
||||||
Ok(()) => return,
|
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => format!("mailbox of {} is full", env.to),
|
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => format!("actor {} is gone", env.to),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if env.from != "broker" {
|
|
||||||
let body = json!({"type": "undeliverable", "reason": error, "to": env.to});
|
|
||||||
self.send("broker", Some(&env.from), None, Some(&env.id), body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append a host note to the stderr log of an actor.
|
|
||||||
pub fn log(&self, id: &str, text: &str) {
|
|
||||||
use std::io::Write;
|
|
||||||
let path = self.world.actor_dir(id).join("stderr.log");
|
|
||||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
|
||||||
let _ = writeln!(f, "[sausage {}] {text}", world::now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn op_lock(&self, id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
|
||||||
self.op_locks.lock().unwrap().entry(id.to_string()).or_default().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What an instance of `id` gets (see PROTOCOL.md). A candidate runs in
|
|
||||||
/// `scratch`, which holds a copy of the actor's `data/` and `public/`,
|
|
||||||
/// and has no network.
|
|
||||||
pub fn instance_config(&self, id: &str, version: u64, scratch: Option<&Path>) -> InstanceConfig {
|
|
||||||
let own = scratch.map_or_else(|| self.world.actor_dir(id), Path::to_path_buf);
|
|
||||||
let mut env = vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_VERSION".into(), version.to_string())];
|
|
||||||
if scratch.is_some() {
|
|
||||||
env.push(("SAUSAGE_CANDIDATE".into(), "1".into()));
|
|
||||||
}
|
|
||||||
InstanceConfig {
|
|
||||||
mounts: vec![
|
|
||||||
Mount { guest: "/".into(), host: self.world.root.clone(), writable: true },
|
|
||||||
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: true },
|
|
||||||
Mount { guest: "/self".into(), host: own.clone(), writable: true },
|
|
||||||
Mount { guest: "/public".into(), host: own.join("public"), writable: true },
|
|
||||||
Mount { guest: "/data".into(), host: own.join("data"), writable: true },
|
|
||||||
],
|
|
||||||
network: scratch.is_none(),
|
|
||||||
stderr_log: own.join("stderr.log"),
|
|
||||||
env,
|
|
||||||
actor: id.to_string(),
|
|
||||||
broker: self.me.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// Lifecycle
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn start_supervisor(self: &Arc<Self>, id: &str, version: u64, running: bool) {
|
|
||||||
let mailbox = self.open_mailbox(id);
|
|
||||||
let (ctl_tx, ctl_rx) = mpsc::channel(16);
|
|
||||||
self.controls.lock().unwrap().insert(id.to_string(), ctl_tx);
|
|
||||||
tokio::spawn(Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx).run());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_supervised(&self, id: &str) -> bool {
|
|
||||||
self.controls.lock().unwrap().contains_key(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn control(&self, id: &str, make: impl FnOnce(Reply) -> Control) -> Result<Value> {
|
|
||||||
let tx = self.controls.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
|
|
||||||
let (reply, rx) = oneshot::channel();
|
|
||||||
tx.send(make(reply)).await.map_err(|_| anyhow!("supervisor of {id} is gone"))?;
|
|
||||||
rx.await.map_err(|_| anyhow!("supervisor of {id} did not answer"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop every actor gracefully, in parallel (host shutdown). The
|
|
||||||
/// `running` flags stay, so the next `sausage run` starts them again.
|
|
||||||
pub async fn shutdown_all(self: &Arc<Self>) {
|
|
||||||
let ids: Vec<String> = self.controls.lock().unwrap().keys().cloned().collect();
|
|
||||||
let mut tasks = tokio::task::JoinSet::new();
|
|
||||||
for id in ids {
|
|
||||||
let this = self.clone();
|
|
||||||
tasks.spawn(async move { this.control(&id, |reply| Control::Shutdown { reply }).await });
|
|
||||||
}
|
|
||||||
tasks.join_all().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compile `versions/<version>/actor.wasm`.
|
|
||||||
pub fn load_version(&self, id: &str, version: u64) -> Result<Component> {
|
|
||||||
self.load(id, &self.world.version_dir(id, version).join("actor.wasm"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load(&self, id: &str, wasm: &Path) -> Result<Component> {
|
|
||||||
let started = Instant::now();
|
|
||||||
let component = self.runtime.load(wasm)?;
|
|
||||||
self.log(id, &format!("compiled {} in {} ms", wasm.display(), started.elapsed().as_millis()));
|
|
||||||
Ok(component)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test a new version where it lies. Only when the tests pass, copy it
|
|
||||||
/// to `versions/<n>/`: that folder holds only versions that run(ran).
|
|
||||||
/// Returns the component and the test report.
|
|
||||||
async fn try_version(&self, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<(Component, Value)> {
|
|
||||||
let component = self.load(id, wasm)?;
|
|
||||||
let tests = candidate::test(self, id, meta.version, &component, source).await;
|
|
||||||
if let Err(e) = &tests {
|
|
||||||
self.log(id, &format!("version {} from {} failed its tests: {e:#}", meta.version, meta.wasm));
|
|
||||||
}
|
|
||||||
let tests = tests?;
|
|
||||||
let dir = self.world.version_dir(id, meta.version);
|
|
||||||
std::fs::create_dir_all(&dir)?;
|
|
||||||
std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?;
|
|
||||||
world::replace_source(source.unwrap_or(&dir.join("no-source")), &dir.join("source"))?;
|
|
||||||
world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?;
|
|
||||||
Ok((component, tests))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make `version` the current `actor.wasm`, `source/`, and `actor.json`,
|
|
||||||
/// and record whether the actor runs.
|
|
||||||
pub fn install(&self, id: &str, version: u64, running: bool) -> Result<()> {
|
|
||||||
self.world.install_files(id, version)?;
|
|
||||||
self.update_meta(id, |m| {
|
|
||||||
m.version = Some(version);
|
|
||||||
m.running = running;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_running(&self, id: &str, running: bool) -> Result<()> {
|
|
||||||
self.update_meta(id, |m| m.running = running)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new actor from a `.wasm` file: test it, install it, start it.
|
|
||||||
pub async fn create_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
|
|
||||||
if !world::valid_id(id) {
|
|
||||||
bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'");
|
|
||||||
}
|
|
||||||
let lock = self.op_lock(id);
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
if NATIVE.contains(&id) || self.is_supervised(id) || self.world.wasm_path(id).exists() {
|
|
||||||
bail!("an actor with ID {id} already exists");
|
|
||||||
}
|
|
||||||
let meta = VersionMeta { version: self.world.next_version(id), ..meta };
|
|
||||||
self.create_version(id, &meta, wasm, source).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
|
|
||||||
let (_, tests) = self.try_version(id, meta, wasm, source).await?;
|
|
||||||
self.install(id, meta.version, true)?;
|
|
||||||
self.start_supervisor(id, meta.version, true);
|
|
||||||
Ok(json!({"type": "spawned", "actor": id, "version": meta.version, "tests": tests}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn upgrade_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
|
|
||||||
let lock = self.op_lock(id);
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
if !self.is_supervised(id) {
|
|
||||||
bail!("no WASM actor with ID {id}");
|
|
||||||
}
|
|
||||||
let version = self.world.next_version(id);
|
|
||||||
self.upgrade_version(id, &VersionMeta { version, ..meta }, wasm, source).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upgrade_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
|
|
||||||
let (component, tests) = self.try_version(id, meta, wasm, source).await?;
|
|
||||||
let (version, requester) = (meta.version, meta.requested_by.clone());
|
|
||||||
let mut result = self.control(id, |reply| Control::Upgrade { version, component, requester, reply }).await?;
|
|
||||||
result["tests"] = tests;
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop an actor (normal shutdown) and delete its folder `actor/<id>/`.
|
|
||||||
/// Afterwards the ID is unknown: messages to it are undeliverable, and
|
|
||||||
/// it can be spawned again from scratch.
|
|
||||||
pub async fn remove_actor(&self, id: &str) -> Result<Value> {
|
|
||||||
if NATIVE.contains(&id) {
|
|
||||||
bail!("{id} is part of the host and cannot be removed");
|
|
||||||
}
|
|
||||||
if !world::valid_id(id) {
|
|
||||||
bail!("invalid actor ID {id:?}");
|
|
||||||
}
|
|
||||||
let lock = self.op_lock(id);
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
let dir = self.world.actor_dir(id);
|
|
||||||
if !self.is_supervised(id) && !dir.is_dir() {
|
|
||||||
bail!("no actor with ID {id}");
|
|
||||||
}
|
|
||||||
if self.is_supervised(id) {
|
|
||||||
self.control(id, |reply| Control::Shutdown { reply }).await?;
|
|
||||||
}
|
|
||||||
// Without its mailbox, messages to `id` are undeliverable.
|
|
||||||
self.controls.lock().unwrap().remove(id);
|
|
||||||
self.mailboxes.lock().unwrap().remove(id);
|
|
||||||
self.metas.lock().unwrap().remove(id);
|
|
||||||
if dir.exists() {
|
|
||||||
std::fs::remove_dir_all(&dir).with_context(|| format!("delete {}", dir.display()))?;
|
|
||||||
}
|
|
||||||
Ok(json!({"type": "removed", "actor": id}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boot the actors in `actor/`: at start, and as a full pass when the
|
|
||||||
/// file watcher dropped events (`overflowed`).
|
|
||||||
///
|
|
||||||
/// - An `actor.wasm` equal to the installed version starts as is.
|
|
||||||
/// - A new or changed `actor.wasm` is tested where it lies. A new actor
|
|
||||||
/// is installed, a changed one is an upgrade. If its tests fail,
|
|
||||||
/// nothing changes, and the file stays until it changes again.
|
|
||||||
pub async fn scan(self: &Arc<Self>) -> Vec<Value> {
|
|
||||||
let mut report = Vec::new();
|
|
||||||
for id in self.world.actor_ids().unwrap_or_default() {
|
|
||||||
if let Some(r) = self.boot_actor(&id).await {
|
|
||||||
report.push(r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
report
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The file watcher saw `actor/<id>/actor.wasm` (or the folder) appear or
|
|
||||||
/// change: start, install or upgrade `id` as at boot, and log the result.
|
|
||||||
pub async fn check_actor(self: Arc<Self>, id: String) {
|
|
||||||
if let Some(result) = self.boot_actor(&id).await {
|
|
||||||
self.log(&id, &format!("actor.wasm changed: {result}"));
|
|
||||||
eprintln!("sausage: {result}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The OS file watcher dropped events: tell every watcher with one
|
|
||||||
/// `fs_changed {path: "/actor", op: "rescan"}`, then run a full pass.
|
|
||||||
pub async fn overflowed(self: Arc<Self>) -> Vec<Value> {
|
|
||||||
self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass");
|
|
||||||
let watchers: Vec<String> =
|
|
||||||
self.metas.lock().unwrap().iter().filter(|(_, m)| !m.watches.is_empty()).map(|(id, _)| id.clone()).collect();
|
|
||||||
self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/", "op": "rescan"}));
|
|
||||||
let report = self.scan().await;
|
|
||||||
for change in &report {
|
|
||||||
eprintln!("sausage: {change}");
|
|
||||||
}
|
|
||||||
report
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send `fs_changed` for one coalesced change (from `watch.rs`) to every
|
|
||||||
/// actor that watches `path` or `to`. Like taps: straight into the
|
|
||||||
/// mailbox, dropped if it is full.
|
|
||||||
pub fn notify_watchers(&self, path: &str, op: &str, to: Option<&str>) {
|
|
||||||
let watchers: Vec<String> = {
|
|
||||||
let metas = self.metas.lock().unwrap();
|
|
||||||
metas
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, m)| m.watches.iter().any(|w| under(path, w) || to.is_some_and(|t| under(t, w))))
|
|
||||||
.map(|(id, _)| id.clone())
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
if watchers.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut body = json!({"type": "fs_changed", "path": path, "op": op});
|
|
||||||
if let Some(to) = to {
|
|
||||||
body["to"] = json!(to);
|
|
||||||
}
|
|
||||||
self.inject(&watchers, vfs::ID, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A problem of the host itself: the broker's `stderr.log` and the terminal.
|
|
||||||
pub fn host_error(&self, text: &str) {
|
|
||||||
self.log("broker", text);
|
|
||||||
eprintln!("sausage: {text}");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn boot_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
|
|
||||||
if NATIVE.contains(&id) || !world::valid_id(id) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let lock = self.op_lock(id);
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
if !self.world.wasm_path(id).is_file() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.scan_actor(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn scan_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
|
|
||||||
// The broker's copy wins: actors change theirs (subscribe, ...) without the op lock.
|
|
||||||
let meta = self.metas.lock().unwrap().entry(id.to_string()).or_insert_with(|| self.world.read_meta(id)).clone();
|
|
||||||
let installed = meta.version.filter(|v| self.world.version_dir(id, *v).join("actor.wasm").is_file());
|
|
||||||
if let Some(v) = installed
|
|
||||||
&& !self.is_supervised(id)
|
|
||||||
{
|
|
||||||
self.start_supervisor(id, v, meta.running);
|
|
||||||
}
|
|
||||||
let wasm = self.world.wasm_path(id);
|
|
||||||
if installed.is_some_and(|v| same_file(&wasm, &self.world.version_dir(id, v).join("actor.wasm"))) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let source = self.world.source_dir(id);
|
|
||||||
let vmeta = VersionMeta {
|
|
||||||
version: self.world.next_version(id),
|
|
||||||
wasm: format!("/actor/{id}/actor.wasm"),
|
|
||||||
source: Some(format!("/actor/{id}/source")),
|
|
||||||
built_by: None,
|
|
||||||
requested_by: "scan".into(),
|
|
||||||
created_at: world::now(),
|
|
||||||
};
|
|
||||||
let source = source.is_dir().then_some(source.as_path());
|
|
||||||
let result = match installed {
|
|
||||||
None => self.create_version(id, &vmeta, &wasm, source).await,
|
|
||||||
Some(_) => self.upgrade_version(id, &vmeta, &wasm, source).await,
|
|
||||||
};
|
|
||||||
Some(result.unwrap_or_else(|e| json!({"type": "error", "actor": id, "reason": format!("{e:#}")})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle one message to `broker`.
|
|
||||||
async fn handle(self: Arc<Self>, env: Envelope) {
|
|
||||||
let body = self.clone().command(&env).await.unwrap_or_else(|e| {
|
|
||||||
json!({"type": "error", "request": env.body.get("type"), "reason": format!("{e:#}")})
|
|
||||||
});
|
|
||||||
self.send("broker", Some(&env.from), None, Some(&env.id), body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn command(self: Arc<Self>, env: &Envelope) -> Result<Value> {
|
|
||||||
let b = &env.body;
|
|
||||||
let ty = b["type"].as_str().unwrap_or("");
|
|
||||||
let actor = || b["actor"].as_str().map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\""));
|
|
||||||
let version_meta = || -> Result<(std::path::PathBuf, Option<std::path::PathBuf>, VersionMeta)> {
|
|
||||||
let wasm_s = b["wasm"].as_str().ok_or_else(|| anyhow!("missing \"wasm\""))?;
|
|
||||||
let wasm = self.world.resolve(&env.from, wasm_s)?;
|
|
||||||
if !wasm.is_file() {
|
|
||||||
bail!("{wasm_s} is not a file");
|
|
||||||
}
|
|
||||||
let source_s = b["source"].as_str();
|
|
||||||
let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?;
|
|
||||||
if let (Some(s), Some(p)) = (source_s, &source)
|
|
||||||
&& !p.is_dir()
|
|
||||||
{
|
|
||||||
bail!("{s} is not a folder");
|
|
||||||
}
|
|
||||||
let meta = VersionMeta {
|
|
||||||
version: 0,
|
|
||||||
wasm: wasm_s.to_string(),
|
|
||||||
source: source_s.map(str::to_string),
|
|
||||||
built_by: b["built_by"].as_str().map(str::to_string),
|
|
||||||
requested_by: env.from.clone(),
|
|
||||||
created_at: world::now(),
|
|
||||||
};
|
|
||||||
Ok((wasm, source, meta))
|
|
||||||
};
|
|
||||||
match ty {
|
|
||||||
"spawn" => {
|
|
||||||
let (wasm, source, meta) = version_meta()?;
|
|
||||||
self.create_actor(&actor()?, &wasm, source.as_deref(), meta).await
|
|
||||||
}
|
|
||||||
"upgrade" => {
|
|
||||||
let (wasm, source, meta) = version_meta()?;
|
|
||||||
self.upgrade_actor(&actor()?, &wasm, source.as_deref(), meta).await
|
|
||||||
}
|
|
||||||
"rollback" => {
|
|
||||||
let id = actor()?;
|
|
||||||
let version = b["version"].as_u64().ok_or_else(|| anyhow!("missing \"version\""))?;
|
|
||||||
let lock = self.op_lock(&id);
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
self.control(&id, |reply| Control::Rollback { version, reply }).await
|
|
||||||
}
|
|
||||||
"stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await,
|
|
||||||
"start" => self.control(&actor()?, |reply| Control::Start { reply }).await,
|
|
||||||
"remove" => self.remove_actor(&actor()?).await,
|
|
||||||
"subscribe" | "unsubscribe" => {
|
|
||||||
let publisher = actor()?;
|
|
||||||
let on = ty == "subscribe";
|
|
||||||
self.update_meta(&env.from, |m| {
|
|
||||||
if on {
|
|
||||||
m.subscriptions.insert(publisher.clone());
|
|
||||||
} else {
|
|
||||||
m.subscriptions.remove(&publisher);
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
|
|
||||||
}
|
|
||||||
"tap" | "untap" => {
|
|
||||||
let on = ty == "tap";
|
|
||||||
self.update_meta(&env.from, |m| m.tapped = on)?;
|
|
||||||
let answer = if on { "tapping" } else { "untapped" };
|
|
||||||
Ok(json!({"type": answer}))
|
|
||||||
}
|
|
||||||
"watch" | "unwatch" => {
|
|
||||||
let on = ty == "watch";
|
|
||||||
let path_s = b["path"].as_str().ok_or_else(|| anyhow!("missing \"path\""))?;
|
|
||||||
let global = self.world.global_path(&self.world.resolve(&env.from, path_s)?);
|
|
||||||
let path = global.ok_or_else(|| anyhow!("{path_s} is not under the world root"))?;
|
|
||||||
let path = match path.trim_end_matches('/') {
|
|
||||||
"" => "/".to_string(),
|
|
||||||
p => p.to_string(),
|
|
||||||
};
|
|
||||||
self.update_meta(&env.from, |m| {
|
|
||||||
if on {
|
|
||||||
m.watches.insert(path.clone());
|
|
||||||
} else {
|
|
||||||
m.watches.remove(&path);
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let answer = if on { "watching" } else { "unwatched" };
|
|
||||||
Ok(json!({"type": answer, "path": path}))
|
|
||||||
}
|
|
||||||
"exit" => {
|
|
||||||
self.log("broker", &format!("exit requested by {}", env.from));
|
|
||||||
// notify_one keeps the permit if nobody waits yet.
|
|
||||||
self.exit.notify_one();
|
|
||||||
Ok(json!({"type": "exiting"}))
|
|
||||||
}
|
|
||||||
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch, exit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The native `broker` actor. Requests run concurrently. Aborting this
|
|
||||||
/// task aborts the requests that still run. Replies (messages with
|
|
||||||
/// `reply_to`) get no answer, so that two actors never answer each other
|
|
||||||
/// forever.
|
|
||||||
pub async fn run_actor(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
|
||||||
let mut requests = tokio::task::JoinSet::new();
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
env = mailbox.recv() => match env {
|
|
||||||
Some(env) if env.reply_to.is_none() => { requests.spawn(self.clone().handle(env)); }
|
|
||||||
Some(_) => {}
|
|
||||||
None => break,
|
|
||||||
},
|
|
||||||
Some(_) = requests.join_next() => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn same_file(a: &Path, b: &Path) -> bool {
|
|
||||||
matches!((std::fs::read(a), std::fs::read(b)), (Ok(x), Ok(y)) if x == y)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the namespace path `path` is `prefix` or inside it.
|
|
||||||
fn under(path: &str, prefix: &str) -> bool {
|
|
||||||
prefix == "/" || path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn truncate(s: &str, max: usize) -> String {
|
|
||||||
if s.len() <= max {
|
|
||||||
return s.to_string();
|
|
||||||
}
|
|
||||||
let mut end = max;
|
|
||||||
while !s.is_char_boundary(end) {
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
format!("{}… ({} bytes)", &s[..end], s.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
//! Tests for a candidate version, before the broker installs it.
|
|
||||||
//!
|
|
||||||
//! A test is a JSON file in `source/tests/*.json`, one scenario per file:
|
|
||||||
//!
|
|
||||||
//! ```json
|
|
||||||
//! {
|
|
||||||
//! "description": "add, then list",
|
|
||||||
//! "steps": [
|
|
||||||
//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
|
|
||||||
//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"}
|
|
||||||
//! ]
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Each step sends `send` (optional, from `from` or "test"), then reads one
|
|
||||||
//! message for each entry of `expect` (an object or a list). An expected body
|
|
||||||
//! matches if it is a subset of the actual body. With `to`, each message must
|
|
||||||
//! also go to that recipient.
|
|
||||||
//!
|
|
||||||
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
|
|
||||||
//! `/public` (empty folders for a new actor), without network. A version
|
|
||||||
//! without test files passes.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use wasmtime::component::Component;
|
|
||||||
|
|
||||||
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
|
|
||||||
use crate::runtime::Instance;
|
|
||||||
use crate::world;
|
|
||||||
|
|
||||||
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
|
|
||||||
/// `expected` matches `actual` if every key of an expected object exists in
|
|
||||||
/// the actual object with a matching value. Other values must be equal.
|
|
||||||
pub fn subset_match(expected: &Value, actual: &Value) -> bool {
|
|
||||||
match (expected, actual) {
|
|
||||||
(Value::Object(e), Value::Object(a)) => e.iter().all(|(k, v)| a.get(k).is_some_and(|av| subset_match(v, av))),
|
|
||||||
(Value::Array(e), Value::Array(a)) => e.len() == a.len() && e.iter().zip(a).all(|(x, y)| subset_match(x, y)),
|
|
||||||
_ => expected == actual,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Scratch {
|
|
||||||
dir: std::path::PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Scratch {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stderr_tail(path: &Path) -> String {
|
|
||||||
let text = std::fs::read_to_string(path).unwrap_or_default();
|
|
||||||
let start = text.len().saturating_sub(2000);
|
|
||||||
let mut s = start;
|
|
||||||
while !text.is_char_boundary(s) {
|
|
||||||
s += 1;
|
|
||||||
}
|
|
||||||
text[s..].to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The version under test, which starts from a copy of the actor's state.
|
|
||||||
struct Candidate<'a> {
|
|
||||||
broker: &'a Broker,
|
|
||||||
id: &'a str,
|
|
||||||
version: u64,
|
|
||||||
component: &'a Component,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run every test file of `<source>/tests/`.
|
|
||||||
pub async fn test(broker: &Broker, id: &str, version: u64, component: &Component, source: Option<&Path>) -> Result<Value> {
|
|
||||||
let candidate = Candidate { broker, id, version, component };
|
|
||||||
let Some(source) = source else { return Ok(json!({"passed": []})) };
|
|
||||||
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
|
|
||||||
.map(|rd| rd.filter_map(|e| Some(e.ok()?.path())).filter(|p| p.extension().is_some_and(|x| x == "json")).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
files.sort();
|
|
||||||
let mut passed = Vec::new();
|
|
||||||
for file in &files {
|
|
||||||
let name = file.file_name().unwrap().to_string_lossy().to_string();
|
|
||||||
candidate.run_file(file).await.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
|
|
||||||
passed.push(name);
|
|
||||||
}
|
|
||||||
Ok(json!({"passed": passed}))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Candidate<'_> {
|
|
||||||
/// Start an instance on a fresh copy of the state, without network.
|
|
||||||
fn start(&self) -> Result<(Instance, Scratch)> {
|
|
||||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
||||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id()));
|
|
||||||
let scratch = Scratch { dir: dir.clone() };
|
|
||||||
let world = &self.broker.world;
|
|
||||||
for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public")] {
|
|
||||||
std::fs::create_dir_all(dir.join(name))?;
|
|
||||||
if from.is_dir() {
|
|
||||||
world::copy_tree(&from, &dir.join(name))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let cfg = self.broker.instance_config(self.id, self.version, Some(&dir));
|
|
||||||
Ok((self.broker.runtime.start(self.component, cfg)?, scratch))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_file(&self, file: &Path) -> Result<()> {
|
|
||||||
let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?;
|
|
||||||
let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?;
|
|
||||||
let (mut inst, scratch) = self.start()?;
|
|
||||||
let stderr = scratch.dir.join("stderr.log");
|
|
||||||
// Lines that the candidate wrote while a step was sent.
|
|
||||||
let mut early = std::collections::VecDeque::new();
|
|
||||||
for (n, step) in steps.iter().enumerate() {
|
|
||||||
let n = n + 1;
|
|
||||||
if let Some(body) = step.get("send") {
|
|
||||||
let from = step["from"].as_str().unwrap_or("test");
|
|
||||||
let env = json!({"id": format!("test/{n}"), "from": from, "body": body});
|
|
||||||
inst.send_line(&env.to_string(), |line| early.push_back(line))
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
|
|
||||||
}
|
|
||||||
let expected = match step.get("expect") {
|
|
||||||
None => vec![],
|
|
||||||
Some(Value::Array(list)) => list.clone(),
|
|
||||||
Some(one) => vec![one.clone()],
|
|
||||||
};
|
|
||||||
for expected in expected {
|
|
||||||
let next = async { if early.is_empty() { inst.stdout.recv().await } else { early.pop_front() } };
|
|
||||||
let line = match tokio::time::timeout(EXPECT_TIMEOUT, next).await {
|
|
||||||
Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"),
|
|
||||||
Ok(None) => {
|
|
||||||
let exit = inst.wait().await;
|
|
||||||
bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr))
|
|
||||||
}
|
|
||||||
Ok(Some(l)) => l,
|
|
||||||
};
|
|
||||||
let msg: Value = serde_json::from_str(&line)
|
|
||||||
.map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?;
|
|
||||||
if !subset_match(&expected, &msg["body"]) {
|
|
||||||
bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000));
|
|
||||||
}
|
|
||||||
if let Some(to) = step.get("to")
|
|
||||||
&& msg.get("to") != Some(to)
|
|
||||||
{
|
|
||||||
bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |_| {}).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,22 @@
|
|||||||
//! The native `cargo` actor: builds Rust source folders into `.wasm` files.
|
//! Rust build helpers for the native cargo plugin.
|
||||||
//!
|
|
||||||
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
|
|
||||||
//! optional `"skip_tests": true` skips native `cargo test` (tests run by default).
|
|
||||||
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "tests": {"passed": 1, "failed": 0, "output": "..."}, ...}`
|
|
||||||
//! `{"type": "build_failed", "errors": "...", "tests": {"passed": 0, "failed": 1, "output": "..."}}`
|
|
||||||
//!
|
|
||||||
//! The broker knows nothing about Rust. This actor is one way to get a
|
|
||||||
//! `.wasm` file. Other actors can add other languages.
|
|
||||||
//!
|
|
||||||
//! Build scripts compile C for WASI with the clang in `CC_wasm32_wasip2`
|
|
||||||
//! and `AR_wasm32_wasip2`. The Nix package and the dev shell set both.
|
|
||||||
//!
|
|
||||||
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
|
|
||||||
//! published: this actor copies the SDK of this host to `public/sdk/` (so
|
|
||||||
//! that every actor can read it), and points the dependency there in the
|
|
||||||
//! build copy of `Cargo.toml`.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use serde_json::{Value, json};
|
use serde_json::Value;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
|
|
||||||
use crate::broker::{Broker, Envelope};
|
|
||||||
use crate::world::{self, World};
|
use crate::world::{self, World};
|
||||||
|
|
||||||
pub const ID: &str = "cargo";
|
pub const ID: &str = "cargo";
|
||||||
pub const TARGET: &str = "wasm32-wasip2";
|
pub const TARGET: &str = "wasm32-wasip2";
|
||||||
|
|
||||||
/// The source of the `sausage-actor` SDK.
|
/// 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)]
|
#[derive(Clone, Default, Debug)]
|
||||||
pub struct TestResults {
|
pub struct TestResults {
|
||||||
@@ -43,12 +25,6 @@ pub struct TestResults {
|
|||||||
pub output: String,
|
pub output: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestResults {
|
|
||||||
fn value(&self) -> Value {
|
|
||||||
json!({"passed": self.passed, "failed": self.failed, "output": self.output})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct BuildOutput {
|
pub struct BuildOutput {
|
||||||
/// Host path of the published `.wasm` file.
|
/// Host path of the published `.wasm` file.
|
||||||
pub wasm: PathBuf,
|
pub wasm: PathBuf,
|
||||||
@@ -77,7 +53,10 @@ impl std::error::Error for BuildFailure {}
|
|||||||
|
|
||||||
impl From<anyhow::Error> for BuildFailure {
|
impl From<anyhow::Error> for BuildFailure {
|
||||||
fn from(error: anyhow::Error) -> Self {
|
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 {
|
impl Cargo {
|
||||||
/// Create the actor and install the SDK of this host into `public/sdk/`.
|
/// Create the plugin and install the SDK of this host into `public/sdk/`.
|
||||||
pub fn new(world: World) -> Result<Self> {
|
pub fn new(world: World) -> Result<Self> {
|
||||||
write_dir(&SDK, &world.public_dir(ID).join("sdk"))?;
|
let sdk = world.plugin_dir(ID).join("sdk");
|
||||||
|
write_dir(&SDK_SRC, &sdk.join("src"))?;
|
||||||
|
write_dir(&SDK_MACROS, &sdk.join("macros/src"))?;
|
||||||
|
world::write_atomic(
|
||||||
|
&sdk.join("Cargo.toml"),
|
||||||
|
include_bytes!("../../sdk/Cargo.toml"),
|
||||||
|
)?;
|
||||||
|
world::write_atomic(
|
||||||
|
&sdk.join("macros/Cargo.toml"),
|
||||||
|
include_bytes!("../../sdk/macros/Cargo.toml"),
|
||||||
|
)?;
|
||||||
Ok(Self { world })
|
Ok(Self { world })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run native `cargo test` unless opted out, then build and publish the
|
/// Run native `cargo test` unless opted out, then build and publish the
|
||||||
/// crate's `wasm32-wasip2` binary under `/actor/cargo/public/builds/<n>/`.
|
/// crate's `wasm32-wasip2` binary under `/plugin/cargo/public/builds/<n>/`.
|
||||||
pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result<BuildOutput, BuildFailure> {
|
pub async fn build(
|
||||||
|
&self,
|
||||||
|
source: &Path,
|
||||||
|
skip_tests: bool,
|
||||||
|
) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||||
if !source.join("Cargo.toml").is_file() {
|
if !source.join("Cargo.toml").is_file() {
|
||||||
return Err(BuildFailure {
|
return Err(BuildFailure {
|
||||||
reason: format!("{} has no Cargo.toml", source.display()),
|
reason: format!("{} has no Cargo.toml", source.display()),
|
||||||
@@ -103,17 +96,20 @@ impl Cargo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let data = self.world.data_dir(ID);
|
let data = self.world.state_dir(ID);
|
||||||
let build_dir = data.join("work");
|
let build_dir = data.join("work");
|
||||||
world::replace_source(source, &build_dir)?;
|
world::replace_source(source, &build_dir)?;
|
||||||
let manifest = build_dir.join("Cargo.toml");
|
let manifest = build_dir.join("Cargo.toml");
|
||||||
let original = std::fs::read_to_string(&manifest).context("read build manifest")?;
|
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")?;
|
std::fs::write(&manifest, prepared).context("write prepared manifest")?;
|
||||||
|
|
||||||
let target_dir = data.join("target");
|
let target_dir = data.join("target");
|
||||||
let tests = if skip_tests {
|
let tests = if skip_tests {
|
||||||
TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() }
|
TestResults {
|
||||||
|
output: "skipped (skip_tests=true)".into(),
|
||||||
|
..TestResults::default()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
match run_tests(&build_dir, &target_dir).await {
|
match run_tests(&build_dir, &target_dir).await {
|
||||||
Ok(tests) => tests,
|
Ok(tests) => tests,
|
||||||
@@ -121,13 +117,20 @@ impl Cargo {
|
|||||||
let output = format!("cannot run cargo tests: {error:#}");
|
let output = format!("cannot run cargo tests: {error:#}");
|
||||||
return Err(BuildFailure {
|
return Err(BuildFailure {
|
||||||
reason: output.clone(),
|
reason: output.clone(),
|
||||||
tests: TestResults { failed: 1, output, ..TestResults::default() },
|
tests: TestResults {
|
||||||
|
failed: 1,
|
||||||
|
output,
|
||||||
|
..TestResults::default()
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if tests.failed > 0 {
|
if tests.failed > 0 {
|
||||||
return Err(BuildFailure { reason: "unit tests failed".into(), tests });
|
return Err(BuildFailure {
|
||||||
|
reason: "unit tests failed".into(),
|
||||||
|
tests,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let build_result: Result<BuildOutput> = async {
|
let build_result: Result<BuildOutput> = async {
|
||||||
@@ -141,7 +144,10 @@ impl Cargo {
|
|||||||
.arg(&target_dir)
|
.arg(&target_dir)
|
||||||
.current_dir(&build_dir)
|
.current_dir(&build_dir)
|
||||||
.kill_on_drop(true);
|
.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();
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
bail!("{}", strip_progress(&stderr));
|
bail!("{}", strip_progress(&stderr));
|
||||||
@@ -155,10 +161,12 @@ impl Cargo {
|
|||||||
artifact = Some(PathBuf::from(exe));
|
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 n = next_build_number(&self.world.plugin_dir(ID).join("builds"))?;
|
||||||
let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string());
|
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 name = artifact.file_name().context("artifact has no file name")?;
|
||||||
let dest = dest_dir.join(name);
|
let dest = dest_dir.join(name);
|
||||||
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
|
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
|
||||||
@@ -170,52 +178,10 @@ impl Cargo {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
build_result.map_err(|error| BuildFailure { reason: format!("{error:#}"), tests })
|
build_result.map_err(|error| BuildFailure {
|
||||||
}
|
reason: format!("{error:#}"),
|
||||||
|
tests,
|
||||||
/// The native `cargo` actor. Builds run one at a time, in order.
|
})
|
||||||
/// Replies (messages with `reply_to`) get no answer.
|
|
||||||
pub async fn run_actor(self, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
|
|
||||||
while let Some(env) = mailbox.recv().await {
|
|
||||||
if env.reply_to.is_some() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
|
|
||||||
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}"), "tests": TestResults::default().value()})
|
|
||||||
});
|
|
||||||
broker.send(ID, Some(&env.from), None, Some(&env.id), body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle(&self, broker: &Broker, env: &Envelope) -> Result<Value> {
|
|
||||||
match env.body.get("type").and_then(Value::as_str) {
|
|
||||||
Some("build") => {
|
|
||||||
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
|
|
||||||
let source = broker.world.resolve(&env.from, source_s)?;
|
|
||||||
let skip_tests = env.body.get("skip_tests").and_then(Value::as_bool).unwrap_or(false);
|
|
||||||
match self.build(&source, skip_tests).await {
|
|
||||||
Ok(out) => {
|
|
||||||
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
|
|
||||||
Ok(json!({
|
|
||||||
"type": "built",
|
|
||||||
"wasm": wasm,
|
|
||||||
"source": source_s,
|
|
||||||
"built_by": ID,
|
|
||||||
"seconds": (out.seconds * 10.0).round() / 10.0,
|
|
||||||
"warnings": out.warnings,
|
|
||||||
"tests": out.tests.value(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Err(failure) => Ok(json!({
|
|
||||||
"type": "build_failed",
|
|
||||||
"source": source_s,
|
|
||||||
"errors": failure.reason,
|
|
||||||
"tests": failure.tests.value(),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => bail!("unknown request {other:?}. Known: build"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +211,11 @@ async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result<TestResults> {
|
|||||||
if !out.status.success() && failed == 0 {
|
if !out.status.success() && failed == 0 {
|
||||||
failed = 1;
|
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.
|
/// Count the `passed` or `failed` totals in cargo's per-target test summaries.
|
||||||
@@ -268,7 +238,12 @@ fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
|
|||||||
let mut doc: toml_edit::DocumentMut = text.parse()?;
|
let mut doc: toml_edit::DocumentMut = text.parse()?;
|
||||||
if let Some(sdk) = sdk {
|
if let Some(sdk) = sdk {
|
||||||
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
|
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();
|
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
|
||||||
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
|
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
|
||||||
table.remove(key);
|
table.remove(key);
|
||||||
@@ -289,7 +264,9 @@ fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
|
|||||||
for file in dir.files() {
|
for file in dir.files() {
|
||||||
let dest = to.join(file.path());
|
let dest = to.join(file.path());
|
||||||
let contents = match file.path().file_name() {
|
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(),
|
_ => file.contents().to_vec(),
|
||||||
};
|
};
|
||||||
if std::fs::read(&dest).ok() != Some(contents.clone()) {
|
if std::fs::read(&dest).ok() != Some(contents.clone()) {
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//! The native `console` actor: your terminal.
|
|
||||||
//!
|
|
||||||
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
|
|
||||||
//! `{"type": "text", "text": ...}`.
|
|
||||||
//! - A line without `@` goes to `agent` as text.
|
|
||||||
//! - `/help`, `/quit`. EOF on stdin also quits (not with `--headless`, which
|
|
||||||
//! never reads stdin).
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
use crate::broker::{Broker, Envelope};
|
|
||||||
|
|
||||||
pub const ID: &str = "console";
|
|
||||||
|
|
||||||
const HELP: &str = "\
|
|
||||||
@<actor> <json> send a JSON body to an actor
|
|
||||||
@<actor> <text> send {\"type\":\"text\",\"text\":...}
|
|
||||||
<text> send text to the agent
|
|
||||||
/quit stop all actors and exit";
|
|
||||||
|
|
||||||
/// Print a message: text bodies as text, everything else as JSON.
|
|
||||||
fn show(env: &Envelope) {
|
|
||||||
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
|
|
||||||
match env.body["text"].as_str() {
|
|
||||||
Some(text) if env.body["type"] == "text" => println!("[{}{reply}] {text}", env.from),
|
|
||||||
_ => println!("[{}{reply}] {}", env.from, env.body),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print every message that arrives for the console.
|
|
||||||
pub async fn run_mailbox(mut mailbox: mpsc::Receiver<Envelope>) {
|
|
||||||
while let Some(env) = mailbox.recv().await {
|
|
||||||
show(&env);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read lines until EOF or `/quit`, and send them as messages.
|
|
||||||
pub async fn run_input(broker: Arc<Broker>) {
|
|
||||||
println!("Sausage console. Type /help for commands.");
|
|
||||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
|
||||||
let line = line.trim();
|
|
||||||
let (to, body) = match line {
|
|
||||||
"" => continue,
|
|
||||||
"/quit" => break,
|
|
||||||
"/help" => {
|
|
||||||
println!("{HELP}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => match line.strip_prefix('@') {
|
|
||||||
Some(rest) => {
|
|
||||||
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
|
||||||
let body = match serde_json::from_str::<Value>(text.trim()) {
|
|
||||||
Ok(v @ Value::Object(_)) => v,
|
|
||||||
_ => json!({"type": "text", "text": text.trim()}),
|
|
||||||
};
|
|
||||||
(to, body)
|
|
||||||
}
|
|
||||||
None => ("agent", json!({"type": "text", "text": line})),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
broker.send(ID, Some(to), None, None, body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! SQLite journal entries and filters.
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{collections::HashSet, path::Path, sync::Mutex};
|
||||||
|
pub struct Journal {
|
||||||
|
db: Mutex<Connection>,
|
||||||
|
warned: Mutex<HashSet<String>>,
|
||||||
|
}
|
||||||
|
impl Journal {
|
||||||
|
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||||
|
let db = Connection::open(path)?;
|
||||||
|
db.execute_batch("CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, pid TEXT, plugin TEXT, stream TEXT, line TEXT)")?;
|
||||||
|
Ok(Self {
|
||||||
|
db: Mutex::new(db),
|
||||||
|
warned: Mutex::new(HashSet::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
|
||||||
|
self.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO journal VALUES (?1,?2,?3,?4,?5)",
|
||||||
|
params![uuid::Uuid::now_v7().to_string(), pid, plugin, stream, line],
|
||||||
|
)
|
||||||
|
.expect("write journal");
|
||||||
|
}
|
||||||
|
pub fn warning(&self, pid: &str, plugin: &str) {
|
||||||
|
if self.warned.lock().unwrap().insert(pid.into()) {
|
||||||
|
self.write(pid, plugin, "core", "warning: discarded stdout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn query(&self, p: &Value) -> Value {
|
||||||
|
let db = self.db.lock().unwrap();
|
||||||
|
let mut stmt=db.prepare("SELECT id,pid,plugin,stream,line FROM journal WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2) AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4) AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6").unwrap();
|
||||||
|
let rows=stmt.query_map(params![p["plugin"].as_str(),p["pid"].as_str(),p["since"].as_str(),p["until"].as_str(),p["grep"].as_str(),p["limit"].as_i64().unwrap_or(100)],|r|Ok(json!({"id":r.get::<_,String>(0)?,"pid":r.get::<_,String>(1)?,"plugin":r.get::<_,String>(2)?,"stream":r.get::<_,String>(3)?,"line":r.get::<_,String>(4)?}))).unwrap();
|
||||||
|
let mut entries: Vec<_> = rows.map(Result::unwrap).collect();
|
||||||
|
entries.reverse();
|
||||||
|
json!(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
+622
-72
@@ -1,86 +1,636 @@
|
|||||||
//! Sausage: grow a system of WASI actors by talking to it.
|
//! Process ownership, JSON-RPC routing, native plugins, and host lifetime.
|
||||||
//!
|
pub mod cargo;
|
||||||
//! The host runs the broker, the native actors (`broker`, `cargo`, and the
|
mod journal;
|
||||||
//! 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;
|
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod supervisor;
|
|
||||||
pub mod vfs;
|
|
||||||
pub mod watch;
|
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
use runtime::{InstanceConfig, Runtime};
|
||||||
use std::path::Path;
|
use serde_json::{Value, json};
|
||||||
use std::sync::Arc;
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
use anyhow::{Result, bail};
|
collections::{HashMap, VecDeque},
|
||||||
use tokio::sync::mpsc;
|
future::Future,
|
||||||
|
path::Path,
|
||||||
pub use broker::{Broker, Envelope};
|
pin::Pin,
|
||||||
pub use world::World;
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
/// The contract between the host and the actors. The host publishes it at
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
/// `/actor/broker/public/PROTOCOL.md`, so that every actor can read it.
|
use uuid::Uuid;
|
||||||
const PROTOCOL: &str = include_str!("../../PROTOCOL.md");
|
use world::World;
|
||||||
|
pub type RpcResult = Result<Value, Value>;
|
||||||
/// A running world.
|
pub type NativeFuture = Pin<Box<dyn Future<Output = RpcResult> + Send>>;
|
||||||
pub struct Host {
|
pub type NativePlugin = Arc<dyn Fn(NativeContext, String, Value) -> NativeFuture + Send + Sync>;
|
||||||
pub world: World,
|
#[derive(Clone)]
|
||||||
pub broker: Arc<Broker>,
|
pub struct NativeContext {
|
||||||
native: Vec<tokio::task::JoinHandle<()>>,
|
pub host: Host,
|
||||||
watcher: Option<watch::Watcher>,
|
pub pid: String,
|
||||||
_lock: std::fs::File,
|
}
|
||||||
|
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<String>,
|
||||||
|
pub detached: bool,
|
||||||
|
pub start_time: u128,
|
||||||
|
pub state: String,
|
||||||
|
}
|
||||||
|
struct Process {
|
||||||
|
info: ProcessInfo,
|
||||||
|
input: Option<mpsc::UnboundedSender<Value>>,
|
||||||
|
abort: Option<tokio::task::AbortHandle>,
|
||||||
|
caller: String,
|
||||||
|
id: Option<Value>,
|
||||||
|
answer: Option<oneshot::Sender<RpcResult>>,
|
||||||
|
ended: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
struct Lock {
|
||||||
|
owner: String,
|
||||||
|
queue: VecDeque<(String, oneshot::Sender<RpcResult>)>,
|
||||||
|
}
|
||||||
|
struct State {
|
||||||
|
processes: HashMap<String, Process>,
|
||||||
|
locks: HashMap<String, Lock>,
|
||||||
|
components: HashMap<String, wasmtime::component::Component>,
|
||||||
|
native: HashMap<String, NativePlugin>,
|
||||||
|
}
|
||||||
|
struct Inner {
|
||||||
|
world: World,
|
||||||
|
runtime: Arc<Runtime>,
|
||||||
|
state: Mutex<State>,
|
||||||
|
journal: journal::Journal,
|
||||||
|
notifications: tokio::sync::broadcast::Sender<Value>,
|
||||||
|
exit: watch::Sender<bool>,
|
||||||
|
compilation: tokio::sync::Mutex<()>,
|
||||||
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Host(Arc<Inner>);
|
||||||
|
struct ProcessGuard {
|
||||||
|
host: Host,
|
||||||
|
pid: String,
|
||||||
|
}
|
||||||
|
impl Drop for ProcessGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.host.finish(&self.pid, "process task ended");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn error(code: i64, text: &str) -> Value {
|
||||||
|
json!({"code":code,"message":text})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Host {
|
impl Host {
|
||||||
/// Start the broker, `cargo`, the file watcher, and every registered
|
pub async fn start(path: &Path) -> anyhow::Result<Self> {
|
||||||
/// actor of the world at `root`. Only one host can run a world at a time.
|
let base = std::env::var_os("XDG_CACHE_HOME")
|
||||||
pub async fn start(root: &Path) -> Result<Host> {
|
.map(std::path::PathBuf::from)
|
||||||
let world = World::new(std::path::absolute(root)?);
|
.unwrap_or_else(|| {
|
||||||
if !world.exists() {
|
std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
|
||||||
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
|
.join(".cache")
|
||||||
|
});
|
||||||
|
Self::start_with_cache(path, &base.join("sausage")).await
|
||||||
}
|
}
|
||||||
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result<Self> {
|
||||||
if lock.try_lock().is_err() {
|
let world = World::open(path)?;
|
||||||
bail!("another sausage process runs {}", root.display());
|
let journal = journal::Journal::open(&world.state_dir("core").join("journal.sqlite"))?;
|
||||||
|
let host = Self(Arc::new(Inner {
|
||||||
|
world,
|
||||||
|
runtime: Runtime::new(cache)?,
|
||||||
|
journal,
|
||||||
|
state: Mutex::new(State {
|
||||||
|
processes: HashMap::new(),
|
||||||
|
locks: HashMap::new(),
|
||||||
|
components: HashMap::new(),
|
||||||
|
native: HashMap::new(),
|
||||||
|
}),
|
||||||
|
notifications: tokio::sync::broadcast::channel(1024).0,
|
||||||
|
exit: watch::channel(false).0,
|
||||||
|
compilation: tokio::sync::Mutex::new(()),
|
||||||
|
}));
|
||||||
|
let boot: Vec<Value> =
|
||||||
|
serde_json::from_slice(&std::fs::read(host.0.world.root.join("config/boot.json"))?)?;
|
||||||
|
for service in boot {
|
||||||
|
let result = host
|
||||||
|
.route(
|
||||||
|
"core".into(),
|
||||||
|
Some(json!(Uuid::now_v7().to_string())),
|
||||||
|
service["method"].as_str().unwrap_or("").into(),
|
||||||
|
service["params"].clone(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = result {
|
||||||
|
host.0.journal.write("core", "core", "core", &e.to_string());
|
||||||
}
|
}
|
||||||
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
|
|
||||||
world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?;
|
|
||||||
|
|
||||||
let cargo = cargo_actor::Cargo::new(world.clone())?;
|
|
||||||
let native = vec![
|
|
||||||
tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))),
|
|
||||||
tokio::spawn(cargo.run_actor(broker.clone(), broker.open_mailbox(cargo_actor::ID))),
|
|
||||||
];
|
|
||||||
// Watch first, so that no change during the boot pass is lost.
|
|
||||||
let watcher = watch::start(broker.clone())
|
|
||||||
.inspect_err(|e| broker.host_error(&format!("no file watching: {e}")))
|
|
||||||
.ok();
|
|
||||||
for change in broker.scan().await {
|
|
||||||
eprintln!("sausage: {change}");
|
|
||||||
}
|
}
|
||||||
Ok(Host { world, broker, native, watcher, _lock: lock })
|
Ok(host)
|
||||||
}
|
}
|
||||||
|
pub fn register_native(&self, name: &str, plugin: NativePlugin) {
|
||||||
/// Register a native actor with the ID `id` and return its mailbox.
|
self.0
|
||||||
pub fn mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
|
.state
|
||||||
self.broker.open_mailbox(id)
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.native
|
||||||
|
.insert(name.into(), plugin);
|
||||||
}
|
}
|
||||||
|
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Value> {
|
||||||
/// Stop the file watcher, the broker and `cargo` (with the requests they
|
self.0.notifications.subscribe()
|
||||||
/// still run), then every actor, gracefully.
|
}
|
||||||
pub async fn stop(mut self) {
|
pub fn processes(&self) -> HashMap<String, ProcessInfo> {
|
||||||
drop(self.watcher.take());
|
self.0
|
||||||
for task in &self.native {
|
.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);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
pub async fn exit_requested(&self) {
|
||||||
|
let mut exit = self.0.exit.subscribe();
|
||||||
|
let _ = exit.wait_for(|requested| *requested).await;
|
||||||
|
}
|
||||||
|
pub fn restart_requested(&self) -> bool {
|
||||||
|
*self.0.exit.borrow()
|
||||||
|
}
|
||||||
|
fn send(&self, pid: &str, value: Value) {
|
||||||
|
if pid == "console" {
|
||||||
|
let _ = self.0.notifications.send(value);
|
||||||
|
} else if let Some(tx) = self
|
||||||
|
.0
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.processes
|
||||||
|
.get(pid)
|
||||||
|
.and_then(|p| p.input.as_ref())
|
||||||
|
{
|
||||||
|
let _ = tx.send(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn notify(&self, pid: &str, name: &str, params: Value) {
|
||||||
|
let target = self
|
||||||
|
.0
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.processes
|
||||||
|
.get(pid)
|
||||||
|
.and_then(|p| p.answer.as_ref().map(|_| (p.caller.clone(), p.id.clone())));
|
||||||
|
if let Some((caller, id)) = target {
|
||||||
|
self.send(&caller,json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":id,"name":name,"params":params}}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn route(
|
||||||
|
&self,
|
||||||
|
caller: String,
|
||||||
|
id: Option<Value>,
|
||||||
|
method: String,
|
||||||
|
params: Value,
|
||||||
|
detached: bool,
|
||||||
|
) -> NativeFuture {
|
||||||
|
let host = self.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
if !params.is_object() {
|
||||||
|
return Err(error(-32602, "params must be an object"));
|
||||||
|
}
|
||||||
|
if caller != "console" && caller != "core" {
|
||||||
|
let state = host.0.state.lock().unwrap();
|
||||||
|
let Some(process) = state.processes.get(&caller) else {
|
||||||
|
return Err(error(-32600, "process ended"));
|
||||||
|
};
|
||||||
|
if process.info.state != "running" && method != "core.notify" {
|
||||||
|
return Err(error(-32600, "process is stopping"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (plugin, method) = method
|
||||||
|
.split_once('.')
|
||||||
|
.ok_or_else(|| error(-32601, "method not found"))?;
|
||||||
|
if plugin == "core" {
|
||||||
|
return host.core(&caller, id, method, params).await;
|
||||||
|
}
|
||||||
|
if plugin.is_empty()
|
||||||
|
|| !plugin
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
|
||||||
|
{
|
||||||
|
return Err(error(-32601, "invalid plugin name"));
|
||||||
|
}
|
||||||
|
let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
|
||||||
|
let (component, version) = if native.is_some() {
|
||||||
|
(None, "native".into())
|
||||||
|
} else {
|
||||||
|
let _compilation = host.0.compilation.lock().await;
|
||||||
|
let bytes = std::fs::read(host.0.world.plugin_dir(plugin).join("plugin.wasm"))
|
||||||
|
.map_err(|_| error(-32001, "no such plugin"))?;
|
||||||
|
let hash = format!("{:x}", Sha256::digest(&bytes));
|
||||||
|
let cached = host.0.state.lock().unwrap().components.get(&hash).cloned();
|
||||||
|
let component = match cached {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
let rt = host.0.runtime.clone();
|
||||||
|
let c = tokio::task::spawn_blocking(move || rt.compile(&bytes))
|
||||||
|
.await
|
||||||
|
.map_err(|e| error(-32603, &e.to_string()))?
|
||||||
|
.map_err(|e| error(-32603, &e.to_string()))?;
|
||||||
|
host.0
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.components
|
||||||
|
.insert(hash.clone(), c.clone());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(Some(component), hash)
|
||||||
|
};
|
||||||
|
let pid = format!("{plugin}-{}", Uuid::now_v7());
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let (input, mut incoming) = mpsc::unbounded_channel::<Value>();
|
||||||
|
let info = ProcessInfo {
|
||||||
|
plugin: plugin.into(),
|
||||||
|
version,
|
||||||
|
method: method.into(),
|
||||||
|
params: params.clone(),
|
||||||
|
parent: if detached { None } else { Some(caller.clone()) },
|
||||||
|
detached,
|
||||||
|
start_time: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis(),
|
||||||
|
state: "running".into(),
|
||||||
|
};
|
||||||
|
let parent_name = info.parent.clone().unwrap_or_else(|| "core".into());
|
||||||
|
{
|
||||||
|
let mut state = host.0.state.lock().unwrap();
|
||||||
|
if caller != "console"
|
||||||
|
&& caller != "core"
|
||||||
|
&& !state
|
||||||
|
.processes
|
||||||
|
.get(&caller)
|
||||||
|
.is_some_and(|p| p.info.state == "running")
|
||||||
|
{
|
||||||
|
return Err(error(-32600, "caller stopped or ended"));
|
||||||
|
}
|
||||||
|
host.0
|
||||||
|
.journal
|
||||||
|
.write(&pid, plugin, "core", &format!("start {method} {params}"));
|
||||||
|
state.processes.insert(
|
||||||
|
pid.clone(),
|
||||||
|
Process {
|
||||||
|
info,
|
||||||
|
input: Some(input),
|
||||||
|
abort: None,
|
||||||
|
caller,
|
||||||
|
id: id.clone(),
|
||||||
|
answer: Some(tx),
|
||||||
|
ended: watch::channel(false).0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let plugin_name = plugin.to_string();
|
||||||
|
let h = host.clone();
|
||||||
|
let p = pid.clone();
|
||||||
|
let method = method.to_string();
|
||||||
|
let has_id = id.is_some();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let _guard = ProcessGuard {
|
||||||
|
host: h.clone(),
|
||||||
|
pid: p.clone(),
|
||||||
|
};
|
||||||
|
if let Some(native) = native {
|
||||||
|
let result = native(
|
||||||
|
NativeContext {
|
||||||
|
host: h.clone(),
|
||||||
|
pid: p.clone(),
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
h.answer(&p, result);
|
||||||
|
h.finish(&p, "exit 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cfg = InstanceConfig {
|
||||||
|
world: h.0.world.root.clone(),
|
||||||
|
network: true,
|
||||||
|
env: vec![
|
||||||
|
("SAUSAGE_PID".into(), p.clone()),
|
||||||
|
("SAUSAGE_PLUGIN".into(), plugin_name.clone()),
|
||||||
|
("SAUSAGE_PARENT".into(), parent_name),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let result = async {
|
||||||
|
let mut instance = h.0.runtime.start(&component.unwrap(),cfg)?;
|
||||||
|
let initial = if has_id { json!({"jsonrpc":"2.0","id":p,"method":method,"params":params}) } else {json!({"jsonrpc":"2.0","method":method,"params":params})};
|
||||||
|
instance.send_line(&initial.to_string(),|line|h.output(&p,line)).await?;
|
||||||
|
let mut stdin_open = true;
|
||||||
|
let mut stderr_open = true;
|
||||||
|
loop { tokio::select! {
|
||||||
|
line = instance.stderr.recv(), if stderr_open => match line { Some(line) => h.0.journal.write(&p,&plugin_name,"stderr",&line), None => stderr_open=false },
|
||||||
|
line = instance.stdout.recv() => match line { Some(line) => h.output(&p,line), None => break },
|
||||||
|
value = incoming.recv(), if stdin_open => match value { Some(v) => { instance.send_line(&v.to_string(),|line|h.output(&p,line)).await?; }, None => { instance.stdin.take(); stdin_open=false; } }
|
||||||
|
}}
|
||||||
|
while let Some(line) = instance.stderr.recv().await { h.0.journal.write(&p,&plugin_name,"stderr",&line); }
|
||||||
|
Ok::<_,anyhow::Error>(instance.wait().await.to_string())
|
||||||
|
}.await;
|
||||||
|
h.finish(&p, &result.unwrap_or_else(|e| e.to_string()));
|
||||||
|
});
|
||||||
|
if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) {
|
||||||
|
p.abort = Some(task.abort_handle());
|
||||||
|
} else {
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
for task in self.native {
|
if id.is_none() {
|
||||||
let _ = task.await;
|
return Ok(Value::Null);
|
||||||
}
|
}
|
||||||
self.broker.shutdown_all().await;
|
rx.await
|
||||||
|
.unwrap_or_else(|_| Err(error(-32002, "process ended")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn answer(&self, pid: &str, result: RpcResult) {
|
||||||
|
if let Some(tx) = self
|
||||||
|
.0
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.processes
|
||||||
|
.get_mut(pid)
|
||||||
|
.and_then(|p| p.answer.take())
|
||||||
|
{
|
||||||
|
let _ = tx.send(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn output(&self, pid: &str, line: String) {
|
||||||
|
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
||||||
|
self.discard(pid);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !v.is_object() || v["jsonrpc"] != "2.0" {
|
||||||
|
self.discard(pid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(method) = v["method"].as_str() {
|
||||||
|
let h = self.clone();
|
||||||
|
let caller = pid.to_string();
|
||||||
|
let exit = requests_exit(method, &v["params"]);
|
||||||
|
let method = method.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let id = v.get("id").cloned();
|
||||||
|
let result = h
|
||||||
|
.route(
|
||||||
|
caller.clone(),
|
||||||
|
id.clone(),
|
||||||
|
method,
|
||||||
|
v.get("params").cloned().unwrap_or(json!({})),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let success = result.is_ok();
|
||||||
|
if let Some(id) = id {
|
||||||
|
let value = match result {
|
||||||
|
Ok(r) => json!({"jsonrpc":"2.0","id":id,"result":r}),
|
||||||
|
Err(e) => json!({"jsonrpc":"2.0","id":id,"error":e}),
|
||||||
|
};
|
||||||
|
h.send(&caller, value);
|
||||||
|
}
|
||||||
|
if exit && success {
|
||||||
|
h.0.exit.send_replace(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if v["id"] == pid {
|
||||||
|
self.answer(
|
||||||
|
pid,
|
||||||
|
if let Some(e) = v.get("error") {
|
||||||
|
Err(e.clone())
|
||||||
|
} else {
|
||||||
|
Ok(v["result"].clone())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn discard(&self, pid: &str) {
|
||||||
|
// The journal deduplicates this warning for each process.
|
||||||
|
if let Some(info) = self.processes().get(pid) {
|
||||||
|
self.0.journal.warning(pid, &info.plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn finish(&self, pid: &str, status: &str) {
|
||||||
|
let mut state = self.0.state.lock().unwrap();
|
||||||
|
let process = state.processes.remove(pid);
|
||||||
|
if let Some(p) = process {
|
||||||
|
self.0.journal.write(pid, &p.info.plugin, "core", status);
|
||||||
|
if let Some(tx) = p.answer {
|
||||||
|
let _=tx.send(Err(json!({"code":-32002,"message":"process ended without an answer","data":{"status":status,"lines":self.0.journal.query(&json!({"pid":pid,"limit":20}))}})));
|
||||||
|
}
|
||||||
|
Self::release_locks(&mut state, pid);
|
||||||
|
let _ = p.ended.send(true);
|
||||||
|
let children: Vec<_> = state
|
||||||
|
.processes
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
drop(state);
|
||||||
|
for child in children {
|
||||||
|
self.begin_stop(&child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn begin_stop(&self, pid: &str) -> Option<watch::Receiver<bool>> {
|
||||||
|
let mut state = self.0.state.lock().unwrap();
|
||||||
|
let p = state.processes.get_mut(pid)?;
|
||||||
|
let ended = p.ended.subscribe();
|
||||||
|
if p.info.state == "stopping" {
|
||||||
|
return Some(ended);
|
||||||
|
}
|
||||||
|
p.info.state = "stopping".into();
|
||||||
|
p.input.take();
|
||||||
|
self.0.journal.write(pid, &p.info.plugin, "core", "stop");
|
||||||
|
let children: Vec<_> = state
|
||||||
|
.processes
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
drop(state);
|
||||||
|
for child in children {
|
||||||
|
self.begin_stop(&child);
|
||||||
|
}
|
||||||
|
let h = self.clone();
|
||||||
|
let pid = pid.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
h.kill(&pid);
|
||||||
|
});
|
||||||
|
Some(ended)
|
||||||
|
}
|
||||||
|
fn kill(&self, pid: &str) {
|
||||||
|
if let Some(p) = self.0.state.lock().unwrap().processes.get(pid) {
|
||||||
|
self.0.journal.write(pid, &p.info.plugin, "core", "kill");
|
||||||
|
if let Some(a) = &p.abort {
|
||||||
|
a.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.finish(pid, "killed");
|
||||||
|
}
|
||||||
|
pub async fn stop(&self) {
|
||||||
|
let ids: Vec<_> = self.processes().into_keys().collect();
|
||||||
|
let mut waits = Vec::new();
|
||||||
|
for pid in ids {
|
||||||
|
if let Some(n) = self.begin_stop(&pid) {
|
||||||
|
waits.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for mut n in waits {
|
||||||
|
let _ = n.wait_for(|ended| *ended).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn release_locks(state: &mut State, pid: &str) {
|
||||||
|
for lock in state.locks.values_mut() {
|
||||||
|
lock.queue.retain(|(p, _)| p != pid);
|
||||||
|
}
|
||||||
|
let paths: Vec<_> = state
|
||||||
|
.locks
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, l)| l.owner == pid)
|
||||||
|
.map(|(p, _)| p.clone())
|
||||||
|
.collect();
|
||||||
|
for path in paths {
|
||||||
|
grant(state, &path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn core(&self, caller: &str, id: Option<Value>, method: &str, p: Value) -> RpcResult {
|
||||||
|
match method {
|
||||||
|
"call" => {
|
||||||
|
self.route(
|
||||||
|
caller.into(),
|
||||||
|
id,
|
||||||
|
required_str(&p, "method")?.into(),
|
||||||
|
p.get("params").cloned().unwrap_or(json!({})),
|
||||||
|
p["detach"].as_bool().unwrap_or(false),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"notify" => {
|
||||||
|
self.notify(
|
||||||
|
caller,
|
||||||
|
required_str(&p, "name")?,
|
||||||
|
p.get("params").cloned().unwrap_or(json!({})),
|
||||||
|
);
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
"kill" => {
|
||||||
|
self.kill(required_str(&p, "pid")?);
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
"stop" => {
|
||||||
|
if let Some(mut n) = self.begin_stop(required_str(&p, "pid")?) {
|
||||||
|
let _ = n.wait_for(|ended| *ended).await;
|
||||||
|
}
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
"lock" => {
|
||||||
|
let path = p["path"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| error(-32602, "missing path"))?;
|
||||||
|
let rx = {
|
||||||
|
let mut state = self.0.state.lock().unwrap();
|
||||||
|
if let Some(lock) = state.locks.get_mut(path) {
|
||||||
|
if p["wait"] == false {
|
||||||
|
return Ok(json!({"held":false}));
|
||||||
|
}
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
lock.queue.push_back((caller.into(), tx));
|
||||||
|
Some(rx)
|
||||||
|
} else {
|
||||||
|
state.locks.insert(
|
||||||
|
path.into(),
|
||||||
|
Lock {
|
||||||
|
owner: caller.into(),
|
||||||
|
queue: VecDeque::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match rx {
|
||||||
|
Some(rx) => rx
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| Err(error(-32603, "lock canceled"))),
|
||||||
|
None => Ok(json!({"held":true})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"unlock" => {
|
||||||
|
let mut state = self.0.state.lock().unwrap();
|
||||||
|
let path = required_str(&p, "path")?;
|
||||||
|
if state.locks.get(path).is_some_and(|l| l.owner == caller) {
|
||||||
|
grant(&mut state, path);
|
||||||
|
}
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
"log" => Ok(self.0.journal.query(&p)),
|
||||||
|
"exit" => Ok(json!({})),
|
||||||
|
_ => Err(error(-32601, "method not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")))
|
||||||
|
}
|
||||||
|
|||||||
+72
-134
@@ -1,164 +1,102 @@
|
|||||||
use std::io::Write;
|
//! Command parsing and the minimal line console.
|
||||||
use std::path::PathBuf;
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use include_dir::{Dir, include_dir};
|
use sausage::{Host, world::World};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::signal::unix::Signal;
|
use std::{io::Write, path::PathBuf};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
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.
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(version, about)]
|
#[command(version)]
|
||||||
struct Cli {
|
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)]
|
#[arg(long, global = true)]
|
||||||
headless: bool,
|
headless: bool,
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
cache_dir: Option<PathBuf>,
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Cmd>,
|
command: Cmd,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
/// Create a new world and build the agent actor.
|
Init { dir: PathBuf },
|
||||||
///
|
Run { dir: PathBuf },
|
||||||
/// 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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of `var`, or the answer to `question`.
|
|
||||||
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
||||||
if let Ok(v) = std::env::var(var)
|
if let Ok(v) = std::env::var(var)
|
||||||
&& !v.is_empty()
|
&& !v.is_empty()
|
||||||
{
|
{
|
||||||
return Ok(v);
|
return Ok(v);
|
||||||
}
|
}
|
||||||
match default {
|
print!("{question}: ");
|
||||||
Some(d) => print!("{question} [{d}]: "),
|
|
||||||
None => print!("{question}: "),
|
|
||||||
}
|
|
||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
std::io::stdin().read_line(&mut line)?;
|
std::io::stdin().read_line(&mut line)?;
|
||||||
match (line.trim(), default) {
|
if !line.trim().is_empty() {
|
||||||
("", Some(d)) => Ok(d.to_string()),
|
return Ok(line.trim().into());
|
||||||
("", None) => bail!("{question} is required (or set {var})"),
|
}
|
||||||
(v, _) => Ok(v.to_string()),
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
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 {
|
match cli.command {
|
||||||
Some(Cmd::Init { dir }) => init(dir).await,
|
Cmd::Init { dir } => {
|
||||||
Some(Cmd::Run { dir }) => run(dir, headless).await,
|
let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?;
|
||||||
None => {
|
if api != "openai-responses" && api != "anthropic-messages" {
|
||||||
let dir = PathBuf::from("world");
|
bail!("unknown API shape {api:?}");
|
||||||
if !dir.join("actor").is_dir() {
|
|
||||||
println!("No world in ./world yet, creating it (same as `sausage init`).");
|
|
||||||
init(dir.clone()).await?;
|
|
||||||
}
|
}
|
||||||
run(dir, headless).await
|
let base_url = setting("SAUSAGE_BASE_URL", "Base URL", None)?;
|
||||||
|
let model = setting("SAUSAGE_MODEL", "Model", None)?;
|
||||||
|
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
|
||||||
|
let world = World::create(&dir)?;
|
||||||
|
std::fs::write(
|
||||||
|
world.root.join("config/agent.json"),
|
||||||
|
serde_json::to_vec_pretty(
|
||||||
|
&json!({"api":api,"base_url":base_url.trim_end_matches('/'),"model":model,"token":token}),
|
||||||
|
)?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Cmd::Run { dir } => {
|
||||||
|
let mut term =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||||
|
let host = match cli.cache_dir {
|
||||||
|
Some(cache) => Host::start_with_cache(&dir, &cache).await?,
|
||||||
|
None => Host::start(&dir).await?,
|
||||||
|
};
|
||||||
|
let mut notifications = host.subscribe();
|
||||||
|
let notification_task = tokio::spawn(async move {
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
println!("{notification}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let h = host.clone();
|
||||||
|
let console = async move {
|
||||||
|
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
line=lines.next_line()=>{let Some(line)=line? else {break;};if line=="quit" {break;}
|
||||||
|
let (method,params)=line.split_once(' ').unwrap_or((&line,"{}"));
|
||||||
|
match serde_json::from_str(params) {Ok(p)=>println!("{}",match h.call(method,p).await {Ok(v)=>v,Err(e)=>e}),Err(e)=>eprintln!("{e}")}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok::<_, anyhow::Error>(())
|
||||||
|
};
|
||||||
|
let exit = tokio::select! {
|
||||||
|
_=host.exit_requested()=>true,
|
||||||
|
_=term.recv()=>false,
|
||||||
|
_=tokio::signal::ctrl_c()=>false,
|
||||||
|
r=console,if !cli.headless=>{r?;false},
|
||||||
|
};
|
||||||
|
let exit = exit || host.restart_requested();
|
||||||
|
host.stop().await;
|
||||||
|
notification_task.abort();
|
||||||
|
if exit {
|
||||||
|
std::process::exit(75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
+72
-115
@@ -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
|
//! An instance is one run of a `wasi:cli/command` component. The host writes
|
||||||
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
|
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Weak};
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use wasmtime::component::{Component, Linker, ResourceTable};
|
use wasmtime::component::{Component, Linker, ResourceTable};
|
||||||
use wasmtime::{Config, Engine, Store};
|
use wasmtime::{Config, Engine, Store};
|
||||||
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
||||||
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
|
|
||||||
use wasmtime_wasi::p2::bindings::Command;
|
use wasmtime_wasi::p2::bindings::Command;
|
||||||
use wasmtime_wasi::p2::bindings::filesystem;
|
|
||||||
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||||
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
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 {
|
pub struct HttpHooks {
|
||||||
allow: bool,
|
allow: bool,
|
||||||
}
|
}
|
||||||
@@ -65,33 +54,35 @@ impl WasiHttpHooks for HttpHooks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ActorState {
|
pub struct ProcessState {
|
||||||
ctx: WasiCtx,
|
ctx: WasiCtx,
|
||||||
table: ResourceTable,
|
table: ResourceTable,
|
||||||
http: WasiHttpCtx,
|
http: WasiHttpCtx,
|
||||||
hooks: HttpHooks,
|
hooks: HttpHooks,
|
||||||
vfs: VfsState,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vfs_view(s: &mut ActorState) -> VfsView<'_> {
|
impl WasiView for ProcessState {
|
||||||
VfsView { inner: WasiFilesystemCtxView { ctx: s.ctx.filesystem(), table: &mut s.table }, state: &mut s.vfs }
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WasiView for ActorState {
|
|
||||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
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<'_> {
|
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 {
|
pub struct Runtime {
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
linker: Linker<ActorState>,
|
linker: Linker<ProcessState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How an instance ended.
|
/// How an instance ended.
|
||||||
@@ -116,22 +107,18 @@ impl std::fmt::Display for Exit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct InstanceConfig {
|
pub struct InstanceConfig {
|
||||||
pub mounts: Vec<Mount>,
|
pub world: PathBuf,
|
||||||
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
|
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
|
||||||
pub network: bool,
|
pub network: bool,
|
||||||
pub stderr_log: PathBuf,
|
|
||||||
pub env: Vec<(String, String)>,
|
pub env: Vec<(String, String)>,
|
||||||
/// The actor that runs in the instance.
|
|
||||||
pub actor: String,
|
|
||||||
/// Asks the actors that serve `/actor/<id>/fs/` (see `vfs.rs`).
|
|
||||||
pub broker: Weak<Broker>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A running guest. Dropping it kills the guest.
|
/// A running guest. Dropping it kills the guest.
|
||||||
pub struct Instance {
|
pub struct Instance {
|
||||||
stdin: Option<DuplexStream>,
|
pub stdin: Option<DuplexStream>,
|
||||||
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
||||||
pub stdout: mpsc::Receiver<String>,
|
pub stdout: mpsc::Receiver<String>,
|
||||||
|
pub stderr: mpsc::Receiver<String>,
|
||||||
task: JoinHandle<Exit>,
|
task: JoinHandle<Exit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +132,11 @@ impl Instance {
|
|||||||
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the
|
/// 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
|
/// lines that the guest writes to `route`: a guest that blocks on a full
|
||||||
/// stdout would never read its stdin.
|
/// 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 stdin = self.stdin.as_mut().ok_or(std::io::ErrorKind::BrokenPipe)?;
|
||||||
let bytes = format!("{line}\n");
|
let bytes = format!("{line}\n");
|
||||||
let write = async {
|
let write = async {
|
||||||
@@ -173,49 +164,6 @@ impl Instance {
|
|||||||
Err(e) => Exit::Trap(format!("host task failed: {e}")),
|
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.
|
/// The most useful line of a trap: the `wasm trap: ...` reason.
|
||||||
@@ -230,85 +178,91 @@ fn short_trap(full: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Runtime {
|
impl Runtime {
|
||||||
pub fn new() -> Result<Arc<Self>> {
|
pub fn new(cache: &Path) -> Result<Arc<Self>> {
|
||||||
let mut config = Config::new();
|
let mut config = Config::new();
|
||||||
config.epoch_interruption(true);
|
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 engine = Engine::new(&config)?;
|
||||||
|
|
||||||
let mut linker: Linker<ActorState> = Linker::new(&engine);
|
let mut linker: Linker<ProcessState> = Linker::new(&engine);
|
||||||
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
|
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
|
||||||
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
|
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
|
||||||
// Replace `wasi:filesystem` with the wrapper that serves `/actor/<id>/fs/`.
|
|
||||||
linker.allow_shadowing(true);
|
|
||||||
filesystem::types::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
|
|
||||||
filesystem::preopens::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
|
|
||||||
linker.allow_shadowing(false);
|
|
||||||
|
|
||||||
// Time slices: every 10 ms each running guest yields to the scheduler.
|
// 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 || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
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> {
|
pub fn compile(&self, bytes: &[u8]) -> Result<Component> {
|
||||||
Component::from_file(&self.engine, wasm)
|
Ok(Component::new(&self.engine, bytes)?)
|
||||||
.map_err(anyhow::Error::from)
|
|
||||||
.with_context(|| format!("load {}", wasm.display()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
|
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
|
||||||
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
||||||
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
|
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
|
||||||
|
|
||||||
let stderr = std::fs::OpenOptions::new()
|
let (guest_err, host_err) = tokio::io::duplex(64 * 1024);
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&cfg.stderr_log)
|
|
||||||
.with_context(|| format!("open {}", cfg.stderr_log.display()))?;
|
|
||||||
|
|
||||||
let mut b = WasiCtx::builder();
|
let mut b = WasiCtx::builder();
|
||||||
if cfg.network {
|
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.stdin(AsyncStdinStream::new(guest_in));
|
||||||
b.stdout(AsyncStdoutStream::new(8192, guest_out));
|
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 {
|
for (k, v) in &cfg.env {
|
||||||
b.env(k, v);
|
b.env(k, v);
|
||||||
}
|
}
|
||||||
for m in &cfg.mounts {
|
// Filesystem setup lives here. T6 replaces this preopen with the VFS.
|
||||||
let perms = if m.writable { FsPerms::ReadWrite } else { FsPerms::ReadOnly };
|
b.preopened_dir(&cfg.world, "/", FsPerms::ReadWrite)?;
|
||||||
b.preopened_dir(&m.host, &m.guest, perms)
|
let state = ProcessState {
|
||||||
.map_err(anyhow::Error::from)
|
|
||||||
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
|
|
||||||
}
|
|
||||||
let state = ActorState {
|
|
||||||
ctx: b.build(),
|
ctx: b.build(),
|
||||||
table: ResourceTable::new(),
|
table: ResourceTable::new(),
|
||||||
http: WasiHttpCtx::new(),
|
http: WasiHttpCtx::new(),
|
||||||
hooks: HttpHooks { allow: cfg.network },
|
hooks: HttpHooks { allow: cfg.network },
|
||||||
vfs: VfsState::new(cfg.broker.clone(), cfg.actor.clone()),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let (line_tx, line_rx) = mpsc::channel(1024);
|
let (line_tx, line_rx) = mpsc::channel(1024);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut lines = BufReader::new(host_out).lines();
|
let mut reader = BufReader::new(host_out);
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
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() {
|
if line_tx.send(line).await.is_err() {
|
||||||
break;
|
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 rt = self.clone();
|
||||||
let component = component.clone();
|
let component = component.clone();
|
||||||
let stderr_log = cfg.stderr_log.clone();
|
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
let mut store = Store::new(&rt.engine, state);
|
let mut store = Store::new(&rt.engine, state);
|
||||||
store.epoch_deadline_async_yield_and_update(1);
|
store.epoch_deadline_async_yield_and_update(1);
|
||||||
@@ -324,15 +278,18 @@ impl Runtime {
|
|||||||
Some(I32Exit(0)) => Exit::Clean,
|
Some(I32Exit(0)) => Exit::Clean,
|
||||||
Some(I32Exit(c)) => Exit::Code(*c),
|
Some(I32Exit(c)) => Exit::Code(*c),
|
||||||
None => {
|
None => {
|
||||||
// Keep the full backtrace in the log, and a short reason in the status.
|
|
||||||
let full = format!("{e:?}");
|
let full = format!("{e:?}");
|
||||||
log_note(&stderr_log, &format!("trap details:\n{full}"));
|
|
||||||
Exit::Trap(short_trap(&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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
//! The supervisor of one WASM actor: it runs the instance, feeds it the
|
|
||||||
//! mailbox, routes its output, restarts it after a crash, and swaps versions.
|
|
||||||
//!
|
|
||||||
//! The broker talks to a supervisor only through `Control` requests.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use tokio::sync::{mpsc, oneshot};
|
|
||||||
use wasmtime::component::Component;
|
|
||||||
|
|
||||||
use crate::broker::{Broker, Envelope, SHUTDOWN_DEADLINE, shutdown_line};
|
|
||||||
use crate::runtime::{Exit, Instance};
|
|
||||||
|
|
||||||
/// A new version that crashes this often within `CRASH_WINDOW` after an
|
|
||||||
/// upgrade is rolled back automatically.
|
|
||||||
const CRASH_LIMIT: usize = 3;
|
|
||||||
const CRASH_WINDOW: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
pub type Reply = oneshot::Sender<Result<Value>>;
|
|
||||||
|
|
||||||
pub enum Control {
|
|
||||||
/// Swap to `version` (already in `versions/`, compiled as `component`).
|
|
||||||
Upgrade { version: u64, component: Component, requester: String, reply: Reply },
|
|
||||||
Rollback { version: u64, reply: Reply },
|
|
||||||
Stop { reply: Reply },
|
|
||||||
Start { reply: Reply },
|
|
||||||
Shutdown { reply: Reply },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The time after an upgrade in which crashes count toward a rollback.
|
|
||||||
struct Probation {
|
|
||||||
previous: u64,
|
|
||||||
since: Instant,
|
|
||||||
crashes: usize,
|
|
||||||
requester: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Supervisor {
|
|
||||||
broker: Arc<Broker>,
|
|
||||||
id: String,
|
|
||||||
version: u64,
|
|
||||||
running: bool,
|
|
||||||
mailbox: mpsc::Receiver<Envelope>,
|
|
||||||
control: mpsc::Receiver<Control>,
|
|
||||||
component: Option<Component>,
|
|
||||||
instance: Option<Instance>,
|
|
||||||
started_at: Instant,
|
|
||||||
restarts: u32,
|
|
||||||
restart_at: Option<Instant>,
|
|
||||||
probation: Option<Probation>,
|
|
||||||
/// A control request that arrived during a blocked delivery.
|
|
||||||
next_control: Option<Control>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next_line(instance: &mut Option<Instance>) -> Option<String> {
|
|
||||||
match instance {
|
|
||||||
Some(i) => i.stdout.recv().await,
|
|
||||||
None => std::future::pending().await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sleep_until(t: Option<Instant>) {
|
|
||||||
match t {
|
|
||||||
Some(t) => tokio::time::sleep_until(t.into()).await,
|
|
||||||
None => std::future::pending().await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Supervisor {
|
|
||||||
pub fn new(
|
|
||||||
broker: Arc<Broker>,
|
|
||||||
id: String,
|
|
||||||
version: u64,
|
|
||||||
running: bool,
|
|
||||||
mailbox: mpsc::Receiver<Envelope>,
|
|
||||||
control: mpsc::Receiver<Control>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
broker,
|
|
||||||
id,
|
|
||||||
version,
|
|
||||||
running,
|
|
||||||
mailbox,
|
|
||||||
control,
|
|
||||||
component: None,
|
|
||||||
instance: None,
|
|
||||||
started_at: Instant::now(),
|
|
||||||
restarts: 0,
|
|
||||||
restart_at: None,
|
|
||||||
probation: None,
|
|
||||||
next_control: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(mut self) {
|
|
||||||
loop {
|
|
||||||
if let Some(ctl) = self.next_control.take() {
|
|
||||||
if self.handle(ctl).await {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if self.running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
|
|
||||||
self.restart_at = None;
|
|
||||||
let _ = self.start().await;
|
|
||||||
}
|
|
||||||
let has_instance = self.instance.is_some();
|
|
||||||
tokio::select! {
|
|
||||||
ctl = self.control.recv() => {
|
|
||||||
let Some(ctl) = ctl else { break };
|
|
||||||
if self.handle(ctl).await {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg = self.mailbox.recv(), if has_instance => {
|
|
||||||
let Some(msg) = msg else { break };
|
|
||||||
self.deliver(msg).await;
|
|
||||||
}
|
|
||||||
line = next_line(&mut self.instance) => match line {
|
|
||||||
Some(line) => self.broker.route_line(&self.id, &line),
|
|
||||||
None => {
|
|
||||||
let exit = self.instance.as_mut().unwrap().wait().await;
|
|
||||||
self.instance = None;
|
|
||||||
self.on_exit(exit).await;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ = sleep_until(self.restart_at), if !has_instance && self.running => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a message to the instance's stdin, and route its output
|
|
||||||
/// meanwhile. If a control request arrives before the actor reads the
|
|
||||||
/// message, the instance is killed (a partly written line would corrupt
|
|
||||||
/// the next message) and the request runs.
|
|
||||||
async fn deliver(&mut self, msg: Envelope) {
|
|
||||||
let Some(inst) = self.instance.as_mut() else { return };
|
|
||||||
let (broker, id, line) = (self.broker.clone(), self.id.clone(), msg.to_line());
|
|
||||||
let stuck = tokio::select! {
|
|
||||||
written = inst.send_line(&line, |l| broker.route_line(&id, &l)) => match written {
|
|
||||||
Ok(()) => return,
|
|
||||||
// The instance is ending. The main loop sees its closed stdout.
|
|
||||||
Err(_) => false,
|
|
||||||
},
|
|
||||||
Some(ctl) = self.control.recv() => {
|
|
||||||
self.next_control = Some(ctl);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if msg.from != "broker" {
|
|
||||||
let body = json!({"type": "undeliverable", "reason": "the actor stopped", "to": self.id});
|
|
||||||
self.broker.send("broker", Some(&msg.from), None, Some(&msg.id), body);
|
|
||||||
}
|
|
||||||
if stuck && let Some(inst) = &self.instance {
|
|
||||||
self.broker.log(&self.id, "killed: it did not read stdin when the broker needed it");
|
|
||||||
inst.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start an instance of the current version. If that fails, the actor
|
|
||||||
/// stays stopped until the next start, upgrade, or rollback.
|
|
||||||
async fn start(&mut self) -> Result<()> {
|
|
||||||
let started = self.try_start();
|
|
||||||
match &started {
|
|
||||||
Ok(()) => self.started_at = Instant::now(),
|
|
||||||
Err(e) => {
|
|
||||||
self.broker.log(&self.id, &format!("cannot start version {}: {e:#}", self.version));
|
|
||||||
self.running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
started
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_start(&mut self) -> Result<()> {
|
|
||||||
if self.component.is_none() {
|
|
||||||
self.component = Some(self.broker.load_version(&self.id, self.version)?);
|
|
||||||
}
|
|
||||||
let cfg = self.broker.instance_config(&self.id, self.version, None);
|
|
||||||
self.instance = Some(self.broker.runtime.start(self.component.as_ref().unwrap(), cfg)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the instance gracefully. Its last messages still get routed.
|
|
||||||
async fn stop(&mut self) {
|
|
||||||
let Some(inst) = self.instance.take() else { return };
|
|
||||||
let (broker, id) = (self.broker.clone(), self.id.clone());
|
|
||||||
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |line| broker.route_line(&id, &line)).await;
|
|
||||||
self.broker.log(&self.id, &format!("stopped: {exit}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_exit(&mut self, exit: Exit) {
|
|
||||||
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
|
|
||||||
if let Some(p) = &mut self.probation
|
|
||||||
&& p.since.elapsed() < CRASH_WINDOW
|
|
||||||
{
|
|
||||||
p.crashes += 1;
|
|
||||||
if p.crashes >= CRASH_LIMIT {
|
|
||||||
let (from, to, requester) = (self.version, p.previous, p.requester.clone());
|
|
||||||
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {to}"));
|
|
||||||
let body = match self.rollback(to).await {
|
|
||||||
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": to,
|
|
||||||
"reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
|
|
||||||
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
|
|
||||||
};
|
|
||||||
self.broker.send("broker", Some(&requester), None, None, body);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Restart after 0.5 s, doubling up to 30 s; a minute of uptime resets it.
|
|
||||||
if self.started_at.elapsed() > Duration::from_secs(60) {
|
|
||||||
self.restarts = 0;
|
|
||||||
}
|
|
||||||
let delay = Duration::from_millis(500 << self.restarts.min(6)).min(Duration::from_secs(30));
|
|
||||||
self.restarts += 1;
|
|
||||||
self.restart_at = Some(Instant::now() + delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make `version` current. It is compiled first (unless `component` is
|
|
||||||
/// given): if that fails, nothing changes. `data/` and `public/` stay as
|
|
||||||
/// they are. A stopped actor stays stopped.
|
|
||||||
async fn install(&mut self, version: u64, component: Option<Component>) -> Result<()> {
|
|
||||||
let component = match component {
|
|
||||||
Some(c) => c,
|
|
||||||
None => self.broker.load_version(&self.id, version)?,
|
|
||||||
};
|
|
||||||
self.stop().await;
|
|
||||||
self.broker.install(&self.id, version, self.running)?;
|
|
||||||
self.version = version;
|
|
||||||
self.component = Some(component);
|
|
||||||
self.restarts = 0;
|
|
||||||
self.restart_at = None;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upgrade(&mut self, version: u64, component: Component, requester: String) -> Result<Value> {
|
|
||||||
let previous = self.version;
|
|
||||||
self.install(version, Some(component)).await?;
|
|
||||||
if !self.running {
|
|
||||||
// Installed, not started: main runs and mail is read at the next `start`.
|
|
||||||
return Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous, "running": false}));
|
|
||||||
}
|
|
||||||
self.probation = Some(Probation { previous, since: Instant::now(), crashes: 0, requester });
|
|
||||||
if let Err(e) = self.start().await {
|
|
||||||
self.rollback(previous).await?;
|
|
||||||
bail!("version {version} did not start ({e:#}); rolled back to {previous}");
|
|
||||||
}
|
|
||||||
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous, "running": true}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rollback(&mut self, version: u64) -> Result<Value> {
|
|
||||||
let from = self.version;
|
|
||||||
self.install(version, None).await?;
|
|
||||||
self.probation = None;
|
|
||||||
if self.running {
|
|
||||||
self.start().await?;
|
|
||||||
}
|
|
||||||
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version, "running": self.running}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a control request. Returns `true` when the supervisor ends.
|
|
||||||
async fn handle(&mut self, ctl: Control) -> bool {
|
|
||||||
match ctl {
|
|
||||||
Control::Upgrade { version, component, requester, reply } => {
|
|
||||||
let _ = reply.send(self.upgrade(version, component, requester).await);
|
|
||||||
}
|
|
||||||
Control::Rollback { version, reply } => {
|
|
||||||
let _ = reply.send(self.rollback(version).await);
|
|
||||||
}
|
|
||||||
Control::Stop { reply } => {
|
|
||||||
self.running = false;
|
|
||||||
self.stop().await;
|
|
||||||
let r = self.broker.set_running(&self.id, false);
|
|
||||||
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
|
|
||||||
}
|
|
||||||
Control::Start { reply } => {
|
|
||||||
self.running = true;
|
|
||||||
self.restart_at = None;
|
|
||||||
let r = self.broker.set_running(&self.id, true);
|
|
||||||
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
|
|
||||||
}
|
|
||||||
Control::Shutdown { reply } => {
|
|
||||||
self.stop().await;
|
|
||||||
let _ = reply.send(Ok(json!({"type": "shut_down"})));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-796
@@ -1,796 +0,0 @@
|
|||||||
//! Actor-served files: `/actor/<id>/fs/...` (see PROTOCOL.md, "Actor-served
|
|
||||||
//! files").
|
|
||||||
//!
|
|
||||||
//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`.
|
|
||||||
//! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the
|
|
||||||
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
|
|
||||||
//! the reply. Other paths go to `wasmtime-wasi` unchanged except that an
|
|
||||||
//! actor directory listing advertises `fs/` when its source docs declare it.
|
|
||||||
//!
|
|
||||||
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
|
|
||||||
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
|
|
||||||
//! `fs_rename {path, to}` -> `fs_done`
|
|
||||||
//! - any of them -> `fs_error {errno}` (a `wasi:filesystem` error-code name)
|
|
||||||
//!
|
|
||||||
//! A file is read once when it is opened. After a change, it is written back
|
|
||||||
//! in one `fs_write` when it is synced or closed.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, Weak};
|
|
||||||
|
|
||||||
use bytes::Bytes;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use wasmtime::component::{HasData, Resource};
|
|
||||||
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
|
|
||||||
use wasmtime_wasi::p2::bindings::filesystem::types::{
|
|
||||||
self, DescriptorFlags, DescriptorType, DirectoryEntry, ErrorCode, HostDescriptor, HostDirectoryEntryStream, OpenFlags,
|
|
||||||
};
|
|
||||||
use wasmtime_wasi::p2::bindings::filesystem::preopens;
|
|
||||||
use wasmtime_wasi::p2::pipe::MemoryInputPipe;
|
|
||||||
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult, OutputStream, Pollable, StreamResult};
|
|
||||||
|
|
||||||
use crate::broker::Broker;
|
|
||||||
|
|
||||||
/// The `from` of the host's requests. Replies to it go back to the waiting call.
|
|
||||||
pub const ID: &str = "fs";
|
|
||||||
pub const ASK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
||||||
|
|
||||||
/// Per-instance state.
|
|
||||||
pub struct VfsState {
|
|
||||||
broker: Weak<Broker>,
|
|
||||||
/// The actor of this instance, for notes in its `stderr.log`.
|
|
||||||
me: String,
|
|
||||||
/// Native directory descriptors and their absolute paths. Keeping every
|
|
||||||
/// `/actor` child (not only the preopen) lets `fs/` work after traversal.
|
|
||||||
dirs: HashMap<u32, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VfsState {
|
|
||||||
pub fn new(broker: Weak<Broker>, me: String) -> Self {
|
|
||||||
Self { broker, me, dirs: HashMap::new() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`HasData`] for the linker: the filesystem functions get a [`VfsView`].
|
|
||||||
pub struct Vfs;
|
|
||||||
|
|
||||||
impl HasData for Vfs {
|
|
||||||
type Data<'a> = VfsView<'a>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct VfsView<'a> {
|
|
||||||
pub inner: WasiFilesystemCtxView<'a>,
|
|
||||||
pub state: &'a mut VfsState,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A descriptor of an actor-served file or folder (in the resource table in
|
|
||||||
/// place of a `wasmtime-wasi` descriptor).
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum Virt {
|
|
||||||
Dir { actor: String, path: String },
|
|
||||||
File(VFile),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct VFile {
|
|
||||||
actor: String,
|
|
||||||
path: String,
|
|
||||||
data: Arc<Mutex<Vec<u8>>>,
|
|
||||||
dirty: Arc<AtomicBool>,
|
|
||||||
write: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A directory-entry-stream of an actor-served folder.
|
|
||||||
struct VDir(std::vec::IntoIter<DirectoryEntry>);
|
|
||||||
|
|
||||||
enum Node {
|
|
||||||
File(Vec<u8>),
|
|
||||||
Dir(Vec<DirectoryEntry>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends the requests. Owned, so that no borrow of the view lives across an await.
|
|
||||||
struct Asker {
|
|
||||||
broker: Option<Arc<Broker>>,
|
|
||||||
me: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Asker {
|
|
||||||
async fn ask(&self, actor: &str, body: Value) -> Result<Value, ErrorCode> {
|
|
||||||
let broker = self.broker.as_ref().ok_or(ErrorCode::Io)?;
|
|
||||||
let what = format!("{} {} to {actor}", body["type"].as_str().unwrap_or(""), body["path"].as_str().unwrap_or(""));
|
|
||||||
match broker.ask(actor, body).await {
|
|
||||||
None => {
|
|
||||||
broker.log(&self.me, &format!("{what}: no reply within {} s", ASK_TIMEOUT.as_secs()));
|
|
||||||
Err(ErrorCode::Io)
|
|
||||||
}
|
|
||||||
Some(reply) => match reply["type"].as_str() {
|
|
||||||
Some("fs_error") => Err(errno(&reply["errno"])),
|
|
||||||
Some("undeliverable") => Err(ErrorCode::NoEntry),
|
|
||||||
_ => Ok(reply),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read(&self, actor: &str, path: &str) -> Result<Node, ErrorCode> {
|
|
||||||
let reply = self.ask(actor, json!({"type": "fs_read", "path": path})).await?;
|
|
||||||
match reply["type"].as_str() {
|
|
||||||
Some("fs_file") => content(&reply).map(Node::File),
|
|
||||||
Some("fs_dir") => Ok(Node::Dir(
|
|
||||||
reply["entries"]
|
|
||||||
.as_array()
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.filter_map(|e| {
|
|
||||||
let type_ = if e["type"] == "dir" { DescriptorType::Directory } else { DescriptorType::RegularFile };
|
|
||||||
Some(DirectoryEntry { type_, name: e["name"].as_str()?.to_string() })
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
)),
|
|
||||||
_ => {
|
|
||||||
if let Some(b) = &self.broker {
|
|
||||||
b.log(&self.me, &format!("fs_read {path} to {actor}: unexpected reply {reply}"));
|
|
||||||
}
|
|
||||||
Err(ErrorCode::Io)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn change(&self, actor: &str, body: Value) -> FsResult<()> {
|
|
||||||
self.ask(actor, body).await.map(|_| ()).map_err(FsError::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn flush(&self, f: &VFile) -> FsResult<()> {
|
|
||||||
if f.dirty.swap(false, Ordering::SeqCst)
|
|
||||||
&& let Err(e) = self.ask(&f.actor, write_body(f)).await
|
|
||||||
{
|
|
||||||
f.dirty.store(true, Ordering::SeqCst);
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn errno(v: &Value) -> ErrorCode {
|
|
||||||
match v.as_str().unwrap_or("") {
|
|
||||||
"no-entry" => ErrorCode::NoEntry,
|
|
||||||
"exist" => ErrorCode::Exist,
|
|
||||||
"not-directory" => ErrorCode::NotDirectory,
|
|
||||||
"is-directory" => ErrorCode::IsDirectory,
|
|
||||||
"not-empty" => ErrorCode::NotEmpty,
|
|
||||||
"access" => ErrorCode::Access,
|
|
||||||
"not-permitted" => ErrorCode::NotPermitted,
|
|
||||||
"invalid" => ErrorCode::Invalid,
|
|
||||||
"unsupported" => ErrorCode::Unsupported,
|
|
||||||
"busy" => ErrorCode::Busy,
|
|
||||||
_ => ErrorCode::Io,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn content(v: &Value) -> Result<Vec<u8>, ErrorCode> {
|
|
||||||
if let Some(text) = v["text"].as_str() {
|
|
||||||
return Ok(text.as_bytes().to_vec());
|
|
||||||
}
|
|
||||||
match v["base64"].as_str() {
|
|
||||||
Some(b) => base64_decode(b).ok_or(ErrorCode::IllegalByteSequence),
|
|
||||||
None => Ok(Vec::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_body(f: &VFile) -> Value {
|
|
||||||
let data = f.data.lock().unwrap().clone();
|
|
||||||
match String::from_utf8(data) {
|
|
||||||
Ok(text) => json!({"type": "fs_write", "path": f.path, "text": text}),
|
|
||||||
Err(e) => json!({"type": "fs_write", "path": f.path, "base64": base64_encode(e.as_bytes())}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `rel` resolved against the folder `base`, as an absolute path.
|
|
||||||
fn join(base: &str, rel: &str) -> String {
|
|
||||||
let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect();
|
|
||||||
for p in rel.split('/') {
|
|
||||||
match p {
|
|
||||||
"" | "." => {}
|
|
||||||
".." => {
|
|
||||||
parts.pop();
|
|
||||||
}
|
|
||||||
p => parts.push(p),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
format!("/{}", parts.join("/"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split a global path under an actor's virtual `fs/` into its owner and
|
|
||||||
/// actor-relative path.
|
|
||||||
fn actor_fs_path(full: &str) -> Option<(String, String)> {
|
|
||||||
let rest = full.strip_prefix("/actor/")?;
|
|
||||||
let (actor, rest) = rest.split_once('/')?;
|
|
||||||
let (fs, path) = rest.split_once('/').unwrap_or((rest, ""));
|
|
||||||
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{path}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn actor_root(path: &str) -> Option<&str> {
|
|
||||||
let actor = path.strip_prefix("/actor/")?;
|
|
||||||
(!actor.is_empty() && !actor.contains('/')).then_some(actor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An actor advertises its virtual filesystem in its leading `//!` docs.
|
|
||||||
/// This is discovery metadata; actual reads still go through `fs_read`.
|
|
||||||
fn declares_actor_fs(source: &str) -> bool {
|
|
||||||
let docs = source
|
|
||||||
.lines()
|
|
||||||
.take_while(|line| line.trim().starts_with("//!"))
|
|
||||||
.map(|line| line.trim().trim_start_matches("//!").trim())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
docs.contains("actor-served files") && docs.contains("fs_read")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat {
|
|
||||||
types::DescriptorStat {
|
|
||||||
type_,
|
|
||||||
link_count: 1,
|
|
||||||
size,
|
|
||||||
data_access_timestamp: None,
|
|
||||||
data_modification_timestamp: None,
|
|
||||||
status_change_timestamp: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn node_stat(node: &Node) -> types::DescriptorStat {
|
|
||||||
match node {
|
|
||||||
Node::File(data) => stat(DescriptorType::RegularFile, data.len() as u64),
|
|
||||||
Node::Dir(_) => stat(DescriptorType::Directory, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash(actor: &str, path: &str) -> types::MetadataHashValue {
|
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
||||||
(actor, path).hash(&mut h);
|
|
||||||
types::MetadataHashValue { lower: h.finish(), upper: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_at(data: &mut Vec<u8>, at: usize, bytes: &[u8]) {
|
|
||||||
if data.len() < at + bytes.len() {
|
|
||||||
data.resize(at + bytes.len(), 0);
|
|
||||||
}
|
|
||||||
data[at..at + bytes.len()].copy_from_slice(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An output stream into an actor-served file (`pos: None` appends).
|
|
||||||
struct VWriter {
|
|
||||||
file: VFile,
|
|
||||||
pos: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasmtime_wasi::async_trait]
|
|
||||||
impl Pollable for VWriter {
|
|
||||||
async fn ready(&mut self) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasmtime_wasi::async_trait]
|
|
||||||
impl OutputStream for VWriter {
|
|
||||||
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
|
|
||||||
let mut data = self.file.data.lock().unwrap();
|
|
||||||
let at = self.pos.unwrap_or(data.len());
|
|
||||||
write_at(&mut data, at, &bytes);
|
|
||||||
if let Some(p) = &mut self.pos {
|
|
||||||
*p += bytes.len();
|
|
||||||
}
|
|
||||||
self.file.dirty.store(true, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn flush(&mut self) -> StreamResult<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn check_write(&mut self) -> StreamResult<usize> {
|
|
||||||
Ok(64 * 1024)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VfsView<'_> {
|
|
||||||
fn asker(&self) -> Asker {
|
|
||||||
Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn actor_serves_fs(&self, actor: &str) -> bool {
|
|
||||||
self.state
|
|
||||||
.broker
|
|
||||||
.upgrade()
|
|
||||||
.and_then(|broker| std::fs::read_to_string(broker.world.source_dir(actor).join("src/main.rs")).ok())
|
|
||||||
.is_some_and(|source| declares_actor_fs(&source))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> {
|
|
||||||
self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file(&mut self, fd: &Resource<types::Descriptor>) -> Option<FsResult<VFile>> {
|
|
||||||
match self.virt(fd)? {
|
|
||||||
Virt::File(f) => Some(Ok(f)),
|
|
||||||
Virt::Dir { .. } => Some(Err(ErrorCode::IsDirectory.into())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The actor and its path, if `path` relative to `fd` is actor-served.
|
|
||||||
fn target(&mut self, fd: &Resource<types::Descriptor>, path: &str) -> Option<(String, String)> {
|
|
||||||
match self.virt(fd) {
|
|
||||||
Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))),
|
|
||||||
Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))),
|
|
||||||
None => {
|
|
||||||
let base = self.state.dirs.get(&fd.rep())?;
|
|
||||||
actor_fs_path(&join(base, path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn native_directory_entries(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Vec<DirectoryEntry>> {
|
|
||||||
let stream = HostDescriptor::read_directory(&mut self.inner, fd).await?;
|
|
||||||
let stream_rep = stream.rep();
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
loop {
|
|
||||||
match HostDirectoryEntryStream::read_directory_entry(&mut self.inner, Resource::new_borrow(stream_rep)).await? {
|
|
||||||
Some(entry) => entries.push(entry),
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HostDirectoryEntryStream::drop(&mut self.inner, stream).map_err(|_| ErrorCode::Io)?;
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> {
|
|
||||||
let r = self.inner.table.push(v)?;
|
|
||||||
Ok(Resource::new_own(r.rep()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn open(&mut self, actor: String, path: String, oflags: OpenFlags, flags: DescriptorFlags) -> FsResult<Resource<types::Descriptor>> {
|
|
||||||
let write = flags.contains(DescriptorFlags::WRITE);
|
|
||||||
let create = oflags.contains(OpenFlags::CREATE);
|
|
||||||
let exclusive = create && oflags.contains(OpenFlags::EXCLUSIVE);
|
|
||||||
let virt = match self.asker().read(&actor, &path).await {
|
|
||||||
Ok(_) if exclusive => return Err(ErrorCode::Exist.into()),
|
|
||||||
Ok(Node::Dir(_)) if write => return Err(ErrorCode::IsDirectory.into()),
|
|
||||||
Ok(Node::Dir(_)) => Virt::Dir { actor, path },
|
|
||||||
Ok(Node::File(_)) if oflags.contains(OpenFlags::DIRECTORY) => return Err(ErrorCode::NotDirectory.into()),
|
|
||||||
Ok(Node::File(data)) => {
|
|
||||||
let truncate = oflags.contains(OpenFlags::TRUNCATE);
|
|
||||||
let data = if truncate { Vec::new() } else { data };
|
|
||||||
Virt::File(VFile { actor, path, data: Arc::new(Mutex::new(data)), dirty: Arc::new(AtomicBool::new(truncate)), write })
|
|
||||||
}
|
|
||||||
Err(ErrorCode::NoEntry) if create && !oflags.contains(OpenFlags::DIRECTORY) => Virt::File(VFile {
|
|
||||||
actor,
|
|
||||||
path,
|
|
||||||
data: Arc::default(),
|
|
||||||
dirty: Arc::new(AtomicBool::new(true)),
|
|
||||||
write,
|
|
||||||
}),
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
self.push(virt)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send the changed file without waiting (the descriptor is gone). The
|
|
||||||
/// request leaves now, so later requests of this instance come after it.
|
|
||||||
fn write_back_later(&self, f: &VFile) {
|
|
||||||
let Some(broker) = self.state.broker.upgrade() else { return };
|
|
||||||
let reply = broker.ask(&f.actor, write_body(f));
|
|
||||||
let (me, what) = (self.state.me.clone(), format!("fs_write {} to {}", f.path, f.actor));
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match reply.await {
|
|
||||||
Some(r) if r["type"] != "fs_error" && r["type"] != "undeliverable" => {}
|
|
||||||
r => broker.log(&me, &format!("{what} failed: {}", r.map_or("no reply".to_string(), |r| r.to_string()))),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl preopens::Host for VfsView<'_> {
|
|
||||||
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
|
|
||||||
let dirs = preopens::Host::get_directories(&mut self.inner)?;
|
|
||||||
for (fd, name) in &dirs {
|
|
||||||
if name == "/actor" || name == "/" {
|
|
||||||
self.state.dirs.insert(fd.rep(), name.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(dirs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl types::Host for VfsView<'_> {
|
|
||||||
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
|
|
||||||
types::Host::convert_error_code(&mut self.inner, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn filesystem_error_code(&mut self, err: Resource<wasmtime::Error>) -> wasmtime::Result<Option<ErrorCode>> {
|
|
||||||
types::Host::filesystem_error_code(&mut self.inner, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HostDescriptor for VfsView<'_> {
|
|
||||||
async fn advise(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize, len: types::Filesize, advice: types::Advice) -> FsResult<()> {
|
|
||||||
if self.virt(&fd).is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
|
||||||
match self.virt(&fd) {
|
|
||||||
Some(Virt::File(f)) => self.asker().flush(&f).await,
|
|
||||||
Some(Virt::Dir { .. }) => Ok(()),
|
|
||||||
None => HostDescriptor::sync_data(&mut self.inner, fd).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorFlags> {
|
|
||||||
match self.virt(&fd) {
|
|
||||||
Some(Virt::File(f)) if f.write => Ok(DescriptorFlags::READ | DescriptorFlags::WRITE),
|
|
||||||
Some(Virt::File(_)) => Ok(DescriptorFlags::READ),
|
|
||||||
Some(Virt::Dir { .. }) => Ok(DescriptorFlags::READ | DescriptorFlags::MUTATE_DIRECTORY),
|
|
||||||
None => HostDescriptor::get_flags(&mut self.inner, fd).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorType> {
|
|
||||||
match self.virt(&fd) {
|
|
||||||
Some(Virt::File(_)) => Ok(DescriptorType::RegularFile),
|
|
||||||
Some(Virt::Dir { .. }) => Ok(DescriptorType::Directory),
|
|
||||||
None => HostDescriptor::get_type(&mut self.inner, fd).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
|
|
||||||
match self.file(&fd) {
|
|
||||||
Some(f) => {
|
|
||||||
let f = f?;
|
|
||||||
if !f.write {
|
|
||||||
return Err(ErrorCode::NotPermitted.into());
|
|
||||||
}
|
|
||||||
f.data.lock().unwrap().resize(size as usize, 0);
|
|
||||||
f.dirty.store(true, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
None => HostDescriptor::set_size(&mut self.inner, fd, size).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_times(&mut self, fd: Resource<types::Descriptor>, atim: types::NewTimestamp, mtim: types::NewTimestamp) -> FsResult<()> {
|
|
||||||
if self.virt(&fd).is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read(&mut self, fd: Resource<types::Descriptor>, len: types::Filesize, offset: types::Filesize) -> FsResult<(Vec<u8>, bool)> {
|
|
||||||
match self.file(&fd) {
|
|
||||||
Some(f) => {
|
|
||||||
let f = f?;
|
|
||||||
let data = f.data.lock().unwrap();
|
|
||||||
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
|
|
||||||
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(data.len());
|
|
||||||
Ok((data[start..end].to_vec(), start == end))
|
|
||||||
}
|
|
||||||
None => HostDescriptor::read(&mut self.inner, fd, len, offset).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write(&mut self, fd: Resource<types::Descriptor>, buf: Vec<u8>, offset: types::Filesize) -> FsResult<types::Filesize> {
|
|
||||||
match self.file(&fd) {
|
|
||||||
Some(f) => {
|
|
||||||
let f = f?;
|
|
||||||
if !f.write {
|
|
||||||
return Err(ErrorCode::NotPermitted.into());
|
|
||||||
}
|
|
||||||
write_at(&mut f.data.lock().unwrap(), offset as usize, &buf);
|
|
||||||
f.dirty.store(true, Ordering::SeqCst);
|
|
||||||
Ok(buf.len() as u64)
|
|
||||||
}
|
|
||||||
None => HostDescriptor::write(&mut self.inner, fd, buf, offset).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_directory(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<types::DirectoryEntryStream>> {
|
|
||||||
match self.virt(&fd) {
|
|
||||||
Some(Virt::Dir { actor, path }) => match self.asker().read(&actor, &path).await? {
|
|
||||||
Node::Dir(entries) => {
|
|
||||||
let r = self.inner.table.push(VDir(entries.into_iter()))?;
|
|
||||||
Ok(Resource::new_own(r.rep()))
|
|
||||||
}
|
|
||||||
Node::File(_) => Err(ErrorCode::NotDirectory.into()),
|
|
||||||
},
|
|
||||||
Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()),
|
|
||||||
None => {
|
|
||||||
let actor = self.state.dirs.get(&fd.rep()).and_then(|path| actor_root(path)).map(str::to_string);
|
|
||||||
if actor.as_deref().is_some_and(|id| self.actor_serves_fs(id)) {
|
|
||||||
let mut entries = self.native_directory_entries(fd).await?;
|
|
||||||
if !entries.iter().any(|entry| entry.name == "fs") {
|
|
||||||
entries.push(DirectoryEntry { type_: DescriptorType::Directory, name: "fs".to_string() });
|
|
||||||
}
|
|
||||||
let r = self.inner.table.push(VDir(entries.into_iter()))?;
|
|
||||||
Ok(Resource::new_own(r.rep()))
|
|
||||||
} else {
|
|
||||||
HostDescriptor::read_directory(&mut self.inner, fd).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
|
||||||
match self.virt(&fd) {
|
|
||||||
Some(Virt::File(f)) => self.asker().flush(&f).await,
|
|
||||||
Some(Virt::Dir { .. }) => Ok(()),
|
|
||||||
None => HostDescriptor::sync(&mut self.inner, fd).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
|
||||||
match self.target(&fd, &path) {
|
|
||||||
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_mkdir", "path": path})).await,
|
|
||||||
None => HostDescriptor::create_directory_at(&mut self.inner, fd, path).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
|
|
||||||
match self.virt(&fd) {
|
|
||||||
Some(Virt::File(f)) => Ok(stat(DescriptorType::RegularFile, f.data.lock().unwrap().len() as u64)),
|
|
||||||
Some(Virt::Dir { .. }) => Ok(stat(DescriptorType::Directory, 0)),
|
|
||||||
None => HostDescriptor::stat(&mut self.inner, fd).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stat_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::DescriptorStat> {
|
|
||||||
match self.target(&fd, &path) {
|
|
||||||
Some((actor, path)) => Ok(node_stat(&self.asker().read(&actor, &path).await?)),
|
|
||||||
None => HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_times_at(
|
|
||||||
&mut self,
|
|
||||||
fd: Resource<types::Descriptor>,
|
|
||||||
path_flags: types::PathFlags,
|
|
||||||
path: String,
|
|
||||||
atim: types::NewTimestamp,
|
|
||||||
mtim: types::NewTimestamp,
|
|
||||||
) -> FsResult<()> {
|
|
||||||
if self.target(&fd, &path).is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn link_at(
|
|
||||||
&mut self,
|
|
||||||
fd: Resource<types::Descriptor>,
|
|
||||||
old_path_flags: types::PathFlags,
|
|
||||||
old_path: String,
|
|
||||||
new_descriptor: Resource<types::Descriptor>,
|
|
||||||
new_path: String,
|
|
||||||
) -> FsResult<()> {
|
|
||||||
if self.target(&fd, &old_path).is_some() || self.target(&new_descriptor, &new_path).is_some() {
|
|
||||||
return Err(ErrorCode::Unsupported.into());
|
|
||||||
}
|
|
||||||
HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn open_at(
|
|
||||||
&mut self,
|
|
||||||
fd: Resource<types::Descriptor>,
|
|
||||||
path_flags: types::PathFlags,
|
|
||||||
path: String,
|
|
||||||
oflags: OpenFlags,
|
|
||||||
flags: DescriptorFlags,
|
|
||||||
) -> FsResult<Resource<types::Descriptor>> {
|
|
||||||
match self.target(&fd, &path) {
|
|
||||||
Some((actor, path)) => self.open(actor, path, oflags, flags).await,
|
|
||||||
None => {
|
|
||||||
let child_path = self.state.dirs.get(&fd.rep()).map(|base| join(base, &path));
|
|
||||||
let opened = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await?;
|
|
||||||
if let Some(path) = child_path {
|
|
||||||
if HostDescriptor::get_type(&mut self.inner, Resource::new_borrow(opened.rep())).await? == DescriptorType::Directory {
|
|
||||||
self.state.dirs.insert(opened.rep(), path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(opened)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
|
|
||||||
if self.virt(&fd).is_some() {
|
|
||||||
if let Virt::File(f) = self.inner.table.delete(Resource::<Virt>::new_own(fd.rep()))?
|
|
||||||
&& f.dirty.load(Ordering::SeqCst)
|
|
||||||
{
|
|
||||||
self.write_back_later(&f);
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.state.dirs.remove(&fd.rep());
|
|
||||||
HostDescriptor::drop(&mut self.inner, fd)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
|
|
||||||
if self.target(&fd, &path).is_some() {
|
|
||||||
return Err(ErrorCode::Invalid.into());
|
|
||||||
}
|
|
||||||
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
|
||||||
match self.target(&fd, &path) {
|
|
||||||
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
|
|
||||||
None => HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rename_at(&mut self, fd: Resource<types::Descriptor>, old_path: String, new_fd: Resource<types::Descriptor>, new_path: String) -> FsResult<()> {
|
|
||||||
match (self.target(&fd, &old_path), self.target(&new_fd, &new_path)) {
|
|
||||||
(None, None) => HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await,
|
|
||||||
(Some((a, from)), Some((b, to))) if a == b => {
|
|
||||||
self.asker().change(&a, json!({"type": "fs_rename", "path": from, "to": to})).await
|
|
||||||
}
|
|
||||||
_ => Err(ErrorCode::Unsupported.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn symlink_at(&mut self, fd: Resource<types::Descriptor>, src_path: String, dest_path: String) -> FsResult<()> {
|
|
||||||
if self.target(&fd, &dest_path).is_some() {
|
|
||||||
return Err(ErrorCode::Unsupported.into());
|
|
||||||
}
|
|
||||||
HostDescriptor::symlink_at(&mut self.inner, fd, src_path, dest_path).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
|
||||||
match self.target(&fd, &path) {
|
|
||||||
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
|
|
||||||
None => HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynInputStream>> {
|
|
||||||
match self.file(&fd) {
|
|
||||||
Some(f) => {
|
|
||||||
let f = f?;
|
|
||||||
let data = f.data.lock().unwrap();
|
|
||||||
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
|
|
||||||
let stream: DynInputStream = Box::new(MemoryInputPipe::new(data[start..].to_vec()));
|
|
||||||
std::mem::drop(data);
|
|
||||||
Ok(self.inner.table.push(stream)?)
|
|
||||||
}
|
|
||||||
None => HostDescriptor::read_via_stream(&mut self.inner, fd, offset),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynOutputStream>> {
|
|
||||||
match self.file(&fd) {
|
|
||||||
Some(f) => {
|
|
||||||
let f = f?;
|
|
||||||
if !f.write {
|
|
||||||
return Err(ErrorCode::NotPermitted.into());
|
|
||||||
}
|
|
||||||
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: Some(offset as usize) });
|
|
||||||
Ok(self.inner.table.push(stream)?)
|
|
||||||
}
|
|
||||||
None => HostDescriptor::write_via_stream(&mut self.inner, fd, offset),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
|
|
||||||
match self.file(&fd) {
|
|
||||||
Some(f) => {
|
|
||||||
let f = f?;
|
|
||||||
if !f.write {
|
|
||||||
return Err(ErrorCode::NotPermitted.into());
|
|
||||||
}
|
|
||||||
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: None });
|
|
||||||
Ok(self.inner.table.push(stream)?)
|
|
||||||
}
|
|
||||||
None => HostDescriptor::append_via_stream(&mut self.inner, fd),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_same_object(&mut self, a: Resource<types::Descriptor>, b: Resource<types::Descriptor>) -> wasmtime::Result<bool> {
|
|
||||||
let key = |v: Virt| match v {
|
|
||||||
Virt::Dir { actor, path } => (actor, path),
|
|
||||||
Virt::File(f) => (f.actor, f.path),
|
|
||||||
};
|
|
||||||
match (self.virt(&a), self.virt(&b)) {
|
|
||||||
(None, None) => HostDescriptor::is_same_object(&mut self.inner, a, b).await,
|
|
||||||
(Some(x), Some(y)) => Ok(key(x) == key(y)),
|
|
||||||
_ => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
|
|
||||||
match self.virt(&fd) {
|
|
||||||
Some(Virt::Dir { actor, path }) => Ok(hash(&actor, &path)),
|
|
||||||
Some(Virt::File(f)) => Ok(hash(&f.actor, &f.path)),
|
|
||||||
None => HostDescriptor::metadata_hash(&mut self.inner, fd).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn metadata_hash_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::MetadataHashValue> {
|
|
||||||
match self.target(&fd, &path) {
|
|
||||||
Some((actor, path)) => Ok(hash(&actor, &path)),
|
|
||||||
None => HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HostDirectoryEntryStream for VfsView<'_> {
|
|
||||||
async fn read_directory_entry(&mut self, stream: Resource<types::DirectoryEntryStream>) -> FsResult<Option<DirectoryEntry>> {
|
|
||||||
if let Some(dir) = self.inner.table.get_any_mut(stream.rep()).ok().and_then(|a| a.downcast_mut::<VDir>()) {
|
|
||||||
return Ok(dir.0.next());
|
|
||||||
}
|
|
||||||
HostDirectoryEntryStream::read_directory_entry(&mut self.inner, stream).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
|
|
||||||
let ours = self.inner.table.get_any_mut(stream.rep()).ok().is_some_and(|a| a.is::<VDir>());
|
|
||||||
if ours {
|
|
||||||
self.inner.table.delete(Resource::<VDir>::new_own(stream.rep()))?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
HostDirectoryEntryStream::drop(&mut self.inner, stream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
|
|
||||||
fn base64_encode(bytes: &[u8]) -> String {
|
|
||||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
|
||||||
for chunk in bytes.chunks(3) {
|
|
||||||
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
|
|
||||||
for i in 0..4 {
|
|
||||||
out.push(if i <= chunk.len() { B64[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
|
||||||
let (mut out, mut n, mut bits) = (Vec::new(), 0u32, 0);
|
|
||||||
for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
|
|
||||||
n = (n << 6 | B64.iter().position(|b| *b == c)? as u32) & 0xFF_FFFF;
|
|
||||||
bits += 6;
|
|
||||||
if bits >= 8 {
|
|
||||||
bits -= 8;
|
|
||||||
out.push((n >> bits) as u8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn actor_fs_paths_and_discovery_docs() {
|
|
||||||
assert_eq!(actor_fs_path("/actor/brain/fs"), Some(("brain".to_string(), "/".to_string())));
|
|
||||||
assert_eq!(actor_fs_path("/actor/brain/fs/docs/readme.md"), Some(("brain".to_string(), "/docs/readme.md".to_string())));
|
|
||||||
assert_eq!(actor_fs_path("/actor/brain/public/fs/file"), None);
|
|
||||||
assert_eq!(actor_root("/actor/brain"), Some("brain"));
|
|
||||||
assert_eq!(actor_root("/actor/brain/fs"), None);
|
|
||||||
assert!(declares_actor_fs("//! brain\n//! Actor-served files include fs_read.\nfn main() {}"));
|
|
||||||
assert!(!declares_actor_fs("//! actor without fs support\nfn main() {}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn base64_round_trip() {
|
|
||||||
for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] {
|
|
||||||
let enc = base64_encode(s.as_bytes());
|
|
||||||
assert_eq!(base64_decode(&enc).unwrap(), s.as_bytes());
|
|
||||||
}
|
|
||||||
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn join_resolves_dots() {
|
|
||||||
assert_eq!(join("/a/b", "../c/./d"), "/a/c/d");
|
|
||||||
assert_eq!(join("/", "x/fs"), "/x/fs");
|
|
||||||
assert_eq!(join("/", ".."), "/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
//! File watching: the host watches the whole world folder with the OS file
|
|
||||||
//! watcher (`notify`, inotify on Linux), whoever writes: actors, a 9P mount,
|
|
||||||
//! a shell or `git` on the host. Nothing outside the folder counts.
|
|
||||||
//!
|
|
||||||
//! Events are coalesced per path: once a path is quiet for `QUIET` (or has
|
|
||||||
//! kept changing for `MAX_DELAY`), it becomes one change
|
|
||||||
//! `{path, op: write | create | remove | rename, to?}` with a namespace path
|
|
||||||
//! (relative to the world root, such as `/actor/...` or `/config/...`). Then the broker
|
|
||||||
//!
|
|
||||||
//! - sends `fs_changed` to the actors that watch the path (`watch` request),
|
|
||||||
//! - starts or upgrades an actor whose `actor/<id>/actor.wasm` (or folder
|
|
||||||
//! `actor/<id>`) changed, tested first, as at boot (`Broker::check_actor`).
|
|
||||||
//!
|
|
||||||
//! The host's temporary files (`.sausage-tmp-*`) are skipped: a rename of one
|
|
||||||
//! onto a file is a `write` of that file. If the OS drops events, the broker
|
|
||||||
//! runs a full pass and tells every watcher (`Broker::overflowed`).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use notify::event::{EventKind, ModifyKind, RenameMode};
|
|
||||||
use notify::{RecursiveMode, Watcher as _};
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio::time::Instant;
|
|
||||||
|
|
||||||
use crate::broker::Broker;
|
|
||||||
use crate::world::TMP_PREFIX;
|
|
||||||
|
|
||||||
/// A path that is quiet this long becomes one change.
|
|
||||||
pub const QUIET: Duration = Duration::from_millis(100);
|
|
||||||
/// A path that keeps changing becomes a change at least this often.
|
|
||||||
pub const MAX_DELAY: Duration = Duration::from_secs(1);
|
|
||||||
|
|
||||||
/// The running watcher. Dropping it stops watching.
|
|
||||||
pub struct Watcher {
|
|
||||||
_os: notify::RecommendedWatcher,
|
|
||||||
task: tokio::task::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Watcher {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Watch the world folder of `broker` recursively. A failure to watch part
|
|
||||||
/// of it (for example too few inotify watches) is logged, and the rest is
|
|
||||||
/// watched.
|
|
||||||
pub fn start(broker: Arc<Broker>) -> notify::Result<Watcher> {
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
|
||||||
let mut os = notify::recommended_watcher(move |event| {
|
|
||||||
let _ = tx.send(event);
|
|
||||||
})?;
|
|
||||||
if let Err(e) = os.watch(&broker.world.root, RecursiveMode::Recursive) {
|
|
||||||
broker.host_error(&format!("cannot watch all of {}: {e}", broker.world.root.display()));
|
|
||||||
}
|
|
||||||
let task = tokio::spawn(run(broker, rx));
|
|
||||||
Ok(Watcher { _os: os, task })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A change waiting for its path to be quiet.
|
|
||||||
struct Pending {
|
|
||||||
op: &'static str,
|
|
||||||
to: Option<String>,
|
|
||||||
first: Instant,
|
|
||||||
last: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Pending {
|
|
||||||
fn due(&self) -> Instant {
|
|
||||||
(self.last + QUIET).min(self.first + MAX_DELAY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(broker: Arc<Broker>, mut events: mpsc::UnboundedReceiver<notify::Result<notify::Event>>) {
|
|
||||||
let root = broker.world.root.clone();
|
|
||||||
let mut pending: HashMap<String, Pending> = HashMap::new();
|
|
||||||
// Actor checks and full passes. Dropping the set (when the host stops) aborts them.
|
|
||||||
let mut tasks = tokio::task::JoinSet::new();
|
|
||||||
loop {
|
|
||||||
let due = pending.values().map(Pending::due).min();
|
|
||||||
tokio::select! {
|
|
||||||
event = events.recv() => match event {
|
|
||||||
Some(Ok(event)) => {
|
|
||||||
if event.need_rescan() {
|
|
||||||
let broker = broker.clone();
|
|
||||||
tasks.spawn(async move {
|
|
||||||
broker.overflowed().await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let now = Instant::now();
|
|
||||||
for (path, op, to) in changes(&root, &event) {
|
|
||||||
add(&mut pending, path, op, to, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Err(e)) => broker.host_error(&format!("file watcher: {e}")),
|
|
||||||
None => break,
|
|
||||||
},
|
|
||||||
_ = sleep_until(due) => {
|
|
||||||
let now = Instant::now();
|
|
||||||
let ready: Vec<String> = pending.iter().filter(|(_, p)| p.due() <= now).map(|(k, _)| k.clone()).collect();
|
|
||||||
for path in ready {
|
|
||||||
let p = pending.remove(&path).unwrap();
|
|
||||||
let op = if p.op == "moved-in" { "create" } else { p.op };
|
|
||||||
broker.notify_watchers(&path, op, p.to.as_deref());
|
|
||||||
if let Some(id) = new_wasm(&path, op, p.to.as_deref()) {
|
|
||||||
tasks.spawn(broker.clone().check_actor(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(_) = tasks.join_next() => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sleep_until(t: Option<Instant>) {
|
|
||||||
match t {
|
|
||||||
Some(t) => tokio::time::sleep_until(t).await,
|
|
||||||
None => std::future::pending().await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The world namespace path of a host path, unless it is outside the
|
|
||||||
/// world or a temporary file of the host.
|
|
||||||
fn namespace(root: &Path, path: &Path) -> Option<String> {
|
|
||||||
let rel = path.strip_prefix(root).ok()?;
|
|
||||||
let mut out = String::new();
|
|
||||||
for part in rel.components() {
|
|
||||||
let name = part.as_os_str().to_str()?;
|
|
||||||
if name.starts_with(TMP_PREFIX) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
out.push('/');
|
|
||||||
out.push_str(name);
|
|
||||||
}
|
|
||||||
Some(if out.is_empty() { "/".to_string() } else { out })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One change: namespace path, op, and the new path of a rename.
|
|
||||||
type Change = (String, &'static str, Option<String>);
|
|
||||||
|
|
||||||
/// The changes of one OS event. inotify reports a rename three times:
|
|
||||||
/// `From` (the old name), `To` (the new name) and `Both`. `To` becomes the
|
|
||||||
/// internal op `moved-in`, which `Both` replaces.
|
|
||||||
fn changes(root: &Path, event: ¬ify::Event) -> Vec<Change> {
|
|
||||||
let path = |i: usize| event.paths.get(i).and_then(|p| namespace(root, p));
|
|
||||||
let one = |op: &'static str| -> Vec<Change> { path(0).map(|p| (p, op, None)).into_iter().collect() };
|
|
||||||
match event.kind {
|
|
||||||
EventKind::Create(_) => one("create"),
|
|
||||||
EventKind::Remove(_) => one("remove"),
|
|
||||||
EventKind::Modify(ModifyKind::Metadata(_)) => vec![],
|
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => one("remove"),
|
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => one("moved-in"),
|
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => match (path(0), path(1)) {
|
|
||||||
(Some(from), Some(to)) => vec![(from, "rename", Some(to))],
|
|
||||||
// Renamed to a temporary name: gone.
|
|
||||||
(Some(from), None) => vec![(from, "remove", None)],
|
|
||||||
// A temporary file renamed onto `to`: an atomic write.
|
|
||||||
(None, Some(to)) => vec![(to, "write", None)],
|
|
||||||
(None, None) => vec![],
|
|
||||||
},
|
|
||||||
EventKind::Modify(_) => one("write"),
|
|
||||||
_ => vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coalesce a change into `pending`. The last op wins, except that a write
|
|
||||||
/// after a create stays a create. A rename replaces the `moved-in` of its
|
|
||||||
/// new name.
|
|
||||||
fn add(pending: &mut HashMap<String, Pending>, path: String, op: &'static str, to: Option<String>, now: Instant) {
|
|
||||||
if let Some(to) = &to
|
|
||||||
&& pending.get(to).is_some_and(|p| p.op == "moved-in")
|
|
||||||
{
|
|
||||||
pending.remove(to);
|
|
||||||
}
|
|
||||||
match pending.get_mut(&path) {
|
|
||||||
Some(p) => {
|
|
||||||
if !(p.op == "create" && op == "write") {
|
|
||||||
p.op = op;
|
|
||||||
p.to = to;
|
|
||||||
}
|
|
||||||
p.last = now;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
pending.insert(path, Pending { op, to, first: now, last: now });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The actor to check when `actor/<id>/actor.wasm` or the folder `actor/<id>`
|
|
||||||
/// appeared or changed.
|
|
||||||
fn new_wasm(path: &str, op: &str, to: Option<&str>) -> Option<String> {
|
|
||||||
let target = match op {
|
|
||||||
"remove" => return None,
|
|
||||||
"rename" => to?,
|
|
||||||
_ => path,
|
|
||||||
};
|
|
||||||
let rest = target.strip_prefix("/actor/")?;
|
|
||||||
let id = rest.strip_suffix("/actor.wasm").unwrap_or(rest);
|
|
||||||
(!id.is_empty() && !id.contains('/')).then(|| id.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wasm_and_folder_changes_name_the_actor() {
|
|
||||||
assert_eq!(new_wasm("/actor/a/actor.wasm", "write", None).as_deref(), Some("a"));
|
|
||||||
assert_eq!(new_wasm("/actor/a", "create", None).as_deref(), Some("a"));
|
|
||||||
assert_eq!(new_wasm("/actor/x/y", "rename", Some("/actor/b/actor.wasm")).as_deref(), Some("b"));
|
|
||||||
assert_eq!(new_wasm("/actor/a/actor.wasm", "remove", None), None);
|
|
||||||
assert_eq!(new_wasm("/actor/a/versions/1/actor.wasm", "write", None), None);
|
|
||||||
assert_eq!(new_wasm("/actor/a/public/x", "write", None), None);
|
|
||||||
assert_eq!(new_wasm("/actor", "write", None), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_rename_is_one_change_and_temp_files_are_skipped() {
|
|
||||||
let root = Path::new("/w");
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut pending = HashMap::new();
|
|
||||||
let ev = |kind, paths: &[&str]| {
|
|
||||||
let mut e = notify::Event::new(kind);
|
|
||||||
for p in paths {
|
|
||||||
e = e.add_path(std::path::PathBuf::from(*p));
|
|
||||||
}
|
|
||||||
e
|
|
||||||
};
|
|
||||||
let name = |mode| EventKind::Modify(ModifyKind::Name(mode));
|
|
||||||
for e in [
|
|
||||||
ev(name(RenameMode::From), &["/w/actor/a/public/x"]),
|
|
||||||
ev(name(RenameMode::To), &["/w/actor/a/public/y"]),
|
|
||||||
ev(name(RenameMode::Both), &["/w/actor/a/public/x", "/w/actor/a/public/y"]),
|
|
||||||
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/actor/a/.sausage-tmp-host-1-actor.json"]),
|
|
||||||
ev(name(RenameMode::Both), &["/w/actor/a/.sausage-tmp-host-1-actor.json", "/w/actor/a/actor.json"]),
|
|
||||||
ev(EventKind::Create(notify::event::CreateKind::File), &["/outside/.lock"]),
|
|
||||||
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/config/x"]),
|
|
||||||
] {
|
|
||||||
for (path, op, to) in changes(root, &e) {
|
|
||||||
add(&mut pending, path, op, to, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut got: Vec<(String, &str, Option<String>)> =
|
|
||||||
pending.into_iter().map(|(k, p)| (k, p.op, p.to)).collect();
|
|
||||||
got.sort();
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
vec![
|
|
||||||
("/actor/a/actor.json".to_string(), "write", None),
|
|
||||||
("/actor/a/public/x".to_string(), "rename", Some("/actor/a/public/y".to_string())),
|
|
||||||
("/config/x".to_string(), "create", None),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+35
-284
@@ -1,312 +1,63 @@
|
|||||||
//! On-disk layout of a Sausage world. The folder is the source of truth:
|
//! World folders and atomic build output helpers.
|
||||||
//! `sausage run` boots every actor that it finds in `actor/`, and then
|
use anyhow::{Result, bail};
|
||||||
//! watches the folder (`watch.rs`). `/self` aliases the running actor's folder;
|
use std::path::{Path, PathBuf};
|
||||||
//! `/public` and `/data` remain aliases for its `public/` and `data/`.
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! <world>/ mounted at /, readable and writable by every actor
|
|
||||||
//! config/ global configuration by convention (no secrets)
|
|
||||||
//! actor/<id>/
|
|
||||||
//! actor.wasm the current version
|
|
||||||
//! actor.json version, running, subscriptions, tapped, watches (broker)
|
|
||||||
//! source/ source of the current version
|
|
||||||
//! public/ published files, readable and writable by everyone
|
|
||||||
//! data/ state, readable and writable by everyone
|
|
||||||
//! stderr.log stderr of the actor and notes of the broker
|
|
||||||
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
use std::path::{Component, Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Name prefix of the host's temporary files and folders. Copies skip them.
|
|
||||||
pub const TMP_PREFIX: &str = ".sausage-tmp-";
|
|
||||||
|
|
||||||
/// IDs of the actors that the host implements natively. `fs` sends the
|
|
||||||
/// requests for actor-served files (`vfs.rs`).
|
|
||||||
pub const NATIVE: &[&str] = &["broker", "console", "cargo", "fs"];
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct World {
|
pub struct World {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `actor/<id>/actor.json`.
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
||||||
pub struct ActorMeta {
|
|
||||||
/// The installed version. `None` for native actors and for a new
|
|
||||||
/// `actor.wasm` that the broker has not installed yet.
|
|
||||||
#[serde(default)]
|
|
||||||
pub version: Option<u64>,
|
|
||||||
#[serde(default = "yes")]
|
|
||||||
pub running: bool,
|
|
||||||
/// The actors whose published messages this actor receives.
|
|
||||||
#[serde(default)]
|
|
||||||
pub subscriptions: BTreeSet<String>,
|
|
||||||
/// Whether this actor gets a copy of every routed message (`tap`).
|
|
||||||
#[serde(default)]
|
|
||||||
pub tapped: bool,
|
|
||||||
/// Namespace paths whose changes this actor gets as `fs_changed` (`watch`).
|
|
||||||
#[serde(default)]
|
|
||||||
pub watches: BTreeSet<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn yes() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `versions/<n>/meta.json`.
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
||||||
pub struct VersionMeta {
|
|
||||||
pub version: u64,
|
|
||||||
/// Path of the `.wasm` file, as the requester gave it.
|
|
||||||
pub wasm: String,
|
|
||||||
/// Path of the source folder, as the requester gave it.
|
|
||||||
pub source: Option<String>,
|
|
||||||
/// The actor that built the `.wasm` file.
|
|
||||||
pub built_by: Option<String>,
|
|
||||||
/// The actor that requested the version ("scan" for a file found on disk).
|
|
||||||
pub requested_by: String,
|
|
||||||
pub created_at: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn valid_id(id: &str) -> bool {
|
|
||||||
!id.is_empty()
|
|
||||||
&& id.len() <= 64
|
|
||||||
&& id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn now() -> u64 {
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl World {
|
impl World {
|
||||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
Self { root: root.into() }
|
for name in ["proc", "run", "self"] {
|
||||||
}
|
if path.join(name).symlink_metadata().is_ok() {
|
||||||
|
bail!("reserved world path: {name}");
|
||||||
pub fn actors_root(&self) -> PathBuf {
|
|
||||||
self.root.join("actor")
|
|
||||||
}
|
|
||||||
pub fn actor_dir(&self, id: &str) -> PathBuf {
|
|
||||||
self.actors_root().join(id)
|
|
||||||
}
|
|
||||||
pub fn wasm_path(&self, id: &str) -> PathBuf {
|
|
||||||
self.actor_dir(id).join("actor.wasm")
|
|
||||||
}
|
|
||||||
pub fn public_dir(&self, id: &str) -> PathBuf {
|
|
||||||
self.actor_dir(id).join("public")
|
|
||||||
}
|
|
||||||
pub fn source_dir(&self, id: &str) -> PathBuf {
|
|
||||||
self.actor_dir(id).join("source")
|
|
||||||
}
|
|
||||||
pub fn data_dir(&self, id: &str) -> PathBuf {
|
|
||||||
self.actor_dir(id).join("data")
|
|
||||||
}
|
|
||||||
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
|
||||||
self.actor_dir(id).join("versions")
|
|
||||||
}
|
|
||||||
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
|
||||||
self.versions_dir(id).join(v.to_string())
|
|
||||||
}
|
|
||||||
/// Private scratch space of the broker (candidate copies, seeds).
|
|
||||||
pub fn scratch_dir(&self) -> PathBuf {
|
|
||||||
self.data_dir("broker").join("scratch")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exists(&self) -> bool {
|
|
||||||
self.actors_root().is_dir()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an empty world with the folders of the native actors.
|
|
||||||
pub fn create(root: &Path) -> Result<World> {
|
|
||||||
let world = World::new(std::path::absolute(root)?);
|
|
||||||
if world.exists() {
|
|
||||||
bail!("{} is already a Sausage world", root.display());
|
|
||||||
}
|
|
||||||
for id in NATIVE {
|
|
||||||
world.ensure_actor_dirs(id)?;
|
|
||||||
}
|
|
||||||
std::fs::create_dir_all(world.root.join("config"))?;
|
|
||||||
Ok(world)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
|
|
||||||
std::fs::create_dir_all(self.public_dir(id))?;
|
|
||||||
std::fs::create_dir_all(self.data_dir(id))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// IDs of every folder in `actor/`, sorted.
|
|
||||||
pub fn actor_ids(&self) -> Result<Vec<String>> {
|
|
||||||
let mut ids: Vec<String> = std::fs::read_dir(self.actors_root())?
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| e.path().is_dir())
|
|
||||||
.filter_map(|e| e.file_name().into_string().ok())
|
|
||||||
.filter(|id| valid_id(id))
|
|
||||||
.collect();
|
|
||||||
ids.sort();
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_meta(&self, id: &str) -> ActorMeta {
|
|
||||||
std::fs::read_to_string(self.actor_dir(id).join("actor.json"))
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
|
||||||
.unwrap_or(ActorMeta { running: true, ..Default::default() })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> {
|
|
||||||
write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make `actor.wasm` and `source/` a copy of `versions/<version>/`,
|
|
||||||
/// unless `actor.wasm` already is that version (it came from there).
|
|
||||||
pub fn install_files(&self, id: &str, version: u64) -> Result<()> {
|
|
||||||
let dir = self.version_dir(id, version);
|
|
||||||
self.ensure_actor_dirs(id)?;
|
|
||||||
let wasm = std::fs::read(dir.join("actor.wasm"))?;
|
|
||||||
if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
write_atomic(&self.wasm_path(id), &wasm)?;
|
|
||||||
replace_tree(&dir.join("source"), &self.source_dir(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Version numbers in `versions/`, ascending.
|
|
||||||
pub fn versions(&self, id: &str) -> Vec<u64> {
|
|
||||||
let mut v: Vec<u64> = std::fs::read_dir(self.versions_dir(id))
|
|
||||||
.map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
v.sort();
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_version(&self, id: &str) -> u64 {
|
|
||||||
self.versions(id).last().map_or(1, |v| v + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a path in an actor's namespace to a host path.
|
|
||||||
///
|
|
||||||
/// - Absolute paths are relative to the world root.
|
|
||||||
/// - `/self/...` aliases `sender`'s folder; `/public/...` and `/data/...`
|
|
||||||
/// alias its corresponding children.
|
|
||||||
/// - Native senders (the console) can also give absolute host paths.
|
|
||||||
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
|
|
||||||
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
|
||||||
let mut out = base;
|
|
||||||
for c in Path::new(rest).components() {
|
|
||||||
match c {
|
|
||||||
Component::Normal(n) => out.push(n),
|
|
||||||
Component::CurDir | Component::RootDir => {}
|
|
||||||
_ => bail!("path must not contain '..': {path}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(Self {
|
||||||
};
|
root: path.canonicalize()?,
|
||||||
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") {
|
pub fn create(path: &Path) -> Result<Self> {
|
||||||
return under(self.actor_dir(sender), rest);
|
if path.exists() {
|
||||||
|
bail!("world already exists: {}", path.display());
|
||||||
}
|
}
|
||||||
if let Some(rest) = prefixed("/public") {
|
for dir in ["plugins", "state/core", "config"] {
|
||||||
return under(self.public_dir(sender), rest);
|
std::fs::create_dir_all(path.join(dir))?;
|
||||||
}
|
}
|
||||||
if let Some(rest) = prefixed("/data") {
|
std::fs::write(path.join("config/boot.json"), "[]\n")?;
|
||||||
return under(self.data_dir(sender), rest);
|
Self::open(path)
|
||||||
}
|
}
|
||||||
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
pub fn plugin_dir(&self, name: &str) -> PathBuf {
|
||||||
return Ok(PathBuf::from(path));
|
self.root.join("plugins").join(name)
|
||||||
}
|
}
|
||||||
if Path::new(path).is_absolute() {
|
pub fn state_dir(&self, name: &str) -> PathBuf {
|
||||||
return under(self.root.clone(), path);
|
self.root.join("state").join(name)
|
||||||
}
|
|
||||||
bail!("cannot resolve path {path} for {sender}: use an absolute world path")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path of a host file in the global filesystem, if it has one.
|
|
||||||
pub fn global_path(&self, host: &Path) -> Option<String> {
|
|
||||||
let rel = host.strip_prefix(&self.root).ok()?;
|
|
||||||
Some(format!("/{}", rel.display()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a file so that readers never see a partial version.
|
|
||||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
let dir = path.parent().context("path has no parent")?;
|
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||||
std::fs::create_dir_all(dir)?;
|
let tmp = path.with_extension("tmp");
|
||||||
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::write(&tmp, bytes)?;
|
std::fs::write(&tmp, bytes)?;
|
||||||
std::fs::rename(&tmp, path)?;
|
std::fs::rename(tmp, path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
pub fn replace_source(from: &Path, to: &Path) -> Result<()> {
|
||||||
/// Folders that a copy of a source folder leaves out.
|
if to.exists() {
|
||||||
const BUILD_DIRS: &[&str] = &["target", ".git"];
|
std::fs::remove_dir_all(to)?;
|
||||||
|
|
||||||
/// Copy a directory tree, except uncommitted files.
|
|
||||||
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
|
||||||
copy_filtered(from, 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)?;
|
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 entry = entry?;
|
||||||
let name = entry.file_name();
|
if entry.file_name() == "target" {
|
||||||
let name = name.to_string_lossy();
|
|
||||||
if name.starts_with(TMP_PREFIX) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let dst = to.join(&*name);
|
if entry.file_type()?.is_dir() {
|
||||||
let ty = entry.file_type()?;
|
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
|
||||||
if ty.is_dir() && !skip.contains(&&*name) {
|
|
||||||
copy_filtered(&entry.path(), &dst, skip)?;
|
|
||||||
} else if ty.is_file() {
|
|
||||||
std::fs::copy(entry.path(), &dst)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the directory `dst` with a copy of `src` (an empty folder if `src`
|
|
||||||
/// does not exist): copy to a sibling, then swap with two renames.
|
|
||||||
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
|
|
||||||
replace_filtered(src, dst, &[])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`.
|
|
||||||
pub fn replace_source(src: &Path, dst: &Path) -> Result<()> {
|
|
||||||
replace_filtered(src, dst, BUILD_DIRS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> {
|
|
||||||
let parent = dst.parent().context("path has no parent")?;
|
|
||||||
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
|
|
||||||
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
|
|
||||||
let old = parent.join(format!("{TMP_PREFIX}old-{name}"));
|
|
||||||
let _ = std::fs::remove_dir_all(&fresh);
|
|
||||||
let _ = std::fs::remove_dir_all(&old);
|
|
||||||
if src.is_dir() {
|
|
||||||
copy_filtered(src, &fresh, skip)?;
|
|
||||||
} else {
|
} else {
|
||||||
std::fs::create_dir_all(&fresh)?;
|
std::fs::copy(entry.path(), to.join(entry.file_name()))?;
|
||||||
}
|
}
|
||||||
if dst.exists() {
|
|
||||||
std::fs::rename(dst, &old)?;
|
|
||||||
}
|
}
|
||||||
std::fs::rename(&fresh, dst)?;
|
|
||||||
let _ = std::fs::remove_dir_all(&old);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-105
@@ -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"
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::io::{self, BufRead, Write};
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
fn send(out: &mut impl Write, message: Value) {
|
|
||||||
writeln!(out, "{message}").unwrap();
|
|
||||||
out.flush().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reply(out: &mut impl Write, message: &Value, body: Value) {
|
|
||||||
send(
|
|
||||||
out,
|
|
||||||
json!({"to": message["from"], "reply_to": message["id"], "body": body}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to `address`, send one line, and return the line that comes back.
|
|
||||||
fn tcp_line(address: &str, line: &str) -> io::Result<String> {
|
|
||||||
let mut stream = std::net::TcpStream::connect(address)?;
|
|
||||||
writeln!(stream, "{line}")?;
|
|
||||||
let mut answer = String::new();
|
|
||||||
io::BufReader::new(stream).read_line(&mut answer)?;
|
|
||||||
Ok(answer.trim_end().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn io_error(error: io::Error) -> Value {
|
|
||||||
json!({"type": "error", "error": error.to_string()})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let actor = std::env::var("SAUSAGE_ACTOR").unwrap_or_default();
|
|
||||||
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_else(|_| "candidate".to_string());
|
|
||||||
let candidate = std::env::var_os("SAUSAGE_CANDIDATE").is_some();
|
|
||||||
if actor == "crashloop" && version == "2" && !candidate {
|
|
||||||
std::fs::write("/data/state", "bad-v2").unwrap();
|
|
||||||
panic!("version 2 crashes on startup");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut open = HashMap::<String, std::fs::File>::new();
|
|
||||||
// Files that this actor serves at /actor/<id>/fs/ (path -> text).
|
|
||||||
let mut served = std::collections::BTreeMap::<String, String>::new();
|
|
||||||
let stdin = io::stdin();
|
|
||||||
let mut out = io::stdout().lock();
|
|
||||||
let mut emit_shutdown = false;
|
|
||||||
if actor == "spawn-race" && !candidate {
|
|
||||||
let stamp = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
std::fs::write(format!("/data/start-{stamp}"), "started").unwrap();
|
|
||||||
}
|
|
||||||
for line in stdin.lock().lines() {
|
|
||||||
let Ok(line) = line else { break };
|
|
||||||
let Ok(message) = serde_json::from_str::<Value>(&line) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let body = &message["body"];
|
|
||||||
let path = body["path"].as_str().unwrap_or_default().to_string();
|
|
||||||
let text = body["text"].as_str().unwrap_or_default();
|
|
||||||
let response = match body["type"].as_str() {
|
|
||||||
Some("shutdown") => {
|
|
||||||
if actor == "queue" {
|
|
||||||
let _ = std::fs::write("/data/shutdown", "stopping");
|
|
||||||
std::thread::sleep(Duration::from_millis(800));
|
|
||||||
}
|
|
||||||
if emit_shutdown {
|
|
||||||
send(
|
|
||||||
&mut out,
|
|
||||||
json!({"to": "console", "reply_to": message["id"], "body": {"type": "shutdown-reply"}}),
|
|
||||||
);
|
|
||||||
send(&mut out, json!({"body": {"type": "shutdown-publish"}}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some("echo") => json!({"type": "echo", "value": body["value"]}),
|
|
||||||
Some("fs_read") => {
|
|
||||||
let dir = if path == "/" { "/".to_string() } else { format!("{path}/") };
|
|
||||||
let mut names: Vec<String> = served
|
|
||||||
.keys()
|
|
||||||
.filter_map(|k| k.strip_prefix(&dir))
|
|
||||||
.map(|rest| match rest.split_once('/') {
|
|
||||||
Some((first, _)) => format!("{first}/"),
|
|
||||||
None => rest.to_string(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
names.dedup();
|
|
||||||
if let Some(text) = served.get(&path) {
|
|
||||||
json!({"type": "fs_file", "text": text})
|
|
||||||
} else if path == "/" || !names.is_empty() {
|
|
||||||
let entries: Vec<Value> = names
|
|
||||||
.iter()
|
|
||||||
.map(|n| match n.strip_suffix('/') {
|
|
||||||
Some(d) => json!({"name": d, "type": "dir"}),
|
|
||||||
None => json!({"name": n, "type": "file"}),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
json!({"type": "fs_dir", "entries": entries})
|
|
||||||
} else {
|
|
||||||
json!({"type": "fs_error", "errno": "no-entry"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("fs_write") => {
|
|
||||||
served.insert(path.clone(), text.to_string());
|
|
||||||
json!({"type": "fs_done"})
|
|
||||||
}
|
|
||||||
Some("fs_remove") => match served.remove(&path) {
|
|
||||||
Some(_) => json!({"type": "fs_done"}),
|
|
||||||
None => json!({"type": "fs_error", "errno": "no-entry"}),
|
|
||||||
},
|
|
||||||
Some("publish") => {
|
|
||||||
send(
|
|
||||||
&mut out,
|
|
||||||
json!({"body": {"type": "published", "value": body["value"]}}),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Some("version") => json!({"type": "version", "version": version}),
|
|
||||||
Some("ordered") => json!({"type": "ordered", "n": body["n"], "version": version}),
|
|
||||||
Some("write") => match std::fs::write(&path, text) {
|
|
||||||
Ok(()) => json!({"type": "ok"}),
|
|
||||||
Err(error) => io_error(error),
|
|
||||||
},
|
|
||||||
Some("mkdir") => match std::fs::create_dir_all(&path) {
|
|
||||||
Ok(()) => json!({"type": "ok"}),
|
|
||||||
Err(error) => io_error(error),
|
|
||||||
},
|
|
||||||
Some("rename") => match std::fs::rename(&path, body["to"].as_str().unwrap_or_default())
|
|
||||||
{
|
|
||||||
Ok(()) => json!({"type": "ok"}),
|
|
||||||
Err(error) => io_error(error),
|
|
||||||
},
|
|
||||||
Some("open") => {
|
|
||||||
let result = (|| -> io::Result<()> {
|
|
||||||
let mut file = std::fs::File::create(&path)?;
|
|
||||||
file.write_all(text.as_bytes())?;
|
|
||||||
open.insert(path.clone(), file);
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
match result {
|
|
||||||
Ok(()) => json!({"type": "opened"}),
|
|
||||||
Err(error) => io_error(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("close") => json!({"type": "closed", "closed": open.remove(&path).is_some()}),
|
|
||||||
Some("arm-shutdown") => {
|
|
||||||
emit_shutdown = true;
|
|
||||||
json!({"type": "armed"})
|
|
||||||
}
|
|
||||||
Some("ignore-stdin") => json!({"type": "ignoring"}),
|
|
||||||
Some("tcp") => match tcp_line(body["address"].as_str().unwrap_or_default(), text) {
|
|
||||||
Ok(line) => json!({"type": "tcp", "line": line}),
|
|
||||||
Err(error) => io_error(error),
|
|
||||||
},
|
|
||||||
Some("read") => match std::fs::read_to_string(&path) {
|
|
||||||
Ok(text) => json!({"type": "content", "text": text}),
|
|
||||||
Err(error) => io_error(error),
|
|
||||||
},
|
|
||||||
Some("list") => match std::fs::read_dir(&path) {
|
|
||||||
Ok(entries) => {
|
|
||||||
let mut names: Vec<String> = entries
|
|
||||||
.filter_map(|entry| entry.ok())
|
|
||||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
|
||||||
.collect();
|
|
||||||
names.sort();
|
|
||||||
json!({"type": "entries", "names": names})
|
|
||||||
}
|
|
||||||
Err(error) => io_error(error),
|
|
||||||
},
|
|
||||||
_ => json!({"type": "error", "error": "unknown message"}),
|
|
||||||
};
|
|
||||||
reply(&mut out, &message, response);
|
|
||||||
if body["type"] == "ignore-stdin" {
|
|
||||||
loop {
|
|
||||||
std::thread::sleep(Duration::from_secs(60));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,555 +0,0 @@
|
|||||||
//! Integration tests for the agent's inference and restart behavior.
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::Command;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use common::{create_world, receive_message, receive_reply, request, source_with_test, start};
|
|
||||||
use sausage::{Envelope, Host, World};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
|
||||||
use tokio::sync::{mpsc, oneshot};
|
|
||||||
use tokio::task::{JoinHandle, JoinSet};
|
|
||||||
|
|
||||||
const APIS: [Api; 2] = [Api::OpenAi, Api::Anthropic];
|
|
||||||
const TEST_TOKEN: &str = "fake-token";
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum Api {
|
|
||||||
OpenAi,
|
|
||||||
Anthropic,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Api {
|
|
||||||
fn name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::OpenAi => "openai-responses",
|
|
||||||
Self::Anthropic => "anthropic-messages",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::OpenAi => "/v1/responses",
|
|
||||||
Self::Anthropic => "/v1/messages",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text_response(self, text: &str) -> Value {
|
|
||||||
match self {
|
|
||||||
Self::OpenAi => {
|
|
||||||
json!({"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]})
|
|
||||||
}
|
|
||||||
Self::Anthropic => json!({"content": [{"type": "text", "text": text}]}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tool_response(self, id: &str, name: &str, arguments: &Value) -> Value {
|
|
||||||
match self {
|
|
||||||
Self::OpenAi => {
|
|
||||||
json!({"output": [{"type": "function_call", "call_id": id, "name": name, "arguments": arguments.to_string()}]})
|
|
||||||
}
|
|
||||||
Self::Anthropic => {
|
|
||||||
json!({"content": [{"type": "tool_use", "id": id, "name": name, "input": arguments}]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_request(self, request: &HttpRequest) {
|
|
||||||
assert_eq!(request.method, "POST");
|
|
||||||
assert_eq!(request.path, self.path());
|
|
||||||
assert_eq!(request.headers["content-type"], "application/json");
|
|
||||||
assert_eq!(request.body["model"], "fake-model");
|
|
||||||
match self {
|
|
||||||
Self::OpenAi => assert_eq!(request.headers["authorization"], "Bearer fake-token"),
|
|
||||||
Self::Anthropic => {
|
|
||||||
assert_eq!(request.headers["x-api-key"], TEST_TOKEN);
|
|
||||||
assert_eq!(request.headers["anthropic-version"], "2023-06-01");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HttpRequest {
|
|
||||||
method: String,
|
|
||||||
path: String,
|
|
||||||
headers: HashMap<String, String>,
|
|
||||||
body: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ReplyPlan {
|
|
||||||
Send(Value),
|
|
||||||
Hold(Value, oneshot::Receiver<()>),
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FakeServer {
|
|
||||||
base_url: String,
|
|
||||||
plans: mpsc::Sender<ReplyPlan>,
|
|
||||||
requests: mpsc::Receiver<HttpRequest>,
|
|
||||||
task: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FakeServer {
|
|
||||||
async fn start() -> Self {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let (plans, mut plan_rx) = mpsc::channel(16);
|
|
||||||
let (request_tx, requests) = mpsc::channel(16);
|
|
||||||
let task = tokio::spawn(async move {
|
|
||||||
let mut handlers = JoinSet::new();
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
accepted = listener.accept() => {
|
|
||||||
let (mut stream, _) = accepted.expect("fake listener accept failed");
|
|
||||||
let request = read_http_request(&mut stream).await;
|
|
||||||
let Some(plan) = plan_rx.recv().await else { break };
|
|
||||||
if request_tx.send(request).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
handlers.spawn(async move {
|
|
||||||
let (body, release) = match plan {
|
|
||||||
ReplyPlan::Send(body) => (body, None),
|
|
||||||
ReplyPlan::Hold(body, release) => (body, Some(release)),
|
|
||||||
};
|
|
||||||
if let Some(release) = release {
|
|
||||||
let _ = release.await;
|
|
||||||
}
|
|
||||||
write_http_response(stream, body).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some(_) = handlers.join_next(), if !handlers.is_empty() => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Self {
|
|
||||||
base_url: format!("http://127.0.0.1:{}", address.port()),
|
|
||||||
plans,
|
|
||||||
requests,
|
|
||||||
task,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn answer(&self, body: Value) {
|
|
||||||
self.plans.send(ReplyPlan::Send(body)).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn hold(&self, body: Value) -> oneshot::Sender<()> {
|
|
||||||
let (release, wait) = oneshot::channel();
|
|
||||||
self.plans.send(ReplyPlan::Hold(body, wait)).await.unwrap();
|
|
||||||
release
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next_request(&mut self) -> HttpRequest {
|
|
||||||
tokio::time::timeout(Duration::from_secs(20), self.requests.recv())
|
|
||||||
.await
|
|
||||||
.expect("timed out waiting for fake inference request")
|
|
||||||
.expect("fake server request channel closed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for FakeServer {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_http_request(stream: &mut TcpStream) -> HttpRequest {
|
|
||||||
let mut received = Vec::new();
|
|
||||||
let mut chunk = [0; 4096];
|
|
||||||
let header_end = loop {
|
|
||||||
let count = stream.read(&mut chunk).await.expect("read HTTP request");
|
|
||||||
assert_ne!(count, 0, "client closed before HTTP headers arrived");
|
|
||||||
received.extend_from_slice(&chunk[..count]);
|
|
||||||
if let Some(end) = received.windows(4).position(|part| part == b"\r\n\r\n") {
|
|
||||||
break end;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let header_text = String::from_utf8_lossy(&received[..header_end]);
|
|
||||||
let mut lines = header_text.split("\r\n");
|
|
||||||
let mut request_line = lines.next().unwrap().split_whitespace();
|
|
||||||
let method = request_line.next().unwrap().to_string();
|
|
||||||
let path = request_line.next().unwrap().to_string();
|
|
||||||
let mut headers = HashMap::new();
|
|
||||||
for line in lines {
|
|
||||||
if let Some((name, value)) = line.split_once(':') {
|
|
||||||
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let body_start = header_end + 4;
|
|
||||||
let request_body = if let Some(length) = headers.get("content-length") {
|
|
||||||
let body_length = length.parse::<usize>().unwrap();
|
|
||||||
while received.len() < body_start + body_length {
|
|
||||||
let count = stream.read(&mut chunk).await.expect("read HTTP body");
|
|
||||||
assert_ne!(count, 0, "client closed before HTTP body arrived");
|
|
||||||
received.extend_from_slice(&chunk[..count]);
|
|
||||||
}
|
|
||||||
received[body_start..body_start + body_length].to_vec()
|
|
||||||
} else {
|
|
||||||
assert!(
|
|
||||||
headers
|
|
||||||
.get("transfer-encoding")
|
|
||||||
.is_some_and(|value| value.eq_ignore_ascii_case("chunked")),
|
|
||||||
"request has neither Content-Length nor chunked transfer encoding: {headers:?}"
|
|
||||||
);
|
|
||||||
read_chunked_body(stream, &mut received, body_start).await
|
|
||||||
};
|
|
||||||
let body = serde_json::from_slice(&request_body).unwrap();
|
|
||||||
HttpRequest {
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_chunked_body(
|
|
||||||
stream: &mut TcpStream,
|
|
||||||
received: &mut Vec<u8>,
|
|
||||||
mut offset: usize,
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let mut body = Vec::new();
|
|
||||||
loop {
|
|
||||||
let line_end = loop {
|
|
||||||
if let Some(end) = received[offset..]
|
|
||||||
.windows(2)
|
|
||||||
.position(|part| part == b"\r\n")
|
|
||||||
{
|
|
||||||
break offset + end;
|
|
||||||
}
|
|
||||||
let mut chunk = [0; 4096];
|
|
||||||
let count = stream.read(&mut chunk).await.expect("read chunk size");
|
|
||||||
assert_ne!(count, 0, "client closed before chunk size arrived");
|
|
||||||
received.extend_from_slice(&chunk[..count]);
|
|
||||||
};
|
|
||||||
let size = String::from_utf8_lossy(&received[offset..line_end]);
|
|
||||||
let size = size.split(';').next().unwrap().trim();
|
|
||||||
let size = usize::from_str_radix(size, 16).unwrap();
|
|
||||||
offset = line_end + 2;
|
|
||||||
if size == 0 {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
while received.len() < offset + size + 2 {
|
|
||||||
let mut chunk = [0; 4096];
|
|
||||||
let count = stream.read(&mut chunk).await.expect("read chunk data");
|
|
||||||
assert_ne!(count, 0, "client closed before chunk data arrived");
|
|
||||||
received.extend_from_slice(&chunk[..count]);
|
|
||||||
}
|
|
||||||
body.extend_from_slice(&received[offset..offset + size]);
|
|
||||||
assert_eq!(&received[offset + size..offset + size + 2], b"\r\n");
|
|
||||||
offset += size + 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write_http_response(mut stream: TcpStream, body: Value) {
|
|
||||||
let body = serde_json::to_vec(&body).unwrap();
|
|
||||||
let head = format!(
|
|
||||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
let mut response = head.into_bytes();
|
|
||||||
response.extend_from_slice(&body);
|
|
||||||
let _ = stream.write_all(&response).await;
|
|
||||||
let _ = stream.shutdown().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_wasm() -> &'static PathBuf {
|
|
||||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
|
||||||
WASM.get_or_init(|| {
|
|
||||||
let root = common::repo_root();
|
|
||||||
let output = Command::new("cargo")
|
|
||||||
.args([
|
|
||||||
"build",
|
|
||||||
"--release",
|
|
||||||
"-p",
|
|
||||||
"agent",
|
|
||||||
"--target",
|
|
||||||
"wasm32-wasip2",
|
|
||||||
"--target-dir",
|
|
||||||
"target/test-actors",
|
|
||||||
])
|
|
||||||
.current_dir(&root)
|
|
||||||
.output()
|
|
||||||
.expect("run cargo to build the agent actor");
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"building agent failed\nstdout:\n{}\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let wasm = root.join("target/test-actors/wasm32-wasip2/release/agent.wasm");
|
|
||||||
assert!(
|
|
||||||
wasm.is_file(),
|
|
||||||
"agent build did not create {}",
|
|
||||||
wasm.display()
|
|
||||||
);
|
|
||||||
wasm
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_agent(
|
|
||||||
api: Api,
|
|
||||||
fake: &FakeServer,
|
|
||||||
label: &str,
|
|
||||||
) -> (World, Host, mpsc::Receiver<Envelope>) {
|
|
||||||
let world = create_world(label);
|
|
||||||
let data = world.data_dir("agent");
|
|
||||||
std::fs::create_dir_all(&data).unwrap();
|
|
||||||
let config = json!({"api": api.name(), "base_url": fake.base_url, "model": "fake-model", "token": TEST_TOKEN});
|
|
||||||
std::fs::write(
|
|
||||||
data.join("config.json"),
|
|
||||||
serde_json::to_vec(&config).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let source = source_with_test(
|
|
||||||
&world,
|
|
||||||
"agent-candidate",
|
|
||||||
json!({"steps": [{"send": {"type": "text", "text": "candidate"}, "expect": {"type": "text"}}]}),
|
|
||||||
);
|
|
||||||
let (host, mut mailbox) = start(&world).await;
|
|
||||||
let spawned = common::spawn_actor(&host, &mut mailbox, "agent", agent_wasm(), &source).await;
|
|
||||||
assert_eq!(
|
|
||||||
spawned.body["type"], "spawned",
|
|
||||||
"agent spawn failed: {spawned:?}"
|
|
||||||
);
|
|
||||||
let subscribed = request(
|
|
||||||
&host,
|
|
||||||
&mut mailbox,
|
|
||||||
json!({"type": "subscribe", "actor": "agent"}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(subscribed.body["type"], "subscribed");
|
|
||||||
(world, host, mailbox)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_text(host: &Host, text: &str) -> String {
|
|
||||||
host.broker.send(
|
|
||||||
"console",
|
|
||||||
Some("agent"),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
json!({"type": "text", "text": text}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_published_text(mailbox: &mut mpsc::Receiver<Envelope>, text: &str) {
|
|
||||||
let message = receive_message(mailbox, Duration::from_secs(20)).await;
|
|
||||||
assert_eq!(message.from, "agent");
|
|
||||||
assert_eq!(message.to, "console");
|
|
||||||
assert_eq!(message.reply_to, None);
|
|
||||||
assert_eq!(message.body, json!({"type": "text", "text": text}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn history(world: &World) -> Vec<Value> {
|
|
||||||
std::fs::read_to_string(world.data_dir("agent").join("history.jsonl"))
|
|
||||||
.unwrap()
|
|
||||||
.lines()
|
|
||||||
.map(|line| serde_json::from_str(line).unwrap())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_history_tools_have_results(history: &[Value]) {
|
|
||||||
let calls: Vec<_> = history
|
|
||||||
.iter()
|
|
||||||
.filter(|entry| entry["kind"] == "tool_call")
|
|
||||||
.collect();
|
|
||||||
let results: Vec<_> = history
|
|
||||||
.iter()
|
|
||||||
.filter(|entry| entry["kind"] == "tool_result")
|
|
||||||
.collect();
|
|
||||||
assert_eq!(calls.len(), results.len());
|
|
||||||
for call in calls {
|
|
||||||
assert!(
|
|
||||||
results.iter().any(|result| result["id"] == call["id"]),
|
|
||||||
"tool call has no result: {call}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_tool_pair(
|
|
||||||
request: &HttpRequest,
|
|
||||||
api: Api,
|
|
||||||
id: &str,
|
|
||||||
name: &str,
|
|
||||||
arguments: &Value,
|
|
||||||
) -> String {
|
|
||||||
match api {
|
|
||||||
Api::OpenAi => {
|
|
||||||
let input = request.body["input"].as_array().unwrap();
|
|
||||||
let call_index = input
|
|
||||||
.iter()
|
|
||||||
.position(|item| item["type"] == "function_call")
|
|
||||||
.unwrap();
|
|
||||||
let result_index = input
|
|
||||||
.iter()
|
|
||||||
.position(|item| item["type"] == "function_call_output")
|
|
||||||
.unwrap();
|
|
||||||
assert!(call_index < result_index);
|
|
||||||
let call = &input[call_index];
|
|
||||||
assert_eq!(call["call_id"], id);
|
|
||||||
assert_eq!(call["name"], name);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<Value>(call["arguments"].as_str().unwrap()).unwrap(),
|
|
||||||
*arguments
|
|
||||||
);
|
|
||||||
let result = &input[result_index];
|
|
||||||
assert_eq!(result["call_id"], id);
|
|
||||||
result["output"].as_str().unwrap().to_string()
|
|
||||||
}
|
|
||||||
Api::Anthropic => {
|
|
||||||
let messages = request.body["messages"].as_array().unwrap();
|
|
||||||
let mut call_position = None;
|
|
||||||
let mut result_position = None;
|
|
||||||
let mut call = None;
|
|
||||||
let mut result = None;
|
|
||||||
for (index, message) in messages.iter().enumerate() {
|
|
||||||
let Some(blocks) = message["content"].as_array() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for block in blocks {
|
|
||||||
match block["type"].as_str() {
|
|
||||||
Some("tool_use") => {
|
|
||||||
call_position = Some(index);
|
|
||||||
call = Some(block);
|
|
||||||
}
|
|
||||||
Some("tool_result") => {
|
|
||||||
result_position = Some(index);
|
|
||||||
result = Some(block);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(call_position.unwrap() < result_position.unwrap());
|
|
||||||
let call = call.unwrap();
|
|
||||||
assert_eq!(call["id"], id);
|
|
||||||
assert_eq!(call["name"], name);
|
|
||||||
assert_eq!(call["input"], *arguments);
|
|
||||||
let result = result.unwrap();
|
|
||||||
assert_eq!(result["tool_use_id"], id);
|
|
||||||
result["content"].as_str().unwrap().to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn text_answers_use_both_inference_apis() {
|
|
||||||
for api in APIS {
|
|
||||||
let mut fake = FakeServer::start().await;
|
|
||||||
fake.answer(api.text_response("hello from the fake model"))
|
|
||||||
.await;
|
|
||||||
let (_, host, mut mailbox) =
|
|
||||||
start_agent(api, &fake, &format!("agent-text-{}", api.name())).await;
|
|
||||||
send_text(&host, "say hello");
|
|
||||||
api.check_request(&fake.next_request().await);
|
|
||||||
assert_published_text(&mut mailbox, "hello from the fake model").await;
|
|
||||||
host.stop().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn tool_calls_pair_with_results_in_both_inference_apis() {
|
|
||||||
let args = json!({"path": "/actor"});
|
|
||||||
for api in APIS {
|
|
||||||
let mut fake = FakeServer::start().await;
|
|
||||||
fake.answer(api.tool_response("list-1", "list_dir", &args))
|
|
||||||
.await;
|
|
||||||
let (world, host, mut mailbox) =
|
|
||||||
start_agent(api, &fake, &format!("agent-tools-{}", api.name())).await;
|
|
||||||
send_text(&host, "list actors");
|
|
||||||
api.check_request(&fake.next_request().await);
|
|
||||||
fake.answer(api.text_response("the actor list is ready"))
|
|
||||||
.await;
|
|
||||||
let second = fake.next_request().await;
|
|
||||||
api.check_request(&second);
|
|
||||||
let output = assert_tool_pair(&second, api, "list-1", "list_dir", &args);
|
|
||||||
let result: Value = serde_json::from_str(&output).unwrap();
|
|
||||||
assert!(
|
|
||||||
result["entries"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry["name"] == "agent")
|
|
||||||
);
|
|
||||||
assert_published_text(&mut mailbox, "the actor list is ready").await;
|
|
||||||
|
|
||||||
host.stop().await;
|
|
||||||
let history = history(&world);
|
|
||||||
assert_eq!(
|
|
||||||
history
|
|
||||||
.iter()
|
|
||||||
.map(|entry| entry["kind"].as_str().unwrap())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
["user", "tool_call", "tool_result", "assistant"]
|
|
||||||
);
|
|
||||||
assert_eq!(history[1]["id"], "list-1");
|
|
||||||
assert_eq!(history[2]["id"], "list-1");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn restart_resumes_a_turn_with_completed_tool_history() {
|
|
||||||
let args = json!({"path": "/actor"});
|
|
||||||
for api in [Api::OpenAi] {
|
|
||||||
let mut fake = FakeServer::start().await;
|
|
||||||
fake.answer(api.tool_response("list-1", "list_dir", &args))
|
|
||||||
.await;
|
|
||||||
let (world, host, mut mailbox) =
|
|
||||||
start_agent(api, &fake, &format!("agent-resume-{}", api.name())).await;
|
|
||||||
let id = send_text(&host, "list actors before the restart");
|
|
||||||
api.check_request(&fake.next_request().await);
|
|
||||||
let release = fake.hold(api.text_response("discarded response")).await;
|
|
||||||
let second = fake.next_request().await;
|
|
||||||
api.check_request(&second);
|
|
||||||
assert_tool_pair(&second, api, "list-1", "list_dir", &args);
|
|
||||||
|
|
||||||
let stopped = request(
|
|
||||||
&host,
|
|
||||||
&mut mailbox,
|
|
||||||
json!({"type": "stop", "actor": "agent"}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(stopped.body["type"], "stopped");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_slice::<Value>(
|
|
||||||
&std::fs::read(world.data_dir("agent").join("turn.json")).unwrap()
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
json!(true)
|
|
||||||
);
|
|
||||||
let _ = release.send(());
|
|
||||||
fake.answer(api.text_response("resumed answer")).await;
|
|
||||||
let start_id = host.broker.send(
|
|
||||||
"console",
|
|
||||||
Some("broker"),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
json!({"type": "start", "actor": "agent"}),
|
|
||||||
);
|
|
||||||
let resumed = fake.next_request().await;
|
|
||||||
api.check_request(&resumed);
|
|
||||||
assert_tool_pair(&resumed, api, "list-1", "list_dir", &args);
|
|
||||||
let started = receive_reply(&mut mailbox, &start_id, Duration::from_secs(20)).await;
|
|
||||||
assert_eq!(started.body["type"], "started");
|
|
||||||
assert_published_text(&mut mailbox, "resumed answer").await;
|
|
||||||
|
|
||||||
let history = history(&world);
|
|
||||||
assert_history_tools_have_results(&history);
|
|
||||||
assert_eq!(
|
|
||||||
history
|
|
||||||
.iter()
|
|
||||||
.filter(|entry| entry["kind"] == "user"
|
|
||||||
&& entry["text"].as_str().unwrap_or_default().contains(&id))
|
|
||||||
.count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
host.stop().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
//! Tests of the `sausage` command line.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn empty_dir(label: &str) -> PathBuf {
|
|
||||||
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
|
||||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.parent()
|
|
||||||
.unwrap()
|
|
||||||
.join("target/test-cli")
|
|
||||||
.join(format!("{label}-{}-{nonce}", std::process::id()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
dir
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bare `sausage` (as in `nix run <flake>`) without a world starts `init`
|
|
||||||
/// for ./world instead of printing a usage error.
|
|
||||||
#[test]
|
|
||||||
fn bare_command_without_a_world_starts_init() {
|
|
||||||
let dir = empty_dir("bare");
|
|
||||||
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
|
||||||
.current_dir(&dir)
|
|
||||||
.env_remove("SAUSAGE_API")
|
|
||||||
.env_remove("SAUSAGE_BASE_URL")
|
|
||||||
.env_remove("SAUSAGE_MODEL")
|
|
||||||
.env_remove("SAUSAGE_API_TOKEN")
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
// With no answers on stdin, init stops at the first required value.
|
|
||||||
assert!(!output.status.success(), "stdout:\n{stdout}\nstderr:\n{stderr}");
|
|
||||||
assert!(stdout.contains("No world in ./world yet"), "stdout:\n{stdout}\nstderr:\n{stderr}");
|
|
||||||
assert!(stderr.contains("SAUSAGE_BASE_URL"), "stdout:\n{stdout}\nstderr:\n{stderr}");
|
|
||||||
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
|
|
||||||
// It asks before it creates anything.
|
|
||||||
assert!(!dir.join("world").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a headless host on a fresh world with stdin at EOF; returns once it
|
|
||||||
/// printed its ready line (printed after the signal handlers are installed).
|
|
||||||
fn start_headless(label: &str) -> (std::process::Child, std::io::Lines<std::io::BufReader<std::process::ChildStdout>>) {
|
|
||||||
use std::io::{BufRead, BufReader};
|
|
||||||
let dir = empty_dir(label);
|
|
||||||
let world = dir.join("world");
|
|
||||||
sausage::World::create(&world).unwrap();
|
|
||||||
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
|
||||||
.args(["run", "--headless"])
|
|
||||||
.arg(&world)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.unwrap();
|
|
||||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
|
||||||
let line = lines.next().expect("host exited before it started").unwrap();
|
|
||||||
assert!(line.contains("headless"), "unexpected first line: {line}");
|
|
||||||
(child, lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With stdin at EOF (as soon as the host starts), a headless host stays up.
|
|
||||||
#[test]
|
|
||||||
fn headless_ignores_stdin_eof() {
|
|
||||||
let (mut child, _lines) = start_headless("headless-eof");
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
let alive = child.try_wait().unwrap().is_none();
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
assert!(alive, "headless host exited on stdin EOF");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SIGTERM right after the ready line stops the actors and exits with 0.
|
|
||||||
#[test]
|
|
||||||
fn headless_stops_cleanly_on_sigterm_right_after_ready() {
|
|
||||||
let (mut child, lines) = start_headless("headless-term");
|
|
||||||
let pid = child.id().to_string();
|
|
||||||
assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success());
|
|
||||||
let rest: Vec<String> = lines.map(|l| l.unwrap()).collect();
|
|
||||||
let status = child.wait().unwrap();
|
|
||||||
assert!(status.success(), "exit status {status:?}, output {rest:?}");
|
|
||||||
assert!(rest.iter().any(|l| l.contains("Stopping actors")), "output {rest:?}");
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use sausage::{Envelope, Host, World};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
static WORLD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
pub fn repo_root() -> PathBuf {
|
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.parent()
|
|
||||||
.unwrap()
|
|
||||||
.to_path_buf()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_world(label: &str) -> World {
|
|
||||||
let nonce = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let sequence = WORLD_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let path = repo_root()
|
|
||||||
.join("target/test-worlds")
|
|
||||||
.join(format!("{label}-{}-{nonce}-{sequence}", std::process::id()));
|
|
||||||
World::create(&path).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn actor_fixture_source(name: &str) -> PathBuf {
|
|
||||||
repo_root().join("cli/tests/actors").join(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cargo_source(name: &str) -> PathBuf {
|
|
||||||
repo_root().join("cli/tests/crates").join(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toolbox_wasm() -> &'static PathBuf {
|
|
||||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
|
||||||
WASM.get_or_init(|| build_fixture("toolbox"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_fixture(name: &str) -> PathBuf {
|
|
||||||
let root = repo_root();
|
|
||||||
let manifest = actor_fixture_source(name).join("Cargo.toml");
|
|
||||||
let target_dir = root.join("target/test-actors");
|
|
||||||
let output = Command::new("cargo")
|
|
||||||
.args([
|
|
||||||
"build",
|
|
||||||
"--release",
|
|
||||||
"--target",
|
|
||||||
"wasm32-wasip2",
|
|
||||||
"--target-dir",
|
|
||||||
])
|
|
||||||
.arg(&target_dir)
|
|
||||||
.arg("--manifest-path")
|
|
||||||
.arg(&manifest)
|
|
||||||
.current_dir(&root)
|
|
||||||
.output()
|
|
||||||
.expect("run cargo to build the test actor");
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"building fixture {name} failed\nstdout:\n{}\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let wasm = target_dir
|
|
||||||
.join("wasm32-wasip2/release")
|
|
||||||
.join(format!("{name}.wasm"));
|
|
||||||
assert!(
|
|
||||||
wasm.is_file(),
|
|
||||||
"fixture build did not create {}",
|
|
||||||
wasm.display()
|
|
||||||
);
|
|
||||||
wasm
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn passing_source(world: &World, name: &str) -> PathBuf {
|
|
||||||
source_with_test(
|
|
||||||
world,
|
|
||||||
name,
|
|
||||||
json!({
|
|
||||||
"steps": [{
|
|
||||||
"send": {"type": "echo", "value": "candidate"},
|
|
||||||
"expect": {"type": "echo", "value": "candidate"}
|
|
||||||
}]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn source_with_test(world: &World, name: &str, test: Value) -> PathBuf {
|
|
||||||
let source = world.source_dir("console").join("test-fixtures").join(name);
|
|
||||||
std::fs::create_dir_all(source.join("tests")).unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
source.join("tests/candidate.json"),
|
|
||||||
serde_json::to_vec(&test).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
source
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start(world: &World) -> (Host, mpsc::Receiver<Envelope>) {
|
|
||||||
let host = Host::start(&world.root).await.unwrap();
|
|
||||||
let mailbox = host.mailbox("console");
|
|
||||||
(host, mailbox)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn request(host: &Host, mailbox: &mut mpsc::Receiver<Envelope>, body: Value) -> Envelope {
|
|
||||||
let id = host
|
|
||||||
.broker
|
|
||||||
.send("console", Some("broker"), None, None, body);
|
|
||||||
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn receive_message(
|
|
||||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Envelope {
|
|
||||||
tokio::time::timeout(timeout, mailbox.recv())
|
|
||||||
.await
|
|
||||||
.expect("timed out waiting for a message")
|
|
||||||
.expect("console mailbox closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn receive_reply(
|
|
||||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
|
||||||
id: &str,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Envelope {
|
|
||||||
tokio::time::timeout(timeout, async {
|
|
||||||
loop {
|
|
||||||
let envelope = mailbox.recv().await.expect("console mailbox closed");
|
|
||||||
if envelope.reply_to.as_deref() == Some(id) {
|
|
||||||
return envelope;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| panic!("timed out waiting for reply to {id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn receive_replies(
|
|
||||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
|
||||||
ids: &[String],
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Vec<Envelope> {
|
|
||||||
let mut replies = Vec::with_capacity(ids.len());
|
|
||||||
tokio::time::timeout(timeout, async {
|
|
||||||
while replies.len() < ids.len() {
|
|
||||||
let message = mailbox.recv().await.expect("console mailbox closed");
|
|
||||||
assert!(
|
|
||||||
ids.iter()
|
|
||||||
.any(|id| message.reply_to.as_deref() == Some(id.as_str())),
|
|
||||||
"unexpected message while waiting for replies: {message:?}"
|
|
||||||
);
|
|
||||||
replies.push(message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("timed out waiting for replies");
|
|
||||||
replies
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ask(
|
|
||||||
host: &Host,
|
|
||||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
|
||||||
actor: &str,
|
|
||||||
body: Value,
|
|
||||||
) -> Envelope {
|
|
||||||
let id = host.broker.send("console", Some(actor), None, None, body);
|
|
||||||
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ask_type(
|
|
||||||
host: &Host,
|
|
||||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
|
||||||
actor: &str,
|
|
||||||
body: Value,
|
|
||||||
expected: &str,
|
|
||||||
) -> Envelope {
|
|
||||||
let reply = ask(host, mailbox, actor, body).await;
|
|
||||||
assert_eq!(reply.body["type"], expected, "unexpected reply: {reply:?}");
|
|
||||||
reply
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn_request(host: &Host, actor: &str, wasm: &Path, source: &Path) -> String {
|
|
||||||
host.broker.send(
|
|
||||||
"console",
|
|
||||||
Some("broker"),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
json!({
|
|
||||||
"type": "spawn",
|
|
||||||
"actor": actor,
|
|
||||||
"wasm": wasm.display().to_string(),
|
|
||||||
"source": source.display().to_string()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn spawn_actor(
|
|
||||||
host: &Host,
|
|
||||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
|
||||||
actor: &str,
|
|
||||||
wasm: &Path,
|
|
||||||
source: &Path,
|
|
||||||
) -> Envelope {
|
|
||||||
let id = spawn_request(host, actor, wasm, source);
|
|
||||||
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn spawn_type(
|
|
||||||
host: &Host,
|
|
||||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
|
||||||
actor: &str,
|
|
||||||
wasm: &Path,
|
|
||||||
source: &Path,
|
|
||||||
) -> Envelope {
|
|
||||||
let reply = spawn_actor(host, mailbox, actor, wasm, source).await;
|
|
||||||
assert_eq!(
|
|
||||||
reply.body["type"], "spawned",
|
|
||||||
"unexpected spawn reply: {reply:?}"
|
|
||||||
);
|
|
||||||
reply
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn upgrade_actor(
|
|
||||||
host: &Host,
|
|
||||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
|
||||||
actor: &str,
|
|
||||||
wasm: &Path,
|
|
||||||
source: &Path,
|
|
||||||
) -> Envelope {
|
|
||||||
request(
|
|
||||||
host,
|
|
||||||
mailbox,
|
|
||||||
json!({
|
|
||||||
"type": "upgrade",
|
|
||||||
"actor": actor,
|
|
||||||
"wasm": wasm.display().to_string(),
|
|
||||||
"source": source.display().to_string()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
//! End-to-end host behavior with a raw WASI guest.
|
||||||
|
use sausage::{Host, world::World};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{path::PathBuf, sync::OnceLock};
|
||||||
|
fn fixture() -> PathBuf {
|
||||||
|
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
WASM.get_or_init(|| {
|
||||||
|
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.to_path_buf();
|
||||||
|
let target = root.join("target/core/guests");
|
||||||
|
let status = std::process::Command::new("cargo")
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
"--offline",
|
||||||
|
"--target",
|
||||||
|
"wasm32-wasip2",
|
||||||
|
"--manifest-path",
|
||||||
|
])
|
||||||
|
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
|
||||||
|
.arg("--target-dir")
|
||||||
|
.arg(&target)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
target.join("wasm32-wasip2/debug/probe.wasm")
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
struct TestWorld(PathBuf);
|
||||||
|
impl TestWorld {
|
||||||
|
fn new() -> Self {
|
||||||
|
let p = std::env::temp_dir().join(format!("sausage-{}", uuid::Uuid::now_v7()));
|
||||||
|
let w = World::create(&p).unwrap();
|
||||||
|
std::fs::create_dir_all(w.plugin_dir("probe")).unwrap();
|
||||||
|
std::fs::copy(fixture(), w.plugin_dir("probe").join("plugin.wasm")).unwrap();
|
||||||
|
Self(p)
|
||||||
|
}
|
||||||
|
async fn start(&self) -> Host {
|
||||||
|
Host::start(&self.0).await.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for TestWorld {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn call(h: &Host, m: &str, p: Value) -> Value {
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(20), h.call(m, p))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn routing_environment_files_and_errors() {
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
assert_eq!(call(&h, "probe.echo", json!({"x":1})).await, json!({"x":1}));
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "probe.rpc.discover", json!({"x":2})).await,
|
||||||
|
json!({"x":2})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(
|
||||||
|
&h,
|
||||||
|
"probe.call",
|
||||||
|
json!({"method":"probe.echo","params":{"nested":true}})
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
json!({"nested":true})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "probe.multicall", json!({})).await,
|
||||||
|
json!({"count":2})
|
||||||
|
);
|
||||||
|
call(
|
||||||
|
&h,
|
||||||
|
"probe.notification",
|
||||||
|
json!({"path":"/state/notification","text":"delivered"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(w.0.join("state/notification")).unwrap(),
|
||||||
|
"delivered"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
h.call("probe.unknownid", json!({})).await.unwrap_err()["code"],
|
||||||
|
-32002
|
||||||
|
);
|
||||||
|
let env = call(&h, "probe.env", json!({})).await;
|
||||||
|
assert_eq!(env["plugin"], "probe");
|
||||||
|
assert_eq!(env["parent"], "console");
|
||||||
|
assert!(env["pid"].as_str().unwrap().starts_with("probe-"));
|
||||||
|
call(
|
||||||
|
&h,
|
||||||
|
"probe.write",
|
||||||
|
json!({"path":"/state/test","text":"hello"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "probe.read", json!({"path":"/state/test"})).await,
|
||||||
|
"hello"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
h.call("absent.run", json!({})).await.unwrap_err()["code"],
|
||||||
|
-32001
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
h.call("core.activate", json!({})).await.unwrap_err()["code"],
|
||||||
|
-32601
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
h.call("probe.echo", json!([])).await.unwrap_err()["code"],
|
||||||
|
-32602
|
||||||
|
);
|
||||||
|
let e = h.call("probe.exit", json!({})).await.unwrap_err();
|
||||||
|
assert_eq!(e["code"], -32002);
|
||||||
|
assert!(
|
||||||
|
e["data"]["lines"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|l| l["line"] == "last line")
|
||||||
|
);
|
||||||
|
h.stop().await;
|
||||||
|
}
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn journal_notifications_locks_and_kill() {
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
let mut notifications = h.subscribe();
|
||||||
|
call(&h, "probe.notify", json!({})).await;
|
||||||
|
assert_eq!(
|
||||||
|
notifications.recv().await.unwrap()["params"]["name"],
|
||||||
|
"progress"
|
||||||
|
);
|
||||||
|
call(&h, "probe.junk", json!({})).await;
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
|
while !h.processes().is_empty() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let starts = call(&h, "core.log", json!({"grep":"start junk"})).await;
|
||||||
|
let pid = &starts[0]["pid"];
|
||||||
|
let warnings = call(&h, "core.log", json!({"pid":pid,"grep":"discarded stdout"})).await;
|
||||||
|
assert_eq!(warnings.as_array().unwrap().len(), 1);
|
||||||
|
assert!(!warnings.to_string().contains("junk"));
|
||||||
|
let log = call(&h, "core.log", json!({"grep":"stderr line"})).await;
|
||||||
|
assert_eq!(log.as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "core.lock", json!({"path":"x"})).await,
|
||||||
|
json!({"held":true})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "core.lock", json!({"path":"x","wait":false})).await,
|
||||||
|
json!({"held":false})
|
||||||
|
);
|
||||||
|
call(&h, "core.unlock", json!({"path":"x"})).await;
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "probe.lock", json!({"path":"x"})).await,
|
||||||
|
json!({"held":true})
|
||||||
|
);
|
||||||
|
let pid = call(&h, "probe.serve", json!({})).await;
|
||||||
|
call(&h, "core.kill", json!({"pid":pid})).await;
|
||||||
|
assert!(!h.processes().contains_key(pid.as_str().unwrap()));
|
||||||
|
h.stop().await;
|
||||||
|
}
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn native_and_reserved_world_paths() {
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
h.register_native(
|
||||||
|
"native",
|
||||||
|
std::sync::Arc::new(|ctx, method, params| {
|
||||||
|
Box::pin(async move {
|
||||||
|
ctx.notify("native", json!({}));
|
||||||
|
Ok(json!({"pid":ctx.pid,"method":method,"params":params}))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let result = call(&h, "native.echo", json!({"x":1})).await;
|
||||||
|
assert_eq!(result["method"], "echo");
|
||||||
|
assert!(result["pid"].as_str().unwrap().starts_with("native-"));
|
||||||
|
h.stop().await;
|
||||||
|
for name in ["proc", "run", "self"] {
|
||||||
|
std::fs::create_dir(w.0.join(name)).unwrap();
|
||||||
|
assert!(World::open(&w.0).is_err());
|
||||||
|
std::fs::remove_dir(w.0.join(name)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn tree_detach_stop_boot_and_scheduler() {
|
||||||
|
let w = TestWorld::new();
|
||||||
|
std::fs::write(
|
||||||
|
w.0.join("config/boot.json"),
|
||||||
|
serde_json::to_vec(&json!([
|
||||||
|
{"method":"absent.start","params":{}},
|
||||||
|
{"method":"probe.write","params":{"path":"/state/boot","text":"ready"}},
|
||||||
|
{"method":"probe.read","params":{"path":"/state/boot"}}
|
||||||
|
]))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let h = w.start().await;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(w.0.join("state/boot")).unwrap(),
|
||||||
|
"ready"
|
||||||
|
);
|
||||||
|
let attached = call(
|
||||||
|
&h,
|
||||||
|
"probe.call",
|
||||||
|
json!({"method":"probe.serve","params":{}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(7), async {
|
||||||
|
while h.processes().contains_key(attached.as_str().unwrap()) {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let detached = call(
|
||||||
|
&h,
|
||||||
|
"probe.call",
|
||||||
|
json!({"method":"core.call","params":{"method":"probe.serve","params":{},"detach":true}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(h.processes()[detached.as_str().unwrap()].detached);
|
||||||
|
call(&h, "core.stop", json!({"pid":detached})).await;
|
||||||
|
let hc = h.clone();
|
||||||
|
let busy = tokio::spawn(async move { hc.call("probe.busy", json!({})).await });
|
||||||
|
let pid = tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||||
|
loop {
|
||||||
|
if let Some((pid, _)) = h.processes().into_iter().find(|(_, p)| p.method == "busy") {
|
||||||
|
break pid;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "probe.echo", json!({"works":true})).await,
|
||||||
|
json!({"works":true})
|
||||||
|
);
|
||||||
|
call(&h, "core.kill", json!({"pid":pid})).await;
|
||||||
|
assert_eq!(busy.await.unwrap().unwrap_err()["code"], -32002);
|
||||||
|
h.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn same_process_lock_acquisitions_queue() {
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
h.register_native(
|
||||||
|
"locks",
|
||||||
|
std::sync::Arc::new(|ctx, _, _| {
|
||||||
|
Box::pin(async move {
|
||||||
|
assert_eq!(
|
||||||
|
ctx.call("core.lock", json!({"path":"fifo"})).await.unwrap(),
|
||||||
|
json!({"held":true})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ctx.call("core.lock", json!({"path":"fifo","wait":false}))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
json!({"held":false})
|
||||||
|
);
|
||||||
|
let mut second = Box::pin(ctx.call("core.lock", json!({"path":"fifo"})));
|
||||||
|
std::future::poll_fn(|cx| {
|
||||||
|
assert!(second.as_mut().poll(cx).is_pending());
|
||||||
|
std::task::Poll::Ready(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
ctx.call("core.unlock", json!({"path":"fifo"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second.await.unwrap(), json!({"held":true}));
|
||||||
|
Ok(json!({}))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
call(&h, "locks.test", json!({})).await;
|
||||||
|
h.stop().await;
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "core.lock", json!({"path":"fifo","wait":false})).await,
|
||||||
|
json!({"held":true})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn stop_closes_stdin_deadline_and_exit() {
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
let hc = h.clone();
|
||||||
|
let pending = tokio::spawn(async move { hc.call("probe.wait_eof", json!({})).await });
|
||||||
|
let pid = tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||||
|
loop {
|
||||||
|
if let Some((pid, _)) = h
|
||||||
|
.processes()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, p)| p.method == "wait_eof")
|
||||||
|
{
|
||||||
|
break pid;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(w.0.join("state/stopped")).unwrap(),
|
||||||
|
"yes"
|
||||||
|
);
|
||||||
|
assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002);
|
||||||
|
assert_eq!(call(&h, "core.exit", json!({})).await, json!({}));
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(1), h.exit_requested())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
h.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn tcp_udp_and_dns() {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let tcp = format!("localhost:{}", listener.local_addr().unwrap().port());
|
||||||
|
let udp = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let udp_addr = udp.local_addr().unwrap().to_string();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
stream.write_all(b"tcp").await.unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "probe.network", json!({"tcp":tcp,"udp":udp_addr})).await,
|
||||||
|
"tcp"
|
||||||
|
);
|
||||||
|
let mut buf = [0; 3];
|
||||||
|
udp.recv_from(&mut buf).await.unwrap();
|
||||||
|
assert_eq!(&buf, b"udp");
|
||||||
|
server.await.unwrap();
|
||||||
|
h.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn native_kill_drops_future_and_releases_locks() {
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
struct Dropped(Arc<AtomicBool>);
|
||||||
|
impl Drop for Dropped {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let flag = dropped.clone();
|
||||||
|
h.register_native(
|
||||||
|
"native",
|
||||||
|
Arc::new(move |ctx, _, _| {
|
||||||
|
let flag = flag.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let _guard = Dropped(flag);
|
||||||
|
ctx.call("core.lock", json!({"path":"native-lock"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ctx.notify("ready", json!({"pid":ctx.pid}));
|
||||||
|
std::future::pending().await
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut notifications = h.subscribe();
|
||||||
|
let hc = h.clone();
|
||||||
|
let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await });
|
||||||
|
let notification =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(10), notifications.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let pid = ¬ification["params"]["params"]["pid"];
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await,
|
||||||
|
json!({"held":false})
|
||||||
|
);
|
||||||
|
call(&h, "core.kill", json!({"pid":pid})).await;
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||||
|
while !dropped.load(Ordering::SeqCst) {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await,
|
||||||
|
json!({"held":true})
|
||||||
|
);
|
||||||
|
assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002);
|
||||||
|
h.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn shutdown_stops_busy_processes_in_parallel_after_five_seconds() {
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let hc = h.clone();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
hc.call("probe.busy", json!({})).await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||||
|
while h.processes().len() != 2 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let before = std::time::Instant::now();
|
||||||
|
h.stop().await;
|
||||||
|
assert!(before.elapsed() >= std::time::Duration::from_millis(4900));
|
||||||
|
assert!(before.elapsed() < std::time::Duration::from_secs(7));
|
||||||
|
for task in tasks {
|
||||||
|
assert_eq!(task.await.unwrap().unwrap_err()["code"], -32002);
|
||||||
|
}
|
||||||
|
assert!(h.processes().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn journal_filters_persist_and_process_versions_are_hashes() {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
let pid = call(&h, "probe.serve", json!({"version":1})).await;
|
||||||
|
let pid = pid.as_str().unwrap();
|
||||||
|
let info = h.processes()[pid].clone();
|
||||||
|
assert_eq!(
|
||||||
|
info.version,
|
||||||
|
format!("{:x}", Sha256::digest(std::fs::read(fixture()).unwrap()))
|
||||||
|
);
|
||||||
|
assert_eq!(info.plugin, "probe");
|
||||||
|
assert_eq!(info.method, "serve");
|
||||||
|
assert_eq!(info.params, json!({"version":1}));
|
||||||
|
assert_eq!(info.parent.as_deref(), Some("console"));
|
||||||
|
assert_eq!(info.state, "running");
|
||||||
|
assert!(!info.detached);
|
||||||
|
assert!(info.start_time > 0);
|
||||||
|
let uuid = uuid::Uuid::parse_str(pid.strip_prefix("probe-").unwrap()).unwrap();
|
||||||
|
assert_eq!(uuid.get_version_num(), 7);
|
||||||
|
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||||
|
let entries = call(
|
||||||
|
&h,
|
||||||
|
"core.log",
|
||||||
|
json!({"plugin":"probe","pid":pid,"limit":2}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(entries.as_array().unwrap().len(), 2);
|
||||||
|
let entry = &entries[0];
|
||||||
|
let id = &entry["id"];
|
||||||
|
let single = call(&h, "core.log", json!({"since":id,"until":id})).await;
|
||||||
|
assert_eq!(single, json!([entry]));
|
||||||
|
h.stop().await;
|
||||||
|
drop(h);
|
||||||
|
let h = w.start().await;
|
||||||
|
assert_eq!(
|
||||||
|
call(&h, "core.log", json!({"since":id,"until":id})).await,
|
||||||
|
single
|
||||||
|
);
|
||||||
|
h.stop().await;
|
||||||
|
assert!(
|
||||||
|
std::fs::read(w.0.join("state/core/journal.sqlite"))
|
||||||
|
.unwrap()
|
||||||
|
.starts_with(b"SQLite format 3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn stopping_rejects_calls_but_allows_notifications() {
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
let w = TestWorld::new();
|
||||||
|
let h = w.start().await;
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let tx = Arc::new(Mutex::new(Some(tx)));
|
||||||
|
let gate = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let release = gate.clone();
|
||||||
|
h.register_native(
|
||||||
|
"native",
|
||||||
|
Arc::new(move |ctx, _, _| {
|
||||||
|
let tx = tx.lock().unwrap().take().unwrap();
|
||||||
|
let gate = gate.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
tx.send(ctx.clone()).ok();
|
||||||
|
gate.notified().await;
|
||||||
|
Ok(json!({}))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let hc = h.clone();
|
||||||
|
let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await });
|
||||||
|
let ctx = rx.await.unwrap();
|
||||||
|
let hc = h.clone();
|
||||||
|
let pid = ctx.pid.clone();
|
||||||
|
let stop = tokio::spawn(async move { hc.call("core.stop", json!({"pid":pid})).await });
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||||
|
while h.processes()[&ctx.pid].state != "stopping" {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ctx.call("probe.echo", json!({})).await.unwrap_err()["code"],
|
||||||
|
-32600
|
||||||
|
);
|
||||||
|
let mut notifications = h.subscribe();
|
||||||
|
ctx.call("core.notify", json!({"name":"stop-hook","params":{}}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
notifications.recv().await.unwrap()["params"]["name"],
|
||||||
|
"stop-hook"
|
||||||
|
);
|
||||||
|
release.notify_one();
|
||||||
|
assert_eq!(pending.await.unwrap().unwrap(), json!({}));
|
||||||
|
assert_eq!(stop.await.unwrap().unwrap(), json!({}));
|
||||||
|
h.stop().await;
|
||||||
|
}
|
||||||
-1252
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "toolbox"
|
name = "probe"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
[workspace]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
[workspace]
|
|
||||||
@@ -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}));}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user