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 { else {
return false; 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; return false;
} }
let body = body.to_ascii_lowercase(); let body = body.to_ascii_lowercase();
@@ -38,6 +41,8 @@ mod tests {
fn detects_context_size_errors_only_for_http_400_or_413() { 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 400: context_length_exceeded"));
assert!(is_too_large("HTTP 413: prompt is too long")); 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: maximum context length"));
assert!(is_too_large("HTTP 400: request is too large")); assert!(is_too_large("HTTP 400: request is too large"));
assert!(!is_too_large("HTTP 500: prompt is too long")); assert!(!is_too_large("HTTP 500: prompt is too long"));
+14 -3
View File
@@ -122,6 +122,9 @@ pub enum Entry {
Opaque { Opaque {
/// The API shape that owns this entry. /// The API shape that owns this entry.
api: String, api: String,
/// The model that produced this item. Legacy items without a model are not replayed.
#[serde(default)]
model: String,
/// The original provider object. /// The original provider object.
item: Value, item: Value,
}, },
@@ -244,7 +247,7 @@ async fn openai(
Entry::ToolResult { id, output } => { Entry::ToolResult { id, output } => {
input.push(json!({"type": "function_call_output", "call_id": id, "output": 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 { .. } => {} Entry::Opaque { .. } => {}
} }
} }
@@ -299,12 +302,17 @@ async fn openai(
}), }),
Some("reasoning") => out.push(Entry::Opaque { Some("reasoning") => out.push(Entry::Opaque {
api: "openai-responses".into(), api: "openai-responses".into(),
model: cfg.model.clone(),
item, 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!( return Err(format!(
"incomplete response: {}", "incomplete response: {}",
resp["incomplete_details"] resp["incomplete_details"]
@@ -350,7 +358,9 @@ async fn anthropic(
json!({"type": "tool_result", "tool_use_id": id, "content": output}), json!({"type": "tool_result", "tool_use_id": id, "content": output}),
), ),
// Thinking blocks go back unchanged (with their signature): required next to tool use. // 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()) push("assistant", item.clone())
} }
Entry::Opaque { .. } => {} Entry::Opaque { .. } => {}
@@ -405,6 +415,7 @@ async fn anthropic(
}), }),
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque { Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
api: "anthropic-messages".into(), api: "anthropic-messages".into(),
model: cfg.model.clone(),
item: block, item: block,
}), }),
_ => {} _ => {}
+42 -11
View File
@@ -33,7 +33,7 @@ impl Agent {
let _guard = lock(HISTORY).await?; let _guard = lock(HISTORY).await?;
std::fs::create_dir_all("/state/agent")?; std::fs::create_dir_all("/state/agent")?;
let cfg: Config = serde_json::from_slice(&std::fs::read("/config/agent.json")?)?; 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)?; repair_tools(&mut entries)?;
let current = entries.len(); let current = entries.len();
append(&mut entries, Entry::User { text: p.text })?; append(&mut entries, Entry::User { text: p.text })?;
@@ -52,12 +52,12 @@ impl Agent {
&& let Some(next) = next_turn_start(&entries, start + 1, current) && let Some(next) = next_turn_start(&entries, start + 1, current)
{ {
start = next; start = next;
std::fs::write(SKIP, start.to_string())?;
continue; continue;
} }
return Err(Error::new(-32603, error)); return Err(Error::new(-32603, error));
} }
}; };
std::fs::write(SKIP, start.to_string())?;
let has_tools = response let has_tools = response
.iter() .iter()
.any(|entry| matches!(entry, Entry::ToolCall { .. })); .any(|entry| matches!(entry, Entry::ToolCall { .. }));
@@ -70,7 +70,12 @@ impl Agent {
{ {
ctx.notify("text", json!({"text":text}))? 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 { Entry::ToolCall {
id, id,
name, name,
@@ -87,14 +92,14 @@ impl Agent {
let output = match serde_json::from_str(&arguments) { let output = match serde_json::from_str(&arguments) {
Ok(args) => tools::run(&name, &args) Ok(args) => tools::run(&name, &args)
.await .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}")}), Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
}; };
append( append(
&mut entries, &mut entries,
Entry::ToolResult { Entry::ToolResult {
id, 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. /// Read persisted entries in their original order. A new world has no history file.
fn load_history() -> Result<Vec<Entry>> { fn load_history(path: &str) -> Result<Vec<Entry>> {
match std::fs::read_to_string(HISTORY) { match std::fs::read_to_string(path) {
Ok(text) => text Ok(text) => Ok(text
.lines() .lines()
.map(|line| serde_json::from_str(line).map_err(Into::into)) .filter_map(|line| match serde_json::from_str(line) {
.collect(), 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) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error.into()), Err(error) => Err(error.into()),
} }
@@ -119,7 +130,7 @@ fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
.create(true) .create(true)
.append(true) .append(true)
.open(HISTORY)?; .open(HISTORY)?;
writeln!(file, "{}", serde_json::to_string(&entry)?)?; file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
history.push(entry); history.push(entry);
Ok(()) Ok(())
} }
@@ -154,3 +165,23 @@ fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
fn main() { fn main() {
sausage_plugin::run(Agent); 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/`. 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`. 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. 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. 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. 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 serde_json::{Value, json};
use std::io; 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. /// Describe the five tools sent to either inference API.
pub fn definitions() -> Vec<Tool> { pub fn definitions() -> Vec<Tool> {
vec![ vec![
Tool { Tool {
name: "call", 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( parameters: schema(
json!({ json!({
"method": {"type": "string", "description": "Qualified plugin method"}, "method": {"type": "string", "description": "Qualified plugin method"},
@@ -21,7 +38,7 @@ pub fn definitions() -> Vec<Tool> {
}, },
Tool { Tool {
name: "read_file", 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( parameters: schema(
json!({ json!({
"path": {"type": "string"}, "path": {"type": "string"},
@@ -33,7 +50,7 @@ pub fn definitions() -> Vec<Tool> {
}, },
Tool { Tool {
name: "write_file", 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( parameters: schema(
json!({"path": {"type": "string"}, "content": {"type": "string"}}), json!({"path": {"type": "string"}, "content": {"type": "string"}}),
&["path", "content"], &["path", "content"],
@@ -41,12 +58,12 @@ pub fn definitions() -> Vec<Tool> {
}, },
Tool { Tool {
name: "list_dir", 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"]), parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
}, },
Tool { Tool {
name: "copy_dir", 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( parameters: schema(
json!({"from": {"type": "string"}, "to": {"type": "string"}}), json!({"from": {"type": "string"}, "to": {"type": "string"}}),
&["from", "to"], &["from", "to"],
@@ -128,6 +145,15 @@ fn list_dir(args: &Value) -> io::Result<Value> {
/// Copy a tree and report its file count. /// Copy a tree and report its file count.
fn copy_dir(args: &Value) -> io::Result<Value> { 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( let count = copy_tree(
args["from"].as_str().unwrap_or_default(), args["from"].as_str().unwrap_or_default(),
args["to"].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) 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"})), ("read_file", json!({"path":"/state/work/missing"})),
("call", json!({"method":"absent.run","params":{}})), ("call", json!({"method":"absent.run","params":{}})),
("call", json!({"method":"probe.fail","params":{}})),
]; ];
reply reply
.send((200, server.answer("I will inspect the world.", &calls))) .send((200, server.answer("I will inspect the world.", &calls)))
.unwrap(); .unwrap();
let (body, reply) = server.next().await; let (body, reply) = server.next().await;
let results = outputs(&body, api); let results = outputs(&body, api);
assert_eq!(results.len(), 7); assert_eq!(results.len(), 8);
assert_eq!(results[0], 9); assert_eq!(results[0], 9);
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC")); assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
assert_eq!(results[2]["written"], "/state/work/source/note.txt"); assert_eq!(results[2]["written"], "/state/work/source/note.txt");
assert_eq!(results[3]["entries"][0]["name"], "note.txt"); assert_eq!(results[3]["entries"][0]["name"], "note.txt");
assert_eq!(results[4]["copied_files"], 1); assert_eq!(results[4]["copied_files"], 1);
assert!(results[5]["error"].is_string()); assert!(results[5]["error"]["message"].is_string());
assert!(results[6]["error"].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!( assert_eq!(
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(), std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
"saved" "saved"
@@ -407,3 +412,142 @@ async fn context_window_advances_by_complete_turns() {
host.stop().await; 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. //! 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}; use serde_json::{Value, json};
/// Generate a formatted private client module for a provider's document. /// 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 = let module = syn::parse_str::<syn::Ident>(&name.replace('-', "_"))
syn::parse_str::<syn::Ident>(&name.replace('-', "_")).map_err(|e| e.to_string())?; .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 space = typify::TypeSpace::default();
let mut defs = doc["components"]["schemas"] let mut defs = doc["components"]["schemas"]
.as_object() .as_object()
@@ -47,19 +48,32 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
let mut result = vec![]; let mut result = vec![];
for method in methods.as_array().into_iter().flatten() { for method in methods.as_array().into_iter().flatten() {
let wire = method["name"].as_str().ok_or("method name missing")?; 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 ident = syn::parse_str::<syn::Ident>(wire)
let mut ps=method.get("x-sausage-params").cloned().unwrap_or_else(|| { .or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{wire}")))
let mut properties=serde_json::Map::new();let mut required=vec![]; .map_err(|e| e.to_string())?;
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 mut rs = method["result"]["schema"].clone(); let mut rs = method["result"]["schema"].clone();
local_refs(&mut rs); local_refs(&mut rs);
let empty = method["params"].as_array().is_none_or(Vec::is_empty); let empty = method["params"].as_array().is_none_or(Vec::is_empty);
let p = if empty { let p = if empty {
None None
} else { } 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( Some(
space space
.add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?) .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 functions = methods(&mut space, name, &doc["methods"], false)?;
let mut endpoints = vec![]; let mut endpoints = vec![];
for (name, description) in doc["x-sausage-endpoints"].as_object().into_iter().flatten() { 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)?; 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)*}}}); 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 proc_macro::TokenStream;
use quote::quote; use quote::quote;
use syn::{ use syn::{
Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, parse_macro_input, Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, ext::IdentExt,
spanned::Spanned, parse_macro_input, spanned::Spanned,
}; };
fn docs(attrs: &[Attribute]) -> String { fn docs(attrs: &[Attribute]) -> String {
attrs attrs
@@ -30,6 +30,12 @@ fn documented(attrs: &[Attribute], span: proc_macro2::Span) -> syn::Result<()> {
} }
fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> { fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> {
for a in attrs { 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") { if a.path().is_ident("serde") {
a.parse_nested_meta(|m| { a.parse_nested_meta(|m| {
if !["rename", "rename_all", "default", "skip_serializing_if"] 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 arms = vec![];
let mut entries = vec![]; let mut entries = vec![];
let mut stop = quote!(); let mut stop = quote!();
let mut has_stop = false;
let mut has_retired = false;
for member in &mut item.items { for member in &mut item.items {
let ImplItem::Fn(f) = member else { continue }; let ImplItem::Fn(f) = member else { continue };
let retired = f.attrs.iter().any(|a| { let retired = f.attrs.iter().any(|a| {
@@ -159,6 +167,18 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
.last() .last()
.is_some_and(|s| s.ident == "on_stop") .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| { f.attrs.retain(|a| {
!a.path() !a.path()
.segments .segments
@@ -207,7 +227,7 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
let wire = if retired { let wire = if retired {
"rpc.retired".to_string() "rpc.retired".to_string()
} else { } else {
name.to_string() name.unraw().to_string()
}; };
let p = param let p = param
.map(|t| quote!(#t)) .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);)); entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);));
} }
} }
let grace = let grace = grace
grace.map(|n| quote!(doc["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);)); .map(|n| quote!(doc.value["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);));
let names: Vec<_> = endpoints.iter().map(|(n, _)| n.to_string()).collect(); let names: Vec<_> = endpoints
.iter()
.map(|(n, _)| n.unraw().to_string())
.collect();
let types: Vec<_> = endpoints.iter().map(|(_, t)| t).collect(); let types: Vec<_> = endpoints.iter().map(|(_, t)| t).collect();
Ok(quote! {#item impl ::sausage_plugin::Interface for #ty { 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 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 add_methods(mut doc:&mut ::sausage_plugin::Discovery) {#(#entries)*}
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 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 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"))}})} 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 #stop
+3 -2
View File
@@ -1,6 +1,7 @@
//! Outgoing core calls and notification callbacks. //! Outgoing core calls and notification callbacks.
use super::*; use super::*;
/// An outgoing request builder. /// An outgoing request builder.
#[must_use = "await the call to send it"]
pub struct Call { pub struct Call {
method: String, method: String,
params: Value, params: Value,
@@ -43,7 +44,7 @@ impl IntoFuture for Call {
let mut s = s.borrow_mut(); let mut s = s.borrow_mut();
if !s.active || s.stopping { if !s.active || s.stopping {
return Err(Error::new( return Err(Error::new(
-32002, -32000,
"no core transport (or stdin reached EOF)", "no core transport (or stdin reached EOF)",
)); ));
} }
@@ -72,7 +73,7 @@ impl IntoFuture for Call {
return Err(e); return Err(e);
} }
rx.await 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. /// Create an OpenRPC document builder for generated interfaces.
#[doc(hidden)] #[doc(hidden)]
pub fn document(title: &str, version: &str) -> Value { pub fn document(title: &str, version: &str) -> Discovery {
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) {
fn generator(serialize: bool) -> schemars::SchemaGenerator { fn generator(serialize: bool) -> schemars::SchemaGenerator {
let mut settings = schemars::generate::SchemaSettings::draft07(); let mut settings = schemars::generate::SchemaSettings::draft07();
settings.definitions_path = if serialize { 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() .into_generator()
} }
let mut input = generator(false); Discovery {
let params_ref = serde_json::to_value(input.subschema_for::<P>()).unwrap(); value: sausage_interface::Document::new(title, version).into_value(),
let definitions = input.take_definitions(true); input: generator(false),
let schema = &definitions[P::schema_name().as_ref()]; 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)| { let params:Vec<_>=schema["properties"].as_object().into_iter().flatten().map(|(name,field)| {
json!({ json!({
"name": name, "name": name,
@@ -68,18 +93,7 @@ pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str)
"schema": field, "schema": field,
}) })
}).collect(); }).collect();
if !params.is_empty() { let result_schema = serde_json::to_value(doc.output.subschema_for::<R>()).unwrap();
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 has_params = !params.is_empty(); let has_params = !params.is_empty();
let mut method = json!({ let mut method = json!({
"name":name, "name":name,
@@ -92,14 +106,15 @@ pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str)
if has_params { if has_params {
method["x-sausage-params"] = params_ref; 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. /// Resolve shared schema names after all methods contribute their contracts.
#[doc(hidden)] #[doc(hidden)]
pub fn finish_document(mut doc: Value) -> Value { pub fn finish_document(mut discovery: Discovery) -> Value {
let input = doc.as_object_mut().unwrap().remove("x-sdk-input").unwrap(); let input = serde_json::to_value(discovery.input.take_definitions(true)).unwrap();
let output = doc.as_object_mut().unwrap().remove("x-sdk-output").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>) { fn refs(value: &mut Value, names: &std::collections::HashMap<String, String>) {
match value { match value {
Value::Object(map) => { 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}; use std::{future::IntoFuture, marker::PhantomData, path::Path, rc::Rc};
/// A verified endpoint address and identity. /// 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)] #[derive(Clone)]
#[cfg_attr(not(target_os = "wasi"), allow(dead_code))] #[cfg_attr(not(target_os = "wasi"), allow(dead_code))]
pub struct Endpoint { pub struct Endpoint {
@@ -20,7 +23,7 @@ impl Endpoint {
if self.interface == name { if self.interface == name {
Ok(()) Ok(())
} else { } else {
Err(Error::new(-32002, "endpoint interface mismatch")) Err(Error::new(-32000, "endpoint interface mismatch"))
} }
} }
/// Build a typed endpoint request. /// Build a typed endpoint request.
@@ -35,6 +38,7 @@ impl Endpoint {
} }
} }
/// A lazy endpoint request with optional progress callbacks. /// A lazy endpoint request with optional progress callbacks.
#[must_use = "await the endpoint call to send it"]
pub struct EndpointCall<T> { pub struct EndpointCall<T> {
endpoint: Endpoint, endpoint: Endpoint,
method: String, 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"))] #[cfg(not(target_os = "wasi"))]
pub async fn listen<T: Interface>(_path: impl AsRef<Path>, _value: Rc<T>) -> Result<()> { pub async fn listen<T: Interface>(_path: impl AsRef<Path>, _value: Rc<T>) -> Result<()> {
crate::transport::endpoint_name::<T>()?; 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. /// 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. /// Connect to an endpoint. Native programs require the WASI runtime.
#[cfg(not(target_os = "wasi"))] #[cfg(not(target_os = "wasi"))]
pub async fn connect(_path: impl AsRef<Path>) -> Result<Endpoint> { 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")] #[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":{}}), json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}}),
) )
.await?; .await?;
let bytes = crate::lines::Lines::default() let reply = crate::lines::Lines::default()
.next(&mut stream) .rpc(&mut stream)
.await? .await?
.ok_or_else(|| Error::new(-32002, "endpoint closed"))?; .ok_or_else(|| Error::new(-32000, "endpoint closed"))?;
let reply: Value = serde_json::from_slice(&bytes)?;
let result = &reply["result"]; let result = &reply["result"];
let plugin = endpoint let plugin = endpoint
.owner .owner
@@ -154,7 +157,7 @@ async fn handshake(endpoint: &Endpoint) -> Result<wstd::net::TcpStream> {
|| result["interface"] != endpoint.interface || result["interface"] != endpoint.interface
|| result["plugin"].as_str() != plugin || result["plugin"].as_str() != plugin
{ {
return Err(Error::new(-32002, "endpoint identity mismatch")); return Err(Error::new(-32000, "endpoint identity mismatch"));
} }
Ok(stream) Ok(stream)
} }
@@ -172,8 +175,7 @@ async fn endpoint_request(
json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}), json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}),
) )
.await?; .await?;
while let Some(bytes) = lines.next(&mut stream).await? { while let Some(value) = lines.rpc(&mut stream).await? {
let value: Value = serde_json::from_slice(&bytes)?;
if value["method"] == "core.notify" { if value["method"] == "core.notify" {
if let Some(f) = &mut notify { if let Some(f) = &mut notify {
f( f(
@@ -188,7 +190,7 @@ async fn endpoint_request(
return Ok(value["result"].clone()); 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"))] #[cfg(not(target_os = "wasi"))]
async fn endpoint_request( async fn endpoint_request(
@@ -197,7 +199,7 @@ async fn endpoint_request(
_params: Value, _params: Value,
_notify: Option<Notify>, _notify: Option<Notify>,
) -> Result<Value> { ) -> Result<Value> {
Err(Error::new(-32002, "endpoints require WASI")) Err(Error::new(-32000, "endpoints require WASI"))
} }
#[cfg(target_os = "wasi")] #[cfg(target_os = "wasi")]
@@ -209,11 +211,7 @@ async fn serve_connection<T: Interface>(
use futures::{FutureExt, StreamExt}; use futures::{FutureExt, StreamExt};
let (mut reader, mut writer) = stream.split(); let (mut reader, mut writer) = stream.split();
let mut lines = crate::lines::Lines::default(); let mut lines = crate::lines::Lines::default();
while let Some(bytes) = lines.next(&mut reader).await? { while let Some(request) = lines.rpc(&mut reader).await? {
let request: Value = serde_json::from_slice(&bytes)?;
if request["jsonrpc"] != "2.0" {
continue;
}
let method = request["method"].as_str().unwrap_or_default(); let method = request["method"].as_str().unwrap_or_default();
let (tx, mut rx) = futures::channel::mpsc::unbounded(); let (tx, mut rx) = futures::channel::mpsc::unbounded();
let ctx = Ctx { let ctx = Ctx {
+1
View File
@@ -14,6 +14,7 @@ pub struct Import {
inventory::collect!(Import); inventory::collect!(Import);
/// An outgoing call whose result decodes to a generated Rust type. /// An outgoing call whose result decodes to a generated Rust type.
#[must_use = "await the call to send it"]
pub struct TypedCall<T> { pub struct TypedCall<T> {
call: Call, call: Call,
result: PhantomData<T>, result: PhantomData<T>,
+2
View File
@@ -44,6 +44,8 @@
//! [`listen`] publishes a declared interface at an endpoint file. //! [`listen`] publishes a declared interface at an endpoint file.
//! [`connect`] verifies its owner before it returns an [`Endpoint`]. //! [`connect`] verifies its owner before it returns an [`Endpoint`].
//! Endpoint calls report progress through [`EndpointCall::on_notify`]. //! 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. //! Log with `eprintln!`. Stdout belongs to JSON-RPC.
#![warn(missing_docs)] #![warn(missing_docs)]
mod discovery; mod discovery;
+45 -4
View File
@@ -1,22 +1,34 @@
//! Buffered newline framing shared by stdin and endpoint connections. //! Buffered newline framing shared by stdin and endpoint connections.
#[cfg(target_os = "wasi")] #[cfg(any(target_os = "wasi", test))]
#[derive(Default)] #[derive(Default)]
pub(crate) struct Lines { pub(crate) struct Lines {
bytes: Vec<u8>, bytes: Vec<u8>,
scanned: usize,
} }
#[cfg(target_os = "wasi")] #[cfg(any(target_os = "wasi", test))]
impl Lines { 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( pub(crate) async fn next(
&mut self, &mut self,
input: &mut impl wstd::io::AsyncRead, input: &mut impl wstd::io::AsyncRead,
) -> std::io::Result<Option<Vec<u8>>> { ) -> std::io::Result<Option<Vec<u8>>> {
loop { loop {
if let Some(end) = self.bytes.iter().position(|b| *b == b'\n') { if let Some(line) = self.frame() {
return Ok(Some(self.bytes.drain(..=end).collect())); return Ok(Some(line));
} }
let mut buffer = [0; 8192]; let mut buffer = [0; 8192];
let count = input.read(&mut buffer).await?; let count = input.read(&mut buffer).await?;
if count == 0 { if count == 0 {
self.scanned = 0;
return Ok(if self.bytes.is_empty() { return Ok(if self.bytes.is_empty() {
None None
} else { } else {
@@ -26,4 +38,33 @@ impl Lines {
self.bytes.extend_from_slice(&buffer[..count]); 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. //! Advisory lock acquisition and guard lifetime.
use super::*; use super::*;
/// An advisory lock acquisition. /// An advisory lock acquisition.
#[must_use = "await the lock to acquire it"]
pub struct Lock { pub struct Lock {
path: String, path: String,
wait: bool, wait: bool,
@@ -20,6 +21,7 @@ impl Lock {
} }
} }
/// Releases its lock on drop, unless kept until process exit. /// Releases its lock on drop, unless kept until process exit.
#[must_use = "bind the guard to hold the lock"]
pub struct Guard { pub struct Guard {
path: Option<String>, path: Option<String>,
} }
+9 -2
View File
@@ -21,6 +21,11 @@ pub use locks::*;
pub trait Interface: 'static { pub trait Interface: 'static {
/// Describe this interface without constructing its value. /// Describe this interface without constructing its value.
fn describe() -> 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 where
Self: Sized; Self: Sized;
/// Describe only this type's methods, without endpoint declarations. /// Describe only this type's methods, without endpoint declarations.
@@ -58,7 +63,7 @@ impl Ctx {
let params = json!({"name":name,"params":params}); let params = json!({"name":name,"params":params});
if let Some(sink) = &self.sink { if let Some(sink) = &self.sink {
sink.unbounded_send(json!({"jsonrpc":"2.0","method":"core.notify","params":params})) 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 { } else {
notify("core.notify", params) notify("core.notify", params)
} }
@@ -152,7 +157,7 @@ fn send(value: Value) -> Result<()> {
} }
fn notify(method: &str, params: Value) -> Result<()> { fn notify(method: &str, params: Value) -> Result<()> {
if !STATE.with(|s| s.borrow().active) { 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})) send(json!({"jsonrpc":"2.0","method":method,"params":params}))
} }
@@ -376,4 +381,6 @@ async fn serve<T: Interface>(value: T) {
s.active = false; s.active = false;
s.pending.clear(); s.pending.clear();
}); });
let _ = std::io::stdout().flush();
std::process::exit(0);
} }
+1
View File
@@ -5,3 +5,4 @@ edition = "2024"
[workspace] [workspace]
[dependencies] [dependencies]
sausage-plugin = { path = "../../.." } sausage-plugin = { path = "../../.." }
wstd = "0.6"
+18 -1
View File
@@ -70,7 +70,11 @@ impl Probe {
} }
/// Return a deliberate error. /// Return a deliberate error.
fn fail(&self) -> Result<String> { 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. /// Exercise calls, notifications, and advisory locks.
async fn exercise(&self, ctx: &Ctx) -> Result<String> { async fn exercise(&self, ctx: &Ctx) -> Result<String> {
@@ -119,6 +123,19 @@ impl Probe {
}); });
Ok("started".into()) 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. /// Save an EOF marker.
#[sausage_plugin::on_stop] #[sausage_plugin::on_stop]
async fn stop(&self) { 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` note: required by a bound in `sausage_plugin::method`
--> src/discovery.rs --> 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` | ^^^^^^ required by this bound in `method`
error[E0277]: RPC params must be a #[schema] struct with named fields 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>, output: BufReader<std::process::ChildStdout>,
} }
impl Process { 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) { fn send(&mut self, v: Value) {
writeln!(self.input.as_mut().unwrap(), "{v}").unwrap(); 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 wasm = target.join("wasm32-wasip2/debug/sdk-probe.wasm");
let dir = std::env::temp_dir().join(format!("sausage-sdk-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("sausage-sdk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap(); 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); let mut p = start(&wasm, &dir);
p.request("rpc.discover", json!({})); p.request("rpc.discover", json!({}));
let d = p.read(); let d = p.read();
@@ -170,7 +194,7 @@ fn protocol() {
p.answer(&outgoing, json!({})); p.answer(&outgoing, json!({}));
assert!(p.child.wait().unwrap().success()); assert!(p.child.wait().unwrap().success());
let provider_doc = &d["result"]; 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::create_dir_all(&imports).unwrap();
std::fs::write( std::fs::write(
imports.join("probe.json"), imports.join("probe.json"),
@@ -237,9 +261,82 @@ fn protocol() {
.contains("identity mismatch") .contains("identity mismatch")
); );
assert!(client.child.wait().unwrap().success()); assert!(client.child.wait().unwrap().success());
std::fs::write(path, original).unwrap(); std::fs::write(&path, original).unwrap();
drop(provider.input.take()); // The endpoint server ignores non-RPC lines before a request.
assert_eq!(provider.read()["params"]["name"], "stopped"); let address = std::fs::read_to_string(&path)
assert!(provider.child.wait().unwrap().success()); .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(); std::fs::remove_dir_all(dir).unwrap();
} }