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
+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,
})