#![cfg(feature = "stdio")]
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::Duration;
use protocol_droid::adapters::primary::stdio::{Diagnostics, StdioConfig, StdioError};
use protocol_droid::adapters::secondary::stdio::{
ChildSpec, ChildSpecError, ClientConfig, SpawnError, StdioClient, StdioClientError,
};
use protocol_droid::application::jsonrpc::SourceError;
use protocol_droid::application::jsonrpc::document::{RequestError, request_document};
use protocol_droid::domain::ir::{JsonObject, JsonObjectError, MethodName, MethodNameError};
use protocol_droid::domain::{MaxFrameSize, MaxFrameSizeError};
use tokio::io::AsyncWrite;
const GUARD: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_millis(750);
const REVISION: &str = "2025-06-18";
const GRACEFUL_EXIT_MARKER: &str = "the peer closed the stream";
const OVERSIZED_PADDING: usize = 5 * 1024 * 1024;
const CLIENT_CEILING: u32 = 8 * 1024 * 1024;
#[derive(Debug)]
#[expect(
dead_code,
reason = "the payloads are read by the derived Debug, not by the tests"
)]
enum Failure {
Io(io::Error),
Spec(ChildSpecError),
Spawn(SpawnError),
Client(StdioClientError),
Source(SourceError),
Stdio(StdioError),
Document(RequestError),
Method(MethodNameError),
Ceiling(MaxFrameSizeError),
Object(JsonObjectError),
Json(serde_json::Error),
NoCurrentExe,
ExitedEarly(String),
TimedOut(&'static str),
NoFrame(&'static str),
Unexpected {
what: &'static str,
got: String,
},
Absent {
what: String,
},
}
impl From<io::Error> for Failure {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<ChildSpecError> for Failure {
fn from(error: ChildSpecError) -> Self {
Self::Spec(error)
}
}
impl From<SpawnError> for Failure {
fn from(error: SpawnError) -> Self {
Self::Spawn(error)
}
}
impl From<StdioClientError> for Failure {
fn from(error: StdioClientError) -> Self {
Self::Client(error)
}
}
impl From<SourceError> for Failure {
fn from(error: SourceError) -> Self {
Self::Source(error)
}
}
impl From<StdioError> for Failure {
fn from(error: StdioError) -> Self {
Self::Stdio(error)
}
}
impl From<RequestError> for Failure {
fn from(error: RequestError) -> Self {
Self::Document(error)
}
}
impl From<MethodNameError> for Failure {
fn from(error: MethodNameError) -> Self {
Self::Method(error)
}
}
impl From<MaxFrameSizeError> for Failure {
fn from(error: MaxFrameSizeError) -> Self {
Self::Ceiling(error)
}
}
impl From<JsonObjectError> for Failure {
fn from(error: JsonObjectError) -> Self {
Self::Object(error)
}
}
impl From<serde_json::Error> for Failure {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
fn example_server() -> Result<Option<PathBuf>, Failure> {
let running = std::env::current_exe().map_err(|_| Failure::NoCurrentExe)?;
let Some(profile_dir) = running.parent().and_then(std::path::Path::parent) else {
return Ok(None);
};
let path = profile_dir
.join("examples")
.join(format!("stdio_server{}", std::env::consts::EXE_SUFFIX));
Ok(path.is_file().then_some(path))
}
fn program_or_skip() -> Result<Option<PathBuf>, Failure> {
let found = example_server()?;
if found.is_none() {
eprintln!(
"skipping: the stdio_server example has not been built. \
`cargo test` builds examples, so a plain \
`cargo test --workspace --all-features` runs this; \
`cargo build -p protocol-droid --example stdio_server` builds it alone."
);
}
Ok(found)
}
fn spec_for(path: &Path) -> Result<ChildSpec, Failure> {
Ok(ChildSpec::new(path.as_os_str())?)
}
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl Capture {
fn text(&self) -> String {
match self.0.lock() {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(poisoned) => String::from_utf8_lossy(&poisoned.into_inner()).into_owned(),
}
}
}
impl AsyncWrite for Capture {
fn poll_write(
self: Pin<&mut Self>,
_context: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let shared = self.as_ref().get_ref();
let written = buf.len();
match shared.0.lock() {
Ok(mut bytes) => bytes.extend_from_slice(buf),
Err(poisoned) => poisoned.into_inner().extend_from_slice(buf),
}
Poll::Ready(Ok(written))
}
fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
async fn capture_gains(capture: &Capture, needle: &str) -> bool {
let deadline = tokio::time::Instant::now() + SETTLE;
loop {
if capture.text().contains(needle) {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
fn at<'a>(value: &'a serde_json::Value, path: &[&str]) -> Result<&'a serde_json::Value, Failure> {
let mut cursor = value;
for key in path {
cursor = cursor.get(key).ok_or_else(|| Failure::Absent {
what: path.join("."),
})?;
}
Ok(cursor)
}
fn string_at(value: &serde_json::Value, path: &[&str]) -> Result<String, Failure> {
let found = at(value, path)?;
found
.as_str()
.map(str::to_owned)
.ok_or_else(|| Failure::Unexpected {
what: "a string",
got: found.to_string(),
})
}
fn result_of(reply: &JsonObject) -> Result<serde_json::Value, Failure> {
Ok(serde_json::from_str(reply.as_str())?)
}
fn tool_names(listed: &serde_json::Value) -> Result<Vec<String>, Failure> {
let tools = at(listed, &["tools"])?;
let array = tools.as_array().ok_or_else(|| Failure::Unexpected {
what: "a tools array",
got: tools.to_string(),
})?;
let mut names = Vec::new();
for tool in array {
let name = tool
.get("name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Failure::Unexpected {
what: "a named tool",
got: tool.to_string(),
})?;
names.push(name.to_owned());
}
Ok(names)
}
fn tool_named<'a>(
listed: &'a serde_json::Value,
name: &str,
) -> Result<&'a serde_json::Value, Failure> {
let tools = at(listed, &["tools"])?;
let array = tools.as_array().ok_or_else(|| Failure::Unexpected {
what: "a tools array",
got: tools.to_string(),
})?;
for tool in array {
if tool.get("name").and_then(serde_json::Value::as_str) == Some(name) {
return Ok(tool);
}
}
Err(Failure::Absent {
what: format!("a tool named {name}"),
})
}
fn initialize_params() -> Result<JsonObject, Failure> {
Ok(JsonObject::new(format!(
r#"{{"protocolVersion":"{REVISION}","capabilities":{{}},"clientInfo":{{"name":"stdio_end_to_end","version":"0.1.0"}}}}"#
))?)
}
async fn client_on(path: &Path, capture: &Capture) -> Result<StdioClient<Capture>, Failure> {
let spec = spec_for(path)?;
let config = ClientConfig::new(StdioConfig::new(MaxFrameSize::new(CLIENT_CEILING)?));
Ok(tokio::time::timeout(
GUARD,
StdioClient::spawn(&spec, config, Diagnostics::new(capture.clone())),
)
.await
.map_err(|_| Failure::TimedOut("spawning the example server"))??)
}
async fn initialize(client: &StdioClient<Capture>) -> Result<serde_json::Value, Failure> {
let id = client.next_request_id();
let method = MethodName::new("initialize")?;
let reply = tokio::time::timeout(GUARD, client.call(&id, &method, &initialize_params()?))
.await
.map_err(|_| Failure::TimedOut("the initialize handshake"))??;
result_of(&reply)
}
#[tokio::test]
async fn a_full_session_runs_over_a_real_pipe() -> Result<(), Failure> {
let Some(path) = program_or_skip()? else {
return Ok(());
};
let capture = Capture::default();
let client = client_on(&path, &capture).await?;
let pid = client.child_id();
if pid.is_none() {
return Err(Failure::Unexpected {
what: "a spawned child with a process id",
got: "none".to_owned(),
});
}
let initialized = initialize(&client).await?;
let version = string_at(&initialized, &["protocolVersion"])?;
if version != REVISION {
return Err(Failure::Unexpected {
what: "the negotiated revision",
got: version,
});
}
let server = string_at(&initialized, &["serverInfo", "name"])?;
if server != "stdio_server::Greeter" {
return Err(Failure::Unexpected {
what: "the server's name",
got: server,
});
}
list_and_check_schemas(&client).await?;
call_and_check_content(&client).await?;
notification_draws_no_answer(&client).await?;
let off_protocol = capture.text();
if !off_protocol.is_empty() {
return Err(Failure::Unexpected {
what: "an empty diagnostics channel",
got: off_protocol,
});
}
Ok(())
}
async fn list_and_check_schemas(client: &StdioClient<Capture>) -> Result<(), Failure> {
let listed = result_of(
&tokio::time::timeout(
GUARD,
client.call(
&client.next_request_id(),
&MethodName::new("tools/list")?,
&JsonObject::default(),
),
)
.await
.map_err(|_| Failure::TimedOut("tools/list"))??,
)?;
let names = tool_names(&listed)?;
for expected in ["shout", "negotiated"] {
if !names.iter().any(|name| name == expected) {
return Err(Failure::Unexpected {
what: "the listed tools",
got: names.join(", "),
});
}
}
let shout = tool_named(&listed, "shout")?;
let required = at(shout, &["inputSchema", "required"])?;
let entries = required.as_array().ok_or_else(|| Failure::Unexpected {
what: "a required-arguments array",
got: required.to_string(),
})?;
let text_is_required = entries.iter().any(|entry| entry.as_str() == Some("text"));
if !text_is_required {
return Err(Failure::Unexpected {
what: "shout's required arguments",
got: required.to_string(),
});
}
Ok(())
}
async fn call_and_check_content(client: &StdioClient<Capture>) -> Result<(), Failure> {
let call_params = JsonObject::new(r#"{"name":"shout","arguments":{"text":"hello droid"}}"#)?;
let called = result_of(
&tokio::time::timeout(
GUARD,
client.call(
&client.next_request_id(),
&MethodName::new("tools/call")?,
&call_params,
),
)
.await
.map_err(|_| Failure::TimedOut("tools/call"))??,
)?;
let block = at(&called, &["content"])?;
let first = block
.as_array()
.and_then(|blocks| blocks.first())
.ok_or_else(|| Failure::Unexpected {
what: "a content block",
got: block.to_string(),
})?;
let text = first
.get("text")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Failure::Unexpected {
what: "text in the content block",
got: first.to_string(),
})?;
if text != r#"{"length":11,"text":"HELLO DROID"}"# {
return Err(Failure::Unexpected {
what: "shout's returned content",
got: text.to_owned(),
});
}
let uppercased = string_at(&called, &["structuredContent", "text"])?;
if uppercased != "HELLO DROID" {
return Err(Failure::Unexpected {
what: "shout's structured content",
got: uppercased,
});
}
let is_error = at(&called, &["isError"])?;
if is_error != &serde_json::Value::Bool(false) {
return Err(Failure::Unexpected {
what: "isError on a successful call",
got: is_error.to_string(),
});
}
Ok(())
}
async fn notification_draws_no_answer(client: &StdioClient<Capture>) -> Result<(), Failure> {
let ping_id = client.next_request_id();
let notification = MethodName::new("notifications/initialized")?;
tokio::time::timeout(GUARD, client.notify(¬ification, &JsonObject::default()))
.await
.map_err(|_| Failure::TimedOut("a notification"))??;
let ping_document =
request_document(&ping_id, &MethodName::new("ping")?, &JsonObject::default())?;
let document = ping_document.to_string();
tokio::time::timeout(GUARD, client.send_document(&document))
.await
.map_err(|_| Failure::TimedOut("writing the ping"))??;
let frame = tokio::time::timeout(GUARD, client.next_frame())
.await
.map_err(|_| Failure::TimedOut("the first frame after a notification"))??;
let frame = frame.ok_or(Failure::NoFrame("the ping's answer"))?;
let answered: serde_json::Value = serde_json::from_str(&frame)?;
let answered_id = answered
.get("id")
.ok_or_else(|| Failure::Absent {
what: "id on the reply".to_owned(),
})?
.to_string();
if answered_id != ping_id.to_string() {
return Err(Failure::Unexpected {
what: "the first frame's id",
got: answered_id,
});
}
let version = string_at(&answered, &["jsonrpc"])?;
if version != "2.0" {
return Err(Failure::Unexpected {
what: "a JSON-RPC 2.0 frame on the protocol stream",
got: version,
});
}
Ok(())
}
#[tokio::test]
async fn an_unknown_tool_is_a_protocol_error_and_the_session_survives() -> Result<(), Failure> {
let Some(path) = program_or_skip()? else {
return Ok(());
};
let capture = Capture::default();
let client = client_on(&path, &capture).await?;
initialize(&client).await?;
let outcome = tokio::time::timeout(
GUARD,
client.call(
&client.next_request_id(),
&MethodName::new("tools/call")?,
&JsonObject::new(r#"{"name":"no-such-tool","arguments":{}}"#)?,
),
)
.await
.map_err(|_| Failure::TimedOut("a call to a missing tool"))?;
match outcome {
Err(StdioClientError::Request(RequestError::PeerError { message, .. })) => {
if message != "no such tool" {
return Err(Failure::Unexpected {
what: "the error's message",
got: message,
});
}
}
Err(other) => {
return Err(Failure::Unexpected {
what: "a peer error reply",
got: other.to_string(),
});
}
Ok(reply) => {
return Err(Failure::Unexpected {
what: "an error reply",
got: reply.as_str().to_owned(),
});
}
}
let listed = result_of(
&tokio::time::timeout(
GUARD,
client.call(
&client.next_request_id(),
&MethodName::new("tools/list")?,
&JsonObject::default(),
),
)
.await
.map_err(|_| Failure::TimedOut("tools/list after an error"))??,
)?;
let names = tool_names(&listed)?;
if names.len() < 2 {
return Err(Failure::Unexpected {
what: "both tools still listed",
got: names.join(", "),
});
}
Ok(())
}
#[tokio::test]
async fn the_childs_stderr_never_reaches_the_protocol_stream() -> Result<(), Failure> {
let Some(path) = program_or_skip()? else {
return Ok(());
};
let capture = Capture::default();
let client = client_on(&path, &capture).await?;
initialize(&client).await?;
let padding = "a".repeat(OVERSIZED_PADDING);
let oversized =
format!(r#"{{"jsonrpc":"2.0","id":1,"method":"ping","params":{{"pad":"{padding}"}}}}"#);
match tokio::time::timeout(GUARD, client.send_document(&oversized)).await {
Ok(Ok(()) | Err(StdioError::Transport)) => {}
Ok(Err(other)) => {
return Err(Failure::Unexpected {
what: "a completed write or a broken pipe",
got: other.to_string(),
});
}
Err(_elapsed) => return Err(Failure::TimedOut("writing an oversized frame")),
}
if !capture_gains(&capture, "FrameTooLarge").await {
return Err(Failure::Unexpected {
what: "the child's frame-too-large report on stderr",
got: capture.text(),
});
}
let leaked = tokio::time::timeout(GUARD, client.next_frame())
.await
.map_err(|_| Failure::TimedOut("the frame after the child refused one"))??;
if let Some(frame) = leaked {
if frame.contains("FrameTooLarge") {
return Err(Failure::Unexpected {
what: "no stderr text on the protocol stream",
got: frame,
});
}
return Err(Failure::Unexpected {
what: "no frame after the child exited",
got: frame,
});
}
Ok(())
}
#[tokio::test]
async fn dropping_the_client_kills_the_child() -> Result<(), Failure> {
let Some(path) = program_or_skip()? else {
return Ok(());
};
let spec = spec_for(&path)?;
let mut control = spec.to_command();
control
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut control_child = control.spawn()?;
let _held_stdin = control_child.stdin.take();
let _held_stdout = control_child.stdout.take();
let _held_stderr = control_child.stderr.take();
tokio::time::sleep(SETTLE).await;
if let Some(status) = control_child.try_wait()? {
return Err(Failure::ExitedEarly(status.to_string()));
}
let _killed = control_child.start_kill();
let _reaped = control_child.wait().await;
let capture = Capture::default();
let client = client_on(&path, &capture).await?;
let pong = tokio::time::timeout(
GUARD,
client.call(
&client.next_request_id(),
&MethodName::new("ping")?,
&JsonObject::default(),
),
)
.await
.map_err(|_| Failure::TimedOut("pinging the live child"))??;
if result_of(&pong)? != serde_json::json!({}) {
return Err(Failure::Unexpected {
what: "an empty ping result",
got: pong.as_str().to_owned(),
});
}
drop(client);
tokio::time::sleep(SETTLE).await;
let off_protocol = capture.text();
if off_protocol.contains(GRACEFUL_EXIT_MARKER) {
return Err(Failure::Unexpected {
what: "no graceful shutdown after the client was dropped",
got: off_protocol,
});
}
Ok(())
}