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
+6 -1
View File
@@ -10,7 +10,10 @@ pub fn is_too_large(error: &str) -> bool {
else {
return false;
};
if !matches!(status.split_whitespace().next(), Some("400" | "413")) {
if status.split_whitespace().next() == Some("413") {
return true;
}
if status.split_whitespace().next() != Some("400") {
return false;
}
let body = body.to_ascii_lowercase();
@@ -38,6 +41,8 @@ mod tests {
fn detects_context_size_errors_only_for_http_400_or_413() {
assert!(is_too_large("HTTP 400: context_length_exceeded"));
assert!(is_too_large("HTTP 413: prompt is too long"));
assert!(is_too_large("HTTP 413: {\"type\":\"request_too_large\"}"));
assert!(is_too_large("HTTP 413: {}"));
assert!(is_too_large("HTTP 400: maximum context length"));
assert!(is_too_large("HTTP 400: request is too large"));
assert!(!is_too_large("HTTP 500: prompt is too long"));
+14 -3
View File
@@ -122,6 +122,9 @@ pub enum Entry {
Opaque {
/// The API shape that owns this entry.
api: String,
/// The model that produced this item. Legacy items without a model are not replayed.
#[serde(default)]
model: String,
/// The original provider object.
item: Value,
},
@@ -244,7 +247,7 @@ async fn openai(
Entry::ToolResult { id, output } => {
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
}
Entry::Opaque { api, item } if api == "openai-responses" => input.push(item.clone()),
Entry::Opaque { api, model, item } if api == "openai-responses" && model == &cfg.model => input.push(item.clone()),
Entry::Opaque { .. } => {}
}
}
@@ -299,12 +302,17 @@ async fn openai(
}),
Some("reasoning") => out.push(Entry::Opaque {
api: "openai-responses".into(),
model: cfg.model.clone(),
item,
}),
_ => {}
}
}
if resp["status"] == "incomplete" && out.is_empty() {
if resp["status"] == "incomplete"
&& !out
.iter()
.any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty()))
{
return Err(format!(
"incomplete response: {}",
resp["incomplete_details"]
@@ -350,7 +358,9 @@ async fn anthropic(
json!({"type": "tool_result", "tool_use_id": id, "content": output}),
),
// Thinking blocks go back unchanged (with their signature): required next to tool use.
Entry::Opaque { api, item } if api == "anthropic-messages" && thinking => {
Entry::Opaque { api, model, item }
if api == "anthropic-messages" && model == &cfg.model && thinking =>
{
push("assistant", item.clone())
}
Entry::Opaque { .. } => {}
@@ -405,6 +415,7 @@ async fn anthropic(
}),
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
api: "anthropic-messages".into(),
model: cfg.model.clone(),
item: block,
}),
_ => {}
+42 -11
View File
@@ -33,7 +33,7 @@ impl Agent {
let _guard = lock(HISTORY).await?;
std::fs::create_dir_all("/state/agent")?;
let cfg: Config = serde_json::from_slice(&std::fs::read("/config/agent.json")?)?;
let mut entries = load_history()?;
let mut entries = load_history(HISTORY)?;
repair_tools(&mut entries)?;
let current = entries.len();
append(&mut entries, Entry::User { text: p.text })?;
@@ -52,12 +52,12 @@ impl Agent {
&& let Some(next) = next_turn_start(&entries, start + 1, current)
{
start = next;
std::fs::write(SKIP, start.to_string())?;
continue;
}
return Err(Error::new(-32603, error));
}
};
std::fs::write(SKIP, start.to_string())?;
let has_tools = response
.iter()
.any(|entry| matches!(entry, Entry::ToolCall { .. }));
@@ -70,7 +70,12 @@ impl Agent {
{
ctx.notify("text", json!({"text":text}))?
}
Entry::Assistant { text, .. } => final_text.push_str(text),
Entry::Assistant { text, .. } => {
if !final_text.is_empty() && !text.is_empty() {
final_text.push_str("\n\n");
}
final_text.push_str(text);
}
Entry::ToolCall {
id,
name,
@@ -87,14 +92,14 @@ impl Agent {
let output = match serde_json::from_str(&arguments) {
Ok(args) => tools::run(&name, &args)
.await
.unwrap_or_else(|error| json!({"error":error.to_string()})),
.unwrap_or_else(|error| json!({"error":error})),
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
};
append(
&mut entries,
Entry::ToolResult {
id,
output: output.to_string(),
output: tools::limit_output(output.to_string()),
},
)?;
}
@@ -103,12 +108,18 @@ impl Agent {
}
/// Read persisted entries in their original order. A new world has no history file.
fn load_history() -> Result<Vec<Entry>> {
match std::fs::read_to_string(HISTORY) {
Ok(text) => text
fn load_history(path: &str) -> Result<Vec<Entry>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(text
.lines()
.map(|line| serde_json::from_str(line).map_err(Into::into))
.collect(),
.filter_map(|line| match serde_json::from_str(line) {
Ok(entry) => Some(entry),
Err(error) => {
eprintln!("skip damaged history line: {error}");
None
}
})
.collect::<Vec<_>>()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error.into()),
}
@@ -119,7 +130,7 @@ fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
.create(true)
.append(true)
.open(HISTORY)?;
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
history.push(entry);
Ok(())
}
@@ -154,3 +165,23 @@ fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
fn main() {
sausage_plugin::run(Agent);
}
#[cfg(test)]
mod tests {
#[test]
fn damaged_history_keeps_valid_entries() {
let path = if cfg!(target_os = "wasi") {
std::path::PathBuf::from("/history-test.jsonl")
} else {
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id()))
};
std::fs::write(
&path,
"{\"kind\":\"user\",\"text\":\"preserved\"}\n{\"kind\":",
)
.unwrap();
let history = super::load_history(path.to_str().unwrap()).unwrap();
assert_eq!(history.len(), 1);
std::fs::remove_file(path).unwrap();
}
}
+1
View File
@@ -3,5 +3,6 @@ You are the Sausage agent. Answer the person. Use tools to inspect or change the
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`.
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin.
Never call `agent.chat`. It would wait on the history lock that your current turn holds.
Write logs to stderr. Use call results and notifications for communication.
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
+54 -5
View File
@@ -4,12 +4,29 @@ use crate::llm::Tool;
use serde_json::{Value, json};
use std::io;
/// Maximum UTF-8 bytes in a serialized tool result, including its truncation marker.
pub const MAX_OUTPUT_BYTES: usize = 65_536;
/// Bound every tool result before it enters history or a provider request.
pub fn limit_output(mut output: String) -> String {
const MARKER: &str = "\n[tool output truncated at 65536 bytes]";
if output.len() > MAX_OUTPUT_BYTES {
let mut end = MAX_OUTPUT_BYTES - MARKER.len();
while !output.is_char_boundary(end) {
end -= 1;
}
output.truncate(end);
output.push_str(MARKER);
}
output
}
/// Describe the five tools sent to either inference API.
pub fn definitions() -> Vec<Tool> {
vec![
Tool {
name: "call",
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller.",
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(
json!({
"method": {"type": "string", "description": "Qualified plugin method"},
@@ -21,7 +38,7 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "read_file",
description: "Read a text file in the world, including `/proc`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
description: "Read a text file in the world, including `/proc`. `offset` and `max_bytes` are optional. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(
json!({
"path": {"type": "string"},
@@ -33,7 +50,7 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "write_file",
description: "Write a text file in the world. Parent folders are created.",
description: "Write a text file in the world. Parent folders are created. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
&["path", "content"],
@@ -41,12 +58,12 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "list_dir",
description: "List entries in a world directory. Returns each name, type, and size.",
description: "List entries in a world directory. Returns each name, type, and size. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
},
Tool {
name: "copy_dir",
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders.",
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
&["from", "to"],
@@ -128,6 +145,15 @@ fn list_dir(args: &Value) -> io::Result<Value> {
/// Copy a tree and report its file count.
fn copy_dir(args: &Value) -> io::Result<Value> {
let from = std::fs::canonicalize(args["from"].as_str().unwrap_or_default())?;
let to = args["to"].as_str().unwrap_or_default();
std::fs::create_dir_all(to)?;
if std::fs::canonicalize(to)?.starts_with(&from) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"destination is inside the source",
));
}
let count = copy_tree(
args["from"].as_str().unwrap_or_default(),
args["to"].as_str().unwrap_or_default(),
@@ -155,3 +181,26 @@ fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
}
Ok(count)
}
#[cfg(test)]
mod tests {
#[test]
fn tool_output_limit_preserves_utf8_and_marks_truncation() {
let limited = super::limit_output("😀".repeat(40_000));
assert!(limited.len() <= super::MAX_OUTPUT_BYTES);
assert!(limited.ends_with("[tool output truncated at 65536 bytes]"));
assert_eq!(super::limit_output("small".into()), "small");
}
#[test]
fn copy_rejects_a_destination_inside_the_source() {
let root = if cfg!(target_os = "wasi") {
std::path::PathBuf::from("/copy-test")
} else {
std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id()))
};
std::fs::create_dir_all(&root).unwrap();
let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")}));
let _ = std::fs::remove_dir_all(root);
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
}
+147 -3
View File
@@ -299,20 +299,25 @@ async fn tools_and_interim_notifications() {
),
("read_file", json!({"path":"/state/work/missing"})),
("call", json!({"method":"absent.run","params":{}})),
("call", json!({"method":"probe.fail","params":{}})),
];
reply
.send((200, server.answer("I will inspect the world.", &calls)))
.unwrap();
let (body, reply) = server.next().await;
let results = outputs(&body, api);
assert_eq!(results.len(), 7);
assert_eq!(results.len(), 8);
assert_eq!(results[0], 9);
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
assert_eq!(results[4]["copied_files"], 1);
assert!(results[5]["error"].is_string());
assert!(results[6]["error"].is_string());
assert!(results[5]["error"]["message"].is_string());
assert!(results[6]["error"]["code"].is_number());
assert_eq!(
results[7]["error"],
json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}})
);
assert_eq!(
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
"saved"
@@ -407,3 +412,142 @@ async fn context_window_advances_by_complete_turns() {
host.stop().await;
}
}
/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_current_turn_preserves_the_previous_cursor() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap();
let host = world.start().await;
let first = chat(&host, "old retained turn");
let (_, reply) = server.next().await;
reply
.send((200, server.answer("old retained answer", &[])))
.unwrap();
first.await.unwrap();
let skip = world.0.join("state/agent/history.skip");
std::fs::write(&skip, "0").unwrap();
let caller = host.clone();
let turn = tokio::spawn(async move {
caller
.call("agent.chat", json!({"text":"read a large tool result"}))
.await
});
let (_, reply) = server.next().await;
reply
.send((
200,
server.answer("", &[("read_file", json!({"path":"/large.txt"}))]),
))
.unwrap();
let (body, reply) = server.next().await;
let serialized = input(&body, api).to_string();
assert!(serialized.contains("[tool output truncated"));
assert!(serialized.len() < 70_000);
reply
.send((413, json!({"type":"request_too_large"})))
.unwrap();
let (body, reply) = server.next().await;
assert!(!input(&body, api).to_string().contains("old retained turn"));
reply
.send((413, json!({"type":"request_too_large"})))
.unwrap();
assert!(turn.await.unwrap().is_err());
assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0");
host.stop().await;
}
}
/// Provider-private items are replayed only for their original API and model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn private_items_are_scoped_to_the_model() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let config_path = world.0.join("config/agent.json");
let mut config: Value =
serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
config["thinking"] = json!("high");
std::fs::write(config_path, config.to_string()).unwrap();
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
for (model, marker) in [
("another-model", "old-private-marker"),
("scripted-test", "current-private-marker"),
] {
let item = if api == APIS[0] {
json!({"type":"reasoning","encrypted_content":marker})
} else {
json!({"type":"thinking","thinking":"private","signature":marker})
};
entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item}));
}
entries.push(json!({"kind":"assistant","text":"old answer"}));
std::fs::write(
world.0.join("state/agent/history.jsonl"),
entries
.iter()
.map(|entry| format!("{entry}\n"))
.collect::<String>(),
)
.unwrap();
let host = world.start().await;
let turn = chat(&host, "next turn");
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
assert!(!text.contains("old-private-marker"));
assert!(text.contains("current-private-marker"));
reply.send((200, server.answer("done", &[]))).unwrap();
assert_eq!(turn.await.unwrap(), "done");
host.stop().await;
}
}
/// Separate assistant messages retain a separator in the final answer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_texts_have_a_separator() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let turn = chat(&host, "two paragraphs");
let (_, reply) = server.next().await;
let mut answer = server.answer("first", &[]);
if api == APIS[0] {
answer["output"]
.as_array_mut()
.unwrap()
.push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]}));
} else {
answer["content"]
.as_array_mut()
.unwrap()
.push(json!({"type":"text","text":"second"}));
}
reply.send((200, answer)).unwrap();
assert_eq!(turn.await.unwrap(), "first\n\nsecond");
host.stop().await;
}
}
/// An incomplete response containing reasoning but no text is an error.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn incomplete_without_text_is_an_error() {
let mut server = Llm::new(APIS[0]).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await });
let (_, reply) = server.next().await;
reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap();
assert!(
turn.await.unwrap().unwrap_err()["message"]
.as_str()
.unwrap()
.contains("incomplete response")
);
host.stop().await;
}
+27 -11
View File
@@ -1,5 +1,5 @@
//! Generate Rust clients from OpenRPC documents with typify's Draft 7 converter.
use quote::{format_ident, quote};
use quote::quote;
use serde_json::{Value, json};
/// Generate a formatted private client module for a provider's document.
@@ -22,8 +22,9 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
_ => {}
}
}
let module =
syn::parse_str::<syn::Ident>(&name.replace('-', "_")).map_err(|e| e.to_string())?;
let module = syn::parse_str::<syn::Ident>(&name.replace('-', "_"))
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{}", name.replace('-', "_"))))
.map_err(|e| e.to_string())?;
let mut space = typify::TypeSpace::default();
let mut defs = doc["components"]["schemas"]
.as_object()
@@ -47,19 +48,32 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
let mut result = vec![];
for method in methods.as_array().into_iter().flatten() {
let wire = method["name"].as_str().ok_or("method name missing")?;
let ident = syn::parse_str::<syn::Ident>(wire).map_err(|e| e.to_string())?;
let mut ps=method.get("x-sausage-params").cloned().unwrap_or_else(|| {
let mut properties=serde_json::Map::new();let mut required=vec![];
for p in method["params"].as_array().into_iter().flatten(){let n=p["name"].as_str().unwrap();properties.insert(n.into(),p["schema"].clone());if p["required"]==true{required.push(n);}}
json!({"title":format!("{}Params",wire.split('_').map(|s|{let mut c=s.chars();c.next().unwrap().to_uppercase().to_string()+c.as_str()}).collect::<String>()),"type":"object","properties":properties,"required":required})
});
local_refs(&mut ps);
let ident = syn::parse_str::<syn::Ident>(wire)
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{wire}")))
.map_err(|e| e.to_string())?;
let mut rs = method["result"]["schema"].clone();
local_refs(&mut rs);
let empty = method["params"].as_array().is_none_or(Vec::is_empty);
let p = if empty {
None
} else {
let mut properties = serde_json::Map::new();
let mut required = vec![];
for param in method["params"].as_array().into_iter().flatten() {
let name = param["name"].as_str().ok_or("parameter name missing")?;
properties.insert(name.into(), param["schema"].clone());
if param["required"] == true {
required.push(name);
}
}
let mut ps = method.get("x-sausage-params").cloned().unwrap_or_else(|| {
let title = wire.split('_').filter(|s| !s.is_empty()).map(|s| {
let mut chars = s.chars();
chars.next().unwrap().to_uppercase().to_string() + chars.as_str()
}).collect::<String>();
json!({"title":format!("{title}Params"),"type":"object","properties":properties,"required":required})
});
local_refs(&mut ps);
Some(
space
.add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?)
@@ -102,7 +116,9 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
let functions = methods(&mut space, name, &doc["methods"], false)?;
let mut endpoints = vec![];
for (name, description) in doc["x-sausage-endpoints"].as_object().into_iter().flatten() {
let ident = format_ident!("{}", name);
let ident = syn::parse_str::<syn::Ident>(name)
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{name}")))
.map_err(|e| e.to_string())?;
let functions = methods(&mut space, "", &description["methods"], true)?;
endpoints.push(quote!{pub mod #ident {use super::*;pub struct Client(::sausage_plugin::Endpoint);pub async fn connect(path:impl AsRef<::std::path::Path>)->::sausage_plugin::Result<Client>{let endpoint=::sausage_plugin::connect(path).await?;endpoint.require_interface(#name)?;Ok(Client(endpoint))} impl Client {#(#functions)*}}});
}
+13
View File
@@ -0,0 +1,13 @@
//! A provider may add its first optional named parameter.
use serde_json::json;
#[test]
fn first_optional_param_is_compatible() {
let consumer =
json!({"methods":[{"name":"status","params":[],"result":{"schema":{"type":"string"}}}]});
let mut provider = consumer.clone();
provider["methods"][0]["params"] =
json!([{"name":"detail","required":false,"schema":{"type":"boolean"}}]);
provider["methods"][0]["x-sausage-params"] =
json!({"type":"object","properties":{"detail":{"type":"boolean"}}});
assert!(sausage_interface::compat::check(&consumer, &provider).is_empty());
}
+29
View File
@@ -0,0 +1,29 @@
//! Client generation accepts unusual method names and reports invalid parameter names.
use serde_json::json;
#[test]
fn underscore_segments_and_empty_params_do_not_panic() {
for name in ["_internal", "a__b", "__"] {
for params in [
json!([]),
json!([{"name":"value","schema":{"type":"string"}}]),
] {
let doc = json!({"methods":[{"name":name,"params":params,"result":{"schema":{"type":"null"}}}]});
syn::parse_file(&sausage_interface::client_rs("probe", &doc).unwrap()).unwrap();
}
}
}
#[test]
fn unnamed_param_returns_error() {
let doc = json!({"methods":[{"name":"call","params":[{"schema":{"type":"string"}}],"result":{"schema":{"type":"null"}}}]});
assert!(sausage_interface::client_rs("probe", &doc).is_err());
}
#[test]
fn invalid_endpoint_identifier_returns_error() {
assert!(
sausage_interface::client_rs(
"probe",
&json!({"x-sausage-endpoints":{"bad-name":{"methods":[]}}})
)
.is_err()
);
}
+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();
}