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:
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "sdk-probe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../.." }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Reject data-carrying enums.
|
||||
/// An invalid enum.
|
||||
#[sausage_plugin::schema]
|
||||
enum Bad {
|
||||
/// A payload variant.
|
||||
Value(String),
|
||||
}
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: schema enums require unit variants
|
||||
--> tests/ui/enum.rs:5:5
|
||||
|
|
||||
5 | /// A payload variant.
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Require field documentation.
|
||||
/// A documented type.
|
||||
#[sausage_plugin::schema]
|
||||
struct Bad { value: String }
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: a doc comment is required
|
||||
--> tests/ui/field_docs.rs:4:14
|
||||
|
|
||||
4 | struct Bad { value: String }
|
||||
| ^^^^^
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Require method documentation.
|
||||
struct Bad;
|
||||
#[sausage_plugin::rpc]
|
||||
impl Bad { fn bad(&self)->sausage_plugin::Result<()> {Ok(())} }
|
||||
fn main() {}
|
||||
@@ -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(())} }
|
||||
| ^^
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Reject undocumented schemas.
|
||||
#[sausage_plugin::schema]
|
||||
struct Bad { value: String }
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: a doc comment is required
|
||||
--> tests/ui/missing_docs.rs:3:8
|
||||
|
|
||||
3 | struct Bad { value: String }
|
||||
| ^^^
|
||||
@@ -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() {}
|
||||
@@ -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)
|
||||
@@ -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() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: serde attribute is outside the schema subset
|
||||
--> tests/ui/serde.rs:6:13
|
||||
|
|
||||
6 | #[serde(flatten)]
|
||||
| ^^^^^^^
|
||||
@@ -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() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: RPC methods require &self
|
||||
--> tests/ui/signature.rs:6:5
|
||||
|
|
||||
6 | fn bad(&mut self) -> sausage_plugin::Result<()> {Ok(())}
|
||||
| ^^
|
||||
@@ -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() {}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Reject tuple params.
|
||||
/// A tuple is not a named object.
|
||||
#[sausage_plugin::schema]
|
||||
struct Bad(String);
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: schema structs require named fields
|
||||
--> tests/ui/tuple.rs:2:1
|
||||
|
|
||||
2 | /// A tuple is not a named object.
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user