diff --git a/agent/src/history.rs b/agent/src/history.rs index afabed8..ebf6619 100644 --- a/agent/src/history.rs +++ b/agent/src/history.rs @@ -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")); diff --git a/agent/src/llm.rs b/agent/src/llm.rs index 49b3497..00ee41b 100644 --- a/agent/src/llm.rs +++ b/agent/src/llm.rs @@ -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, }), _ => {} diff --git a/agent/src/main.rs b/agent/src/main.rs index af72fb1..a5b38d2 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -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> { - match std::fs::read_to_string(HISTORY) { - Ok(text) => text +fn load_history(path: &str) -> Result> { + 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::>()), 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) -> 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) -> 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(); + } +} diff --git a/agent/src/prompt.md b/agent/src/prompt.md index 5d65e2b..32027dc 100644 --- a/agent/src/prompt.md +++ b/agent/src/prompt.md @@ -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. diff --git a/agent/src/tools.rs b/agent/src/tools.rs index cd45a46..fb8b09c 100644 --- a/agent/src/tools.rs +++ b/agent/src/tools.rs @@ -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 { 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 { 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 { 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 { 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 { /// Copy a tree and report its file count. fn copy_dir(args: &Value) -> io::Result { + 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 { } 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); + } +} diff --git a/cli/tests/agent.rs b/cli/tests/agent.rs index 62baa16..e5f745b 100644 --- a/cli/tests/agent.rs +++ b/cli/tests/agent.rs @@ -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::(), + ) + .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; +} diff --git a/interface/src/client.rs b/interface/src/client.rs index 0d43c5c..baf3a94 100644 --- a/interface/src/client.rs +++ b/interface/src/client.rs @@ -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 { _ => {} } } - let module = - syn::parse_str::(&name.replace('-', "_")).map_err(|e| e.to_string())?; + let module = syn::parse_str::(&name.replace('-', "_")) + .or_else(|_| syn::parse_str::(&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 { 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::(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::()),"type":"object","properties":properties,"required":required}) - }); - local_refs(&mut ps); + let ident = syn::parse_str::(wire) + .or_else(|_| syn::parse_str::(&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::(); + 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 { 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::(name) + .or_else(|_| syn::parse_str::(&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{let endpoint=::sausage_plugin::connect(path).await?;endpoint.require_interface(#name)?;Ok(Client(endpoint))} impl Client {#(#functions)*}}}); } diff --git a/interface/tests/compat_params.rs b/interface/tests/compat_params.rs new file mode 100644 index 0000000..fa410df --- /dev/null +++ b/interface/tests/compat_params.rs @@ -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()); +} diff --git a/interface/tests/review_client.rs b/interface/tests/review_client.rs new file mode 100644 index 0000000..fef2358 --- /dev/null +++ b/interface/tests/review_client.rs @@ -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() + ); +} diff --git a/sdk/macros/src/lib.rs b/sdk/macros/src/lib.rs index 0fd1236..133408b 100644 --- a/sdk/macros/src/lib.rs +++ b/sdk/macros/src/lib.rs @@ -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=::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 {::describe()} fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin>+'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 diff --git a/sdk/src/calls.rs b/sdk/src/calls.rs index ec55bda..91a24c8 100644 --- a/sdk/src/calls.rs +++ b/sdk/src/calls.rs @@ -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"))) }) } } diff --git a/sdk/src/discovery.rs b/sdk/src/discovery.rs index 7353b46..19d0673 100644 --- a/sdk/src/discovery.rs +++ b/sdk/src/discovery.rs @@ -31,16 +31,7 @@ impl SchemaType for Option {} /// 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(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(doc: &mut Value, name: &str, docs: &str) } .into_generator() } - let mut input = generator(false); - let params_ref = serde_json::to_value(input.subschema_for::

()).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(&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(doc: &mut Discovery, name: &str, docs: &str) { + let params_ref = if P::schema_id() == ::schema_id() { + Value::Null + } else { + serde_json::to_value(doc.input.subschema_for::

()).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(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::()).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::()).unwrap(); let has_params = !params.is_empty(); let mut method = json!({ "name":name, @@ -92,14 +106,15 @@ pub fn method(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) { match value { Value::Object(map) => { diff --git a/sdk/src/endpoints.rs b/sdk/src/endpoints.rs index cacb1b9..c86842c 100644 --- a/sdk/src/endpoints.rs +++ b/sdk/src/endpoints.rs @@ -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 { endpoint: Endpoint, method: String, @@ -87,7 +91,7 @@ pub async fn listen(path: impl AsRef, value: Rc) -> Resul #[cfg(not(target_os = "wasi"))] pub async fn listen(_path: impl AsRef, _value: Rc) -> Result<()> { crate::transport::endpoint_name::()?; - 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) -> Result { /// Connect to an endpoint. Native programs require the WASI runtime. #[cfg(not(target_os = "wasi"))] pub async fn connect(_path: impl AsRef) -> Result { - 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 { 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 { || 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, ) -> Result { - 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( 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 { diff --git a/sdk/src/imports.rs b/sdk/src/imports.rs index 8dee1e4..0bbadd8 100644 --- a/sdk/src/imports.rs +++ b/sdk/src/imports.rs @@ -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 { call: Call, result: PhantomData, diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index 9bfd430..1f076da 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -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; diff --git a/sdk/src/lines.rs b/sdk/src/lines.rs index 8236b90..02f654a 100644 --- a/sdk/src/lines.rs +++ b/sdk/src/lines.rs @@ -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, + scanned: usize, } -#[cfg(target_os = "wasi")] +#[cfg(any(target_os = "wasi", test))] impl Lines { + fn frame(&mut self) -> Option> { + 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>> { 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> { + while let Some(bytes) = self.next(input).await? { + if let Ok(value) = serde_json::from_slice::(&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()); + } } diff --git a/sdk/src/locks.rs b/sdk/src/locks.rs index 72c3f83..297b5c9 100644 --- a/sdk/src/locks.rs +++ b/sdk/src/locks.rs @@ -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, } diff --git a/sdk/src/transport.rs b/sdk/src/transport.rs index b8c75dd..97e7aa7 100644 --- a/sdk/src/transport.rs +++ b/sdk/src/transport.rs @@ -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(value: T) { s.active = false; s.pending.clear(); }); + let _ = std::io::stdout().flush(); + std::process::exit(0); } diff --git a/sdk/tests/plugins/probe/Cargo.toml b/sdk/tests/plugins/probe/Cargo.toml index df95a6c..1b3e7c5 100644 --- a/sdk/tests/plugins/probe/Cargo.toml +++ b/sdk/tests/plugins/probe/Cargo.toml @@ -5,3 +5,4 @@ edition = "2024" [workspace] [dependencies] sausage-plugin = { path = "../../.." } +wstd = "0.6" diff --git a/sdk/tests/plugins/probe/src/main.rs b/sdk/tests/plugins/probe/src/main.rs index 6fdc4b7..623ee48 100644 --- a/sdk/tests/plugins/probe/src/main.rs +++ b/sdk/tests/plugins/probe/src/main.rs @@ -70,7 +70,11 @@ impl Probe { } /// Return a deliberate error. fn fail(&self) -> Result { - 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 { @@ -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) { diff --git a/sdk/tests/review.rs b/sdk/tests/review.rs new file mode 100644 index 0000000..31f53d7 --- /dev/null +++ b/sdk/tests/review.rs @@ -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 { + Ok(p) + } + /// Return counter status. + fn two(&self, p: b::Status) -> Result { + Ok(p) + } + /// Use an unescaped wire name. + fn r#type(&self) -> Result<()> { + Ok(()) + } +} +#[rpc] +impl Control { + /// Return endpoint counter status. + fn status(&self) -> Result { + 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 + ); +} diff --git a/sdk/tests/ui/hooks.rs b/sdk/tests/ui/hooks.rs new file mode 100644 index 0000000..96609c9 --- /dev/null +++ b/sdk/tests/ui/hooks.rs @@ -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() {} diff --git a/sdk/tests/ui/hooks.stderr b/sdk/tests/ui/hooks.stderr new file mode 100644 index 0000000..a9e7d9a --- /dev/null +++ b/sdk/tests/ui/hooks.stderr @@ -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 diff --git a/sdk/tests/ui/must_use.rs b/sdk/tests/ui/must_use.rs new file mode 100644 index 0000000..3ae6a6b --- /dev/null +++ b/sdk/tests/ui/must_use.rs @@ -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() {} diff --git a/sdk/tests/ui/must_use.stderr b/sdk/tests/ui/must_use.stderr new file mode 100644 index 0000000..5842531 --- /dev/null +++ b/sdk/tests/ui/must_use.stderr @@ -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); } + | +++++++ diff --git a/sdk/tests/ui/params.stderr b/sdk/tests/ui/params.stderr index a028ddb..7bebe38 100644 --- a/sdk/tests/ui/params.stderr +++ b/sdk/tests/ui/params.stderr @@ -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(doc: &mut Value, name: &str, docs: &str) { + | pub fn method(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 diff --git a/sdk/tests/ui/schemars.rs b/sdk/tests/ui/schemars.rs new file mode 100644 index 0000000..e656c31 --- /dev/null +++ b/sdk/tests/ui/schemars.rs @@ -0,0 +1,9 @@ +use sausage_plugin::schema; +/// A documented object. +#[schema] +struct Object { + /// A documented field. + #[schemars(with = "std::collections::HashMap")] + value: String, +} +fn main() {} diff --git a/sdk/tests/ui/schemars.stderr b/sdk/tests/ui/schemars.stderr new file mode 100644 index 0000000..657a2d6 --- /dev/null +++ b/sdk/tests/ui/schemars.stderr @@ -0,0 +1,5 @@ +error: schemars attributes are outside the schema subset + --> tests/ui/schemars.rs:6:5 + | +6 | #[schemars(with = "std::collections::HashMap")] + | ^ diff --git a/sdk/tests/wasi.rs b/sdk/tests/wasi.rs index 116d0b1..d67ebc2 100644 --- a/sdk/tests/wasi.rs +++ b/sdk/tests/wasi.rs @@ -11,6 +11,21 @@ struct Process { output: BufReader, } 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::(&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::(&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(); }