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:
@@ -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.
|
||||
//!
|
||||
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
|
||||
//! optional `"skip_tests": true` skips native `cargo test` (tests run by default).
|
||||
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "tests": {"passed": 1, "failed": 0, "output": "..."}, ...}`
|
||||
//! `{"type": "build_failed", "errors": "...", "tests": {"passed": 0, "failed": 1, "output": "..."}}`
|
||||
//!
|
||||
//! The broker knows nothing about Rust. This actor is one way to get a
|
||||
//! `.wasm` file. Other actors can add other languages.
|
||||
//!
|
||||
//! Build scripts compile C for WASI with the clang in `CC_wasm32_wasip2`
|
||||
//! and `AR_wasm32_wasip2`. The Nix package and the dev shell set both.
|
||||
//!
|
||||
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
|
||||
//! published: this actor copies the SDK of this host to `public/sdk/` (so
|
||||
//! that every actor can read it), and points the dependency there in the
|
||||
//! build copy of `Cargo.toml`.
|
||||
//! Rust build helpers for the native cargo plugin.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
|
||||
use crate::broker::{Broker, Envelope};
|
||||
use crate::world::{self, World};
|
||||
|
||||
pub const ID: &str = "cargo";
|
||||
pub const TARGET: &str = "wasm32-wasip2";
|
||||
|
||||
/// The source of the `sausage-actor` SDK.
|
||||
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk");
|
||||
static SDK_SRC: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src");
|
||||
static SDK_MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src");
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct TestResults {
|
||||
@@ -43,12 +25,6 @@ pub struct TestResults {
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
impl TestResults {
|
||||
fn value(&self) -> Value {
|
||||
json!({"passed": self.passed, "failed": self.failed, "output": self.output})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BuildOutput {
|
||||
/// Host path of the published `.wasm` file.
|
||||
pub wasm: PathBuf,
|
||||
@@ -77,7 +53,10 @@ impl std::error::Error for BuildFailure {}
|
||||
|
||||
impl From<anyhow::Error> for BuildFailure {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
Self { reason: format!("{error:#}"), tests: TestResults::default() }
|
||||
Self {
|
||||
reason: format!("{error:#}"),
|
||||
tests: TestResults::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,15 +66,29 @@ pub struct Cargo {
|
||||
}
|
||||
|
||||
impl Cargo {
|
||||
/// Create the actor and install the SDK of this host into `public/sdk/`.
|
||||
/// Create the plugin and install the SDK of this host into `public/sdk/`.
|
||||
pub fn new(world: World) -> Result<Self> {
|
||||
write_dir(&SDK, &world.public_dir(ID).join("sdk"))?;
|
||||
let sdk = world.plugin_dir(ID).join("sdk");
|
||||
write_dir(&SDK_SRC, &sdk.join("src"))?;
|
||||
write_dir(&SDK_MACROS, &sdk.join("macros/src"))?;
|
||||
world::write_atomic(
|
||||
&sdk.join("Cargo.toml"),
|
||||
include_bytes!("../../sdk/Cargo.toml"),
|
||||
)?;
|
||||
world::write_atomic(
|
||||
&sdk.join("macros/Cargo.toml"),
|
||||
include_bytes!("../../sdk/macros/Cargo.toml"),
|
||||
)?;
|
||||
Ok(Self { world })
|
||||
}
|
||||
|
||||
/// Run native `cargo test` unless opted out, then build and publish the
|
||||
/// crate's `wasm32-wasip2` binary under `/actor/cargo/public/builds/<n>/`.
|
||||
pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
/// crate's `wasm32-wasip2` binary under `/plugin/cargo/public/builds/<n>/`.
|
||||
pub async fn build(
|
||||
&self,
|
||||
source: &Path,
|
||||
skip_tests: bool,
|
||||
) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
if !source.join("Cargo.toml").is_file() {
|
||||
return Err(BuildFailure {
|
||||
reason: format!("{} has no Cargo.toml", source.display()),
|
||||
@@ -103,17 +96,20 @@ impl Cargo {
|
||||
});
|
||||
}
|
||||
let started = Instant::now();
|
||||
let data = self.world.data_dir(ID);
|
||||
let data = self.world.state_dir(ID);
|
||||
let build_dir = data.join("work");
|
||||
world::replace_source(source, &build_dir)?;
|
||||
let manifest = build_dir.join("Cargo.toml");
|
||||
let original = std::fs::read_to_string(&manifest).context("read build manifest")?;
|
||||
let prepared = prepare_manifest(&original, Some(&self.world.public_dir(ID).join("sdk")))?;
|
||||
let prepared = prepare_manifest(&original, Some(&self.world.plugin_dir(ID).join("sdk")))?;
|
||||
std::fs::write(&manifest, prepared).context("write prepared manifest")?;
|
||||
|
||||
let target_dir = data.join("target");
|
||||
let tests = if skip_tests {
|
||||
TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() }
|
||||
TestResults {
|
||||
output: "skipped (skip_tests=true)".into(),
|
||||
..TestResults::default()
|
||||
}
|
||||
} else {
|
||||
match run_tests(&build_dir, &target_dir).await {
|
||||
Ok(tests) => tests,
|
||||
@@ -121,13 +117,20 @@ impl Cargo {
|
||||
let output = format!("cannot run cargo tests: {error:#}");
|
||||
return Err(BuildFailure {
|
||||
reason: output.clone(),
|
||||
tests: TestResults { failed: 1, output, ..TestResults::default() },
|
||||
tests: TestResults {
|
||||
failed: 1,
|
||||
output,
|
||||
..TestResults::default()
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
if tests.failed > 0 {
|
||||
return Err(BuildFailure { reason: "unit tests failed".into(), tests });
|
||||
return Err(BuildFailure {
|
||||
reason: "unit tests failed".into(),
|
||||
tests,
|
||||
});
|
||||
}
|
||||
|
||||
let build_result: Result<BuildOutput> = async {
|
||||
@@ -141,7 +144,10 @@ impl Cargo {
|
||||
.arg(&target_dir)
|
||||
.current_dir(&build_dir)
|
||||
.kill_on_drop(true);
|
||||
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
|
||||
let out = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
if !out.status.success() {
|
||||
bail!("{}", strip_progress(&stderr));
|
||||
@@ -155,10 +161,12 @@ impl Cargo {
|
||||
artifact = Some(PathBuf::from(exe));
|
||||
}
|
||||
}
|
||||
let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?;
|
||||
let artifact = artifact.ok_or_else(|| {
|
||||
anyhow!("cargo built no binary. Does the crate have a src/main.rs?")
|
||||
})?;
|
||||
|
||||
let n = next_build_number(&self.world.public_dir(ID).join("builds"))?;
|
||||
let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string());
|
||||
let n = next_build_number(&self.world.plugin_dir(ID).join("builds"))?;
|
||||
let dest_dir = self.world.plugin_dir(ID).join("builds").join(n.to_string());
|
||||
let name = artifact.file_name().context("artifact has no file name")?;
|
||||
let dest = dest_dir.join(name);
|
||||
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
|
||||
@@ -170,52 +178,10 @@ impl Cargo {
|
||||
})
|
||||
}
|
||||
.await;
|
||||
build_result.map_err(|error| BuildFailure { reason: format!("{error:#}"), tests })
|
||||
}
|
||||
|
||||
/// The native `cargo` actor. Builds run one at a time, in order.
|
||||
/// Replies (messages with `reply_to`) get no answer.
|
||||
pub async fn run_actor(self, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
if env.reply_to.is_some() {
|
||||
continue;
|
||||
}
|
||||
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
|
||||
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}"), "tests": TestResults::default().value()})
|
||||
});
|
||||
broker.send(ID, Some(&env.from), None, Some(&env.id), body);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(&self, broker: &Broker, env: &Envelope) -> Result<Value> {
|
||||
match env.body.get("type").and_then(Value::as_str) {
|
||||
Some("build") => {
|
||||
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
|
||||
let source = broker.world.resolve(&env.from, source_s)?;
|
||||
let skip_tests = env.body.get("skip_tests").and_then(Value::as_bool).unwrap_or(false);
|
||||
match self.build(&source, skip_tests).await {
|
||||
Ok(out) => {
|
||||
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
|
||||
Ok(json!({
|
||||
"type": "built",
|
||||
"wasm": wasm,
|
||||
"source": source_s,
|
||||
"built_by": ID,
|
||||
"seconds": (out.seconds * 10.0).round() / 10.0,
|
||||
"warnings": out.warnings,
|
||||
"tests": out.tests.value(),
|
||||
}))
|
||||
}
|
||||
Err(failure) => Ok(json!({
|
||||
"type": "build_failed",
|
||||
"source": source_s,
|
||||
"errors": failure.reason,
|
||||
"tests": failure.tests.value(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
other => bail!("unknown request {other:?}. Known: build"),
|
||||
}
|
||||
build_result.map_err(|error| BuildFailure {
|
||||
reason: format!("{error:#}"),
|
||||
tests,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +211,11 @@ async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result<TestResults> {
|
||||
if !out.status.success() && failed == 0 {
|
||||
failed = 1;
|
||||
}
|
||||
Ok(TestResults { passed, failed, output })
|
||||
Ok(TestResults {
|
||||
passed,
|
||||
failed,
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
/// Count the `passed` or `failed` totals in cargo's per-target test summaries.
|
||||
@@ -268,7 +238,12 @@ fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
|
||||
let mut doc: toml_edit::DocumentMut = text.parse()?;
|
||||
if let Some(sdk) = sdk {
|
||||
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
|
||||
let Some(dep) = doc.get_mut(section).and_then(|t| t.get_mut("sausage-actor")) else { continue };
|
||||
let Some(dep) = doc
|
||||
.get_mut(section)
|
||||
.and_then(|t| t.get_mut("sausage-actor"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
|
||||
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
|
||||
table.remove(key);
|
||||
@@ -289,7 +264,9 @@ fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
|
||||
for file in dir.files() {
|
||||
let dest = to.join(file.path());
|
||||
let contents = match file.path().file_name() {
|
||||
Some(n) if n == "Cargo.toml" => prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes(),
|
||||
Some(n) if n == "Cargo.toml" => {
|
||||
prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes()
|
||||
}
|
||||
_ => file.contents().to_vec(),
|
||||
};
|
||||
if std::fs::read(&dest).ok() != Some(contents.clone()) {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+626
-76
@@ -1,86 +1,636 @@
|
||||
//! Sausage: grow a system of WASI actors by talking to it.
|
||||
//!
|
||||
//! The host runs the broker, the native actors (`broker`, `cargo`, and the
|
||||
//! console), one supervisor per WASM actor, and the file watcher of the
|
||||
//! world folder (`watch.rs`). See `PROTOCOL.md`.
|
||||
|
||||
pub mod broker;
|
||||
pub mod candidate;
|
||||
pub mod cargo_actor;
|
||||
pub mod console;
|
||||
//! Process ownership, JSON-RPC routing, native plugins, and host lifetime.
|
||||
pub mod cargo;
|
||||
mod journal;
|
||||
pub mod runtime;
|
||||
pub mod supervisor;
|
||||
pub mod vfs;
|
||||
pub mod watch;
|
||||
pub mod world;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub use broker::{Broker, Envelope};
|
||||
pub use world::World;
|
||||
|
||||
/// The contract between the host and the actors. The host publishes it at
|
||||
/// `/actor/broker/public/PROTOCOL.md`, so that every actor can read it.
|
||||
const PROTOCOL: &str = include_str!("../../PROTOCOL.md");
|
||||
|
||||
/// A running world.
|
||||
pub struct Host {
|
||||
pub world: World,
|
||||
pub broker: Arc<Broker>,
|
||||
native: Vec<tokio::task::JoinHandle<()>>,
|
||||
watcher: Option<watch::Watcher>,
|
||||
_lock: std::fs::File,
|
||||
use runtime::{InstanceConfig, Runtime};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
future::Future,
|
||||
path::Path,
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use uuid::Uuid;
|
||||
use world::World;
|
||||
pub type RpcResult = Result<Value, Value>;
|
||||
pub type NativeFuture = Pin<Box<dyn Future<Output = RpcResult> + Send>>;
|
||||
pub type NativePlugin = Arc<dyn Fn(NativeContext, String, Value) -> NativeFuture + Send + Sync>;
|
||||
#[derive(Clone)]
|
||||
pub struct NativeContext {
|
||||
pub host: Host,
|
||||
pub pid: String,
|
||||
}
|
||||
impl NativeContext {
|
||||
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
|
||||
let exit = requests_exit(method, ¶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 {
|
||||
/// Start the broker, `cargo`, the file watcher, and every registered
|
||||
/// actor of the world at `root`. Only one host can run a world at a time.
|
||||
pub async fn start(root: &Path) -> Result<Host> {
|
||||
let world = World::new(std::path::absolute(root)?);
|
||||
if !world.exists() {
|
||||
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
|
||||
}
|
||||
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
||||
if lock.try_lock().is_err() {
|
||||
bail!("another sausage process runs {}", root.display());
|
||||
}
|
||||
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
|
||||
world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?;
|
||||
|
||||
let cargo = cargo_actor::Cargo::new(world.clone())?;
|
||||
let native = vec![
|
||||
tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))),
|
||||
tokio::spawn(cargo.run_actor(broker.clone(), broker.open_mailbox(cargo_actor::ID))),
|
||||
];
|
||||
// Watch first, so that no change during the boot pass is lost.
|
||||
let watcher = watch::start(broker.clone())
|
||||
.inspect_err(|e| broker.host_error(&format!("no file watching: {e}")))
|
||||
.ok();
|
||||
for change in broker.scan().await {
|
||||
eprintln!("sausage: {change}");
|
||||
}
|
||||
Ok(Host { world, broker, native, watcher, _lock: lock })
|
||||
pub async fn start(path: &Path) -> anyhow::Result<Self> {
|
||||
let base = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
|
||||
.join(".cache")
|
||||
});
|
||||
Self::start_with_cache(path, &base.join("sausage")).await
|
||||
}
|
||||
|
||||
/// Register a native actor with the ID `id` and return its mailbox.
|
||||
pub fn mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
|
||||
self.broker.open_mailbox(id)
|
||||
pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result<Self> {
|
||||
let world = World::open(path)?;
|
||||
let journal = journal::Journal::open(&world.state_dir("core").join("journal.sqlite"))?;
|
||||
let host = Self(Arc::new(Inner {
|
||||
world,
|
||||
runtime: Runtime::new(cache)?,
|
||||
journal,
|
||||
state: Mutex::new(State {
|
||||
processes: HashMap::new(),
|
||||
locks: HashMap::new(),
|
||||
components: HashMap::new(),
|
||||
native: HashMap::new(),
|
||||
}),
|
||||
notifications: tokio::sync::broadcast::channel(1024).0,
|
||||
exit: watch::channel(false).0,
|
||||
compilation: tokio::sync::Mutex::new(()),
|
||||
}));
|
||||
let boot: Vec<Value> =
|
||||
serde_json::from_slice(&std::fs::read(host.0.world.root.join("config/boot.json"))?)?;
|
||||
for service in boot {
|
||||
let result = host
|
||||
.route(
|
||||
"core".into(),
|
||||
Some(json!(Uuid::now_v7().to_string())),
|
||||
service["method"].as_str().unwrap_or("").into(),
|
||||
service["params"].clone(),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
host.0.journal.write("core", "core", "core", &e.to_string());
|
||||
}
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Stop the file watcher, the broker and `cargo` (with the requests they
|
||||
/// still run), then every actor, gracefully.
|
||||
pub async fn stop(mut self) {
|
||||
drop(self.watcher.take());
|
||||
for task in &self.native {
|
||||
task.abort();
|
||||
pub fn register_native(&self, name: &str, plugin: NativePlugin) {
|
||||
self.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.native
|
||||
.insert(name.into(), plugin);
|
||||
}
|
||||
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Value> {
|
||||
self.0.notifications.subscribe()
|
||||
}
|
||||
pub fn processes(&self) -> HashMap<String, ProcessInfo> {
|
||||
self.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.iter()
|
||||
.map(|(p, s)| (p.clone(), s.info.clone()))
|
||||
.collect()
|
||||
}
|
||||
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
|
||||
let exit = requests_exit(method, ¶ms);
|
||||
let result = self
|
||||
.route(
|
||||
"console".into(),
|
||||
Some(json!(Uuid::now_v7().to_string())),
|
||||
method.into(),
|
||||
params,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
if exit && result.is_ok() {
|
||||
self.0.exit.send_replace(true);
|
||||
}
|
||||
for task in self.native {
|
||||
let _ = task.await;
|
||||
result
|
||||
}
|
||||
pub async fn exit_requested(&self) {
|
||||
let mut exit = self.0.exit.subscribe();
|
||||
let _ = exit.wait_for(|requested| *requested).await;
|
||||
}
|
||||
pub fn restart_requested(&self) -> bool {
|
||||
*self.0.exit.borrow()
|
||||
}
|
||||
fn send(&self, pid: &str, value: Value) {
|
||||
if pid == "console" {
|
||||
let _ = self.0.notifications.send(value);
|
||||
} else if let Some(tx) = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.and_then(|p| p.input.as_ref())
|
||||
{
|
||||
let _ = tx.send(value);
|
||||
}
|
||||
}
|
||||
fn notify(&self, pid: &str, name: &str, params: Value) {
|
||||
let target = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.and_then(|p| p.answer.as_ref().map(|_| (p.caller.clone(), p.id.clone())));
|
||||
if let Some((caller, id)) = target {
|
||||
self.send(&caller,json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":id,"name":name,"params":params}}));
|
||||
}
|
||||
}
|
||||
fn route(
|
||||
&self,
|
||||
caller: String,
|
||||
id: Option<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();
|
||||
}
|
||||
if id.is_none() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
rx.await
|
||||
.unwrap_or_else(|_| Err(error(-32002, "process ended")))
|
||||
})
|
||||
}
|
||||
fn answer(&self, pid: &str, result: RpcResult) {
|
||||
if let Some(tx) = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get_mut(pid)
|
||||
.and_then(|p| p.answer.take())
|
||||
{
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
fn output(&self, pid: &str, line: String) {
|
||||
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
||||
self.discard(pid);
|
||||
return;
|
||||
};
|
||||
if !v.is_object() || v["jsonrpc"] != "2.0" {
|
||||
self.discard(pid);
|
||||
return;
|
||||
}
|
||||
if let Some(method) = v["method"].as_str() {
|
||||
let h = self.clone();
|
||||
let caller = pid.to_string();
|
||||
let exit = requests_exit(method, &v["params"]);
|
||||
let method = method.to_string();
|
||||
tokio::spawn(async move {
|
||||
let id = v.get("id").cloned();
|
||||
let result = h
|
||||
.route(
|
||||
caller.clone(),
|
||||
id.clone(),
|
||||
method,
|
||||
v.get("params").cloned().unwrap_or(json!({})),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let success = result.is_ok();
|
||||
if let Some(id) = id {
|
||||
let value = match result {
|
||||
Ok(r) => json!({"jsonrpc":"2.0","id":id,"result":r}),
|
||||
Err(e) => json!({"jsonrpc":"2.0","id":id,"error":e}),
|
||||
};
|
||||
h.send(&caller, value);
|
||||
}
|
||||
if exit && success {
|
||||
h.0.exit.send_replace(true);
|
||||
}
|
||||
});
|
||||
} else if v["id"] == pid {
|
||||
self.answer(
|
||||
pid,
|
||||
if let Some(e) = v.get("error") {
|
||||
Err(e.clone())
|
||||
} else {
|
||||
Ok(v["result"].clone())
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
fn discard(&self, pid: &str) {
|
||||
// The journal deduplicates this warning for each process.
|
||||
if let Some(info) = self.processes().get(pid) {
|
||||
self.0.journal.warning(pid, &info.plugin);
|
||||
}
|
||||
}
|
||||
fn finish(&self, pid: &str, status: &str) {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let process = state.processes.remove(pid);
|
||||
if let Some(p) = process {
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", status);
|
||||
if let Some(tx) = p.answer {
|
||||
let _=tx.send(Err(json!({"code":-32002,"message":"process ended without an answer","data":{"status":status,"lines":self.0.journal.query(&json!({"pid":pid,"limit":20}))}})));
|
||||
}
|
||||
Self::release_locks(&mut state, pid);
|
||||
let _ = p.ended.send(true);
|
||||
let children: Vec<_> = state
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
drop(state);
|
||||
for child in children {
|
||||
self.begin_stop(&child);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn begin_stop(&self, pid: &str) -> Option<watch::Receiver<bool>> {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let p = state.processes.get_mut(pid)?;
|
||||
let ended = p.ended.subscribe();
|
||||
if p.info.state == "stopping" {
|
||||
return Some(ended);
|
||||
}
|
||||
p.info.state = "stopping".into();
|
||||
p.input.take();
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", "stop");
|
||||
let children: Vec<_> = state
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
drop(state);
|
||||
for child in children {
|
||||
self.begin_stop(&child);
|
||||
}
|
||||
let h = self.clone();
|
||||
let pid = pid.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
h.kill(&pid);
|
||||
});
|
||||
Some(ended)
|
||||
}
|
||||
fn kill(&self, pid: &str) {
|
||||
if let Some(p) = self.0.state.lock().unwrap().processes.get(pid) {
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", "kill");
|
||||
if let Some(a) = &p.abort {
|
||||
a.abort();
|
||||
}
|
||||
}
|
||||
self.finish(pid, "killed");
|
||||
}
|
||||
pub async fn stop(&self) {
|
||||
let ids: Vec<_> = self.processes().into_keys().collect();
|
||||
let mut waits = Vec::new();
|
||||
for pid in ids {
|
||||
if let Some(n) = self.begin_stop(&pid) {
|
||||
waits.push(n);
|
||||
}
|
||||
}
|
||||
for mut n in waits {
|
||||
let _ = n.wait_for(|ended| *ended).await;
|
||||
}
|
||||
}
|
||||
fn release_locks(state: &mut State, pid: &str) {
|
||||
for lock in state.locks.values_mut() {
|
||||
lock.queue.retain(|(p, _)| p != pid);
|
||||
}
|
||||
let paths: Vec<_> = state
|
||||
.locks
|
||||
.iter()
|
||||
.filter(|(_, l)| l.owner == pid)
|
||||
.map(|(p, _)| p.clone())
|
||||
.collect();
|
||||
for path in paths {
|
||||
grant(state, &path);
|
||||
}
|
||||
}
|
||||
async fn core(&self, caller: &str, id: Option<Value>, method: &str, p: Value) -> RpcResult {
|
||||
match method {
|
||||
"call" => {
|
||||
self.route(
|
||||
caller.into(),
|
||||
id,
|
||||
required_str(&p, "method")?.into(),
|
||||
p.get("params").cloned().unwrap_or(json!({})),
|
||||
p["detach"].as_bool().unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"notify" => {
|
||||
self.notify(
|
||||
caller,
|
||||
required_str(&p, "name")?,
|
||||
p.get("params").cloned().unwrap_or(json!({})),
|
||||
);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"kill" => {
|
||||
self.kill(required_str(&p, "pid")?);
|
||||
Ok(json!({}))
|
||||
}
|
||||
"stop" => {
|
||||
if let Some(mut n) = self.begin_stop(required_str(&p, "pid")?) {
|
||||
let _ = n.wait_for(|ended| *ended).await;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"lock" => {
|
||||
let path = p["path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| error(-32602, "missing path"))?;
|
||||
let rx = {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if let Some(lock) = state.locks.get_mut(path) {
|
||||
if p["wait"] == false {
|
||||
return Ok(json!({"held":false}));
|
||||
}
|
||||
let (tx, rx) = oneshot::channel();
|
||||
lock.queue.push_back((caller.into(), tx));
|
||||
Some(rx)
|
||||
} else {
|
||||
state.locks.insert(
|
||||
path.into(),
|
||||
Lock {
|
||||
owner: caller.into(),
|
||||
queue: VecDeque::new(),
|
||||
},
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
match rx {
|
||||
Some(rx) => rx
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(error(-32603, "lock canceled"))),
|
||||
None => Ok(json!({"held":true})),
|
||||
}
|
||||
}
|
||||
"unlock" => {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let path = required_str(&p, "path")?;
|
||||
if state.locks.get(path).is_some_and(|l| l.owner == caller) {
|
||||
grant(&mut state, path);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"log" => Ok(self.0.journal.query(&p)),
|
||||
"exit" => Ok(json!({})),
|
||||
_ => Err(error(-32601, "method not found")),
|
||||
}
|
||||
self.broker.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
fn grant(state: &mut State, path: &str) {
|
||||
if let Some(mut lock) = state.locks.remove(path) {
|
||||
while let Some((owner, tx)) = lock.queue.pop_front() {
|
||||
if tx.send(Ok(json!({"held":true}))).is_ok() {
|
||||
lock.owner = owner;
|
||||
state.locks.insert(path.into(), lock);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn requests_exit(method: &str, params: &Value) -> bool {
|
||||
method == "core.exit"
|
||||
|| (method == "core.call"
|
||||
&& requests_exit(params["method"].as_str().unwrap_or(""), ¶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;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
//! Command parsing and the minimal line console.
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::json;
|
||||
use tokio::signal::unix::Signal;
|
||||
|
||||
use sausage::world::{ActorMeta, World};
|
||||
use sausage::{Host, cargo_actor, console};
|
||||
|
||||
/// The source of the agent actor. `init` builds it like any other actor.
|
||||
static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
|
||||
|
||||
// Without a command, `sausage` runs the world in `./world` and creates it
|
||||
// first (like `init`) if it does not exist yet.
|
||||
use std::{io::Write, path::PathBuf};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
#[derive(Parser)]
|
||||
#[command(version, about)]
|
||||
#[command(version)]
|
||||
struct Cli {
|
||||
/// Do not read stdin, so end of input does not stop the world. It runs
|
||||
/// until SIGTERM (or SIGINT), or until an actor sends
|
||||
/// `@broker {"type":"exit"}`. Also set by SAUSAGE_HEADLESS=1.
|
||||
#[arg(long, global = true)]
|
||||
headless: bool,
|
||||
#[arg(long, global = true)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Option<Cmd>,
|
||||
command: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Create a new world and build the agent actor.
|
||||
///
|
||||
/// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and
|
||||
/// SAUSAGE_API_TOKEN, and asks for every value that is missing.
|
||||
Init {
|
||||
/// Folder of the new world.
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Start the broker, all actors, and the console.
|
||||
Run {
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
Init { dir: PathBuf },
|
||||
Run { dir: PathBuf },
|
||||
}
|
||||
|
||||
/// The value of `var`, or the answer to `question`.
|
||||
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
||||
if let Ok(v) = std::env::var(var)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
return Ok(v);
|
||||
}
|
||||
match default {
|
||||
Some(d) => print!("{question} [{d}]: "),
|
||||
None => print!("{question}: "),
|
||||
}
|
||||
print!("{question}: ");
|
||||
std::io::stdout().flush()?;
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line)?;
|
||||
match (line.trim(), default) {
|
||||
("", Some(d)) => Ok(d.to_string()),
|
||||
("", None) => bail!("{question} is required (or set {var})"),
|
||||
(v, _) => Ok(v.to_string()),
|
||||
if !line.trim().is_empty() {
|
||||
return Ok(line.trim().into());
|
||||
}
|
||||
match default {
|
||||
Some(v) => Ok(v.into()),
|
||||
None => bail!("{question} is required"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn init(dir: PathBuf) -> Result<()> {
|
||||
let api = setting("SAUSAGE_API", "API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?;
|
||||
if api != "openai-responses" && api != "anthropic-messages" {
|
||||
bail!("unknown API shape {api:?}");
|
||||
}
|
||||
let base_url = setting("SAUSAGE_BASE_URL", "API base URL", None)?;
|
||||
let model = setting("SAUSAGE_MODEL", "Model", None)?;
|
||||
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
|
||||
|
||||
let world = World::create(&dir)?;
|
||||
let console = ActorMeta { subscriptions: ["agent".to_string()].into(), ..world.read_meta(console::ID) };
|
||||
world.write_meta(console::ID, &console)?;
|
||||
|
||||
// The agent's configuration, in its /data.
|
||||
world.ensure_actor_dirs("agent")?;
|
||||
let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
|
||||
let config_path = world.data_dir("agent").join("config.json");
|
||||
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
||||
|
||||
// The agent is an ordinary actor folder: `run` finds its `actor.wasm`,
|
||||
// tests it, and installs it as version 1.
|
||||
let source = world.source_dir("agent");
|
||||
std::fs::create_dir_all(&source)?;
|
||||
AGENT_SOURCE.extract(&source).context("extract agent source")?;
|
||||
println!("Building the agent actor (the first build takes a few minutes) …");
|
||||
let out = cargo_actor::Cargo::new(world.clone())?.build(&source, false).await.context("build agent")?;
|
||||
std::fs::copy(&out.wasm, world.wasm_path("agent"))?;
|
||||
println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the SIGTERM and SIGINT handlers now (not when first polled).
|
||||
fn termination_signals() -> Result<(Signal, Signal)> {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
let term = signal(SignalKind::terminate()).context("install SIGTERM handler")?;
|
||||
let int = signal(SignalKind::interrupt()).context("install SIGINT handler")?;
|
||||
Ok((term, int))
|
||||
}
|
||||
|
||||
/// Resolves on SIGTERM or SIGINT.
|
||||
async fn termination((mut term, mut int): (Signal, Signal)) {
|
||||
tokio::select! {
|
||||
_ = term.recv() => {}
|
||||
_ = int.recv() => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(dir: PathBuf, headless: bool) -> Result<()> {
|
||||
// Register the signal handlers before the host starts, so a signal that
|
||||
// arrives early is never the default (kill) action.
|
||||
let signals = if headless { Some(termination_signals()?) } else { None };
|
||||
let host = Host::start(&dir).await?;
|
||||
tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
|
||||
let broker = host.broker.clone();
|
||||
let exit = match signals {
|
||||
Some(signals) => {
|
||||
println!("Sausage headless: running until SIGTERM or an exit request.");
|
||||
tokio::select! {
|
||||
_ = termination(signals) => false,
|
||||
_ = broker.exit_requested() => true,
|
||||
}
|
||||
}
|
||||
None => tokio::select! {
|
||||
_ = console::run_input(host.broker.clone()) => false,
|
||||
_ = broker.exit_requested() => true,
|
||||
},
|
||||
};
|
||||
println!("Stopping actors …");
|
||||
host.stop().await;
|
||||
if exit {
|
||||
// The console still blocks on a stdin read that cannot be cancelled,
|
||||
// so the runtime would not shut down on its own. Exit code 75 tells
|
||||
// `nix run .#loop` to start the host again (0 = /quit = stop the loop).
|
||||
println!("Exiting, as an actor asked the broker to.");
|
||||
std::process::exit(75);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let headless = cli.headless || std::env::var("SAUSAGE_HEADLESS").is_ok_and(|v| !v.is_empty() && v != "0");
|
||||
match cli.command {
|
||||
Some(Cmd::Init { dir }) => init(dir).await,
|
||||
Some(Cmd::Run { dir }) => run(dir, headless).await,
|
||||
None => {
|
||||
let dir = PathBuf::from("world");
|
||||
if !dir.join("actor").is_dir() {
|
||||
println!("No world in ./world yet, creating it (same as `sausage init`).");
|
||||
init(dir.clone()).await?;
|
||||
Cmd::Init { dir } => {
|
||||
let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?;
|
||||
if api != "openai-responses" && api != "anthropic-messages" {
|
||||
bail!("unknown API shape {api:?}");
|
||||
}
|
||||
let base_url = setting("SAUSAGE_BASE_URL", "Base URL", None)?;
|
||||
let model = setting("SAUSAGE_MODEL", "Model", None)?;
|
||||
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
|
||||
let world = World::create(&dir)?;
|
||||
std::fs::write(
|
||||
world.root.join("config/agent.json"),
|
||||
serde_json::to_vec_pretty(
|
||||
&json!({"api":api,"base_url":base_url.trim_end_matches('/'),"model":model,"token":token}),
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
Cmd::Run { dir } => {
|
||||
let mut term =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
let host = match cli.cache_dir {
|
||||
Some(cache) => Host::start_with_cache(&dir, &cache).await?,
|
||||
None => Host::start(&dir).await?,
|
||||
};
|
||||
let mut notifications = host.subscribe();
|
||||
let notification_task = tokio::spawn(async move {
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
println!("{notification}");
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
let console = async move {
|
||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||
loop {
|
||||
tokio::select! {
|
||||
line=lines.next_line()=>{let Some(line)=line? else {break;};if line=="quit" {break;}
|
||||
let (method,params)=line.split_once(' ').unwrap_or((&line,"{}"));
|
||||
match serde_json::from_str(params) {Ok(p)=>println!("{}",match h.call(method,p).await {Ok(v)=>v,Err(e)=>e}),Err(e)=>eprintln!("{e}")}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
let exit = tokio::select! {
|
||||
_=host.exit_requested()=>true,
|
||||
_=term.recv()=>false,
|
||||
_=tokio::signal::ctrl_c()=>false,
|
||||
r=console,if !cli.headless=>{r?;false},
|
||||
};
|
||||
let exit = exit || host.restart_requested();
|
||||
host.stop().await;
|
||||
notification_task.abort();
|
||||
if exit {
|
||||
std::process::exit(75);
|
||||
}
|
||||
run(dir, headless).await
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+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
|
||||
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use wasmtime::component::{Component, Linker, ResourceTable};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
||||
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
|
||||
|
||||
use wasmtime_wasi::p2::bindings::Command;
|
||||
use wasmtime_wasi::p2::bindings::filesystem;
|
||||
|
||||
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
||||
|
||||
use crate::broker::Broker;
|
||||
use crate::vfs::{Vfs, VfsState, VfsView};
|
||||
|
||||
/// A host folder that the guest sees at `guest`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mount {
|
||||
pub guest: String,
|
||||
pub host: PathBuf,
|
||||
pub writable: bool,
|
||||
}
|
||||
|
||||
pub struct HttpHooks {
|
||||
allow: bool,
|
||||
}
|
||||
@@ -65,33 +54,35 @@ impl WasiHttpHooks for HttpHooks {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ActorState {
|
||||
pub struct ProcessState {
|
||||
ctx: WasiCtx,
|
||||
table: ResourceTable,
|
||||
http: WasiHttpCtx,
|
||||
hooks: HttpHooks,
|
||||
vfs: VfsState,
|
||||
}
|
||||
|
||||
fn vfs_view(s: &mut ActorState) -> VfsView<'_> {
|
||||
VfsView { inner: WasiFilesystemCtxView { ctx: s.ctx.filesystem(), table: &mut s.table }, state: &mut s.vfs }
|
||||
}
|
||||
|
||||
impl WasiView for ActorState {
|
||||
impl WasiView for ProcessState {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
|
||||
WasiCtxView {
|
||||
ctx: &mut self.ctx,
|
||||
table: &mut self.table,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WasiHttpView for ActorState {
|
||||
impl WasiHttpView for ProcessState {
|
||||
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
||||
WasiHttpCtxView { ctx: &mut self.http, table: &mut self.table, hooks: &mut self.hooks }
|
||||
WasiHttpCtxView {
|
||||
ctx: &mut self.http,
|
||||
table: &mut self.table,
|
||||
hooks: &mut self.hooks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Runtime {
|
||||
engine: Engine,
|
||||
linker: Linker<ActorState>,
|
||||
linker: Linker<ProcessState>,
|
||||
}
|
||||
|
||||
/// How an instance ended.
|
||||
@@ -116,22 +107,18 @@ impl std::fmt::Display for Exit {
|
||||
}
|
||||
|
||||
pub struct InstanceConfig {
|
||||
pub mounts: Vec<Mount>,
|
||||
pub world: PathBuf,
|
||||
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
|
||||
pub network: bool,
|
||||
pub stderr_log: PathBuf,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// The actor that runs in the instance.
|
||||
pub actor: String,
|
||||
/// Asks the actors that serve `/actor/<id>/fs/` (see `vfs.rs`).
|
||||
pub broker: Weak<Broker>,
|
||||
}
|
||||
|
||||
/// A running guest. Dropping it kills the guest.
|
||||
pub struct Instance {
|
||||
stdin: Option<DuplexStream>,
|
||||
pub stdin: Option<DuplexStream>,
|
||||
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
||||
pub stdout: mpsc::Receiver<String>,
|
||||
pub stderr: mpsc::Receiver<String>,
|
||||
task: JoinHandle<Exit>,
|
||||
}
|
||||
|
||||
@@ -145,7 +132,11 @@ impl Instance {
|
||||
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the
|
||||
/// lines that the guest writes to `route`: a guest that blocks on a full
|
||||
/// stdout would never read its stdin.
|
||||
pub async fn send_line(&mut self, line: &str, mut route: impl FnMut(String)) -> std::io::Result<()> {
|
||||
pub async fn send_line(
|
||||
&mut self,
|
||||
line: &str,
|
||||
mut route: impl FnMut(String),
|
||||
) -> std::io::Result<()> {
|
||||
let stdin = self.stdin.as_mut().ok_or(std::io::ErrorKind::BrokenPipe)?;
|
||||
let bytes = format!("{line}\n");
|
||||
let write = async {
|
||||
@@ -173,49 +164,6 @@ impl Instance {
|
||||
Err(e) => Exit::Trap(format!("host task failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.task.is_finished()
|
||||
}
|
||||
|
||||
/// Kill the guest. Its stdout closes when it has ended.
|
||||
pub fn kill(&self) {
|
||||
self.task.abort();
|
||||
}
|
||||
|
||||
/// Send `shutdown_line`, close stdin, and wait until the guest ends.
|
||||
/// Pass every line that the guest writes meanwhile to `route`. Kill the
|
||||
/// guest when `deadline` has passed.
|
||||
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration, mut route: impl FnMut(String)) -> Exit {
|
||||
let stdin = self.stdin.take();
|
||||
let stdout = &mut self.stdout;
|
||||
let finished = tokio::time::timeout(deadline, async {
|
||||
let close = async {
|
||||
if let Some(mut stdin) = stdin {
|
||||
let _ = stdin.write_all(format!("{shutdown_line}\n").as_bytes()).await;
|
||||
let _ = stdin.shutdown().await;
|
||||
}
|
||||
};
|
||||
let drain = async {
|
||||
while let Some(line) = stdout.recv().await {
|
||||
route(line);
|
||||
}
|
||||
};
|
||||
tokio::join!(close, drain);
|
||||
})
|
||||
.await;
|
||||
match finished {
|
||||
Ok(()) => self.wait().await,
|
||||
Err(_) => Exit::Trap("killed after shutdown deadline".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_note(path: &Path, text: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "[sausage] {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The most useful line of a trap: the `wasm trap: ...` reason.
|
||||
@@ -230,85 +178,91 @@ fn short_trap(full: &str) -> String {
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn new() -> Result<Arc<Self>> {
|
||||
pub fn new(cache: &Path) -> Result<Arc<Self>> {
|
||||
let mut config = Config::new();
|
||||
config.epoch_interruption(true);
|
||||
std::fs::create_dir_all(cache)?;
|
||||
let mut cache_config = wasmtime::CacheConfig::new();
|
||||
cache_config.with_directory(cache.canonicalize()?);
|
||||
config.cache(Some(wasmtime::Cache::new(cache_config)?));
|
||||
let engine = Engine::new(&config)?;
|
||||
|
||||
let mut linker: Linker<ActorState> = Linker::new(&engine);
|
||||
let mut linker: Linker<ProcessState> = Linker::new(&engine);
|
||||
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
|
||||
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
|
||||
// Replace `wasi:filesystem` with the wrapper that serves `/actor/<id>/fs/`.
|
||||
linker.allow_shadowing(true);
|
||||
filesystem::types::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
|
||||
filesystem::preopens::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
|
||||
linker.allow_shadowing(false);
|
||||
|
||||
// Time slices: every 10 ms each running guest yields to the scheduler.
|
||||
let ticker = engine.clone();
|
||||
let runtime = Arc::new(Self { engine, linker });
|
||||
let ticker = Arc::downgrade(&runtime);
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
ticker.increment_epoch();
|
||||
let Some(runtime) = ticker.upgrade() else {
|
||||
break;
|
||||
};
|
||||
runtime.engine.increment_epoch();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Arc::new(Self { engine, linker }))
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
pub fn load(&self, wasm: &Path) -> Result<Component> {
|
||||
Component::from_file(&self.engine, wasm)
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| format!("load {}", wasm.display()))
|
||||
pub fn compile(&self, bytes: &[u8]) -> Result<Component> {
|
||||
Ok(Component::new(&self.engine, bytes)?)
|
||||
}
|
||||
|
||||
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
|
||||
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
||||
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
|
||||
|
||||
let stderr = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&cfg.stderr_log)
|
||||
.with_context(|| format!("open {}", cfg.stderr_log.display()))?;
|
||||
|
||||
let (guest_err, host_err) = tokio::io::duplex(64 * 1024);
|
||||
let mut b = WasiCtx::builder();
|
||||
if cfg.network {
|
||||
b.inherit_network().allow_tcp(true).allow_udp(true).allow_ip_name_lookup(true);
|
||||
b.inherit_network()
|
||||
.allow_tcp(true)
|
||||
.allow_udp(true)
|
||||
.allow_ip_name_lookup(true);
|
||||
}
|
||||
b.stdin(AsyncStdinStream::new(guest_in));
|
||||
b.stdout(AsyncStdoutStream::new(8192, guest_out));
|
||||
b.stderr(AsyncStdoutStream::new(8192, tokio::fs::File::from_std(stderr)));
|
||||
b.stderr(AsyncStdoutStream::new(8192, guest_err));
|
||||
for (k, v) in &cfg.env {
|
||||
b.env(k, v);
|
||||
}
|
||||
for m in &cfg.mounts {
|
||||
let perms = if m.writable { FsPerms::ReadWrite } else { FsPerms::ReadOnly };
|
||||
b.preopened_dir(&m.host, &m.guest, perms)
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
|
||||
}
|
||||
let state = ActorState {
|
||||
// Filesystem setup lives here. T6 replaces this preopen with the VFS.
|
||||
b.preopened_dir(&cfg.world, "/", FsPerms::ReadWrite)?;
|
||||
let state = ProcessState {
|
||||
ctx: b.build(),
|
||||
table: ResourceTable::new(),
|
||||
http: WasiHttpCtx::new(),
|
||||
hooks: HttpHooks { allow: cfg.network },
|
||||
vfs: VfsState::new(cfg.broker.clone(), cfg.actor.clone()),
|
||||
};
|
||||
|
||||
let (line_tx, line_rx) = mpsc::channel(1024);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(host_out).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let mut reader = BufReader::new(host_out);
|
||||
let mut bytes = Vec::new();
|
||||
while let Ok(n) = reader.read_until(b'\n', &mut bytes).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let line = String::from_utf8(std::mem::take(&mut bytes)).unwrap_or_default();
|
||||
if line_tx.send(line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (err_tx, err_rx) = mpsc::channel(1024);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(host_err).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if err_tx.send(line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let rt = self.clone();
|
||||
let component = component.clone();
|
||||
let stderr_log = cfg.stderr_log.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut store = Store::new(&rt.engine, state);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
@@ -324,15 +278,18 @@ impl Runtime {
|
||||
Some(I32Exit(0)) => Exit::Clean,
|
||||
Some(I32Exit(c)) => Exit::Code(*c),
|
||||
None => {
|
||||
// Keep the full backtrace in the log, and a short reason in the status.
|
||||
let full = format!("{e:?}");
|
||||
log_note(&stderr_log, &format!("trap details:\n{full}"));
|
||||
Exit::Trap(short_trap(&full))
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Instance { stdin: Some(host_in), stdout: line_rx, task })
|
||||
Ok(Instance {
|
||||
stdin: Some(host_in),
|
||||
stdout: line_rx,
|
||||
stderr: err_rx,
|
||||
task,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
-289
@@ -1,312 +1,63 @@
|
||||
//! On-disk layout of a Sausage world. The folder is the source of truth:
|
||||
//! `sausage run` boots every actor that it finds in `actor/`, and then
|
||||
//! watches the folder (`watch.rs`). `/self` aliases the running actor's folder;
|
||||
//! `/public` and `/data` remain aliases for its `public/` and `data/`.
|
||||
//!
|
||||
//! ```text
|
||||
//! <world>/ mounted at /, readable and writable by every actor
|
||||
//! config/ global configuration by convention (no secrets)
|
||||
//! actor/<id>/
|
||||
//! actor.wasm the current version
|
||||
//! actor.json version, running, subscriptions, tapped, watches (broker)
|
||||
//! source/ source of the current version
|
||||
//! public/ published files, readable and writable by everyone
|
||||
//! data/ state, readable and writable by everyone
|
||||
//! stderr.log stderr of the actor and notes of the broker
|
||||
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Name prefix of the host's temporary files and folders. Copies skip them.
|
||||
pub const TMP_PREFIX: &str = ".sausage-tmp-";
|
||||
|
||||
/// IDs of the actors that the host implements natively. `fs` sends the
|
||||
/// requests for actor-served files (`vfs.rs`).
|
||||
pub const NATIVE: &[&str] = &["broker", "console", "cargo", "fs"];
|
||||
|
||||
//! World folders and atomic build output helpers.
|
||||
use anyhow::{Result, bail};
|
||||
use std::path::{Path, PathBuf};
|
||||
#[derive(Clone)]
|
||||
pub struct World {
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
/// `actor/<id>/actor.json`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct ActorMeta {
|
||||
/// The installed version. `None` for native actors and for a new
|
||||
/// `actor.wasm` that the broker has not installed yet.
|
||||
#[serde(default)]
|
||||
pub version: Option<u64>,
|
||||
#[serde(default = "yes")]
|
||||
pub running: bool,
|
||||
/// The actors whose published messages this actor receives.
|
||||
#[serde(default)]
|
||||
pub subscriptions: BTreeSet<String>,
|
||||
/// Whether this actor gets a copy of every routed message (`tap`).
|
||||
#[serde(default)]
|
||||
pub tapped: bool,
|
||||
/// Namespace paths whose changes this actor gets as `fs_changed` (`watch`).
|
||||
#[serde(default)]
|
||||
pub watches: BTreeSet<String>,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `versions/<n>/meta.json`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct VersionMeta {
|
||||
pub version: u64,
|
||||
/// Path of the `.wasm` file, as the requester gave it.
|
||||
pub wasm: String,
|
||||
/// Path of the source folder, as the requester gave it.
|
||||
pub source: Option<String>,
|
||||
/// The actor that built the `.wasm` file.
|
||||
pub built_by: Option<String>,
|
||||
/// The actor that requested the version ("scan" for a file found on disk).
|
||||
pub requested_by: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
pub fn now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl World {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn actors_root(&self) -> PathBuf {
|
||||
self.root.join("actor")
|
||||
}
|
||||
pub fn actor_dir(&self, id: &str) -> PathBuf {
|
||||
self.actors_root().join(id)
|
||||
}
|
||||
pub fn wasm_path(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("actor.wasm")
|
||||
}
|
||||
pub fn public_dir(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("public")
|
||||
}
|
||||
pub fn source_dir(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("source")
|
||||
}
|
||||
pub fn data_dir(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("data")
|
||||
}
|
||||
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("versions")
|
||||
}
|
||||
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
||||
self.versions_dir(id).join(v.to_string())
|
||||
}
|
||||
/// Private scratch space of the broker (candidate copies, seeds).
|
||||
pub fn scratch_dir(&self) -> PathBuf {
|
||||
self.data_dir("broker").join("scratch")
|
||||
}
|
||||
|
||||
pub fn exists(&self) -> bool {
|
||||
self.actors_root().is_dir()
|
||||
}
|
||||
|
||||
/// Create an empty world with the folders of the native actors.
|
||||
pub fn create(root: &Path) -> Result<World> {
|
||||
let world = World::new(std::path::absolute(root)?);
|
||||
if world.exists() {
|
||||
bail!("{} is already a Sausage world", root.display());
|
||||
}
|
||||
for id in NATIVE {
|
||||
world.ensure_actor_dirs(id)?;
|
||||
}
|
||||
std::fs::create_dir_all(world.root.join("config"))?;
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
|
||||
std::fs::create_dir_all(self.public_dir(id))?;
|
||||
std::fs::create_dir_all(self.data_dir(id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// IDs of every folder in `actor/`, sorted.
|
||||
pub fn actor_ids(&self) -> Result<Vec<String>> {
|
||||
let mut ids: Vec<String> = std::fs::read_dir(self.actors_root())?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.filter(|id| valid_id(id))
|
||||
.collect();
|
||||
ids.sort();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub fn read_meta(&self, id: &str) -> ActorMeta {
|
||||
std::fs::read_to_string(self.actor_dir(id).join("actor.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(ActorMeta { running: true, ..Default::default() })
|
||||
}
|
||||
|
||||
pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> {
|
||||
write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes())
|
||||
}
|
||||
|
||||
/// Make `actor.wasm` and `source/` a copy of `versions/<version>/`,
|
||||
/// unless `actor.wasm` already is that version (it came from there).
|
||||
pub fn install_files(&self, id: &str, version: u64) -> Result<()> {
|
||||
let dir = self.version_dir(id, version);
|
||||
self.ensure_actor_dirs(id)?;
|
||||
let wasm = std::fs::read(dir.join("actor.wasm"))?;
|
||||
if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) {
|
||||
return Ok(());
|
||||
}
|
||||
write_atomic(&self.wasm_path(id), &wasm)?;
|
||||
replace_tree(&dir.join("source"), &self.source_dir(id))
|
||||
}
|
||||
|
||||
/// Version numbers in `versions/`, ascending.
|
||||
pub fn versions(&self, id: &str) -> Vec<u64> {
|
||||
let mut v: Vec<u64> = std::fs::read_dir(self.versions_dir(id))
|
||||
.map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn next_version(&self, id: &str) -> u64 {
|
||||
self.versions(id).last().map_or(1, |v| v + 1)
|
||||
}
|
||||
|
||||
/// Map a path in an actor's namespace to a host path.
|
||||
///
|
||||
/// - Absolute paths are relative to the world root.
|
||||
/// - `/self/...` aliases `sender`'s folder; `/public/...` and `/data/...`
|
||||
/// alias its corresponding children.
|
||||
/// - Native senders (the console) can also give absolute host paths.
|
||||
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
|
||||
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||
let mut out = base;
|
||||
for c in Path::new(rest).components() {
|
||||
match c {
|
||||
Component::Normal(n) => out.push(n),
|
||||
Component::CurDir | Component::RootDir => {}
|
||||
_ => bail!("path must not contain '..': {path}"),
|
||||
}
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
for name in ["proc", "run", "self"] {
|
||||
if path.join(name).symlink_metadata().is_ok() {
|
||||
bail!("reserved world path: {name}");
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let prefixed = |prefix: &str| {
|
||||
path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||
};
|
||||
if let Some(rest) = prefixed("/actor") {
|
||||
return under(self.actors_root(), rest);
|
||||
}
|
||||
if let Some(rest) = prefixed("/self") {
|
||||
return under(self.actor_dir(sender), rest);
|
||||
}
|
||||
if let Some(rest) = prefixed("/public") {
|
||||
return under(self.public_dir(sender), rest);
|
||||
}
|
||||
if let Some(rest) = prefixed("/data") {
|
||||
return under(self.data_dir(sender), rest);
|
||||
}
|
||||
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
if Path::new(path).is_absolute() {
|
||||
return under(self.root.clone(), path);
|
||||
}
|
||||
bail!("cannot resolve path {path} for {sender}: use an absolute world path")
|
||||
Ok(Self {
|
||||
root: path.canonicalize()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The path of a host file in the global filesystem, if it has one.
|
||||
pub fn global_path(&self, host: &Path) -> Option<String> {
|
||||
let rel = host.strip_prefix(&self.root).ok()?;
|
||||
Some(format!("/{}", rel.display()))
|
||||
pub fn create(path: &Path) -> Result<Self> {
|
||||
if path.exists() {
|
||||
bail!("world already exists: {}", path.display());
|
||||
}
|
||||
for dir in ["plugins", "state/core", "config"] {
|
||||
std::fs::create_dir_all(path.join(dir))?;
|
||||
}
|
||||
std::fs::write(path.join("config/boot.json"), "[]\n")?;
|
||||
Self::open(path)
|
||||
}
|
||||
pub fn plugin_dir(&self, name: &str) -> PathBuf {
|
||||
self.root.join("plugins").join(name)
|
||||
}
|
||||
pub fn state_dir(&self, name: &str) -> PathBuf {
|
||||
self.root.join("state").join(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a file so that readers never see a partial version.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let dir = path.parent().context("path has no parent")?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||
let tmp = dir.join(format!("{TMP_PREFIX}host-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
std::fs::rename(tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Folders that a copy of a source folder leaves out.
|
||||
const BUILD_DIRS: &[&str] = &["target", ".git"];
|
||||
|
||||
/// Copy a directory tree, except uncommitted files.
|
||||
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
copy_filtered(from, to, &[])
|
||||
pub fn replace_source(from: &Path, to: &Path) -> Result<()> {
|
||||
if to.exists() {
|
||||
std::fs::remove_dir_all(to)?;
|
||||
}
|
||||
copy_tree(from, to)
|
||||
}
|
||||
|
||||
fn copy_filtered(from: &Path, to: &Path, skip: &[&str]) -> Result<()> {
|
||||
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with(TMP_PREFIX) {
|
||||
if entry.file_name() == "target" {
|
||||
continue;
|
||||
}
|
||||
let dst = to.join(&*name);
|
||||
let ty = entry.file_type()?;
|
||||
if ty.is_dir() && !skip.contains(&&*name) {
|
||||
copy_filtered(&entry.path(), &dst, skip)?;
|
||||
} else if ty.is_file() {
|
||||
std::fs::copy(entry.path(), &dst)?;
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), to.join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the directory `dst` with a copy of `src` (an empty folder if `src`
|
||||
/// does not exist): copy to a sibling, then swap with two renames.
|
||||
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
|
||||
replace_filtered(src, dst, &[])
|
||||
}
|
||||
|
||||
/// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`.
|
||||
pub fn replace_source(src: &Path, dst: &Path) -> Result<()> {
|
||||
replace_filtered(src, dst, BUILD_DIRS)
|
||||
}
|
||||
|
||||
fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> {
|
||||
let parent = dst.parent().context("path has no parent")?;
|
||||
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
|
||||
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
|
||||
let old = parent.join(format!("{TMP_PREFIX}old-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
let _ = std::fs::remove_dir_all(&old);
|
||||
if src.is_dir() {
|
||||
copy_filtered(src, &fresh, skip)?;
|
||||
} else {
|
||||
std::fs::create_dir_all(&fresh)?;
|
||||
}
|
||||
if dst.exists() {
|
||||
std::fs::rename(dst, &old)?;
|
||||
}
|
||||
std::fs::rename(&fresh, dst)?;
|
||||
let _ = std::fs::remove_dir_all(&old);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user