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:
@@ -5,3 +5,4 @@ edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../.." }
|
||||
wstd = "0.6"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
@@ -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() {}
|
||||
@@ -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); }
|
||||
| +++++++
|
||||
@@ -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
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user