sausage-plugin part 1: run, #[rpc], #[schema], rpc.discover, calls, locks, Ctx, hooks

Checkpoint. Known issues, fixed in part 2: short writes on stdout,
byte-wise stdin reads, Result suffix on all result schemas, lost enum
variant docs, serde errors as invalid params, missing item docs.
This commit is contained in:
2026-10-23 00:05:37 +01:00
parent 4e41061ed4
commit 01712e745a
30 changed files with 1722 additions and 93 deletions
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "sdk-probe"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../.." }
+114
View File
@@ -0,0 +1,114 @@
//! Probe of SDK transport and lifecycle behavior.
use sausage_plugin::serde_json::{Value, json};
use sausage_plugin::{Ctx, Result, call, lock, rpc, schema};
struct Probe;
/// The values to add.
#[schema]
struct Add {
/// The first number.
a: i32,
/// The second number.
b: i32,
}
/// The retired version.
#[schema]
struct Retired {
/// The previous hash.
previous: String,
}
#[rpc(grace_seconds = 12)]
impl Probe {
/// Add two numbers.
///
/// The result is their sum.
fn add(&self, p: Add) -> Result<i32> {
Ok(p.a + p.b)
}
/// Return a deliberate error.
fn fail(&self) -> Result<String> {
Err(sausage_plugin::Error::new(-32010, "deliberate"))
}
/// Exercise calls, notifications, and advisory locks.
async fn exercise(&self, ctx: &Ctx) -> Result<String> {
let a =
call("other.first", json!({})).on_notify(|name, _| eprintln!("notification: {name}"));
let b = call("other.second", json!({})).detach();
let (a, b) = join(a, b).await;
a?;
b?;
ctx.notify("progress", json!({"done":true}))?;
{
let guard = lock("/state/probe/file").await?;
assert!(guard.held());
}
assert!(!lock("/state/probe/file").try_now().await?.held());
Ok("done".into())
}
/// Drop an acquisition before the core grants its lock.
async fn late(&self) -> Result<String> {
{
let mut future = std::pin::pin!(std::future::IntoFuture::into_future(lock("/late")));
std::future::poll_fn(|cx| {
assert!(future.as_mut().poll(cx).is_pending());
std::task::Poll::Ready(())
})
.await;
}
call("other.barrier", json!({})).await?;
lock("/kept").await?.keep();
Ok("late done".into())
}
/// Mark a retired version.
#[sausage_plugin::on_retired]
fn retired(&self, p: Retired) -> Result<String> {
Ok(p.previous)
}
/// Wait until EOF, then run the stop hook.
async fn wait(&self) -> Result<String> {
call("other.wait", json!({})).await?;
Ok("done".into())
}
/// Keep the process alive with a background call.
fn start(&self) -> Result<String> {
sausage_plugin::background(async {
let _ = call("other.background", json!({})).await;
});
Ok("started".into())
}
/// Save an EOF marker.
#[sausage_plugin::on_stop]
async fn stop(&self) {
std::fs::write("/stopped", b"yes").unwrap();
Ctx::default().notify("stopped", json!({})).unwrap();
assert!(call("other.no", json!({})).await.is_err());
}
}
async fn join(
a: impl std::future::IntoFuture<Output = Result<Value>>,
b: impl std::future::IntoFuture<Output = Result<Value>>,
) -> (Result<Value>, Result<Value>) {
let mut a = std::pin::pin!(a.into_future());
let mut b = std::pin::pin!(b.into_future());
let (mut av, mut bv) = (None, None);
std::future::poll_fn(move |cx| {
if av.is_none() {
if let std::task::Poll::Ready(v) = a.as_mut().poll(cx) {
av = Some(v);
}
}
if bv.is_none() {
if let std::task::Poll::Ready(v) = b.as_mut().poll(cx) {
bv = Some(v);
}
}
if av.is_some() && bv.is_some() {
std::task::Poll::Ready((av.take().unwrap(), bv.take().unwrap()))
} else {
std::task::Poll::Pending
}
})
.await
}
fn main() {
sausage_plugin::run(Probe)
}
+101
View File
@@ -0,0 +1,101 @@
//! Native checks for generated schemas, dispatch, and missing core transports.
use sausage_plugin::serde_json::json;
use sausage_plugin::{Ctx, Interface, Result, rpc, schema};
/// The addition parameters.
#[schema]
struct Add {
/// The first integer.
a: i32,
/// The second integer.
b: i32,
}
/// A type with different serialize and deserialize contracts.
#[schema]
struct Defaulted {
/// A value which the provider defaults.
#[serde(default)]
value: String,
}
/// A documented string enum.
#[schema]
enum Choice {
/// The first choice.
First,
/// The second choice.
Second,
}
struct Plugin;
#[rpc]
impl Plugin {
/// Choose the first value.
fn choice(&self) -> Result<Choice> {
Ok(Choice::First)
}
/// Round-trip the defaulted object.
fn defaults(&self, p: Defaulted) -> Result<Defaulted> {
Ok(p)
}
/// Add integers.
///
/// Return their sum.
fn add(&self, p: Add) -> Result<i32> {
Ok(p.a + p.b)
}
}
#[test]
fn discovery_and_dispatch() {
let doc = Plugin.discover();
assert!(
doc["components"]["schemas"]["Defaulted"]
.get("required")
.is_none()
);
assert_eq!(
doc["components"]["schemas"]["DefaultedResult"]["required"],
json!(["value"])
);
assert_eq!(
doc["components"]["schemas"]["ChoiceResult"]["enum"],
json!(["First", "Second"])
);
let add = doc["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "add")
.unwrap();
assert_eq!(add["summary"], "Add integers.");
assert_eq!(
doc["components"]["schemas"]["Add"]["description"],
"The addition parameters."
);
assert_eq!(add["params"][0]["description"], "The first integer.");
eprintln!("DISCOVER={doc}");
assert_eq!(
sausage_plugin::block_on(Plugin.dispatch("add", json!({"a":2,"b":3}), &Ctx::default()))
.unwrap(),
5
);
assert!(sausage_plugin::block_on(Plugin.dispatch("add", json!({}), &Ctx::default())).is_err());
}
#[sausage_plugin::test]
async fn missing_core_fails_without_wait() {
assert!(
Ctx::default()
.notify("progress", json!({}))
.unwrap_err()
.message
.contains("no core transport")
);
assert!(
sausage_plugin::call("core.lock", json!({}))
.await
.unwrap_err()
.message
.contains("no core transport")
);
}
#[test]
fn compile_fail() {
trybuild::TestCases::new().compile_fail("tests/ui/*.rs");
}
+8
View File
@@ -0,0 +1,8 @@
//! Reject data-carrying enums.
/// An invalid enum.
#[sausage_plugin::schema]
enum Bad {
/// A payload variant.
Value(String),
}
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: schema enums require unit variants
--> tests/ui/enum.rs:5:5
|
5 | /// A payload variant.
| ^^^^^^^^^^^^^^^^^^^^^^
+5
View File
@@ -0,0 +1,5 @@
//! Require field documentation.
/// A documented type.
#[sausage_plugin::schema]
struct Bad { value: String }
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: a doc comment is required
--> tests/ui/field_docs.rs:4:14
|
4 | struct Bad { value: String }
| ^^^^^
+5
View File
@@ -0,0 +1,5 @@
//! Require method documentation.
struct Bad;
#[sausage_plugin::rpc]
impl Bad { fn bad(&self)->sausage_plugin::Result<()> {Ok(())} }
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: a doc comment is required
--> tests/ui/method_docs.rs:4:12
|
4 | impl Bad { fn bad(&self)->sausage_plugin::Result<()> {Ok(())} }
| ^^
+4
View File
@@ -0,0 +1,4 @@
//! Reject undocumented schemas.
#[sausage_plugin::schema]
struct Bad { value: String }
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: a doc comment is required
--> tests/ui/missing_docs.rs:3:8
|
3 | struct Bad { value: String }
| ^^^
+8
View File
@@ -0,0 +1,8 @@
//! Reject primitive params.
struct Bad;
#[sausage_plugin::rpc]
impl Bad {
/// Invalid params.
fn bad(&self, value:String)->sausage_plugin::Result<String> {Ok(value)}
}
fn main() {}
+34
View File
@@ -0,0 +1,34 @@
error[E0277]: RPC params must be a #[schema] struct with named fields
--> tests/ui/params.rs:6:25
|
6 | fn bad(&self, value:String)->sausage_plugin::Result<String> {Ok(value)}
| ^^^^^^ the trait `Params` is not implemented for `std::string::String`
|
help: the trait `Params` is implemented for `sausage_plugin::Empty`
--> src/lib.rs
|
| impl Params for Empty {}
| ^^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `method`
--> src/lib.rs
|
| pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
| ^^^^^^ required by this bound in `method`
error[E0277]: RPC params must be a #[schema] struct with named fields
--> tests/ui/params.rs:6:25
|
6 | fn bad(&self, value:String)->sausage_plugin::Result<String> {Ok(value)}
| ^^^^^^ the trait `Params` is not implemented for `std::string::String`
|
help: the trait `Params` is implemented for `sausage_plugin::Empty`
--> src/lib.rs
|
| impl Params for Empty {}
| ^^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `params_type`
--> tests/ui/params.rs:3:1
|
3 | #[sausage_plugin::rpc]
| ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `params_type`
= note: this error originates in the attribute macro `sausage_plugin::rpc` (in Nightly builds, run with -Z macro-backtrace for more info)
+9
View File
@@ -0,0 +1,9 @@
//! Reject serde shapes outside the subset.
/// A schema with flatten.
#[sausage_plugin::schema]
struct Bad {
/// A flattened value.
#[serde(flatten)]
value: String,
}
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: serde attribute is outside the schema subset
--> tests/ui/serde.rs:6:13
|
6 | #[serde(flatten)]
| ^^^^^^^
+8
View File
@@ -0,0 +1,8 @@
//! Reject invalid receiver signatures.
struct Bad;
#[sausage_plugin::rpc]
impl Bad {
/// An invalid receiver.
fn bad(&mut self) -> sausage_plugin::Result<()> {Ok(())}
}
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: RPC methods require &self
--> tests/ui/signature.rs:6:5
|
6 | fn bad(&mut self) -> sausage_plugin::Result<()> {Ok(())}
| ^^
+8
View File
@@ -0,0 +1,8 @@
//! Reject unsupported field types.
/// An invalid schema.
#[sausage_plugin::schema]
struct Bad {
/// An unsupported dictionary.
value: std::collections::HashMap<String,String>,
}
fn main() {}
+22
View File
@@ -0,0 +1,22 @@
error[E0277]: this type is outside the Sausage schema subset
--> tests/ui/subset.rs:6:12
|
6 | value: std::collections::HashMap<String,String>,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `SchemaType` is not implemented for `HashMap<std::string::String, std::string::String>`
|
= help: the following other types implement trait `SchemaType`:
()
Bad
Vec<T>
bool
f32
f64
i16
i32
and $N others
note: required by a bound in `supported`
--> tests/ui/subset.rs:3:1
|
3 | #[sausage_plugin::schema]
| ^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `supported`
= note: this error originates in the attribute macro `sausage_plugin::schema` (in Nightly builds, run with -Z macro-backtrace for more info)
+5
View File
@@ -0,0 +1,5 @@
//! Reject tuple params.
/// A tuple is not a named object.
#[sausage_plugin::schema]
struct Bad(String);
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: schema structs require named fields
--> tests/ui/tuple.rs:2:1
|
2 | /// A tuple is not a named object.
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+147
View File
@@ -0,0 +1,147 @@
//! End-to-end protocol checks against a WASI plugin under Wasmtime.
use sausage_plugin::serde_json::{Value, json};
use std::{
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
};
struct Process {
child: std::process::Child,
input: Option<std::process::ChildStdin>,
output: BufReader<std::process::ChildStdout>,
}
impl Process {
fn send(&mut self, v: Value) {
writeln!(self.input.as_mut().unwrap(), "{v}").unwrap();
}
fn read(&mut self) -> Value {
loop {
let mut s = String::new();
assert_ne!(self.output.read_line(&mut s).unwrap(), 0, "unexpected EOF");
if let Ok(v) = sausage_plugin::serde_json::from_str::<Value>(&s)
&& v["jsonrpc"] == "2.0"
{
return v;
}
}
}
fn request(&mut self, method: &str, params: Value) {
self.send(json!({"jsonrpc":"2.0","id":"probe-1","method":method,"params":params}));
}
fn answer(&mut self, r: &Value, result: Value) {
self.send(json!({"jsonrpc":"2.0","id":r["id"],"result":result}));
}
}
fn start(wasm: &Path, dir: &Path) -> Process {
let mut child = Command::new("wasmtime")
.args(["run", "--dir"])
.arg(format!("{}::/", dir.display()))
.arg(wasm)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let input = child.stdin.take();
let output = BufReader::new(child.stdout.take().unwrap());
Process {
child,
input,
output,
}
}
#[test]
fn protocol() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let target = root.join("../target/sdk/probe");
assert!(
Command::new("cargo")
.env("CARGO_TARGET_DIR", &target)
.args(["build", "--offline", "--manifest-path"])
.arg(root.join("tests/plugins/probe/Cargo.toml"))
.args(["--target", "wasm32-wasip2"])
.status()
.unwrap()
.success()
);
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();
let mut p = start(&wasm, &dir);
p.request("rpc.discover", json!({}));
let d = p.read();
assert_eq!(d["result"]["x-sausage-grace"], 12);
assert_eq!(
d["result"]["methods"][0]["description"],
"Add two numbers.\n\nThe result is their sum."
);
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("add", json!({"a":2,"b":4}));
assert_eq!(p.read()["result"], 6);
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());
let mut p = start(&wasm, &dir);
p.request("exercise", json!({}));
let a = p.read();
let b = p.read();
assert_eq!(a["method"], "other.first");
assert_eq!(b["method"], "core.call");
assert_eq!(b["params"]["detach"], true);
p.send(json!({"jsonrpc":"2.0","id":9876,"result":{}}));
p.send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":a["id"],"name":"tick","params":{}}}));
p.answer(&b, json!({}));
p.answer(&a, json!({}));
assert_eq!(p.read()["method"], "core.notify");
let grant = p.read();
assert_eq!(grant["method"], "core.lock");
p.answer(&grant, json!({"held":true}));
assert_eq!(p.read()["method"], "core.unlock");
let attempt = p.read();
assert_eq!(attempt["params"]["wait"], false);
p.answer(&attempt, json!({"held":false}));
assert_eq!(p.read()["result"], "done");
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("notification: tick"));
let mut p = start(&wasm, &dir);
p.request("rpc.retired", json!({"previous":"hash"}));
assert_eq!(p.read()["result"], "hash");
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("late", json!({}));
let grant = p.read();
let barrier = p.read();
p.answer(&grant, json!({"held":true}));
assert_eq!(p.read()["method"], "core.unlock");
p.answer(&barrier, json!({}));
let kept = p.read();
p.answer(&kept, json!({"held":true}));
assert_eq!(p.read()["result"], "late done");
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("wait", json!({}));
assert_eq!(p.read()["method"], "other.wait");
drop(p.input.take());
assert_eq!(p.read()["params"]["name"], "stopped");
assert!(p.child.wait().unwrap().success());
assert_eq!(std::fs::read(dir.join("stopped")).unwrap(), b"yes");
let mut p = start(&wasm, &dir);
p.request("start", json!({}));
let first = p.read();
let second = p.read();
let outgoing = if first["method"] == "other.background" {
first
} else {
second
};
assert_eq!(outgoing["method"], "other.background");
p.answer(&outgoing, json!({}));
assert!(p.child.wait().unwrap().success());
std::fs::remove_dir_all(dir).unwrap();
}