core: review fixes, init builds the agent, console without a terminal

Activation path (review T6R): a broken plugin or a stray file in
/plugins blocks no activation; candidates replace their own entries;
discovery has a 10 s deadline and its process is stopped after the
answer; an unchanged hash keeps plugin.prev.wasm; activations run in
their own task; the watcher is not recursive; no duplicate services;
rpc.retired waits for live processes of its version; reads and hashes
on blocking threads.

Guest I/O and per-call paths: a stdin write error is EOF, stdout and
stderr are drained before the status; stderr is lossy UTF-8 and goes
to the journal from its own reader; compiled components are freed;
shutdown starts no new processes; lock queues drop stopping processes;
compile before the admission gate; CommandPre per component;
core.unlock in order; null errors are successes; the journal thread
survives SQLite errors.

sausage init builds the agent with cargo.build; sausage without a
command creates ./world. The console reads plain lines without a
terminal. PROTOCOL.md and DECISIONS.md describe the decided rules. The
flake tests the whole workspace and checks the packaged cargo.build.
This commit is contained in:
2026-10-23 02:25:03 +01:00
parent 53b90a8ecf
commit d7b78ed0fd
27 changed files with 1800 additions and 352 deletions
+20 -13
View File
@@ -1,7 +1,7 @@
//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
use crate::{
Host, RpcResult, error,
graph::{Interface, io_error, problems},
graph::{Interface, io_error, problems, read_hashed},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, path::PathBuf};
@@ -14,6 +14,13 @@ struct Candidate {
impl Host {
/// Copies first, discovers that copy, checks the combined graph, then installs it.
pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult {
let host = self.clone();
let method = method.to_owned();
tokio::spawn(async move { host.activation_body(&method, params).await })
.await
.map_err(|failure| error(-32603, &failure.to_string()))?
}
async fn activation_body(&self, method: &str, params: Value) -> RpcResult {
let _activation = self.0.activations.lock().await;
let mut candidates = Vec::new();
let result = self
@@ -79,12 +86,12 @@ impl Host {
};
let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7()));
// Read once. Discovery and installation both use the same private bytes.
let bytes = tokio::fs::read(source).await.map_err(io_error)?;
let (bytes, hash) = read_hashed(source).await.map_err(io_error)?;
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
self.0
.journal
.write("core", plugin, "core", "activation copied candidate");
let discovery = self.discover_copy(plugin, &copy, &bytes).await;
let discovery = self.discover_copy(plugin, &copy, hash).await;
let interface = match discovery {
Ok(interface) => interface,
Err(error) => {
@@ -101,19 +108,13 @@ impl Host {
interface,
});
}
self.refresh_graph().await?;
let previous = self.0.graph.lock().unwrap().plugins.clone();
if previous.values().any(|entry| entry.error.is_some()) {
return Err(error(
-32003,
"activation waits for successful discovery of every plugin",
));
}
let mut proposed = previous.clone();
let names: Vec<_> = candidates
.iter()
.map(|candidate| candidate.plugin.clone())
.collect();
self.refresh_graph_except(&names).await?;
let previous = self.0.graph.lock().unwrap().plugins.clone();
let mut proposed = previous.clone();
for candidate in candidates.iter() {
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
}
@@ -160,6 +161,12 @@ impl Host {
let admission = self.0.admission.write().await;
// Every candidate already has a temporary name before any installed file changes.
for candidate in candidates {
if previous
.get(&candidate.plugin)
.is_some_and(|old| old.hash == candidate.interface.hash)
{
continue;
}
let directory = self.0.world.plugin_dir(&candidate.plugin);
let current = directory.join("plugin.wasm");
if tokio::fs::try_exists(&current).await.map_err(io_error)? {
@@ -180,7 +187,7 @@ impl Host {
);
if let Some(old) = previous
.get(&candidate.plugin)
.filter(|old| old.hash != candidate.interface.hash)
.filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash)
{
let grace = old.document["x-sausage-grace"]
.as_f64()
+180 -49
View File
@@ -1,55 +1,176 @@
//! Component versions cached by file metadata, with independent compilation per hash.
use crate::{error, runtime::Runtime};
//! Metadata-keyed component loads and weak compiled versions; candidate copies never enter the path cache.
#[cfg(test)]
mod tests {
use super::*;
/// PROTOCOL 10.3: a pending component load does not occupy admission.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn loading_does_not_hold_admission() {
let folder =
std::env::temp_dir().join(format!("sausage-admission-{}", uuid::Uuid::now_v7()));
let world = crate::world::World::create(&folder).unwrap();
let path = world.plugin_dir("probe").join("plugin.wasm");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, []).unwrap();
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
let entry = host
.0
.components
.paths
.lock()
.unwrap()
.entry(path)
.or_default()
.clone();
let blocked = entry.lock().await;
let caller = host.clone();
let pending =
tokio::spawn(async move { caller.call("probe.echo", serde_json::json!({})).await });
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while Arc::strong_count(&entry) < 3 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let admitted = host.0.admission.try_write().is_ok();
drop(blocked);
assert!(pending.await.unwrap().is_err());
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert!(admitted, "a component load held the admission gate");
}
/// PROTOCOL 10.3: temporary discovery and activation copies never accumulate cached paths.
#[tokio::test]
async fn candidate_copies_do_not_enter_path_cache() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let target = root.join("target/core/guests");
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--offline",
"--target",
"wasm32-wasip2",
"--manifest-path"
])
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.status()
.unwrap()
.success()
);
let folder =
std::env::temp_dir().join(format!("sausage-component-cache-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&folder).unwrap();
let runtime = Runtime::new(&root.join("target/core/cache-test")).unwrap();
let cache = Components::default();
for name in ["discover-test.wasm", "activate-test.wasm"] {
let path = folder.join(name);
std::fs::copy(target.join("wasm32-wasip2/debug/probe.wasm"), &path).unwrap();
let loaded = cache.load(path.clone(), runtime.clone()).await.unwrap();
assert!(cache.paths.lock().unwrap().is_empty());
let weak = Arc::downgrade(&loaded.prepared);
drop(loaded);
std::fs::remove_file(path).unwrap();
assert!(weak.upgrade().is_none());
}
std::fs::remove_dir(folder).unwrap();
}
}
use crate::{
error,
runtime::{Prepared, Runtime},
};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
path::PathBuf,
sync::{Arc, Mutex},
path::{Path, PathBuf},
sync::{Arc, Mutex, Weak},
time::SystemTime,
};
use tokio::sync::{Mutex as AsyncMutex, OnceCell};
use wasmtime::component::Component;
struct Version {
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Fingerprint {
size: u64,
modified: SystemTime,
hash: String,
component: Component,
#[cfg(unix)]
identity: (u64, u64, i64, i64),
}
type PathEntry = Arc<AsyncMutex<Option<Version>>>;
type CompiledEntry = Arc<OnceCell<Component>>;
impl Fingerprint {
pub(crate) async fn read(path: &Path) -> Result<Self, serde_json::Value> {
let metadata = tokio::fs::metadata(path)
.await
.map_err(|_| error(-32001, "no such plugin"))?;
Ok(Self {
size: metadata.len(),
modified: metadata
.modified()
.map_err(|e| error(-32603, &e.to_string()))?,
#[cfg(unix)]
identity: {
use std::os::unix::fs::MetadataExt;
(
metadata.dev(),
metadata.ino(),
metadata.ctime(),
metadata.ctime_nsec(),
)
},
})
}
}
#[derive(Clone)]
pub(crate) struct Loaded {
pub(crate) prepared: Arc<Prepared>,
pub(crate) hash: String,
pub(crate) fingerprint: Fingerprint,
// Retain the per-hash cell only while a cached path refers to this version.
_compiled: Arc<OnceCell<Arc<Prepared>>>,
}
type PathEntry = Arc<AsyncMutex<Option<Loaded>>>;
#[derive(Default)]
pub(crate) struct Components {
paths: Mutex<HashMap<PathBuf, PathEntry>>,
compiled: Mutex<HashMap<String, CompiledEntry>>,
compiled: Mutex<HashMap<String, Weak<OnceCell<Arc<Prepared>>>>>,
}
impl Components {
/// Stat each path, but read and hash its bytes only after its metadata changes.
pub(crate) async fn load(
&self,
path: PathBuf,
runtime: Arc<Runtime>,
) -> Result<(Component, String), serde_json::Value> {
let entry = self
.paths
.lock()
.unwrap()
.entry(path.clone())
.or_default()
.clone();
) -> Result<Loaded, serde_json::Value> {
let candidate = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("activate-") || n.starts_with("discover-"));
let entry = if candidate {
Arc::default()
} else {
self.paths
.lock()
.unwrap()
.entry(path.clone())
.or_default()
.clone()
};
let mut cached = entry.lock().await;
let metadata = tokio::fs::metadata(&path)
.await
.map_err(|_| error(-32001, "no such plugin"))?;
let modified = metadata
.modified()
.map_err(|e| error(-32603, &e.to_string()))?;
let fingerprint = match Fingerprint::read(&path).await {
Ok(metadata) => metadata,
Err(error) => {
*cached = None;
return Err(error);
}
};
if let Some(version) = cached.as_ref()
&& version.size == metadata.len()
&& version.modified == modified
&& version.fingerprint == fingerprint
{
return Ok((version.component.clone(), version.hash.clone()));
return Ok(version.clone());
}
let (bytes, hash) = tokio::task::spawn_blocking(move || -> std::io::Result<_> {
let bytes = std::fs::read(path)?;
@@ -59,28 +180,38 @@ impl Components {
.await
.map_err(|e| error(-32603, &e.to_string()))?
.map_err(|e| error(-32603, &e.to_string()))?;
let compiled = self
.compiled
.lock()
.unwrap()
.entry(hash.clone())
.or_default()
.clone();
let component = compiled
let compiled = {
let mut versions = self.compiled.lock().unwrap();
versions.retain(|_, v| v.strong_count() > 0);
if let Some(cell) = versions.get(&hash).and_then(Weak::upgrade) {
cell
} else {
let cell = Arc::new(OnceCell::new());
versions.insert(hash.clone(), Arc::downgrade(&cell));
cell
}
};
let prepared = compiled
.get_or_try_init(|| async move {
tokio::task::spawn_blocking(move || runtime.compile(&bytes))
.await
.map_err(|e| error(-32603, &e.to_string()))?
.map_err(|e| error(-32603, &e.to_string()))
tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let component = runtime.compile(&bytes)?;
Ok(Arc::new(runtime.prepare(&component)))
})
.await
.map_err(|e| error(-32603, &e.to_string()))?
.map_err(|e| error(-32603, &e.to_string()))
})
.await?
.clone();
*cached = Some(Version {
size: metadata.len(),
modified,
hash: hash.clone(),
component: component.clone(),
});
Ok((component, hash))
let loaded = Loaded {
prepared,
hash,
fingerprint,
_compiled: compiled,
};
if !candidate {
*cached = Some(loaded.clone());
}
Ok(loaded)
}
}
+71 -1
View File
@@ -177,7 +177,34 @@ pub async fn execute_line(host: &Host, line: &str) -> Result<String> {
}
i += 1;
}
call(host, "core.log", params, false).await
match host.call("core.log", params).await {
Ok(entries) => entries
.as_array()
.unwrap()
.iter()
.map(|entry| {
let time =
chrono::DateTime::parse_from_rfc3339(entry["time"].as_str().unwrap())?
.with_timezone(&chrono::Local);
let pid = entry["pid"].as_str().unwrap();
let prefix = format!("{}-", entry["plugin"].as_str().unwrap());
let short = pid
.strip_prefix(&prefix)
.and_then(|uuid| uuid.get(..8))
.map(|uuid| format!("{prefix}{uuid}"))
.unwrap_or_else(|| pid.to_owned());
Ok(format!(
"{} {} {} {}",
time.format("%H:%M:%S%.3f"),
short,
entry["stream"].as_str().unwrap(),
entry["line"].as_str().unwrap()
))
})
.collect::<Result<Vec<_>>>()
.map(|lines| lines.join("\n")),
Err(error) => Ok(format!("error: {}", pretty(&error)?)),
}
}
"activate" if words.len() == 2 => {
call(
@@ -245,7 +272,11 @@ fn indent(text: &str) -> String {
}
/// Run the line editor on a dedicated thread, with notifications through its external printer.
/// Without a terminal (for example, input from a pipe), read plain lines instead.
pub async fn run_input(host: Host) -> Result<()> {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return run_plain(host).await;
}
let history = host.0.world.state_dir("core").join("history");
let (ready, started) = tokio::sync::oneshot::channel();
let (sender, mut lines) = tokio::sync::mpsc::unbounded_channel::<
@@ -323,3 +354,42 @@ pub async fn run_input(host: Host) -> Result<()> {
};
tokio::select! {result=commands=>result,result=print_notifications=>result}
}
/// Read plain lines from stdin, without line editing or history. A detached
/// thread reads them, so that a blocked read does not hold the runtime open.
async fn run_plain(host: Host) -> Result<()> {
let (sender, mut lines) = tokio::sync::mpsc::unbounded_channel::<String>();
std::thread::spawn(move || {
for line in std::io::stdin().lines() {
let Ok(line) = line else { break };
if sender.send(line).is_err() {
break;
}
}
});
let mut notifications = host.subscribe();
let print_notifications = async move {
loop {
match notifications.recv().await {
Ok(value) => println!("{}", indent(&notification_text(&value))),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return Ok(()),
}
}
};
let commands = async {
while let Some(line) = lines.recv().await {
let text = execute_line(&host, &line)
.await
.unwrap_or_else(|error| format!("error: {error}"));
if !text.is_empty() {
println!("{}", indent(&text));
}
if line.trim() == "quit" {
break;
}
}
Ok(())
};
tokio::select! {result=commands=>result,result=print_notifications=>result}
}
+22 -28
View File
@@ -13,7 +13,7 @@ impl Host {
&self,
pid: String,
info: ProcessInfo,
component: Option<wasmtime::component::Component>,
component: Option<std::sync::Arc<crate::runtime::Prepared>>,
native: Option<NativePlugin>,
incoming: mpsc::UnboundedReceiver<Value>,
has_id: bool,
@@ -38,7 +38,7 @@ impl Host {
&self,
pid: &str,
info: &ProcessInfo,
component: &wasmtime::component::Component,
component: &crate::runtime::Prepared,
mut incoming: mpsc::UnboundedReceiver<Value>,
has_id: bool,
) -> anyhow::Result<String> {
@@ -60,7 +60,17 @@ impl Host {
),
],
};
let mut instance = self.0.runtime.start(component, config).await?;
let mut instance = self
.0
.runtime
.start_logged(
component,
config,
self.0.journal.clone(),
pid.into(),
info.plugin.clone(),
)
.await?;
let mut initial = json!({
"jsonrpc": "2.0",
"method": info.method,
@@ -69,25 +79,17 @@ impl Host {
if has_id {
initial["id"] = json!(pid);
}
self.write_input(pid, &mut instance, initial).await?;
let mut stdin_open = true;
let mut stderr_open = true;
let mut stdin_open = self.forward_input(pid, &mut instance, Some(initial)).await;
loop {
tokio::select! {
line = instance.stderr.recv(), if stderr_open => {
stderr_open = self.stderr_line(pid,&info.plugin,line);
}
line = instance.stdout.recv() => {
if !self.stdout_line(pid,line) { break; }
}
value = incoming.recv(), if stdin_open => {
stdin_open = self.forward_input(pid,&mut instance,value).await?;
stdin_open = self.forward_input(pid,&mut instance,value).await;
}
}
}
while let Some(line) = instance.stderr.recv().await {
self.0.journal.write(pid, &info.plugin, "stderr", &line);
}
Ok(instance.wait().await.to_string())
}
@@ -97,14 +99,14 @@ impl Host {
pid: &str,
instance: &mut Instance,
value: Option<Value>,
) -> std::io::Result<bool> {
if let Some(value) = value {
self.write_input(pid, instance, value).await?;
Ok(true)
} else {
instance.stdin.take();
Ok(false)
) -> bool {
if let Some(value) = value
&& self.write_input(pid, instance, value).await.is_ok()
{
return true;
}
instance.stdin.take();
false
}
async fn write_input(
&self,
@@ -124,12 +126,4 @@ impl Host {
false
}
}
fn stderr_line(&self, pid: &str, plugin: &str, line: Option<String>) -> bool {
if let Some(line) = line {
self.0.journal.write(pid, plugin, "stderr", &line);
true
} else {
false
}
}
}
+6
View File
@@ -137,6 +137,8 @@ impl Proc {
#[async_trait]
impl Layer for Proc {
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
return Ok(directory());
}
@@ -147,6 +149,8 @@ impl Layer for Proc {
folder.stat(&path, follow).await
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if path == "/" {
let mut entries = vec![entry("core".into(), true)];
entries.extend(self.0.processes().into_keys().map(|pid| entry(pid, true)));
@@ -176,6 +180,8 @@ impl Layer for Proc {
folder.list(&path).await
}
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if options.write || options.create || options.truncate {
return denied();
}
+74 -34
View File
@@ -4,8 +4,8 @@ use crate::{Host, RpcResult};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, HashMap},
path::Path,
collections::BTreeMap,
path::{Path, PathBuf},
};
#[derive(Clone)]
@@ -16,7 +16,6 @@ pub(crate) struct Interface {
}
pub(crate) struct Graph {
pub(crate) plugins: BTreeMap<String, Interface>,
pub(crate) versions: HashMap<String, Value>,
json: Vec<u8>,
dot: Vec<u8>,
}
@@ -26,7 +25,6 @@ impl Default for Graph {
fn default() -> Self {
Self {
plugins: BTreeMap::new(),
versions: HashMap::new(),
json: b"{\"plugins\":{}}".to_vec(),
dot: b"digraph plugins {}\n".to_vec(),
}
@@ -38,6 +36,17 @@ pub(crate) fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// Reads and hashes one immutable snapshot without blocking an async worker.
pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec<u8>, String)> {
tokio::task::spawn_blocking(move || -> std::io::Result<_> {
let bytes = std::fs::read(path)?;
let hash = hash(&bytes);
Ok((bytes, hash))
})
.await
.map_err(std::io::Error::other)?
}
/// Lists broken imports, optionally restricted to changed consumers or providers.
pub(crate) fn problems(
plugins: &BTreeMap<String, Interface>,
@@ -105,32 +114,39 @@ impl Host {
&self,
plugin: &str,
path: &Path,
bytes: &[u8],
hash: String,
) -> Result<Interface, Value> {
let hash = hash(bytes);
let cache_key = format!("{plugin}/{hash}");
let document = self
.route_from(
"core".into(),
Some(json!(uuid::Uuid::now_v7().to_string())),
format!("{plugin}.rpc.discover"),
json!({}),
true,
Some(path.to_path_buf()),
)
.await?;
let request = json!(uuid::Uuid::now_v7().to_string());
let discovery = self.route_from(
"core".into(),
Some(request.clone()),
format!("{plugin}.rpc.discover"),
json!({}),
true,
Some(path.to_path_buf()),
);
let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await;
let processes: Vec<_> = self
.0
.state
.lock()
.unwrap()
.processes
.iter()
.filter(|(_, process)| process.id.as_ref() == Some(&request))
.map(|(pid, _)| pid.clone())
.collect();
for pid in processes {
self.begin_stop(&pid);
}
let document = result
.map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??;
if !document["methods"].is_array() {
return Err(crate::error(
-32003,
"rpc.discover did not return an OpenRPC document",
));
}
self.0
.graph
.lock()
.unwrap()
.versions
.insert(cache_key, document.clone());
Ok(Interface {
hash,
document,
@@ -139,21 +155,44 @@ impl Host {
}
/// Refreshes every installed plugin while the caller holds the activation mutex.
pub(crate) async fn refresh_graph(&self) -> RpcResult {
self.refresh_graph_except(&[]).await
}
pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult {
let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins"))
.await
.map_err(io_error)?;
let mut plugins = BTreeMap::new();
while let Some(entry) = directory.next_entry().await.map_err(io_error)? {
if !entry.file_type().await.map_err(io_error)?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let source = entry.path().join("plugin.wasm");
let bytes = match tokio::fs::read(&source).await {
Ok(bytes) => bytes,
let (bytes, hash) = match read_hashed(source).await {
Ok(snapshot) => snapshot,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_error(error)),
Err(error) => {
plugins.insert(
name,
Interface {
hash: String::new(),
document: Value::Null,
error: Some(io_error(error)),
},
);
continue;
}
};
let hash = hash(&bytes);
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
let interface = if let Some(previous) =
let interface = if candidates.contains(&name) {
previous
.filter(|entry| entry.hash == hash)
.unwrap_or(Interface {
hash,
document: Value::Null,
error: None,
})
} else if let Some(previous) =
previous.filter(|entry| entry.hash == hash && entry.error.is_none())
{
previous
@@ -161,8 +200,10 @@ impl Host {
let copy = entry
.path()
.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
let result = self.discover_copy(&name, &copy, &bytes).await;
let result = match tokio::fs::write(&copy, &bytes).await {
Ok(()) => self.discover_copy(&name, &copy, hash.clone()).await,
Err(error) => Err(io_error(error)),
};
let _ = tokio::fs::remove_file(&copy).await;
match result {
Ok(interface) => interface,
@@ -179,10 +220,9 @@ impl Host {
let old_plugins = self.0.graph.lock().unwrap().plugins.clone();
let mut upgrades = Vec::new();
for (plugin, current) in &plugins {
if let Some(old) = old_plugins
.get(plugin)
.filter(|old| old.hash != current.hash && current.error.is_none())
{
if let Some(old) = old_plugins.get(plugin).filter(|old| {
!old.hash.is_empty() && old.hash != current.hash && current.error.is_none()
}) {
let grace = old.document["x-sausage-grace"]
.as_f64()
.filter(|value| value.is_finite() && *value >= 0.0)
+6 -2
View File
@@ -23,7 +23,7 @@ pub(crate) struct Inner {
pub(crate) world: World,
pub(crate) runtime: Arc<Runtime>,
pub(crate) state: Mutex<State>,
pub(crate) journal: journal::Journal,
pub(crate) journal: Arc<journal::Journal>,
pub(crate) notifications: tokio::sync::broadcast::Sender<Value>,
pub(crate) exit: watch::Sender<bool>,
pub(crate) components: components::Components,
@@ -31,6 +31,9 @@ pub(crate) struct Inner {
/// process is in the table. Activation holds it for writing while it
/// installs a version, so that it sees every process of the old version.
pub(crate) admission: tokio::sync::RwLock<()>,
/// Set when the host starts to stop. After that, the core starts no new
/// processes, and upgrade tasks do nothing.
pub(crate) shutdown: std::sync::atomic::AtomicBool,
pub(crate) run: sausage_vfs::Memory,
pub(crate) graph: Mutex<crate::graph::Graph>,
pub(crate) activations: tokio::sync::Mutex<()>,
@@ -65,7 +68,7 @@ impl Host {
let host = Self(Arc::new(Inner {
world,
runtime,
journal,
journal: Arc::new(journal),
state: Mutex::new(State {
processes: HashMap::new(),
locks: HashMap::new(),
@@ -76,6 +79,7 @@ impl Host {
exit: watch::channel(false).0,
components: components::Components::default(),
admission: tokio::sync::RwLock::new(()),
shutdown: std::sync::atomic::AtomicBool::new(false),
run: sausage_vfs::Memory::default(),
graph: Mutex::new(crate::graph::Graph::default()),
activations: tokio::sync::Mutex::new(()),
+87 -82
View File
@@ -1,4 +1,4 @@
//! A dedicated SQLite writer batches entries and serves ordered journal queries.
//! A dedicated SQLite writer batches entries and serves ordered queries without propagating disk failures into the host.
use rusqlite::{Connection, params};
use serde_json::{Value, json};
use std::{
@@ -7,7 +7,6 @@ use std::{
sync::{Mutex, mpsc},
};
use tokio::sync::oneshot;
struct Entry {
id: String,
pid: String,
@@ -19,31 +18,27 @@ enum Command {
Write(Entry),
Query(Value, oneshot::Sender<Value>),
}
pub(crate) struct Journal {
sender: mpsc::Sender<Command>,
warned: Mutex<HashSet<String>>,
}
impl Journal {
/// Open SQLite on its owning thread and wait for its schema to become ready.
pub(crate) async fn open(path: PathBuf) -> anyhow::Result<Self> {
let (sender, receiver) = mpsc::channel();
let (ready, initialized) = oneshot::channel();
std::thread::spawn(move || {
let opened = open_database(path);
match opened {
Ok(mut db) => {
let _ = ready.send(Ok(()));
while let Ok(first) = receiver.recv() {
let mut batch = vec![first];
batch.extend(receiver.try_iter().take(255));
write_batch(&mut db, batch);
}
}
Err(error) => {
let _ = ready.send(Err(error));
std::thread::spawn(move || match open_database(path) {
Ok(mut db) => {
let _ = ready.send(Ok(()));
while let Ok(first) = receiver.recv() {
let mut batch = vec![first];
batch.extend(receiver.try_iter().take(255));
write_batch(&mut db, batch);
}
}
Err(error) => {
eprintln!("journal open failed: {error}");
let _ = ready.send(Err(error));
}
});
initialized.await??;
Ok(Self {
@@ -51,7 +46,6 @@ impl Journal {
warned: Mutex::new(HashSet::new()),
})
}
/// Queue an entry without file operations or SQLite work on the caller's thread.
pub(crate) fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
let entry = Entry {
id: uuid::Uuid::now_v7().to_string(),
@@ -60,99 +54,110 @@ impl Journal {
stream: stream.into(),
line: line.into(),
};
self.sender
.send(Command::Write(entry))
.expect("journal writer stopped");
if self.sender.send(Command::Write(entry)).is_err() {
eprintln!("journal writer stopped");
}
}
/// Queue one warning for the first non-blank discarded line of a process.
pub(crate) fn warning(&self, pid: &str, plugin: &str) {
if self.warned.lock().unwrap().insert(pid.into()) {
self.write(pid, plugin, "core", "warning: discarded stdout");
}
}
/// Queue a query after all entries that the caller already submitted.
pub(crate) fn forget(&self, pid: &str) {
self.warned.lock().unwrap().remove(pid);
}
pub(crate) fn query_later(&self, filters: Value) -> oneshot::Receiver<Value> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Command::Query(filters, tx))
.expect("journal writer stopped");
if self.sender.send(Command::Query(filters, tx)).is_err() {
eprintln!("journal writer stopped");
}
rx
}
/// Wait asynchronously for the journal thread to execute a query.
pub(crate) async fn query(&self, filters: Value) -> Value {
self.query_later(filters)
.await
.expect("journal writer stopped")
.unwrap_or_else(|_| json!([]))
}
}
fn open_database(path: PathBuf) -> anyhow::Result<Connection> {
std::fs::create_dir_all(path.parent().unwrap())?;
let db = Connection::open(path)?;
db.execute_batch(
"PRAGMA journal_mode=WAL;
db.execute_batch("PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS journal (
id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT
);
CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT);
CREATE INDEX IF NOT EXISTS journal_pid ON journal(pid);
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);",
)?;
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);")?;
Ok(db)
}
/// Commit queued writes together before answering the batch's queries.
fn write_batch(db: &mut Connection, batch: Vec<Command>) {
let transaction = db.transaction().expect("journal transaction");
for command in &batch {
if let Command::Write(entry) = command {
transaction
.execute(
"INSERT INTO journal (id,time,pid,plugin,stream,line)
VALUES (?1,strftime('%Y-%m-%dT%H:%M:%fZ','now'),?2,?3,?4,?5)",
params![entry.id, entry.pid, entry.plugin, entry.stream, entry.line],
)
.expect("write journal");
let write = (|| -> rusqlite::Result<()> {
let transaction = db.transaction()?;
for command in &batch {
if let Command::Write(e) = command {
transaction.execute("INSERT INTO journal (id,time,pid,plugin,stream,line) VALUES (?1,strftime('%Y-%m-%dT%H:%M:%fZ','now'),?2,?3,?4,?5)",params![e.id,e.pid,e.plugin,e.stream,e.line])?;
}
}
transaction.commit()
})();
if let Err(error) = write {
eprintln!("journal write failed: {error}");
}
transaction.commit().expect("commit journal");
for command in batch {
if let Command::Query(filters, response) = command {
let _ = response.send(query_database(db, &filters));
let value = query_database(db, &filters).unwrap_or_else(|error| {
eprintln!("journal query failed: {error}");
json!([])
});
let _ = response.send(value);
}
}
}
fn query_database(db: &Connection, filters: &Value) -> Value {
let mut statement = db
.prepare(
"SELECT id,time,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",
)
.expect("prepare journal query");
let rows = statement
.query_map(
params![
filters["plugin"].as_str(),
filters["pid"].as_str(),
filters["since"].as_str(),
filters["until"].as_str(),
filters["grep"].as_str(),
filters["limit"].as_i64().unwrap_or(100)
],
|row| {
Ok(json!({
"id":row.get::<_,String>(0)?,
"time":row.get::<_,String>(1)?,
"pid":row.get::<_,String>(2)?,
"plugin":row.get::<_,String>(3)?,
"stream":row.get::<_,String>(4)?,
"line":row.get::<_,String>(5)?,
}))
},
)
.expect("query journal");
let mut entries: Vec<_> = rows.map(Result::unwrap).collect();
fn query_database(db: &Connection, filters: &Value) -> rusqlite::Result<Value> {
let mut statement = db.prepare(
"SELECT id,time,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",
)?;
let rows=statement.query_map(params![filters["plugin"].as_str(),filters["pid"].as_str(),filters["since"].as_str(),filters["until"].as_str(),filters["grep"].as_str(),filters["limit"].as_i64().unwrap_or(100)],|row| Ok(json!({
"id":row.get::<_,String>(0)?,"time":row.get::<_,String>(1)?,"pid":row.get::<_,String>(2)?,"plugin":row.get::<_,String>(3)?,"stream":row.get::<_,String>(4)?,"line":row.get::<_,String>(5)?,
})))?;
let mut entries: Vec<_> = rows.collect::<rusqlite::Result<_>>()?;
entries.reverse();
json!(entries)
Ok(json!(entries))
}
#[cfg(test)]
mod tests {
use super::*;
/// PROTOCOL 7: completed processes do not leave warning identities in memory.
#[tokio::test]
async fn completion_forgets_warning_identity() {
let folder = std::env::temp_dir().join(format!("sausage-warning-{}", uuid::Uuid::now_v7()));
crate::world::World::create(&folder).unwrap();
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
host.register_native(
"warn",
std::sync::Arc::new(|ctx, _, _| {
Box::pin(async move {
ctx.host.output(&ctx.pid, "not JSON".into());
Ok(json!(ctx.pid))
})
}),
);
let pid = host.call("warn.once", json!({})).await.unwrap();
host.call("core.stop", json!({"pid":pid})).await.unwrap();
let retained = host
.0
.journal
.warned
.lock()
.unwrap()
.contains(pid.as_str().unwrap());
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert!(!retained);
}
}
+9
View File
@@ -15,6 +15,15 @@ impl Host {
pub(crate) async fn lock(&self, caller: &str, path: &str, wait: bool) -> RpcResult {
let rx = {
let mut state = self.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, "process is stopping or ended"));
}
if let Some(lock) = state.locks.get_mut(path) {
if !wait {
return Ok(json!({"held":false}));
+58 -21
View File
@@ -1,9 +1,15 @@
//! 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 std::{io::Write, path::PathBuf};
use std::{
io::Write,
path::{Path, PathBuf},
};
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
mod test_runner;
#[derive(Parser)]
#[command(version)]
@@ -13,14 +19,16 @@ struct Cli {
#[arg(long, global = true)]
cache_dir: Option<PathBuf>,
#[command(subcommand)]
command: Cmd,
command: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
Init {
#[arg(default_value = "world")]
dir: PathBuf,
},
Run {
#[arg(default_value = "world")]
dir: PathBuf,
},
/// Run a WASI libtest component in isolated instances.
@@ -48,31 +56,56 @@ fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
None => bail!("{question} is required"),
}
}
/// Creates a configured world and builds its embedded agent through the standalone builder.
async fn init(dir: &Path) -> Result<()> {
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}),
)?,
)?;
let builder = sausage::cargo::Cargo::new(world.clone())?;
let source = world.plugin_dir("agent").join("source");
std::fs::create_dir_all(source.join("src"))?;
AGENT.extract(source.join("src"))?;
std::fs::write(
source.join("Cargo.toml"),
include_str!("../../agent/Cargo.toml"),
)?;
println!("The first agent build takes a few minutes.");
let output = builder.build(&source).await?;
std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?;
println!("Start the world with: sausage run {}", dir.display());
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
let command = match cli.command {
Some(command) => command,
None => {
let dir = PathBuf::from("world");
if !dir.join("config/boot.json").is_file() {
init(&dir).await?;
}
Cmd::Run { dir }
}
};
match command {
Cmd::TestRunner { wasm, args } => {
if !test_runner::run(&wasm, args).await? {
std::process::exit(1);
}
}
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::Init { dir } => init(&dir).await?,
Cmd::Run { dir } => {
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
@@ -83,8 +116,12 @@ async fn main() -> Result<()> {
let mut notifications = host.subscribe();
let notification_task = cli.headless.then(|| {
tokio::spawn(async move {
while let Ok(notification) = notifications.recv().await {
println!("{notification}");
loop {
match notifications.recv().await {
Ok(notification) => println!("{notification}"),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
})
});
+33 -11
View File
@@ -51,12 +51,16 @@ impl Host {
let process = state.processes.remove(pid);
if let Some(p) = process {
self.0.journal.write(pid, &p.info.plugin, "core", status);
let logs = self.0.journal.query_later(json!({"pid":pid,"limit":20}));
let logs = p
.answer
.as_ref()
.map(|_| self.0.journal.query_later(json!({"pid":pid,"limit":20})));
self.0.journal.forget(pid);
Self::release_locks(&mut state, pid);
let status = status.to_string();
tokio::spawn(async move {
let lines = logs.await.expect("journal writer stopped");
if let Some(answer) = p.answer {
let lines = logs.unwrap().await.unwrap_or_else(|_| json!([]));
let failure = json!({
"code": -32002,
"message": "process ended without an answer",
@@ -89,6 +93,9 @@ impl Host {
p.info.state = "stopping".into();
p.input.take();
self.0.journal.write(pid, &p.info.plugin, "core", "stop");
for lock in state.locks.values_mut() {
lock.queue.retain(|(queued, _)| queued != pid);
}
let children: Vec<_> = state
.processes
.iter()
@@ -109,7 +116,11 @@ impl Host {
}
/// Abort a process task and perform its ended-state cleanup.
pub(crate) fn kill(&self, pid: &str) {
if let Some(p) = self.0.state.lock().unwrap().processes.get_mut(pid) {
let mut state = self.0.state.lock().unwrap();
for lock in state.locks.values_mut() {
lock.queue.retain(|(queued, _)| queued != pid);
}
if let Some(p) = state.processes.get_mut(pid) {
p.info.state = "stopping".into();
p.input.take();
self.0.journal.write(pid, &p.info.plugin, "core", "kill");
@@ -120,15 +131,26 @@ impl Host {
}
/// Stop every process in parallel and wait for journal writes to finish.
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);
}
{
let _state = self.0.state.lock().unwrap();
self.0
.shutdown
.store(true, std::sync::atomic::Ordering::SeqCst);
}
for mut n in waits {
let _ = n.wait_for(|ended| *ended).await;
loop {
let ids: Vec<_> = self.processes().into_keys().collect();
if ids.is_empty() {
break;
}
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;
}
}
self.0.journal.query(json!({"limit":0})).await;
}
+70 -18
View File
@@ -93,20 +93,31 @@ impl Host {
{
return Err(error(-32601, "invalid plugin name"));
}
let admission = host.0.admission.read().await;
let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
let (component, version) = if native.is_some() {
(None, "native".into())
} else {
let (component, hash) = host
.0
.components
.load(
wasm.unwrap_or_else(|| host.0.world.plugin_dir(plugin).join("plugin.wasm")),
host.0.runtime.clone(),
let path = wasm.unwrap_or_else(|| host.0.world.plugin_dir(plugin).join("plugin.wasm"));
let (native, component, version, admission) = loop {
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return Err(error(-32600, "host is stopping"));
}
let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
let loaded = if native.is_none() {
Some(
host.0
.components
.load(path.clone(), host.0.runtime.clone())
.await?,
)
.await?;
(Some(component), hash)
} else {
None
};
let admission = host.0.admission.read().await;
if let Some(loaded) = loaded {
if crate::components::Fingerprint::read(&path).await? != loaded.fingerprint {
drop(admission);
continue;
}
break (native, Some(loaded.prepared), loaded.hash, admission);
}
break (native, None, "native".into(), admission);
};
let pid = format!("{plugin}-{}", Uuid::now_v7());
let (tx, rx) = oneshot::channel();
@@ -126,6 +137,9 @@ impl Host {
};
{
let mut state = host.0.state.lock().unwrap();
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return Err(error(-32600, "host is stopping"));
}
if caller != "console"
&& caller != "core"
&& !state
@@ -149,7 +163,7 @@ impl Host {
abort: None,
caller,
id: id.clone(),
answer: Some(tx),
answer: if id.is_some() { Some(tx) } else { None },
ended: watch::channel(false).0,
},
);
@@ -220,11 +234,18 @@ impl Host {
return;
}
if let Some(method) = v["method"].as_str() {
if method == "core.notify" {
if method == "core.notify" || method == "core.unlock" {
// Deliver progress before a following response removes its destination.
let params = v.get("params").cloned().unwrap_or(json!({}));
let result = if !params.is_object() {
Err(error(-32602, "params must be an object"))
} else if method == "core.unlock" {
if let Some(path) = params["path"].as_str() {
self.unlock(pid, path);
Ok(Value::Null)
} else {
Err(error(-32602, "path must be a string"))
}
} else if let Some(name) = params["name"].as_str() {
self.notify(
pid,
@@ -277,7 +298,7 @@ impl Host {
} else if v["id"] == pid {
self.answer(
pid,
if let Some(e) = v.get("error") {
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
Err(e.clone())
} else {
Ok(v["result"].clone())
@@ -287,8 +308,39 @@ impl Host {
}
pub(crate) 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);
if let Some(plugin) = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.info.plugin.clone())
{
self.0.journal.warning(pid, &plugin);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// PROTOCOL 6: an unlock from stdout completes before output returns.
#[tokio::test]
async fn stdout_unlock_is_synchronous() {
let folder = std::env::temp_dir().join(format!("sausage-unlock-{}", Uuid::now_v7()));
crate::world::World::create(&folder).unwrap();
let host = Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
host.lock("core", "ordered", true).await.unwrap();
host.output(
"core",
r#"{"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered"}}"#.into(),
);
let held = host.lock("console", "ordered", false).await.unwrap();
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert_eq!(held, json!({"held":true}));
}
}
+72 -9
View File
@@ -15,7 +15,10 @@ use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
use wasmtime_wasi::p2::bindings::Command;
use wasmtime_wasi::p2::bindings::CommandPre;
/// A component with its imports resolved once for this runtime.
pub(crate) struct Prepared(Result<CommandPre<ProcessState>, String>);
use wasmtime_wasi::{I32Exit, WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
@@ -130,17 +133,27 @@ pub struct Instance {
pub stdout: mpsc::Receiver<String>,
/// The stderr lines that the process writes.
pub stderr: mpsc::Receiver<String>,
stderr_task: Option<JoinHandle<()>>,
task: Option<JoinHandle<Exit>>,
store_ended: tokio::sync::watch::Sender<bool>,
}
impl Drop for Instance {
fn drop(&mut self) {
if let Some(task) = self.task.take() {
let task = self.task.take();
let stderr = self.stderr_task.take();
if let Some(task) = &task {
task.abort();
}
if task.is_some() || stderr.is_some() {
let ended = self.store_ended.clone();
tokio::spawn(async move {
let _ = task.await;
if let Some(task) = task {
let _ = task.await;
}
if let Some(stderr) = stderr {
let _ = stderr.await;
}
ended.send_replace(true);
});
}
@@ -179,6 +192,10 @@ impl Instance {
pub async fn wait(&mut self) -> Exit {
let result = self.task.as_mut().unwrap().await;
self.task.take();
if let Some(stderr) = self.stderr_task.as_mut() {
let _ = stderr.await;
}
self.stderr_task.take();
self.store_ended.send_replace(true);
match result {
Ok(exit) => exit,
@@ -238,6 +255,28 @@ impl Runtime {
Ok(Component::new(&self.engine, bytes)?)
}
pub(crate) fn prepare(&self, component: &Component) -> Prepared {
// Preserve link failures as process exits, rather than load errors.
Prepared(
self.linker
.instantiate_pre(component)
.and_then(CommandPre::new)
.map_err(|error| error.to_string()),
)
}
pub(crate) async fn start_logged(
self: &Arc<Self>,
prepared: &Prepared,
cfg: InstanceConfig,
journal: Arc<crate::journal::Journal>,
pid: String,
plugin: String,
) -> Result<Instance> {
self.start_inner(prepared.0.clone(), cfg, &[], Some((journal, pid, plugin)))
.await
}
/// Start a WASI task with the supplied virtual namespace.
pub async fn start(
self: &Arc<Self>,
@@ -253,6 +292,17 @@ impl Runtime {
component: &Component,
cfg: InstanceConfig,
args: &[String],
) -> Result<Instance> {
self.start_inner(self.prepare(component).0, cfg, args, None)
.await
}
async fn start_inner(
self: &Arc<Self>,
prepared: Result<CommandPre<ProcessState>, String>,
cfg: InstanceConfig,
args: &[String],
log: Option<(Arc<crate::journal::Journal>, String, String)>,
) -> Result<Instance> {
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
@@ -299,22 +349,34 @@ impl Runtime {
});
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() {
let stderr_task = tokio::spawn(async move {
let mut reader = BufReader::new(host_err);
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_lossy(&bytes)
.trim_end_matches(['\r', '\n'])
.to_string();
bytes.clear();
if let Some((journal, pid, plugin)) = &log {
journal.write(pid, plugin, "stderr", &line);
} else if err_tx.send(line).await.is_err() {
break;
}
}
});
let rt = self.clone();
let component = component.clone();
cfg.store_ended.send_replace(false);
let task = tokio::spawn(async move {
let mut store = Store::new(&rt.engine, state);
store.epoch_deadline_async_yield_and_update(1);
let result = async {
let cmd = Command::instantiate_async(&mut store, &component, &rt.linker).await?;
let cmd = prepared
.map_err(wasmtime::Error::msg)?
.instantiate_async(&mut store)
.await?;
cmd.wasi_cli_run().call_run(&mut store).await
}
.await;
@@ -336,6 +398,7 @@ impl Runtime {
stdin: Some(host_in),
stdout: line_rx,
stderr: err_rx,
stderr_task: Some(stderr_task),
task: Some(task),
store_ended: cfg.store_ended,
})
+7
View File
@@ -6,6 +6,7 @@ pub(crate) struct Service {
pub(crate) method: String,
pub(crate) params: Value,
pub(crate) request: Value,
pub(crate) starting: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl Host {
/// Starts discovery in the background, then waits for each boot service in order.
@@ -25,11 +26,16 @@ impl Host {
}
/// Records a unique request identity before the service can answer or exit.
pub(crate) async fn start_service(&self, method: String, params: Value) {
if self.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
let request = json!(uuid::Uuid::now_v7().to_string());
let starting = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
self.0.state.lock().unwrap().services.push(Service {
method: method.clone(),
params: params.clone(),
request: request.clone(),
starting: starting.clone(),
});
self.0
.journal
@@ -42,5 +48,6 @@ impl Host {
.journal
.write("core", "core", "core", &format!("service failed {error}"));
}
starting.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
+47 -7
View File
@@ -1,7 +1,7 @@
//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7).
use crate::{Host, services::Service};
use serde_json::json;
use std::{collections::BTreeMap, time::Duration};
use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration};
use tokio::sync::watch;
pub(crate) struct OldProcess {
@@ -29,7 +29,11 @@ impl Host {
let processes = state
.processes
.iter()
.filter(|(_, process)| process.info.plugin == plugin && process.info.version != current)
.filter(|(_, process)| {
process.info.plugin == plugin
&& process.info.version != current
&& process.info.method != "rpc.discover"
})
.map(|(pid, process)| OldProcess {
pid: pid.clone(),
version: process.info.version.clone(),
@@ -44,12 +48,17 @@ impl Host {
.split_once('.')
.is_some_and(|(name, _)| name == plugin)
{
let ended = state
let process = state
.processes
.values()
.find(|process| process.id.as_ref() == Some(&service.request))
.map(|process| process.ended.subscribe());
services.push((service, ended));
.find(|process| process.id.as_ref() == Some(&service.request));
if process.is_some_and(|process| process.info.version == current)
|| (process.is_none() && service.starting.load(Ordering::SeqCst))
{
retained.push(service);
} else {
services.push((service, process.map(|process| process.ended.subscribe())));
}
} else {
retained.push(service);
}
@@ -67,6 +76,9 @@ impl Host {
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
let host = self.clone();
tokio::spawn(async move {
if host.0.shutdown.load(Ordering::SeqCst) {
return;
}
host.0.journal.write(
"core",
&upgrade.plugin,
@@ -96,7 +108,9 @@ impl Host {
if let Some(mut ended) = ended {
let _ = ended.wait_for(|ended| *ended).await;
}
host.start_service(service.method, service.params).await;
if !host.0.shutdown.load(Ordering::SeqCst) {
host.start_service(service.method, service.params).await;
}
});
}
for (version, processes) in versions {
@@ -116,6 +130,32 @@ impl Host {
for mut ended in processes {
let _ = ended.wait_for(|ended| *ended).await;
}
loop {
if host.0.shutdown.load(Ordering::SeqCst) {
host.0.retirements.lock().unwrap().remove(&retirement);
return;
}
let live: Vec<_> = host
.0
.state
.lock()
.unwrap()
.processes
.values()
.filter(|process| {
process.info.plugin == plugin
&& process.info.version == version
&& process.info.method != "rpc.discover"
})
.map(|process| process.ended.subscribe())
.collect();
if live.is_empty() {
break;
}
for mut ended in live {
let _ = ended.wait_for(|ended| *ended).await;
}
}
if host
.0
.graph
+51 -15
View File
@@ -1,24 +1,42 @@
//! Background discovery and file notifications for installed plugin versions.
//! Background discovery and nonrecursive notifications for installed plugin versions.
use crate::Host;
use notify::{RecursiveMode, Watcher};
impl Host {
/// Starts background discovery and retains the watcher for this host's lifetime.
/// Watches only the plugin root and its immediate folders. Discovery survives watcher failure.
pub(crate) fn watch_plugins(&self) -> anyhow::Result<()> {
let root = self.0.world.root.join("plugins");
let watched_root = root.clone();
let (events, mut incoming) = tokio::sync::mpsc::unbounded_channel();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event
&& !matches!(event.kind, notify::EventKind::Access(_))
&& event
.paths
.iter()
.any(|path| path.file_name().is_some_and(|name| name == "plugin.wasm"))
{
let _ = events.send(());
let setup = (|| -> anyhow::Result<_> {
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event
&& !matches!(event.kind, notify::EventKind::Access(_))
&& event.paths.iter().any(|path| {
path.parent() == Some(&watched_root)
|| path.file_name().is_some_and(|name| name == "plugin.wasm")
})
{
let _ = events.send(());
}
})?;
watcher.watch(&root, RecursiveMode::NonRecursive)?;
for entry in std::fs::read_dir(&root)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
watcher.watch(&entry.path(), RecursiveMode::NonRecursive)?;
}
})?;
watcher.watch(&self.0.world.root.join("plugins"), RecursiveMode::Recursive)?;
*self.0.watcher.lock().unwrap() = Some(watcher);
}
Ok(watcher)
})();
match setup {
Ok(watcher) => *self.0.watcher.lock().unwrap() = Some(watcher),
Err(error) => {
self.0
.journal
.write("core", "core", "core", &format!("watcher failed {error}"))
}
}
let weak = std::sync::Arc::downgrade(&self.0);
tokio::spawn(async move {
loop {
@@ -26,6 +44,24 @@ impl Host {
break;
};
let host = Host(inner);
// Root events also cover plugin folders created after startup.
if let Some(watcher) = host.0.watcher.lock().unwrap().as_mut()
&& let Ok(entries) = std::fs::read_dir(&root)
{
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|kind| kind.is_dir())
&& let Err(error) =
watcher.watch(&entry.path(), RecursiveMode::NonRecursive)
{
host.0.journal.write(
"core",
"core",
"core",
&format!("watcher failed {error}"),
);
}
}
}
let _activation = host.0.activations.lock().await;
if let Err(error) = host.refresh_graph().await {
host.0.journal.write(