sausage-plugin part 2: import! with typed clients (sausage_interface::client_rs), TCP endpoints with rpc.endpoint, examples

Part 1 fixes: complete stdout writes, buffered stdin, Rust type names
in schemas, enum variant docs, -32603 for serde errors in handlers,
crate docs and modules. End-to-end check of a generated consumer through
a fake core.
This commit is contained in:
2026-10-23 00:35:45 +01:00
parent ae1cb4cc99
commit 8d3c163992
42 changed files with 3020 additions and 856 deletions
+3 -1
View File
@@ -6,14 +6,16 @@ description = "SDK for Sausage plugins."
[dependencies]
sausage-plugin-macros = { version = "0.1", path = "macros" }
sausage-interface = { path = "../interface" }
sausage-interface = { path = "../interface", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "1"
futures = "0.3"
inventory = "0.3"
[target.'cfg(target_os = "wasi")'.dependencies]
wstd = "0.6"
[dev-dependencies]
sausage-interface = { path = "../interface", features = ["codegen"] }
trybuild = "1"
+2
View File
@@ -8,6 +8,8 @@ description = "Macros of the Sausage plugin SDK. Use them through `sausage-plugi
proc-macro = true
[dependencies]
sausage-interface = { path = "../../interface" }
serde_json = "1"
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
+72 -3
View File
@@ -47,6 +47,7 @@ fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> {
}
Ok(())
}
/// Derive serde and Draft 7 schemas for a documented type in the supported subset.
#[proc_macro_attribute]
pub fn schema(_: TokenStream, input: TokenStream) -> TokenStream {
let item = parse_macro_input!(input as Item);
@@ -110,12 +111,14 @@ pub fn schema(_: TokenStream, input: TokenStream) -> TokenStream {
})();
result.unwrap_or_else(syn::Error::into_compile_error).into()
}
/// Generate interface dispatch and discovery from documented methods.
#[proc_macro_attribute]
pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
let mut item = parse_macro_input!(input as ItemImpl);
let args = proc_macro2::TokenStream::from(args);
let result = (|| -> syn::Result<proc_macro2::TokenStream> {
let mut grace = None;
let mut endpoints: Vec<(syn::Ident, syn::Type)> = vec![];
let parser = syn::meta::parser(|m| {
if m.path.is_ident("grace_seconds") {
grace = Some(m.value()?.parse::<syn::LitInt>()?);
@@ -123,7 +126,15 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
} else if m.path.is_ident("endpoints") {
let content;
syn::parenthesized!(content in m.input);
let _ = content.parse::<proc_macro2::TokenStream>()?;
while !content.is_empty() {
let name = content.parse()?;
let _: syn::Token![=] = content.parse()?;
let ty = content.parse()?;
endpoints.push((name, ty));
if !content.is_empty() {
let _: syn::Token![,] = content.parse()?;
}
}
Ok(())
} else {
Err(m.error("unknown RPC option"))
@@ -179,7 +190,10 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
));
}
param = Some(p.ty.as_ref());
args.push(quote!(::sausage_plugin::serde_json::from_value(params)?));
args.push(quote!(
::sausage_plugin::serde_json::from_value(params)
.map_err(|e| ::sausage_plugin::Error::new(-32602, e.to_string()))?
));
}
}
let await_ = f.sig.asyncness.map(|_| quote!(.await));
@@ -225,22 +239,77 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
}
let grace =
grace.map(|n| quote!(doc["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);));
let names: Vec<_> = endpoints.iter().map(|(n, _)| n.to_string()).collect();
let types: Vec<_> = endpoints.iter().map(|(_, t)| t).collect();
Ok(quote! {#item impl ::sausage_plugin::Interface for #ty {
fn discover(&self)->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); #(#entries)* #grace doc}
fn endpoint_types(&self)->Vec<(::std::any::TypeId,String)>{vec![#((::std::any::TypeId::of::<#types>(),#names.into())),*]}
fn methods_document()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); #(#entries)* #grace ::sausage_plugin::finish_document(doc)}
fn describe()->::sausage_plugin::serde_json::Value {let mut doc=<Self as ::sausage_plugin::Interface>::methods_document(); #(let endpoint=<#types as ::sausage_plugin::Interface>::methods_document();doc["x-sausage-endpoints"][#names]=::sausage_plugin::serde_json::json!({"methods":endpoint["methods"]});doc["components"]["schemas"].as_object_mut().unwrap().extend(endpoint["components"]["schemas"].as_object().unwrap().clone());)* doc}
fn discover(&self)->::sausage_plugin::serde_json::Value {<Self as ::sausage_plugin::Interface>::describe()}
fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=::sausage_plugin::Result<::sausage_plugin::serde_json::Value>>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::new(-32602,"params must be an object"));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})}
#stop
}})
})();
result.unwrap_or_else(syn::Error::into_compile_error).into()
}
/// Generate a private client from `imports/<provider>.json`.
#[proc_macro]
pub fn import(input: TokenStream) -> TokenStream {
let name = parse_macro_input!(input as syn::LitStr);
let result = (|| -> Result<proc_macro2::TokenStream, String> {
let path = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?,
)
.join("imports")
.join(format!("{}.json", name.value()));
let contents =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let doc = serde_json::from_str(&contents).map_err(|e| e.to_string())?;
let code = sausage_interface::client_rs(&name.value(), &doc)?;
let generated: proc_macro2::TokenStream = code
.parse()
.map_err(|e: proc_macro2::LexError| e.to_string())?;
fn source_spans(
tokens: proc_macro2::TokenStream,
span: proc_macro2::Span,
) -> proc_macro2::TokenStream {
tokens
.into_iter()
.map(|mut token| {
if let proc_macro2::TokenTree::Group(group) = &token {
let mut replacement = proc_macro2::Group::new(
group.delimiter(),
source_spans(group.stream(), span),
);
replacement.set_span(span);
token = proc_macro2::TokenTree::Group(replacement);
}
token.set_span(span);
token
})
.collect()
}
let generated = source_spans(generated, name.span());
let path = path.to_str().ok_or("non-UTF8 import path")?;
Ok(
quote! {#generated ::sausage_plugin::inventory::submit! {::sausage_plugin::Import {name:#name,document:include_str!(#path)}}},
)
})();
result
.unwrap_or_else(|e| syn::Error::new(name.span(), e).into_compile_error())
.into()
}
/// Mark the method that handles `rpc.retired` after an upgrade.
#[proc_macro_attribute]
pub fn on_retired(_: TokenStream, input: TokenStream) -> TokenStream {
input
}
/// Mark the hook that runs at stdin EOF without a live call transport.
#[proc_macro_attribute]
pub fn on_stop(_: TokenStream, input: TokenStream) -> TokenStream {
input
}
/// Run an async test with the SDK runtime and the available core transport.
#[proc_macro_attribute]
pub fn test(_: TokenStream, input: TokenStream) -> TokenStream {
let mut f = parse_macro_input!(input as syn::ItemFn);
+78
View File
@@ -0,0 +1,78 @@
//! Outgoing core calls and notification callbacks.
use super::*;
/// An outgoing request builder.
pub struct Call {
method: String,
params: Value,
detach: bool,
notify: Option<Notify>,
pub(super) unlock: Option<std::rc::Rc<Lease>>,
}
/// Build an outgoing call with object parameters.
pub fn call(method: impl Into<String>, params: impl serde::Serialize) -> Call {
Call {
method: method.into(),
params: serde_json::to_value(params).expect("serializable params"),
detach: false,
notify: None,
unlock: None,
}
}
impl Call {
/// Detach the child process from its caller.
pub fn detach(mut self) -> Self {
self.detach = true;
self
}
/// Receive progress notifications before the result arrives.
pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self {
self.notify = Some(Box::new(f));
self
}
}
impl IntoFuture for Call {
type Output = Result<Value>;
type IntoFuture = LocalBoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
if !self.params.is_object() {
return Err(Error::new(-32602, "params must be an object"));
}
let (tx, rx) = oneshot::channel();
let id = STATE.with(|s| {
let mut s = s.borrow_mut();
if !s.active || s.stopping {
return Err(Error::new(
-32002,
"no core transport (or stdin reached EOF)",
));
}
s.next += 1;
let id = s.next;
s.pending.insert(
id,
Pending {
tx,
notify: self.notify,
unlock: self.unlock,
},
);
Ok(id)
})?;
let (method, params) = if self.detach {
(
"core.call".to_string(),
json!({"method":self.method,"params":self.params,"detach":true}),
)
} else {
(self.method, self.params)
};
if let Err(e) = send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})) {
STATE.with(|s| s.borrow_mut().pending.remove(&id));
return Err(e);
}
rx.await
.unwrap_or_else(|_| Err(Error::new(-32002, "core transport closed")))
})
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Schema contracts and OpenRPC document generation.
use crate::*;
use serde_json::{Value, json};
/// Types within the supported schema subset.
#[diagnostic::on_unimplemented(message = "this type is outside the Sausage schema subset")]
pub trait SchemaType: schemars::JsonSchema {}
/// Named parameter objects.
#[diagnostic::on_unimplemented(message = "RPC params must be a #[schema] struct with named fields")]
pub trait Params: SchemaType {}
macro_rules! primitive { ($($t:ty),*) => { $(impl SchemaType for $t {})* }; }
primitive!(
String,
bool,
u8,
u16,
u32,
u64,
usize,
i8,
i16,
i32,
i64,
isize,
f32,
f64,
()
);
impl<T: SchemaType> SchemaType for Vec<T> {}
impl<T: SchemaType> SchemaType for Option<T> {}
/// Create an OpenRPC document builder for generated interfaces.
#[doc(hidden)]
pub fn document(title: &str, version: &str) -> Value {
let mut doc = sausage_interface::Document::new(title, version).into_value();
doc["x-sdk-input"] = json!({});
doc["x-sdk-output"] = json!({});
doc
}
/// Add a method with independent deserialize and serialize contracts.
#[doc(hidden)]
pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
fn generator(serialize: bool) -> schemars::SchemaGenerator {
let mut settings = schemars::generate::SchemaSettings::draft07();
settings.definitions_path = if serialize {
"/sdk-output"
} else {
"/sdk-input"
}
.into();
if serialize {
settings.for_serialize()
} else {
settings.for_deserialize()
}
.into_generator()
}
let mut input = generator(false);
let params_ref = serde_json::to_value(input.subschema_for::<P>()).unwrap();
let definitions = input.take_definitions(true);
let schema = &definitions[P::schema_name().as_ref()];
let params:Vec<_>=schema["properties"].as_object().into_iter().flatten().map(|(name,field)| {
json!({
"name": name,
"description": field.get("description").cloned().unwrap_or(Value::Null),
"required": schema["required"].as_array().is_some_and(|required|required.contains(&json!(name))),
"schema": field,
})
}).collect();
doc["x-sdk-input"]
.as_object_mut()
.unwrap()
.extend(definitions);
let mut output = generator(true);
let result_schema = serde_json::to_value(output.subschema_for::<R>()).unwrap();
doc["x-sdk-output"]
.as_object_mut()
.unwrap()
.extend(output.take_definitions(true));
doc["methods"].as_array_mut().unwrap().push(json!({
"name":name,
"summary":docs.split("\n\n").next().unwrap_or_default(),
"description":docs,
"paramStructure":"by-name",
"params":params,
"result":{"name":"result","schema":result_schema},
"x-sausage-params":params_ref,
}));
}
/// Resolve shared schema names after all methods contribute their contracts.
#[doc(hidden)]
pub fn finish_document(mut doc: Value) -> Value {
let input = doc.as_object_mut().unwrap().remove("x-sdk-input").unwrap();
let output = doc.as_object_mut().unwrap().remove("x-sdk-output").unwrap();
fn refs(value: &mut Value, names: &std::collections::HashMap<String, String>) {
match value {
Value::Object(map) => {
if let Some(Value::String(reference)) = map.get_mut("$ref") {
if let Some(name) = reference.strip_prefix("#/sdk-input/") {
*reference = format!("#/components/schemas/{name}");
} else if let Some(name) = reference.strip_prefix("#/sdk-output/") {
*reference = format!(
"#/components/schemas/{}",
names.get(name).map(String::as_str).unwrap_or(name)
);
}
}
for child in map.values_mut() {
refs(child, names);
}
}
Value::Array(values) => {
for child in values {
refs(child, names);
}
}
_ => {}
}
}
let mut names = std::collections::HashMap::new();
for (name, schema) in output.as_object().unwrap() {
if let Some(other) = input.get(name) {
let mut a = schema.clone();
let mut b = other.clone();
refs(&mut a, &names);
refs(&mut b, &names);
if a != b {
names.insert(name.clone(), format!("{name}Result"));
}
}
}
// Propagate differences through references to nested contracts.
loop {
let before = names.len();
for (name, schema) in output.as_object().unwrap() {
if let Some(other) = input.get(name) {
let mut a = schema.clone();
let mut b = other.clone();
refs(&mut a, &names);
refs(&mut b, &names);
if a != b {
names.insert(name.clone(), format!("{name}Result"));
}
}
}
if names.len() == before {
break;
}
}
doc["components"]["schemas"] = input;
for (name, schema) in output.as_object().unwrap() {
let name = names.get(name).unwrap_or(name);
doc["components"]["schemas"]
.as_object_mut()
.unwrap()
.insert(name.clone(), schema.clone());
}
refs(&mut doc, &names);
for import in inventory::iter::<crate::Import> {
doc["x-sausage-imports"][import.name] =
serde_json::from_str(import.document).expect("validated import document");
}
doc
}
/// Empty parameters for methods without arguments.
#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct Empty {}
impl SchemaType for Empty {}
impl Params for Empty {}
+247
View File
@@ -0,0 +1,247 @@
//! TCP endpoint publication, verified connections, and per-request notifications.
use crate::*;
type Notify = Box<dyn FnMut(&str, Value)>;
use serde_json::Value;
#[cfg(target_os = "wasi")]
use serde_json::json;
use std::{future::IntoFuture, marker::PhantomData, path::Path, rc::Rc};
/// A verified endpoint address and identity.
#[derive(Clone)]
#[cfg_attr(not(target_os = "wasi"), allow(dead_code))]
pub struct Endpoint {
address: String,
owner: String,
interface: String,
}
impl Endpoint {
/// Require the interface expected by a generated client.
pub fn require_interface(&self, name: &str) -> Result<()> {
if self.interface == name {
Ok(())
} else {
Err(Error::new(-32002, "endpoint interface mismatch"))
}
}
/// Build a typed endpoint request.
pub fn call<T>(&self, method: &str, params: impl serde::Serialize) -> EndpointCall<T> {
EndpointCall {
endpoint: self.clone(),
method: method.into(),
params: serde_json::to_value(params).expect("serializable params"),
notify: None,
result: PhantomData,
}
}
}
/// A lazy endpoint request with optional progress callbacks.
pub struct EndpointCall<T> {
endpoint: Endpoint,
method: String,
params: Value,
notify: Option<Notify>,
result: PhantomData<T>,
}
impl<T> EndpointCall<T> {
/// Receive progress from this endpoint handler.
pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self {
self.notify = Some(Box::new(f));
self
}
}
impl<T: serde::de::DeserializeOwned + 'static> IntoFuture for EndpointCall<T> {
type Output = Result<T>;
type IntoFuture = futures::future::LocalBoxFuture<'static, Result<T>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
Ok(serde_json::from_value(
endpoint_request(self.endpoint, &self.method, self.params, self.notify).await?,
)?)
})
}
}
/// Publish a declared interface and keep its listener alive until stdin EOF.
#[cfg(target_os = "wasi")]
pub async fn listen<T: Interface>(path: impl AsRef<Path>, value: Rc<T>) -> Result<()> {
use wstd::iter::AsyncIterator;
let name = crate::transport::endpoint_name::<T>()?;
let listener = wstd::net::TcpListener::bind("127.0.0.1:0").await?;
write_atomic(
path,
format!("tcp:{} {} {}\n", listener.local_addr()?, pid(), name),
)?;
crate::transport::endpoint_task(async move {
let mut incoming = listener.incoming();
while let Some(Ok(stream)) = incoming.next().await {
let value = value.clone();
let name = name.clone();
crate::transport::endpoint_task(async move {
let _ = serve_connection(stream, value, name).await;
});
}
});
Ok(())
}
/// Publish an endpoint. Native programs require the WASI runtime.
#[cfg(not(target_os = "wasi"))]
pub async fn listen<T: Interface>(_path: impl AsRef<Path>, _value: Rc<T>) -> Result<()> {
crate::transport::endpoint_name::<T>()?;
Err(Error::new(-32002, "endpoints require WASI"))
}
/// Read an endpoint file and verify its owner before returning a client.
#[cfg(target_os = "wasi")]
pub async fn connect(path: impl AsRef<Path>) -> Result<Endpoint> {
let mut attempts = 0;
let contents = loop {
match std::fs::read_to_string(path.as_ref()) {
Ok(s) => break s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && attempts < 20 => {
attempts += 1;
wstd::task::sleep(wstd::time::Duration::from_millis(25)).await;
}
Err(e) => return Err(e.into()),
}
};
let words: Vec<_> = contents.split_whitespace().collect();
if words.len() != 3 {
return Err(Error::new(-32602, "invalid endpoint file"));
}
let endpoint = Endpoint {
address: words[0]
.strip_prefix("tcp:")
.ok_or_else(|| Error::new(-32602, "invalid endpoint address"))?
.into(),
owner: words[1].into(),
interface: words[2].into(),
};
let _ = handshake(&endpoint).await?;
Ok(endpoint)
}
/// Connect to an endpoint. Native programs require the WASI runtime.
#[cfg(not(target_os = "wasi"))]
pub async fn connect(_path: impl AsRef<Path>) -> Result<Endpoint> {
Err(Error::new(-32002, "endpoints require WASI"))
}
#[cfg(target_os = "wasi")]
async fn send(stream: &mut impl wstd::io::AsyncWrite, value: Value) -> Result<()> {
stream.write_all(format!("{value}\n").as_bytes()).await?;
stream.flush().await?;
Ok(())
}
#[cfg(target_os = "wasi")]
async fn handshake(endpoint: &Endpoint) -> Result<wstd::net::TcpStream> {
let mut stream = wstd::net::TcpStream::connect(endpoint.address.as_str()).await?;
send(
&mut stream,
json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}}),
)
.await?;
let bytes = crate::lines::Lines::default()
.next(&mut stream)
.await?
.ok_or_else(|| Error::new(-32002, "endpoint closed"))?;
let reply: Value = serde_json::from_slice(&bytes)?;
let result = &reply["result"];
let plugin = endpoint
.owner
.len()
.checked_sub(37)
.and_then(|end| endpoint.owner.get(..end));
if result["owner"] != endpoint.owner
|| result["interface"] != endpoint.interface
|| result["plugin"].as_str() != plugin
{
return Err(Error::new(-32002, "endpoint identity mismatch"));
}
Ok(stream)
}
#[cfg(target_os = "wasi")]
async fn endpoint_request(
endpoint: Endpoint,
method: &str,
params: Value,
mut notify: Option<Notify>,
) -> Result<Value> {
let mut stream = handshake(&endpoint).await?;
let mut lines = crate::lines::Lines::default();
send(
&mut stream,
json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}),
)
.await?;
while let Some(bytes) = lines.next(&mut stream).await? {
let value: Value = serde_json::from_slice(&bytes)?;
if value["method"] == "core.notify" {
if let Some(f) = &mut notify {
f(
value["params"]["name"].as_str().unwrap_or_default(),
value["params"]["params"].clone(),
);
}
} else if value["id"] == 1 {
if let Some(error) = value.get("error") {
return Err(serde_json::from_value(error.clone())?);
}
return Ok(value["result"].clone());
}
}
Err(Error::new(-32002, "endpoint closed before response"))
}
#[cfg(not(target_os = "wasi"))]
async fn endpoint_request(
_endpoint: Endpoint,
_method: &str,
_params: Value,
_notify: Option<Notify>,
) -> Result<Value> {
Err(Error::new(-32002, "endpoints require WASI"))
}
#[cfg(target_os = "wasi")]
async fn serve_connection<T: Interface>(
stream: wstd::net::TcpStream,
value: Rc<T>,
name: String,
) -> Result<()> {
use futures::{FutureExt, StreamExt};
let (mut reader, mut writer) = stream.split();
let mut lines = crate::lines::Lines::default();
while let Some(bytes) = lines.next(&mut reader).await? {
let request: Value = serde_json::from_slice(&bytes)?;
if request["jsonrpc"] != "2.0" {
continue;
}
let method = request["method"].as_str().unwrap_or_default();
let (tx, mut rx) = futures::channel::mpsc::unbounded();
let ctx = Ctx {
sink: Some(tx),
..Ctx::default()
};
let handler=async {
if method=="rpc.endpoint" {Ok(json!({"owner":pid(),"plugin":std::env::var("SAUSAGE_PLUGIN").unwrap_or_default(),"interface":name}))}
else {value.dispatch(method,request.get("params").cloned().unwrap_or(json!({})),&ctx).await}
}.fuse();
futures::pin_mut!(handler);
let result = loop {
futures::select! {result=handler=>break result,notification=rx.next().fuse()=>{if let Some(notification)=notification {send(&mut writer,notification).await?;}}}
};
while let Ok(notification) = rx.try_recv() {
send(&mut writer, notification).await?;
}
if let Some(id) = request.get("id") {
let response = match result {
Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}),
Err(error) => json!({"jsonrpc":"2.0","id":id,"error":error}),
};
send(&mut writer, response).await?;
}
if ctx.exit.get() {
crate::transport::exit();
break;
}
}
Ok(())
}
+42
View File
@@ -0,0 +1,42 @@
//! JSON-RPC failures and conversions from local errors.
use serde_json::Value;
/// An SDK operation that can fail.
pub type Result<T> = std::result::Result<T, Error>;
/// A JSON-RPC failure.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Error {
/// The JSON-RPC error code.
pub code: i64,
/// The diagnostic message.
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
/// Optional structured error details.
pub data: Option<Value>,
}
impl Error {
/// Construct an error without structured details.
pub fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
data: None,
}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::new(-32603, e.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::new(-32603, e.to_string())
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Process identity and atomic state-file helpers.
use crate::Result;
use std::path::{Path, PathBuf};
/// Return the process ID from `SAUSAGE_PID`.
pub fn pid() -> String {
std::env::var("SAUSAGE_PID").unwrap_or_default()
}
/// Return the persistent state directory of this plugin.
pub fn state_dir() -> PathBuf {
PathBuf::from("/state").join(std::env::var("SAUSAGE_PLUGIN").unwrap_or_default())
}
/// Replace a file atomically after a complete temporary write.
pub fn write_atomic(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let path = path.as_ref();
let temp = path.with_extension(format!("tmp-{}", pid()));
std::fs::write(&temp, bytes)?;
std::fs::rename(temp, path)?;
Ok(())
}
+46
View File
@@ -0,0 +1,46 @@
//! Typed outgoing calls and statically registered imported interfaces.
use crate::{Call, Result};
use serde_json::Value;
use std::{future::IntoFuture, marker::PhantomData};
/// An imported provider document registered by `import!`.
#[doc(hidden)]
pub struct Import {
/// The provider name.
pub name: &'static str,
/// Its complete OpenRPC document.
pub document: &'static str,
}
inventory::collect!(Import);
/// An outgoing call whose result decodes to a generated Rust type.
pub struct TypedCall<T> {
call: Call,
result: PhantomData<T>,
}
impl<T> TypedCall<T> {
/// Wrap an untyped call without sending it yet.
pub fn new(call: Call) -> Self {
Self {
call,
result: PhantomData,
}
}
/// Detach the child process from its caller.
pub fn detach(mut self) -> Self {
self.call = self.call.detach();
self
}
/// Receive progress notifications while the call runs.
pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self {
self.call = self.call.on_notify(f);
self
}
}
impl<T: serde::de::DeserializeOwned + 'static> IntoFuture for TypedCall<T> {
type Output = Result<T>;
type IntoFuture = futures::future::LocalBoxFuture<'static, Result<T>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move { Ok(serde_json::from_value(self.call.await?)?) })
}
}
+64 -632
View File
@@ -1,633 +1,65 @@
//! JSON-RPC transport, lifetime, and helpers for single-threaded Sausage plugins.
use futures::{channel::oneshot, future::LocalBoxFuture};
pub use sausage_plugin_macros::{on_retired, on_stop, rpc, schema, test};
use serde_json::{Value, json};
use std::{
cell::RefCell,
collections::HashMap,
future::{Future, IntoFuture},
io::Write,
path::{Path, PathBuf},
};
pub use {schemars, serde, serde_json};
pub type Result<T> = std::result::Result<T, Error>;
/// A JSON-RPC failure.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Error {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl Error {
pub fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
data: None,
}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::new(-32603, e.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::new(-32602, e.to_string())
}
}
/// Types within the supported schema subset.
#[diagnostic::on_unimplemented(message = "this type is outside the Sausage schema subset")]
pub trait SchemaType: schemars::JsonSchema {}
/// Named parameter objects.
#[diagnostic::on_unimplemented(message = "RPC params must be a #[schema] struct with named fields")]
pub trait Params: SchemaType {}
macro_rules! primitive { ($($t:ty),*) => { $(impl SchemaType for $t {})* }; }
primitive!(
String,
bool,
u8,
u16,
u32,
u64,
usize,
i8,
i16,
i32,
i64,
isize,
f32,
f64,
()
);
impl<T: SchemaType> SchemaType for Vec<T> {}
impl<T: SchemaType> SchemaType for Option<T> {}
/// Generated main-interface dispatch.
pub trait Interface: 'static {
fn discover(&self) -> Value;
fn dispatch<'a>(
&'a self,
method: &'a str,
params: Value,
ctx: &'a Ctx,
) -> LocalBoxFuture<'a, Result<Value>>;
fn stop(&self) -> LocalBoxFuture<'_, ()> {
Box::pin(async {})
}
}
/// Context of an incoming call.
#[derive(Default)]
pub struct Ctx {
exit: std::cell::Cell<bool>,
}
impl Ctx {
pub fn notify(&self, name: &str, params: impl serde::Serialize) -> Result<()> {
notify("core.notify", json!({"name":name,"params":params}))
}
pub fn exit_after_response(&self) {
self.exit.set(true);
}
}
type Notify = Box<dyn FnMut(&str, Value)>;
struct Pending {
tx: oneshot::Sender<Result<Value>>,
notify: Option<Notify>,
unlock: Option<std::rc::Rc<Lease>>,
}
struct Lease {
path: String,
held: std::cell::Cell<bool>,
}
impl Drop for Lease {
fn drop(&mut self) {
if self.held.get() {
let _ = notify("core.unlock", json!({"path":self.path}));
}
}
}
#[derive(Default)]
struct State {
active: bool,
stopping: bool,
next: u64,
pending: HashMap<u64, Pending>,
#[cfg(target_os = "wasi")]
tasks: usize,
#[cfg(target_os = "wasi")]
wake: Option<std::task::Waker>,
#[cfg(target_os = "wasi")]
exit: bool,
}
thread_local! { static STATE: RefCell<State> = RefCell::new(State::default()); }
fn send(value: Value) -> Result<()> {
let bytes = format!("\n{value}\n").into_bytes();
let written = std::io::stdout().write(&bytes)?;
if written != bytes.len() {
return Err(Error::new(-32603, "short protocol write"));
}
Ok(())
}
fn notify(method: &str, params: Value) -> Result<()> {
if !STATE.with(|s| s.borrow().active) {
return Err(Error::new(-32002, "no core transport"));
}
send(json!({"jsonrpc":"2.0","method":method,"params":params}))
}
/// An outgoing request builder.
pub struct Call {
method: String,
params: Value,
detach: bool,
notify: Option<Notify>,
unlock: Option<std::rc::Rc<Lease>>,
}
pub fn call(method: impl Into<String>, params: impl serde::Serialize) -> Call {
Call {
method: method.into(),
params: serde_json::to_value(params).expect("serializable params"),
detach: false,
notify: None,
unlock: None,
}
}
impl Call {
pub fn detach(mut self) -> Self {
self.detach = true;
self
}
pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self {
self.notify = Some(Box::new(f));
self
}
}
impl IntoFuture for Call {
type Output = Result<Value>;
type IntoFuture = LocalBoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
if !self.params.is_object() {
return Err(Error::new(-32602, "params must be an object"));
}
let (tx, rx) = oneshot::channel();
let id = STATE.with(|s| {
let mut s = s.borrow_mut();
if !s.active || s.stopping {
return Err(Error::new(
-32002,
"no core transport (or stdin reached EOF)",
));
}
s.next += 1;
let id = s.next;
s.pending.insert(
id,
Pending {
tx,
notify: self.notify,
unlock: self.unlock,
},
);
Ok(id)
})?;
let (method, params) = if self.detach {
(
"core.call".to_string(),
json!({"method":self.method,"params":self.params,"detach":true}),
)
} else {
(self.method, self.params)
};
if let Err(e) = send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})) {
STATE.with(|s| s.borrow_mut().pending.remove(&id));
return Err(e);
}
rx.await
.unwrap_or_else(|_| Err(Error::new(-32002, "core transport closed")))
})
}
}
/// An advisory lock acquisition.
pub struct Lock {
path: String,
wait: bool,
}
pub fn lock(path: impl AsRef<Path>) -> Lock {
Lock {
path: path.as_ref().to_string_lossy().into_owned(),
wait: true,
}
}
impl Lock {
pub fn try_now(mut self) -> Self {
self.wait = false;
self
}
}
/// Releases its lock on drop, unless kept until process exit.
pub struct Guard {
path: Option<String>,
}
impl Guard {
pub fn keep(mut self) {
self.path.take();
}
pub fn held(&self) -> bool {
self.path.is_some()
}
}
impl Drop for Guard {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
let _ = notify("core.unlock", json!({"path":path}));
}
}
}
impl IntoFuture for Lock {
type Output = Result<Guard>;
type IntoFuture = LocalBoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
let lease = std::rc::Rc::new(Lease {
path: self.path.clone(),
held: std::cell::Cell::new(false),
});
let mut c = call("core.lock", json!({"path":self.path,"wait":self.wait}));
c.unlock = Some(lease.clone());
let result = c.await?;
lease.held.set(false);
Ok(Guard {
path: if result["held"] == true {
Some(self.path)
} else {
None
},
})
})
}
}
pub fn pid() -> String {
std::env::var("SAUSAGE_PID").unwrap_or_default()
}
pub fn state_dir() -> PathBuf {
PathBuf::from("/state").join(std::env::var("SAUSAGE_PLUGIN").unwrap_or_default())
}
pub fn write_atomic(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let path = path.as_ref();
let temp = path.with_extension(format!("tmp-{}", pid()));
std::fs::write(&temp, bytes)?;
std::fs::rename(temp, path)?;
Ok(())
}
#[cfg(target_os = "wasi")]
pub fn block_on<F: Future>(f: F) -> F::Output {
wstd::runtime::block_on(f)
}
#[cfg(not(target_os = "wasi"))]
pub fn block_on<F: Future>(f: F) -> F::Output {
futures::executor::block_on(f)
}
/// Run an SDK test with a core transport when the WASI runner supplies one.
#[cfg(target_os = "wasi")]
pub fn test_run<F: Future>(f: F) -> F::Output {
block_on(async {
use wstd::io::AsyncRead;
STATE.with(|s| s.borrow_mut().active = true);
let reader = wstd::runtime::spawn(async {
let mut input = wstd::io::stdin();
let mut line = Vec::new();
loop {
let mut byte = [0];
if !matches!(input.read(&mut byte).await, Ok(1)) {
break;
}
if byte[0] == b'\n' {
if let Ok(v) = serde_json::from_slice::<Value>(&line) {
if v["jsonrpc"] == "2.0" {
receive(v);
}
}
line.clear();
} else {
line.push(byte[0]);
}
}
STATE.with(|s| {
let mut s = s.borrow_mut();
s.stopping = true;
s.pending.clear();
});
});
let result = f.await;
drop(reader);
STATE.with(|s| {
let mut s = s.borrow_mut();
s.active = false;
s.pending.clear();
});
result
})
}
#[cfg(not(target_os = "wasi"))]
pub fn test_run<F: Future>(f: F) -> F::Output {
block_on(f)
}
/// Register a task that keeps the process alive.
#[cfg(target_os = "wasi")]
pub fn background(f: impl Future<Output = ()> + 'static) {
STATE.with(|s| s.borrow_mut().tasks += 1);
wstd::runtime::spawn(async move {
f.await;
STATE.with(|s| {
let mut s = s.borrow_mut();
s.tasks -= 1;
if let Some(w) = s.wake.take() {
w.wake();
}
});
})
.detach();
}
#[cfg(not(target_os = "wasi"))]
pub fn background(_f: impl Future<Output = ()> + 'static) {
panic!("background requires the WASI runtime");
}
//! Write single-threaded WASI plugins for Sausage.
//!
//! Each core call starts a process. Keep its initial value cheap to construct.
//! Declare documented parameter and result types with [`macro@schema`].
//! Declare methods with [`macro@rpc`], then serve them with [`run`].
//!
//! ```no_run
//! use sausage_plugin::{rpc, schema, Result};
//! struct Calculator;
//! /// The numbers to add.
//! #[schema]
//! struct AddParams {
//! /// The first number.
//! a: i32,
//! /// The second number.
//! b: i32,
//! }
//! #[rpc]
//! impl Calculator {
//! /// Add two numbers.
//! fn add(&self, p: AddParams) -> Result<i32> { Ok(p.a + p.b) }
//! }
//! fn main() { sausage_plugin::run(Calculator); }
//! ```
//!
//! Use [`call`] for outgoing calls. Await its builder for the JSON result.
//! Use `detach()` to detach the child and `on_notify(f)` to receive progress.
//! Use [`Ctx::notify`] to report progress to your caller.
//! Use [`Ctx::exit_after_response`] to end the process after the response.
//!
//! [`background`] keeps the process alive while a task runs.
//! At stdin EOF, pending calls fail and the optional [`macro@on_stop`] hook runs.
//! The stop hook can write files and send notifications, but cannot make calls.
//! [`macro@on_retired`] handles `rpc.retired` after an upgrade.
//!
//! Use [`lock`] before shared state changes. A guard releases its lock on drop.
//! `try_now()` returns a guard whose `held()` method reports success.
//! `keep()` retains a lock until process exit.
//! [`state_dir`], [`write_atomic`], and [`pid`] provide filesystem and identity helpers.
//! Use [`macro@test`] for async tests. Native tests fail clearly without a core transport.
//! [`macro@import`] reads `imports/<provider>.json` and generates a private typed client.
//! Its functions return [`TypedCall`] builders with progress and detach options.
//! Declare endpoint types with `#[rpc(endpoints(control = Control))]`.
//! [`listen`] publishes a declared interface at an endpoint file.
//! [`connect`] verifies its owner before it returns an [`Endpoint`].
//! Endpoint calls report progress through [`EndpointCall::on_notify`].
//! Log with `eprintln!`. Stdout belongs to JSON-RPC.
#![warn(missing_docs)]
mod discovery;
mod endpoints;
mod error;
mod helpers;
mod imports;
mod lines;
mod transport;
pub use discovery::*;
pub use endpoints::*;
pub use error::*;
pub use helpers::*;
pub use imports::*;
#[doc(hidden)]
pub fn receive(value: Value) {
if value["method"] == "core.notify" {
if let Some(id) = value["params"]["call"].as_u64() {
let callback = STATE.with(|s| {
s.borrow_mut()
.pending
.get_mut(&id)
.and_then(|p| p.notify.take())
});
if let Some(mut f) = callback {
f(
value["params"]["name"].as_str().unwrap_or_default(),
value["params"]["params"].clone(),
);
STATE.with(|s| {
if let Some(p) = s.borrow_mut().pending.get_mut(&id) {
p.notify = Some(f);
}
});
}
}
return;
}
let pending = value["id"]
.as_u64()
.and_then(|id| STATE.with(|s| s.borrow_mut().pending.remove(&id)));
if let Some(p) = pending {
let result = if value.get("error").is_some() {
serde_json::from_value(value["error"].clone()).map_or_else(|e| Err(e.into()), Err)
} else {
Ok(value["result"].clone())
};
if let Some(lease) = &p.unlock {
lease
.held
.set(result.as_ref().is_ok_and(|v| v["held"] == true));
}
let _ = p.tx.send(result);
} else {
eprintln!("note: response with unknown ID");
}
}
/// Serve the main interface until its work ends or stdin reaches EOF.
#[cfg(target_os = "wasi")]
pub fn run<T: Interface>(value: T) {
block_on(serve(value));
}
#[cfg(not(target_os = "wasi"))]
pub fn run<T: Interface>(_value: T) {
panic!("run requires WASI stdin/stdout");
}
#[cfg(target_os = "wasi")]
async fn serve<T: Interface>(value: T) {
use std::{rc::Rc, task::Poll};
use wstd::io::AsyncRead;
STATE.with(|s| {
*s.borrow_mut() = State {
active: true,
..State::default()
}
});
let value = Rc::new(value);
let mut input = wstd::io::stdin();
let mut line = Vec::new();
let mut started = false;
loop {
let read = async {
loop {
let mut byte = [0];
if input.read(&mut byte).await? == 0 {
return Ok::<_, std::io::Error>(None);
}
if byte[0] == b'\n' {
return Ok(Some(std::mem::take(&mut line)));
}
line.push(byte[0]);
}
};
let idle = futures::future::poll_fn(|cx| {
STATE.with(|s| {
let mut s = s.borrow_mut();
s.wake = Some(cx.waker().clone());
if started && (s.tasks == 0 || s.exit) {
Poll::Ready(())
} else {
Poll::Pending
}
})
});
futures::pin_mut!(read, idle);
let bytes = match futures::future::select(read, idle).await {
futures::future::Either::Left((Ok(Some(b)), _)) => b,
futures::future::Either::Left((_, _)) => {
STATE.with(|s| {
let mut s = s.borrow_mut();
s.stopping = true;
s.pending.clear();
});
value.stop().await;
break;
}
futures::future::Either::Right(_) => break,
};
let Ok(msg) = serde_json::from_slice::<Value>(&bytes) else {
continue;
};
if msg["jsonrpc"] != "2.0" {
continue;
}
if let Some(method) = msg["method"].as_str() {
if method == "core.notify" {
receive(msg);
continue;
}
started = true;
let value = value.clone();
let method = method.to_owned();
background(async move {
let ctx = Ctx::default();
let result = if method == "rpc.discover" {
Ok(value.discover())
} else {
value
.dispatch(
&method,
msg.get("params").cloned().unwrap_or(json!({})),
&ctx,
)
.await
};
if let Some(id) = msg.get("id") {
let response = match result {
Ok(v) => json!({"jsonrpc":"2.0","id":id,"result":v}),
Err(e) => json!({"jsonrpc":"2.0","id":id,"error":e}),
};
let _ = send(response);
}
if ctx.exit.get() {
STATE.with(|s| s.borrow_mut().exit = true);
}
});
} else {
receive(msg);
}
}
STATE.with(|s| {
let mut s = s.borrow_mut();
s.active = false;
s.pending.clear();
});
}
/// Create a shared OpenRPC document.
pub fn document(title: &str, version: &str) -> Value {
sausage_interface::Document::new(title, version).into_value()
}
/// Build OpenRPC entries with separate deserialize and serialize contracts.
pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
fn generator(serialize: bool) -> schemars::SchemaGenerator {
let mut s = schemars::generate::SchemaSettings::draft07();
s.definitions_path = "/components/schemas".into();
if serialize {
s.for_serialize()
} else {
s.for_deserialize()
}
.into_generator()
}
let mut p = generator(false);
let pref = serde_json::to_value(p.subschema_for::<P>()).unwrap();
let mut defs = p.take_definitions(true);
for schema in defs.values_mut() {
normalize_enums(schema);
}
let ps = &defs[P::schema_name().as_ref()];
let params:Vec<_>=ps["properties"].as_object().into_iter().flatten().map(|(name,s)| json!({"name":name,"description":s.get("description").cloned().unwrap_or(Value::Null),"required":ps["required"].as_array().is_some_and(|a|a.contains(&json!(name))),"schema":s})).collect();
doc["components"]["schemas"]
.as_object_mut()
.unwrap()
.extend(defs);
let mut r = generator(true);
let mut rs = serde_json::to_value(r.subschema_for::<R>()).unwrap();
fn result_refs(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(Value::String(reference)) = map.get_mut("$ref")
&& reference.starts_with("#/components/schemas/")
{
reference.push_str("Result");
}
for child in map.values_mut() {
result_refs(child);
}
}
Value::Array(values) => {
for child in values {
result_refs(child);
}
}
_ => {}
}
}
result_refs(&mut rs);
for (name, mut schema) in r.take_definitions(true) {
normalize_enums(&mut schema);
result_refs(&mut schema);
doc["components"]["schemas"]
.as_object_mut()
.unwrap()
.insert(format!("{name}Result"), schema);
}
doc["methods"].as_array_mut().unwrap().push(json!({"name":name,"summary":docs.split("\n\n").next().unwrap_or_default(),"description":docs,"paramStructure":"by-name","params":params,"result":{"name":"result","schema":rs},"x-sausage-params":pref}));
}
// Schemars represents documented unit variants as oneOf/const. Export string enums instead.
fn normalize_enums(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(variants) = map.get("oneOf").and_then(Value::as_array) {
let values: Option<Vec<Value>> = variants
.iter()
.map(|v| {
if let Some(s) = v.get("const").filter(|v| v.is_string()) {
Some(vec![s.clone()])
} else {
v.get("enum")
.and_then(Value::as_array)
.filter(|a| a.iter().all(Value::is_string))
.cloned()
}
})
.collect::<Option<Vec<Vec<Value>>>>()
.map(|v| v.into_iter().flatten().collect());
if let Some(values) = values {
map.remove("oneOf");
map.insert("type".into(), json!("string"));
map.insert("enum".into(), json!(values));
}
}
for child in map.values_mut() {
normalize_enums(child);
}
}
Value::Array(values) => {
for child in values {
normalize_enums(child);
}
}
_ => {}
}
}
/// Empty parameters for methods without arguments.
#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct Empty {}
impl SchemaType for Empty {}
impl Params for Empty {}
pub use inventory;
pub use sausage_plugin_macros::{import, on_retired, on_stop, rpc, schema, test};
pub use transport::*;
pub use {schemars, serde, serde_json};
+29
View File
@@ -0,0 +1,29 @@
//! Buffered newline framing shared by stdin and endpoint connections.
#[cfg(target_os = "wasi")]
#[derive(Default)]
pub(crate) struct Lines {
bytes: Vec<u8>,
}
#[cfg(target_os = "wasi")]
impl Lines {
pub(crate) async fn next(
&mut self,
input: &mut impl wstd::io::AsyncRead,
) -> std::io::Result<Option<Vec<u8>>> {
loop {
if let Some(end) = self.bytes.iter().position(|b| *b == b'\n') {
return Ok(Some(self.bytes.drain(..=end).collect()));
}
let mut buffer = [0; 8192];
let count = input.read(&mut buffer).await?;
if count == 0 {
return Ok(if self.bytes.is_empty() {
None
} else {
Some(std::mem::take(&mut self.bytes))
});
}
self.bytes.extend_from_slice(&buffer[..count]);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Advisory lock acquisition and guard lifetime.
use super::*;
/// An advisory lock acquisition.
pub struct Lock {
path: String,
wait: bool,
}
/// Acquire an advisory lock on a path.
pub fn lock(path: impl AsRef<Path>) -> Lock {
Lock {
path: path.as_ref().to_string_lossy().into_owned(),
wait: true,
}
}
impl Lock {
/// Return immediately, without waiting for a held lock.
pub fn try_now(mut self) -> Self {
self.wait = false;
self
}
}
/// Releases its lock on drop, unless kept until process exit.
pub struct Guard {
path: Option<String>,
}
impl Guard {
/// Hold this lock until process exit.
pub fn keep(mut self) {
self.path.take();
}
/// Report whether this guard holds the lock.
pub fn held(&self) -> bool {
self.path.is_some()
}
}
impl Drop for Guard {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
let _ = notify("core.unlock", json!({"path":path}));
}
}
}
impl IntoFuture for Lock {
type Output = Result<Guard>;
type IntoFuture = LocalBoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
let lease = std::rc::Rc::new(Lease {
path: self.path.clone(),
held: std::cell::Cell::new(false),
});
let mut c = call("core.lock", json!({"path":self.path,"wait":self.wait}));
c.unlock = Some(lease.clone());
let result = c.await?;
lease.held.set(false);
Ok(Guard {
path: if result["held"] == true {
Some(self.path)
} else {
None
},
})
})
}
}
+379
View File
@@ -0,0 +1,379 @@
//! Stdin routing, stdout framing, and process lifetime.
#[cfg(target_os = "wasi")]
use crate::lines::Lines;
use crate::*;
use futures::{channel::oneshot, future::LocalBoxFuture};
use serde_json::{Value, json};
use std::{
cell::RefCell,
collections::HashMap,
future::{Future, IntoFuture},
io::Write,
path::Path,
};
#[path = "calls.rs"]
mod calls;
#[path = "locks.rs"]
mod locks;
pub use calls::*;
pub use locks::*;
/// Generated main-interface dispatch.
pub trait Interface: 'static {
/// Describe this interface without constructing its value.
fn describe() -> Value
where
Self: Sized;
/// Describe only this type's methods, without endpoint declarations.
fn methods_document() -> Value
where
Self: Sized;
/// List the declared endpoint types and their names.
fn endpoint_types(&self) -> Vec<(std::any::TypeId, String)> {
vec![]
}
/// Describe this interface as OpenRPC.
fn discover(&self) -> Value;
/// Dispatch a request to a generated handler.
fn dispatch<'a>(
&'a self,
method: &'a str,
params: Value,
ctx: &'a Ctx,
) -> LocalBoxFuture<'a, Result<Value>>;
/// Run the EOF hook.
fn stop(&self) -> LocalBoxFuture<'_, ()> {
Box::pin(async {})
}
}
/// Context of an incoming call.
#[derive(Default)]
pub struct Ctx {
pub(crate) exit: std::cell::Cell<bool>,
pub(crate) sink: Option<futures::channel::mpsc::UnboundedSender<Value>>,
}
impl Ctx {
/// Send progress to the caller of this request.
pub fn notify(&self, name: &str, params: impl serde::Serialize) -> Result<()> {
let params = json!({"name":name,"params":params});
if let Some(sink) = &self.sink {
sink.unbounded_send(json!({"jsonrpc":"2.0","method":"core.notify","params":params}))
.map_err(|_| Error::new(-32002, "endpoint closed"))
} else {
notify("core.notify", params)
}
}
/// End the process after this request receives its response.
pub fn exit_after_response(&self) {
self.exit.set(true);
}
}
type Notify = Box<dyn FnMut(&str, Value)>;
struct Pending {
tx: oneshot::Sender<Result<Value>>,
notify: Option<Notify>,
unlock: Option<std::rc::Rc<Lease>>,
}
struct Lease {
path: String,
held: std::cell::Cell<bool>,
}
impl Drop for Lease {
fn drop(&mut self) {
if self.held.get() {
let _ = notify("core.unlock", json!({"path":self.path}));
}
}
}
#[derive(Default)]
struct State {
active: bool,
endpoints: HashMap<std::any::TypeId, String>,
stopping: bool,
next: u64,
pending: HashMap<u64, Pending>,
#[cfg(target_os = "wasi")]
tasks: usize,
#[cfg(target_os = "wasi")]
wake: Option<std::task::Waker>,
#[cfg(target_os = "wasi")]
exit: bool,
#[cfg(target_os = "wasi")]
listeners: HashMap<u64, futures::future::AbortHandle>,
}
thread_local! { static STATE: RefCell<State> = RefCell::new(State::default()); }
pub(crate) fn endpoint_name<T: 'static>() -> Result<String> {
STATE
.with(|s| {
s.borrow()
.endpoints
.get(&std::any::TypeId::of::<T>())
.cloned()
})
.ok_or_else(|| {
Error::new(
-32602,
"endpoint type is not declared by the main interface",
)
})
}
#[cfg(target_os = "wasi")]
pub(crate) fn endpoint_task(future: impl Future<Output = ()> + 'static) {
let (handle, registration) = futures::future::AbortHandle::new_pair();
let id = STATE.with(|s| {
let mut s = s.borrow_mut();
s.next += 1;
let id = s.next;
s.listeners.insert(id, handle);
id
});
background(async move {
let _ = futures::future::Abortable::new(future, registration).await;
STATE.with(|s| s.borrow_mut().listeners.remove(&id));
});
}
#[cfg(target_os = "wasi")]
pub(crate) fn exit() {
STATE.with(|s| {
let mut s = s.borrow_mut();
s.exit = true;
if let Some(w) = s.wake.take() {
w.wake();
}
});
}
fn send(value: Value) -> Result<()> {
let bytes = format!("\n{value}\n").into_bytes();
let mut stdout = std::io::stdout().lock();
stdout.write_all(&bytes)?;
stdout.flush()?;
Ok(())
}
fn notify(method: &str, params: Value) -> Result<()> {
if !STATE.with(|s| s.borrow().active) {
return Err(Error::new(-32002, "no core transport"));
}
send(json!({"jsonrpc":"2.0","method":method,"params":params}))
}
#[cfg(target_os = "wasi")]
/// Execute a future with the SDK runtime.
pub fn block_on<F: Future>(f: F) -> F::Output {
wstd::runtime::block_on(f)
}
#[cfg(not(target_os = "wasi"))]
/// Execute a future with the SDK runtime.
pub fn block_on<F: Future>(f: F) -> F::Output {
futures::executor::block_on(f)
}
/// Run an SDK test with a core transport when the WASI runner supplies one.
#[cfg(target_os = "wasi")]
/// Execute an async test with the available core transport.
pub fn test_run<F: Future>(f: F) -> F::Output {
block_on(async {
STATE.with(|s| s.borrow_mut().active = true);
let reader = wstd::runtime::spawn(async {
let mut input = wstd::io::stdin();
let mut lines = Lines::default();
while let Ok(Some(line)) = lines.next(&mut input).await {
if let Ok(v) = serde_json::from_slice::<Value>(&line)
&& v["jsonrpc"] == "2.0"
{
receive(v);
}
}
STATE.with(|s| {
let mut s = s.borrow_mut();
s.stopping = true;
s.pending.clear();
});
});
let result = f.await;
drop(reader);
STATE.with(|s| {
let mut s = s.borrow_mut();
s.active = false;
s.pending.clear();
});
result
})
}
#[cfg(not(target_os = "wasi"))]
/// Execute an async test with the available core transport.
pub fn test_run<F: Future>(f: F) -> F::Output {
block_on(f)
}
/// Register a task that keeps the process alive.
#[cfg(target_os = "wasi")]
/// Keep this process alive while a future runs.
pub fn background(f: impl Future<Output = ()> + 'static) {
STATE.with(|s| s.borrow_mut().tasks += 1);
wstd::runtime::spawn(async move {
f.await;
STATE.with(|s| {
let mut s = s.borrow_mut();
s.tasks -= 1;
if let Some(w) = s.wake.take() {
w.wake();
}
});
})
.detach();
}
#[cfg(not(target_os = "wasi"))]
/// Keep this process alive while a future runs.
pub fn background(_f: impl Future<Output = ()> + 'static) {
panic!("background requires the WASI runtime");
}
#[doc(hidden)]
pub fn receive(value: Value) {
if value["method"] == "core.notify" {
if let Some(id) = value["params"]["call"].as_u64() {
let callback = STATE.with(|s| {
s.borrow_mut()
.pending
.get_mut(&id)
.and_then(|p| p.notify.take())
});
if let Some(mut f) = callback {
f(
value["params"]["name"].as_str().unwrap_or_default(),
value["params"]["params"].clone(),
);
STATE.with(|s| {
if let Some(p) = s.borrow_mut().pending.get_mut(&id) {
p.notify = Some(f);
}
});
}
}
return;
}
let pending = value["id"]
.as_u64()
.and_then(|id| STATE.with(|s| s.borrow_mut().pending.remove(&id)));
if let Some(p) = pending {
let result = if value.get("error").is_some() {
serde_json::from_value(value["error"].clone()).map_or_else(|e| Err(e.into()), Err)
} else {
Ok(value["result"].clone())
};
if let Some(lease) = &p.unlock {
lease
.held
.set(result.as_ref().is_ok_and(|v| v["held"] == true));
}
let _ = p.tx.send(result);
} else {
eprintln!("note: response with unknown ID {}", value["id"]);
}
}
/// Serve the main interface until its work ends or stdin reaches EOF.
#[cfg(target_os = "wasi")]
/// Serve the main interface over stdin and stdout.
pub fn run<T: Interface>(value: T) {
block_on(serve(value));
}
#[cfg(not(target_os = "wasi"))]
/// Serve the main interface over stdin and stdout.
pub fn run<T: Interface>(_value: T) {
panic!("run requires WASI stdin/stdout");
}
#[cfg(target_os = "wasi")]
async fn serve<T: Interface>(value: T) {
use std::{rc::Rc, task::Poll};
STATE.with(|s| {
*s.borrow_mut() = State {
active: true,
endpoints: value.endpoint_types().into_iter().collect(),
..State::default()
}
});
let value = Rc::new(value);
let mut input = wstd::io::stdin();
let mut lines = Lines::default();
let mut started = false;
loop {
let read = lines.next(&mut input);
let idle = futures::future::poll_fn(|cx| {
STATE.with(|s| {
let mut s = s.borrow_mut();
s.wake = Some(cx.waker().clone());
if started && (s.tasks == 0 || s.exit) {
Poll::Ready(())
} else {
Poll::Pending
}
})
});
futures::pin_mut!(read, idle);
let bytes = match futures::future::select(read, idle).await {
futures::future::Either::Left((Ok(Some(b)), _)) => b,
futures::future::Either::Left((_, _)) => {
STATE.with(|s| {
let mut s = s.borrow_mut();
s.stopping = true;
s.pending.clear();
for (_, listener) in s.listeners.drain() {
listener.abort();
}
});
value.stop().await;
break;
}
futures::future::Either::Right(_) => break,
};
let Ok(msg) = serde_json::from_slice::<Value>(&bytes) else {
continue;
};
if msg["jsonrpc"] != "2.0" {
continue;
}
if let Some(method) = msg["method"].as_str() {
if method == "core.notify" {
receive(msg);
continue;
}
started = true;
let value = value.clone();
let method = method.to_owned();
background(async move {
let ctx = Ctx::default();
let result = if method == "rpc.discover" {
Ok(value.discover())
} else {
value
.dispatch(
&method,
msg.get("params").cloned().unwrap_or(json!({})),
&ctx,
)
.await
};
if let Some(id) = msg.get("id") {
let response = match result {
Ok(v) => json!({"jsonrpc":"2.0","id":id,"result":v}),
Err(e) => json!({"jsonrpc":"2.0","id":id,"error":e}),
};
let _ = send(response);
}
if ctx.exit.get() {
STATE.with(|s| s.borrow_mut().exit = true);
}
});
} else {
receive(msg);
}
}
STATE.with(|s| {
let mut s = s.borrow_mut();
s.active = false;
s.pending.clear();
});
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "sdk-consumer"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../.." }
@@ -0,0 +1,268 @@
{
"components": {
"schemas": {
"Add": {
"description": "The values to add.",
"properties": {
"a": {
"description": "The first number.",
"format": "int32",
"type": "integer"
},
"b": {
"description": "The second number.",
"format": "int32",
"type": "integer"
}
},
"required": [
"a",
"b"
],
"type": "object"
},
"Echo": {
"description": "A large payload.",
"properties": {
"text": {
"description": "The text to return unchanged.",
"type": "string"
}
},
"required": [
"text"
],
"type": "object"
},
"Empty": {
"description": "Empty parameters for methods without arguments.",
"type": "object"
}
}
},
"info": {
"title": "sdk-probe",
"version": "0.1.0"
},
"methods": [
{
"description": "Publish a control endpoint until EOF.",
"name": "serve",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "Publish a control endpoint until EOF.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Add two numbers.\n\nThe result is their sum.",
"name": "add",
"paramStructure": "by-name",
"params": [
{
"description": "The first number.",
"name": "a",
"required": true,
"schema": {
"description": "The first number.",
"format": "int32",
"type": "integer"
}
},
{
"description": "The second number.",
"name": "b",
"required": true,
"schema": {
"description": "The second number.",
"format": "int32",
"type": "integer"
}
}
],
"result": {
"name": "result",
"schema": {
"format": "int32",
"type": "integer"
}
},
"summary": "Add two numbers.",
"x-sausage-params": {
"$ref": "#/components/schemas/Add"
}
},
{
"description": "Echo a payload larger than a host I/O buffer.",
"name": "echo",
"paramStructure": "by-name",
"params": [
{
"description": "The text to return unchanged.",
"name": "text",
"required": true,
"schema": {
"description": "The text to return unchanged.",
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Echo a payload larger than a host I/O buffer.",
"x-sausage-params": {
"$ref": "#/components/schemas/Echo"
}
},
{
"description": "Return an internal serialization error.",
"name": "serde_error",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Return an internal serialization error.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Return a deliberate error.",
"name": "fail",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Return a deliberate error.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Exercise calls, notifications, and advisory locks.",
"name": "exercise",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Exercise calls, notifications, and advisory locks.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Drop an acquisition before the core grants its lock.",
"name": "late",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Drop an acquisition before the core grants its lock.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Wait until EOF, then run the stop hook.",
"name": "wait",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Wait until EOF, then run the stop hook.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Keep the process alive with a background call.",
"name": "start",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Keep the process alive with a background call.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
}
],
"openrpc": "1.2.6",
"x-sausage-endpoints": {
"control": {
"methods": [
{
"description": "Report progress and return a stable counter.",
"name": "stats",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"summary": "Report progress and return a stable counter.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "End the endpoint process after the response.",
"name": "stop",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "End the endpoint process after the response.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
}
]
}
},
"x-sausage-grace": 12,
"x-sausage-imports": {}
}
+25
View File
@@ -0,0 +1,25 @@
//! Consumer of the probe's actual discovered interface.
use sausage_plugin::{Result, rpc};
sausage_plugin::import!("probe");
struct Consumer;
#[rpc]
impl Consumer {
/// Route an imported call through the core.
async fn run(&self) -> Result<i32> {
Ok(probe::add(probe::Add { a: 2, b: 3 })
.detach()
.on_notify(|name, _| eprintln!("typed progress: {name}"))
.await? as i32)
}
/// Connect to the provider's endpoint and receive its notification.
async fn endpoint(&self) -> Result<u64> {
let control = probe::control::connect("/endpoint").await?;
control
.stats()
.on_notify(|name, _| eprintln!("endpoint notification: {name}"))
.await
}
}
fn main() {
sausage_plugin::run(Consumer)
}
+33 -1
View File
@@ -16,14 +16,32 @@ struct Retired {
/// The previous hash.
previous: String,
}
#[rpc(grace_seconds = 12)]
/// A large payload.
#[schema]
struct Echo {
/// The text to return unchanged.
text: String,
}
#[rpc(grace_seconds = 12,endpoints(control=Control))]
impl Probe {
/// Publish a control endpoint until EOF.
async fn serve(&self) -> Result<()> {
sausage_plugin::listen("/endpoint", std::rc::Rc::new(Control)).await
}
/// Add two numbers.
///
/// The result is their sum.
fn add(&self, p: Add) -> Result<i32> {
Ok(p.a + p.b)
}
/// Echo a payload larger than a host I/O buffer.
fn echo(&self, p: Echo) -> Result<String> {
Ok(p.text)
}
/// Return an internal serialization error.
fn serde_error(&self) -> Result<String> {
Ok(sausage_plugin::serde_json::from_str::<String>("invalid")?)
}
/// Return a deliberate error.
fn fail(&self) -> Result<String> {
Err(sausage_plugin::Error::new(-32010, "deliberate"))
@@ -109,6 +127,20 @@ async fn join(
})
.await
}
struct Control;
#[rpc]
impl Control {
/// Report progress and return a stable counter.
fn stats(&self, ctx: &Ctx) -> Result<u64> {
ctx.notify("endpoint-progress", json!({"value":7}))?;
Ok(7)
}
/// End the endpoint process after the response.
fn stop(&self, ctx: &Ctx) -> Result<()> {
ctx.exit_after_response();
Ok(())
}
}
fn main() {
sausage_plugin::run(Probe)
}
+34 -4
View File
@@ -25,8 +25,12 @@ enum Choice {
Second,
}
struct Plugin;
#[rpc]
#[rpc(endpoints(control=Plugin))]
impl Plugin {
/// Return the same named object without a contract change.
fn identity(&self, p: Add) -> Result<Add> {
Ok(p)
}
/// Choose the first value.
fn choice(&self) -> Result<Choice> {
Ok(Choice::First)
@@ -45,6 +49,23 @@ impl Plugin {
#[test]
fn discovery_and_dispatch() {
let doc = Plugin.discover();
assert!(
doc["x-sausage-endpoints"]["control"]["methods"]
.as_array()
.unwrap()
.iter()
.any(|m| m["name"] == "add")
);
assert!(doc["components"]["schemas"].get("AddResult").is_none());
assert_eq!(
doc["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "identity")
.unwrap()["result"]["schema"]["$ref"],
"#/components/schemas/Add"
);
assert!(
doc["components"]["schemas"]["Defaulted"]
.get("required")
@@ -55,8 +76,8 @@ fn discovery_and_dispatch() {
json!(["value"])
);
assert_eq!(
doc["components"]["schemas"]["ChoiceResult"]["enum"],
json!(["First", "Second"])
doc["components"]["schemas"]["Choice"]["oneOf"][0]["description"],
json!("The first choice.")
);
let add = doc["methods"]
.as_array()
@@ -76,7 +97,16 @@ fn discovery_and_dispatch() {
.unwrap(),
5
);
assert!(sausage_plugin::block_on(Plugin.dispatch("add", json!({}), &Ctx::default())).is_err());
assert_eq!(
sausage_plugin::block_on(Plugin.dispatch("add", json!({}), &Ctx::default()))
.unwrap_err()
.code,
-32602
);
let internal: sausage_plugin::Error = sausage_plugin::serde_json::from_str::<i32>("bad")
.unwrap_err()
.into();
assert_eq!(internal.code, -32603);
}
#[sausage_plugin::test]
async fn missing_core_fails_without_wait() {
+4 -4
View File
@@ -5,12 +5,12 @@ error[E0277]: RPC params must be a #[schema] struct with named fields
| ^^^^^^ the trait `Params` is not implemented for `std::string::String`
|
help: the trait `Params` is implemented for `sausage_plugin::Empty`
--> src/lib.rs
--> src/discovery.rs
|
| impl Params for Empty {}
| ^^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `method`
--> src/lib.rs
note: required by a bound in `sausage_plugin::method`
--> src/discovery.rs
|
| pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
| ^^^^^^ required by this bound in `method`
@@ -22,7 +22,7 @@ error[E0277]: RPC params must be a #[schema] struct with named fields
| ^^^^^^ the trait `Params` is not implemented for `std::string::String`
|
help: the trait `Params` is implemented for `sausage_plugin::Empty`
--> src/lib.rs
--> src/discovery.rs
|
| impl Params for Empty {}
| ^^^^^^^^^^^^^^^^^^^^^
+101 -3
View File
@@ -34,7 +34,16 @@ impl Process {
}
fn start(wasm: &Path, dir: &Path) -> Process {
let mut child = Command::new("wasmtime")
.args(["run", "--dir"])
.args([
"run",
"-S",
"inherit-network=y",
"--env",
"SAUSAGE_PID=probe-00000000-0000-7000-8000-000000000000",
"--env",
"SAUSAGE_PLUGIN=probe",
"--dir",
])
.arg(format!("{}::/", dir.display()))
.arg(wasm)
.stdin(Stdio::piped())
@@ -72,7 +81,12 @@ fn protocol() {
let d = p.read();
assert_eq!(d["result"]["x-sausage-grace"], 12);
assert_eq!(
d["result"]["methods"][0]["description"],
d["result"]["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "add")
.unwrap()["description"],
"Add two numbers.\n\nThe result is their sum."
);
assert!(p.child.wait().unwrap().success());
@@ -81,6 +95,18 @@ fn protocol() {
assert_eq!(p.read()["result"], 6);
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("echo", json!({"text":"x".repeat(70_000)}));
assert_eq!(p.read()["result"], "x".repeat(70_000));
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("echo", json!({"text":5}));
assert_eq!(p.read()["error"]["code"], -32602);
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("serde_error", json!({}));
assert_eq!(p.read()["error"]["code"], -32603);
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("fail", json!({}));
assert_eq!(p.read()["error"]["code"], -32010);
assert!(p.child.wait().unwrap().success());
@@ -107,7 +133,7 @@ fn protocol() {
assert!(p.child.wait().unwrap().success());
let mut err = String::new();
std::io::Read::read_to_string(p.child.stderr.as_mut().unwrap(), &mut err).unwrap();
assert!(err.contains("unknown ID"));
assert!(err.contains("unknown ID 9876"));
assert!(err.contains("notification: tick"));
let mut p = start(&wasm, &dir);
p.request("rpc.retired", json!({"previous":"hash"}));
@@ -143,5 +169,77 @@ fn protocol() {
assert_eq!(outgoing["method"], "other.background");
p.answer(&outgoing, json!({}));
assert!(p.child.wait().unwrap().success());
let provider_doc = &d["result"];
let imports = root.join("tests/plugins/consumer/imports");
std::fs::create_dir_all(&imports).unwrap();
std::fs::write(
imports.join("probe.json"),
sausage_plugin::serde_json::to_vec_pretty(provider_doc).unwrap(),
)
.unwrap();
let generated = sausage_interface::client_rs("probe", provider_doc).unwrap();
std::fs::write(target.join("client.rs"), generated).unwrap();
assert!(
Command::new("cargo")
.env("CARGO_TARGET_DIR", &target)
.args(["build", "--offline", "--manifest-path"])
.arg(root.join("tests/plugins/consumer/Cargo.toml"))
.args(["--target", "wasm32-wasip2"])
.status()
.unwrap()
.success()
);
let consumer = target.join("wasm32-wasip2/debug/sdk-consumer.wasm");
let mut client = start(&consumer, &dir);
client.request("rpc.discover", json!({}));
let consumer_doc = client.read()["result"].clone();
assert!(client.child.wait().unwrap().success());
let imported = &consumer_doc["x-sausage-imports"]["probe"];
assert!(sausage_interface::compat::check(imported, provider_doc).is_empty());
let mut broken = provider_doc.clone();
broken["methods"]
.as_array_mut()
.unwrap()
.retain(|m| m["name"] != "add");
assert!(!sausage_interface::compat::check(imported, &broken).is_empty());
let mut client = start(&consumer, &dir);
client.request("run", json!({}));
let outgoing = client.read();
assert_eq!(outgoing["method"], "core.call");
assert_eq!(outgoing["params"]["method"], "probe.add");
let mut provider = start(&wasm, &dir);
provider.request("add", outgoing["params"]["params"].clone());
let reply = provider.read();
assert!(provider.child.wait().unwrap().success());
client.send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":outgoing["id"],"name":"typed","params":{}}}));
client.answer(&outgoing, reply["result"].clone());
assert_eq!(client.read()["result"], 5);
assert!(client.child.wait().unwrap().success());
let mut provider = start(&wasm, &dir);
provider.request("serve", json!({}));
assert_eq!(provider.read()["result"], Value::Null);
let mut client = start(&consumer, &dir);
client.request("endpoint", json!({}));
assert_eq!(client.read()["result"], 7);
assert!(client.child.wait().unwrap().success());
let mut stderr = String::new();
std::io::Read::read_to_string(client.child.stderr.as_mut().unwrap(), &mut stderr).unwrap();
assert!(stderr.contains("endpoint notification: endpoint-progress"));
let path = dir.join("endpoint");
let original = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, original.replace("probe-00000000", "probe-11111111")).unwrap();
let mut client = start(&consumer, &dir);
client.request("endpoint", json!({}));
assert!(
client.read()["error"]["message"]
.as_str()
.unwrap()
.contains("identity mismatch")
);
assert!(client.child.wait().unwrap().success());
std::fs::write(path, original).unwrap();
drop(provider.input.take());
assert_eq!(provider.read()["params"]["name"], "stopped");
assert!(provider.child.wait().unwrap().success());
std::fs::remove_dir_all(dir).unwrap();
}