sdk, agent: review fixes (T18R)

- The serve loop ends the process after EOF or exit_after_response,
  also with background tasks or listeners.
- One schema generator per direction for the whole document, so types
  with the same name get distinct names.
- The agent gives the complete error (code, message, data) to the
  model, writes SKIP only after a successful request, caps tool output
  at 64 KiB, writes each history line in one write, and skips a damaged
  line.
- Endpoints skip blank and non-RPC lines. client_rs does not panic.
- #[must_use] on builders and the lock guard; #[schemars] attributes
  and duplicate hooks are compile errors; raw identifiers; -32000 for
  local SDK errors; x-sausage-params only for methods with params.
This commit is contained in:
2026-10-23 02:38:35 +01:00
parent d7b78ed0fd
commit c728429d04
29 changed files with 829 additions and 104 deletions
+32 -8
View File
@@ -2,8 +2,8 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{
Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, parse_macro_input,
spanned::Spanned,
Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, ext::IdentExt,
parse_macro_input, spanned::Spanned,
};
fn docs(attrs: &[Attribute]) -> String {
attrs
@@ -30,6 +30,12 @@ fn documented(attrs: &[Attribute], span: proc_macro2::Span) -> syn::Result<()> {
}
fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> {
for a in attrs {
if a.path().is_ident("schemars") {
return Err(syn::Error::new(
a.span(),
"schemars attributes are outside the schema subset",
));
}
if a.path().is_ident("serde") {
a.parse_nested_meta(|m| {
if !["rename", "rename_all", "default", "skip_serializing_if"]
@@ -145,6 +151,8 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
let mut arms = vec![];
let mut entries = vec![];
let mut stop = quote!();
let mut has_stop = false;
let mut has_retired = false;
for member in &mut item.items {
let ImplItem::Fn(f) = member else { continue };
let retired = f.attrs.iter().any(|a| {
@@ -159,6 +167,18 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
.last()
.is_some_and(|s| s.ident == "on_stop")
});
if stopping && std::mem::replace(&mut has_stop, true) {
return Err(syn::Error::new(
f.span(),
"only one on_stop method is allowed",
));
}
if retired && std::mem::replace(&mut has_retired, true) {
return Err(syn::Error::new(
f.span(),
"only one on_retired method is allowed",
));
}
f.attrs.retain(|a| {
!a.path()
.segments
@@ -207,7 +227,7 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
let wire = if retired {
"rpc.retired".to_string()
} else {
name.to_string()
name.unraw().to_string()
};
let p = param
.map(|t| quote!(#t))
@@ -237,14 +257,18 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);));
}
}
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 grace = grace
.map(|n| quote!(doc.value["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);));
let names: Vec<_> = endpoints
.iter()
.map(|(n, _)| n.unraw().to_string())
.collect();
let types: Vec<_> = endpoints.iter().map(|(_, t)| t).collect();
Ok(quote! {#item impl ::sausage_plugin::Interface for #ty {
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 add_methods(mut doc:&mut ::sausage_plugin::Discovery) {#(#entries)*}
fn methods_document()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace ::sausage_plugin::finish_document(doc)}
fn describe()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace #(doc.endpoint::<#types>(#names);)* ::sausage_plugin::finish_document(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
+3 -2
View File
@@ -1,6 +1,7 @@
//! Outgoing core calls and notification callbacks.
use super::*;
/// An outgoing request builder.
#[must_use = "await the call to send it"]
pub struct Call {
method: String,
params: Value,
@@ -43,7 +44,7 @@ impl IntoFuture for Call {
let mut s = s.borrow_mut();
if !s.active || s.stopping {
return Err(Error::new(
-32002,
-32000,
"no core transport (or stdin reached EOF)",
));
}
@@ -72,7 +73,7 @@ impl IntoFuture for Call {
return Err(e);
}
rx.await
.unwrap_or_else(|_| Err(Error::new(-32002, "core transport closed")))
.unwrap_or_else(|_| Err(Error::new(-32000, "core transport closed")))
})
}
}
+45 -30
View File
@@ -31,16 +31,7 @@ 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) {
pub fn document(title: &str, version: &str) -> Discovery {
fn generator(serialize: bool) -> schemars::SchemaGenerator {
let mut settings = schemars::generate::SchemaSettings::draft07();
settings.definitions_path = if serialize {
@@ -56,10 +47,44 @@ pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str)
}
.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()];
Discovery {
value: sausage_interface::Document::new(title, version).into_value(),
input: generator(false),
output: generator(true),
}
}
/// Shared generators for all methods and endpoint types in one document.
#[doc(hidden)]
pub struct Discovery {
/// The OpenRPC document before reference resolution.
pub value: Value,
input: schemars::SchemaGenerator,
output: schemars::SchemaGenerator,
}
impl Discovery {
/// Add an endpoint with the same input and output generators.
pub fn endpoint<T: Interface>(&mut self, name: &str) {
let methods = std::mem::replace(&mut self.value["methods"], json!([]));
T::add_methods(self);
let endpoint = std::mem::replace(&mut self.value["methods"], methods);
self.value["x-sausage-endpoints"][name] = json!({"methods":endpoint});
}
}
/// Add a method with independent deserialize and serialize contracts.
#[doc(hidden)]
pub fn method<P: Params, R: SchemaType>(doc: &mut Discovery, name: &str, docs: &str) {
let params_ref = if P::schema_id() == <Empty as schemars::JsonSchema>::schema_id() {
Value::Null
} else {
serde_json::to_value(doc.input.subschema_for::<P>()).unwrap()
};
let schema = params_ref["$ref"]
.as_str()
.and_then(|reference| reference.strip_prefix("#/sdk-input/"))
.and_then(|name| doc.input.definitions().get(name))
.unwrap_or(&Value::Null);
let params:Vec<_>=schema["properties"].as_object().into_iter().flatten().map(|(name,field)| {
json!({
"name": name,
@@ -68,18 +93,7 @@ pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str)
"schema": field,
})
}).collect();
if !params.is_empty() {
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));
let result_schema = serde_json::to_value(doc.output.subschema_for::<R>()).unwrap();
let has_params = !params.is_empty();
let mut method = json!({
"name":name,
@@ -92,14 +106,15 @@ pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str)
if has_params {
method["x-sausage-params"] = params_ref;
}
doc["methods"].as_array_mut().unwrap().push(method);
doc.value["methods"].as_array_mut().unwrap().push(method);
}
/// 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();
pub fn finish_document(mut discovery: Discovery) -> Value {
let input = serde_json::to_value(discovery.input.take_definitions(true)).unwrap();
let output = serde_json::to_value(discovery.output.take_definitions(true)).unwrap();
let mut doc = discovery.value;
fn refs(value: &mut Value, names: &std::collections::HashMap<String, String>) {
match value {
Value::Object(map) => {
+15 -17
View File
@@ -7,6 +7,9 @@ use serde_json::json;
use std::{future::IntoFuture, marker::PhantomData, path::Path, rc::Rc};
/// A verified endpoint address and identity.
///
/// Connect verifies and closes one connection. Each call opens a new connection
/// and repeats the identity handshake before its request. The first call costs three round trips.
#[derive(Clone)]
#[cfg_attr(not(target_os = "wasi"), allow(dead_code))]
pub struct Endpoint {
@@ -20,7 +23,7 @@ impl Endpoint {
if self.interface == name {
Ok(())
} else {
Err(Error::new(-32002, "endpoint interface mismatch"))
Err(Error::new(-32000, "endpoint interface mismatch"))
}
}
/// Build a typed endpoint request.
@@ -35,6 +38,7 @@ impl Endpoint {
}
}
/// A lazy endpoint request with optional progress callbacks.
#[must_use = "await the endpoint call to send it"]
pub struct EndpointCall<T> {
endpoint: Endpoint,
method: String,
@@ -87,7 +91,7 @@ pub async fn listen<T: Interface>(path: impl AsRef<Path>, value: Rc<T>) -> Resul
#[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"))
Err(Error::new(-32000, "endpoints require WASI"))
}
/// Read an endpoint file and verify its owner before returning a client.
@@ -122,7 +126,7 @@ pub async fn connect(path: impl AsRef<Path>) -> Result<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"))
Err(Error::new(-32000, "endpoints require WASI"))
}
#[cfg(target_os = "wasi")]
@@ -139,11 +143,10 @@ async fn handshake(endpoint: &Endpoint) -> Result<wstd::net::TcpStream> {
json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}}),
)
.await?;
let bytes = crate::lines::Lines::default()
.next(&mut stream)
let reply = crate::lines::Lines::default()
.rpc(&mut stream)
.await?
.ok_or_else(|| Error::new(-32002, "endpoint closed"))?;
let reply: Value = serde_json::from_slice(&bytes)?;
.ok_or_else(|| Error::new(-32000, "endpoint closed"))?;
let result = &reply["result"];
let plugin = endpoint
.owner
@@ -154,7 +157,7 @@ async fn handshake(endpoint: &Endpoint) -> Result<wstd::net::TcpStream> {
|| result["interface"] != endpoint.interface
|| result["plugin"].as_str() != plugin
{
return Err(Error::new(-32002, "endpoint identity mismatch"));
return Err(Error::new(-32000, "endpoint identity mismatch"));
}
Ok(stream)
}
@@ -172,8 +175,7 @@ async fn endpoint_request(
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)?;
while let Some(value) = lines.rpc(&mut stream).await? {
if value["method"] == "core.notify" {
if let Some(f) = &mut notify {
f(
@@ -188,7 +190,7 @@ async fn endpoint_request(
return Ok(value["result"].clone());
}
}
Err(Error::new(-32002, "endpoint closed before response"))
Err(Error::new(-32000, "endpoint closed before response"))
}
#[cfg(not(target_os = "wasi"))]
async fn endpoint_request(
@@ -197,7 +199,7 @@ async fn endpoint_request(
_params: Value,
_notify: Option<Notify>,
) -> Result<Value> {
Err(Error::new(-32002, "endpoints require WASI"))
Err(Error::new(-32000, "endpoints require WASI"))
}
#[cfg(target_os = "wasi")]
@@ -209,11 +211,7 @@ async fn serve_connection<T: Interface>(
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;
}
while let Some(request) = lines.rpc(&mut reader).await? {
let method = request["method"].as_str().unwrap_or_default();
let (tx, mut rx) = futures::channel::mpsc::unbounded();
let ctx = Ctx {
+1
View File
@@ -14,6 +14,7 @@ pub struct Import {
inventory::collect!(Import);
/// An outgoing call whose result decodes to a generated Rust type.
#[must_use = "await the call to send it"]
pub struct TypedCall<T> {
call: Call,
result: PhantomData<T>,
+2
View File
@@ -44,6 +44,8 @@
//! [`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`].
//! Local transport errors use code -32000: no transport, stdin EOF, a closed endpoint,
//! or an endpoint identity or interface mismatch. Code -32002 belongs to core process failures.
//! Log with `eprintln!`. Stdout belongs to JSON-RPC.
#![warn(missing_docs)]
mod discovery;
+45 -4
View File
@@ -1,22 +1,34 @@
//! Buffered newline framing shared by stdin and endpoint connections.
#[cfg(target_os = "wasi")]
#[cfg(any(target_os = "wasi", test))]
#[derive(Default)]
pub(crate) struct Lines {
bytes: Vec<u8>,
scanned: usize,
}
#[cfg(target_os = "wasi")]
#[cfg(any(target_os = "wasi", test))]
impl Lines {
fn frame(&mut self) -> Option<Vec<u8>> {
if let Some(end) = self.bytes[self.scanned..].iter().position(|b| *b == b'\n') {
let end = self.scanned + end;
self.scanned = 0;
return Some(self.bytes.drain(..=end).collect());
}
self.scanned = self.bytes.len();
None
}
#[cfg(target_os = "wasi")]
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()));
if let Some(line) = self.frame() {
return Ok(Some(line));
}
let mut buffer = [0; 8192];
let count = input.read(&mut buffer).await?;
if count == 0 {
self.scanned = 0;
return Ok(if self.bytes.is_empty() {
None
} else {
@@ -26,4 +38,33 @@ impl Lines {
self.bytes.extend_from_slice(&buffer[..count]);
}
}
#[cfg(target_os = "wasi")]
pub(crate) async fn rpc(
&mut self,
input: &mut impl wstd::io::AsyncRead,
) -> std::io::Result<Option<serde_json::Value>> {
while let Some(bytes) = self.next(input).await? {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
&& value.is_object()
&& value["jsonrpc"] == "2.0"
{
return Ok(Some(value));
}
}
Ok(None)
}
}
#[cfg(test)]
mod tests {
#[test]
fn incomplete_frames_retain_the_scanned_length() {
let mut lines = super::Lines::default();
lines.bytes.extend_from_slice(b"first");
assert!(lines.frame().is_none());
assert_eq!(lines.scanned, 5);
lines.bytes.extend_from_slice(b"\nsecond\n");
assert_eq!(lines.frame().unwrap(), b"first\n");
assert_eq!(lines.frame().unwrap(), b"second\n");
assert!(lines.frame().is_none());
}
}
+2
View File
@@ -1,6 +1,7 @@
//! Advisory lock acquisition and guard lifetime.
use super::*;
/// An advisory lock acquisition.
#[must_use = "await the lock to acquire it"]
pub struct Lock {
path: String,
wait: bool,
@@ -20,6 +21,7 @@ impl Lock {
}
}
/// Releases its lock on drop, unless kept until process exit.
#[must_use = "bind the guard to hold the lock"]
pub struct Guard {
path: Option<String>,
}
+9 -2
View File
@@ -21,6 +21,11 @@ pub use locks::*;
pub trait Interface: 'static {
/// Describe this interface without constructing its value.
fn describe() -> Value
where
Self: Sized;
/// Add this type's methods to shared document generators.
#[doc(hidden)]
fn add_methods(doc: &mut crate::Discovery)
where
Self: Sized;
/// Describe only this type's methods, without endpoint declarations.
@@ -58,7 +63,7 @@ impl Ctx {
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"))
.map_err(|_| Error::new(-32000, "endpoint closed"))
} else {
notify("core.notify", params)
}
@@ -152,7 +157,7 @@ fn send(value: Value) -> Result<()> {
}
fn notify(method: &str, params: Value) -> Result<()> {
if !STATE.with(|s| s.borrow().active) {
return Err(Error::new(-32002, "no core transport"));
return Err(Error::new(-32000, "no core transport"));
}
send(json!({"jsonrpc":"2.0","method":method,"params":params}))
}
@@ -376,4 +381,6 @@ async fn serve<T: Interface>(value: T) {
s.active = false;
s.pending.clear();
});
let _ = std::io::stdout().flush();
std::process::exit(0);
}
+1
View File
@@ -5,3 +5,4 @@ edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../.." }
wstd = "0.6"
+18 -1
View File
@@ -70,7 +70,11 @@ impl Probe {
}
/// Return a deliberate error.
fn fail(&self) -> Result<String> {
Err(sausage_plugin::Error::new(-32010, "deliberate"))
Err(sausage_plugin::Error {
code: -32010,
message: "deliberate".into(),
data: Some(json!({"diagnostic":"probe error details"})),
})
}
/// Exercise calls, notifications, and advisory locks.
async fn exercise(&self, ctx: &Ctx) -> Result<String> {
@@ -119,6 +123,19 @@ impl Probe {
});
Ok("started".into())
}
/// Keep a pollable alive for one hour.
fn sleepy(&self) -> Result<()> {
sausage_plugin::background(async {
wstd::task::sleep(wstd::time::Duration::from_secs(3600)).await;
});
Ok(())
}
/// Exit after the response despite a pending background pollable.
fn sleepy_exit(&self, ctx: &Ctx) -> Result<()> {
self.sleepy()?;
ctx.exit_after_response();
Ok(())
}
/// Save an EOF marker.
#[sausage_plugin::on_stop]
async fn stop(&self) {
+90
View File
@@ -0,0 +1,90 @@
//! Regression checks for shared schema names and wire identifiers.
use sausage_plugin::{Interface, Result, rpc};
mod a {
/// Text status.
#[sausage_plugin::schema]
pub struct Status {
/// Text value.
pub text: String,
}
}
mod b {
/// Counter status.
#[sausage_plugin::schema]
pub struct Status {
/// Counter value.
pub count: u32,
}
}
struct Main;
struct Control;
#[rpc(endpoints(control = Control))]
impl Main {
/// Return text status.
fn one(&self, p: a::Status) -> Result<a::Status> {
Ok(p)
}
/// Return counter status.
fn two(&self, p: b::Status) -> Result<b::Status> {
Ok(p)
}
/// Use an unescaped wire name.
fn r#type(&self) -> Result<()> {
Ok(())
}
}
#[rpc]
impl Control {
/// Return endpoint counter status.
fn status(&self) -> Result<b::Status> {
Ok(b::Status { count: 1 })
}
}
#[test]
fn distinct_status_types_share_document_generators() {
let doc = Main::describe();
let methods = doc["methods"].as_array().unwrap();
let one = methods.iter().find(|m| m["name"] == "one").unwrap();
let two = methods.iter().find(|m| m["name"] == "two").unwrap();
assert_eq!(one["params"][0]["name"], "text");
assert_eq!(two["params"][0]["name"], "count");
assert_ne!(one["result"]["schema"], two["result"]["schema"]);
for (method, field) in [
(one, "text"),
(two, "count"),
(
&doc["x-sausage-endpoints"]["control"]["methods"][0],
"count",
),
] {
let schema = doc
.pointer(
method["result"]["schema"]["$ref"]
.as_str()
.unwrap()
.trim_start_matches('#'),
)
.unwrap();
assert!(schema["properties"].get(field).is_some(), "{doc}");
}
}
#[test]
fn raw_identifier_has_plain_wire_name() {
assert!(
Main::describe()["methods"]
.as_array()
.unwrap()
.iter()
.any(|m| m["name"] == "type")
);
}
#[test]
fn local_transport_errors_have_reserved_sdk_code() {
assert_eq!(
sausage_plugin::Ctx::default()
.notify("x", ())
.unwrap_err()
.code,
-32000
);
}
+22
View File
@@ -0,0 +1,22 @@
use sausage_plugin::{rpc, on_stop, on_retired, Result};
struct Stops;
#[rpc]
impl Stops {
/// First stop hook.
#[on_stop]
fn a(&self) {}
/// Second stop hook.
#[on_stop]
fn b(&self) {}
}
struct Retires;
#[rpc]
impl Retires {
/// First retired hook.
#[on_retired]
fn a(&self) -> Result<()> { Ok(()) }
/// Second retired hook.
#[on_retired]
fn b(&self) -> Result<()> { Ok(()) }
}
fn main() {}
+19
View File
@@ -0,0 +1,19 @@
error: only one on_stop method is allowed
--> tests/ui/hooks.rs:8:5
|
8 | /// Second stop hook.
| ^^^^^^^^^^^^^^^^^^^^^
error: only one on_retired method is allowed
--> tests/ui/hooks.rs:18:5
|
18 | /// Second retired hook.
| ^^^^^^^^^^^^^^^^^^^^^^^^
warning: unused imports: `Result`, `on_retired`, and `on_stop`
--> tests/ui/hooks.rs:1:27
|
1 | use sausage_plugin::{rpc, on_stop, on_retired, Result};
| ^^^^^^^ ^^^^^^^^^^ ^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
+11
View File
@@ -0,0 +1,11 @@
#![deny(unused_must_use)]
use sausage_plugin::{call, lock, Endpoint, Guard, TypedCall};
fn builders(endpoint: Endpoint) {
call("p.m", ());
TypedCall::<()>::new(call("p.m", ()));
lock("/state/file");
endpoint.call::<()>("m", ());
}
fn guard(g: Guard) -> Guard { g }
fn dropped(g: Guard) { guard(g); }
fn main() {}
+64
View File
@@ -0,0 +1,64 @@
error: unused `Call` that must be used
--> tests/ui/must_use.rs:4:5
|
4 | call("p.m", ());
| ^^^^^^^^^^^^^^^
|
= note: await the call to send it
note: the lint level is defined here
--> tests/ui/must_use.rs:1:9
|
1 | #![deny(unused_must_use)]
| ^^^^^^^^^^^^^^^
help: use `let _ = ...` to ignore the resulting value
|
4 | let _ = call("p.m", ());
| +++++++
error: unused `TypedCall` that must be used
--> tests/ui/must_use.rs:5:5
|
5 | TypedCall::<()>::new(call("p.m", ()));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: await the call to send it
help: use `let _ = ...` to ignore the resulting value
|
5 | let _ = TypedCall::<()>::new(call("p.m", ()));
| +++++++
error: unused `Lock` that must be used
--> tests/ui/must_use.rs:6:5
|
6 | lock("/state/file");
| ^^^^^^^^^^^^^^^^^^^
|
= note: await the lock to acquire it
help: use `let _ = ...` to ignore the resulting value
|
6 | let _ = lock("/state/file");
| +++++++
error: unused `EndpointCall` that must be used
--> tests/ui/must_use.rs:7:5
|
7 | endpoint.call::<()>("m", ());
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: await the endpoint call to send it
help: use `let _ = ...` to ignore the resulting value
|
7 | let _ = endpoint.call::<()>("m", ());
| +++++++
error: unused `Guard` that must be used
--> tests/ui/must_use.rs:10:24
|
10 | fn dropped(g: Guard) { guard(g); }
| ^^^^^^^^
|
= note: bind the guard to hold the lock
help: use `let _ = ...` to ignore the resulting value
|
10 | fn dropped(g: Guard) { let _ = guard(g); }
| +++++++
+1 -1
View File
@@ -12,7 +12,7 @@ help: the trait `Params` is implemented for `sausage_plugin::Empty`
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) {
| pub fn method<P: Params, R: SchemaType>(doc: &mut Discovery, name: &str, docs: &str) {
| ^^^^^^ required by this bound in `method`
error[E0277]: RPC params must be a #[schema] struct with named fields
+9
View File
@@ -0,0 +1,9 @@
use sausage_plugin::schema;
/// A documented object.
#[schema]
struct Object {
/// A documented field.
#[schemars(with = "std::collections::HashMap<String, String>")]
value: String,
}
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: schemars attributes are outside the schema subset
--> tests/ui/schemars.rs:6:5
|
6 | #[schemars(with = "std::collections::HashMap<String, String>")]
| ^
+102 -5
View File
@@ -11,6 +11,21 @@ struct Process {
output: BufReader<std::process::ChildStdout>,
}
impl Process {
fn finishes(&mut self) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
loop {
if let Some(status) = self.child.try_wait().unwrap() {
assert!(status.success());
return;
}
if std::time::Instant::now() >= deadline {
self.child.kill().unwrap();
let _ = self.child.wait();
panic!("process survived exit or EOF for four seconds");
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn send(&mut self, v: Value) {
writeln!(self.input.as_mut().unwrap(), "{v}").unwrap();
}
@@ -76,6 +91,15 @@ fn protocol() {
let wasm = target.join("wasm32-wasip2/debug/sdk-probe.wasm");
let dir = std::env::temp_dir().join(format!("sausage-sdk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for method in ["sleepy_exit", "sleepy"] {
let mut p = start(&wasm, &dir);
p.request(method, json!({}));
assert_eq!(p.read()["result"], Value::Null);
if method == "sleepy" {
drop(p.input.take());
}
p.finishes();
}
let mut p = start(&wasm, &dir);
p.request("rpc.discover", json!({}));
let d = p.read();
@@ -170,7 +194,7 @@ fn protocol() {
p.answer(&outgoing, json!({}));
assert!(p.child.wait().unwrap().success());
let provider_doc = &d["result"];
let imports = root.join("tests/plugins/consumer/imports");
let imports = target.join("imports");
std::fs::create_dir_all(&imports).unwrap();
std::fs::write(
imports.join("probe.json"),
@@ -237,9 +261,82 @@ fn protocol() {
.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::write(&path, original).unwrap();
// The endpoint server ignores non-RPC lines before a request.
let address = std::fs::read_to_string(&path)
.unwrap()
.split_whitespace()
.next()
.unwrap()
.trim_start_matches("tcp:")
.to_owned();
let mut stream = std::net::TcpStream::connect(address).unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(4)))
.unwrap();
stream.write_all(b"\nnot-json\n[]\n{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"rpc.endpoint\",\"params\":{}}\n").unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(
sausage_plugin::serde_json::from_str::<Value>(&line).unwrap()["id"],
99
);
reader
.get_mut()
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":100,\"method\":\"stop\",\"params\":{}}\n")
.unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
assert_eq!(
sausage_plugin::serde_json::from_str::<Value>(&line).unwrap()["id"],
100
);
provider.finishes();
// The endpoint client ignores non-RPC lines during verification and responses.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let owner = "probe-00000000-0000-7000-8000-000000000000";
std::fs::write(
&path,
format!("tcp:{} {owner} control\n", listener.local_addr().unwrap()),
)
.unwrap();
let server = std::thread::spawn(move || {
for _ in 0..2 {
let (stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(4)))
.unwrap();
let mut reader = BufReader::new(stream);
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap() == 0 {
break;
}
let request: Value = sausage_plugin::serde_json::from_str(&line).unwrap();
let result = if request["method"] == "rpc.endpoint" {
json!({"owner":owner,"plugin":"probe","interface":"control"})
} else {
json!(7)
};
writeln!(
reader.get_mut(),
"\nnot-json\n[]\n{}",
json!({"jsonrpc":"2.0","id":request["id"],"result":result})
)
.unwrap();
}
}
});
let mut client = start(&consumer, &dir);
client.request("endpoint", json!({}));
assert_eq!(client.read()["result"], 7);
client.finishes();
server.join().unwrap();
assert_eq!(
std::fs::read(root.join("tests/plugins/consumer/imports/probe.json")).unwrap(),
include_bytes!("plugins/consumer/imports/probe.json")
);
std::fs::remove_dir_all(dir).unwrap();
}