sausage core: modules per topic, journal writer thread with WAL and batches, hash cache by metadata, 49 tests per rule

Blank stdout lines give no warning. core.exit requests the exit in its
own handler.
This commit is contained in:
2026-10-23 00:32:33 +01:00
parent 218a718a3d
commit 8542b9fcb8
17 changed files with 2016 additions and 814 deletions
+233
View File
@@ -0,0 +1,233 @@
//! JSON-RPC routing and responses between callers and processes.
use crate::{
Host, NativeFuture, RpcResult, error,
process::{Process, ProcessInfo},
};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot, watch};
use uuid::Uuid;
impl Host {
/// Queue an incoming response or notification for its destination.
pub(crate) 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);
}
}
/// Remap progress to the caller's original request ID.
pub(crate) 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 {
let notification = json!({
"jsonrpc": "2.0",
"method": "core.notify",
"params": { "call": id, "name": name, "params": params }
});
self.send(&caller, notification);
}
}
/// Validate and route a request, then start its native or WASI process.
pub(crate) 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 (component, hash) = host
.0
.components
.load(
host.0.world.plugin_dir(plugin).join("plugin.wasm"),
host.0.runtime.clone(),
)
.await?;
(Some(component), hash)
};
let pid = format!("{plugin}-{}", Uuid::now_v7());
let (tx, rx) = oneshot::channel();
let (input, 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 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: info.clone(),
input: Some(input),
abort: None,
caller,
id: id.clone(),
answer: Some(tx),
ended: watch::channel(false).0,
},
);
}
let h = host.clone();
let p = pid.clone();
let has_id = id.is_some();
let task = tokio::spawn(async move {
h.run_process(p, info, component, native, incoming, has_id)
.await;
});
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")))
})
}
/// Complete the initial call once and discard late responses.
pub(crate) 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);
}
}
/// Classify a stdout line and route requests or the initial response.
pub(crate) fn output(&self, pid: &str, line: String) {
if line.trim().is_empty() {
return;
}
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 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;
if let Some(id) = id {
let value = match result {
Ok(result) => json!({
"jsonrpc": "2.0",
"id": id,
"result": result
}),
Err(error) => json!({
"jsonrpc": "2.0",
"id": id,
"error": error
}),
};
h.send(&caller, value);
}
});
} else if v["id"] == pid {
self.answer(
pid,
if let Some(e) = v.get("error") {
Err(e.clone())
} else {
Ok(v["result"].clone())
},
);
}
}
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);
}
}
}