Files
sausage/cli/src/routing.rs
T
surma edc2d1a63b core: sausage-vfs per process (/, /proc, /self, /run), activation, graph, watcher, upgrades
- /proc is a core layer: core/ (PROTOCOL.md, graph.json, graph.dot,
  locks.json, plugins/<name>/discover.json and client.rs) and one folder
  per process (info.json, log, and its /self files).
- Locks are released only after the task ended, the store dropped, and
  in-flight file operations returned.
- core.activate and core.revert with copies, discovery, compatibility
  checks of imports and consumers, groups, force. An admission gate
  makes the snapshot of old processes complete.
- Graph of discover documents by hash, refreshed by a file watcher.
- Upgrades: grace periods (x-sausage-grace), stop, service restarts,
  rpc.retired.
- SDK plugins in the real core (cli/tests/sdk.rs).
2026-10-23 01:05:19 +01:00

271 lines
9.6 KiB
Rust

//! 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 {
self.route_from(caller, id, method, params, detached, None)
}
/// Like `route`, but a WASI process runs the component at `wasm` instead
/// of the current `plugin.wasm` of its plugin. Activation uses this to
/// call `rpc.discover` on a candidate file.
pub(crate) fn route_from(
&self,
caller: String,
id: Option<Value>,
method: String,
params: Value,
detached: bool,
wasm: Option<std::path::PathBuf>,
) -> 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 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(),
)
.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(),
memory: sausage_vfs::Memory::default(),
files: sausage_vfs::Process::default(),
store_ended: watch::channel(true).0,
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 (start, ready) = oneshot::channel();
let task = tokio::spawn(async move {
let _ = ready.await;
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());
if p.info.state == "stopping" {
task.abort();
}
} else {
task.abort();
}
drop(admission);
let cleanup = host.clone();
let finished_pid = pid.clone();
tokio::spawn(async move {
let status = task.await.unwrap_or_else(|error| {
if error.is_cancelled() {
"killed".into()
} else {
format!("host task failed: {error}")
}
});
cleanup.finish(&finished_pid, &status).await;
});
let _ = start.send(());
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);
}
}
}