use core::future::Future;
use core::pin::Pin;
use core::sync::atomic::{AtomicI64, Ordering};
use core::time::Duration;
use std::process::ExitStatus;
use std::sync::Arc;
use tokio::io::AsyncWrite;
use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio::task::JoinHandle;
use crate::adapters::primary::stdio::{
Diagnostics, StdioConfig, StdioError, StdioSink, StdioSource,
};
use crate::application::client::{ClientTransport, TransportError};
use crate::application::jsonrpc::SourceError;
use crate::domain::RequestId;
use crate::domain::ir::{JsonObject, MethodName};
use super::child::{self, SpawnError};
use super::spec::ChildSpec;
use crate::application::jsonrpc::document::RequestError;
use crate::application::jsonrpc::document::{
notification_document, reply_result, request_document,
};
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const FIRST_REQUEST_ID: i64 = 1;
const FRAMES_A_REPLY_MAY_SIT_BEHIND: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientConfig {
transport: StdioConfig,
request_timeout: Option<Duration>,
}
impl ClientConfig {
#[must_use]
pub const fn new(transport: StdioConfig) -> Self {
Self {
transport,
request_timeout: Some(DEFAULT_REQUEST_TIMEOUT),
}
}
#[must_use]
pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = Some(timeout);
self
}
#[must_use]
pub const fn without_request_timeout(mut self) -> Self {
self.request_timeout = None;
self
}
#[must_use]
pub const fn transport(&self) -> StdioConfig {
self.transport
}
#[must_use]
pub const fn request_timeout(&self) -> Option<Duration> {
self.request_timeout
}
}
impl Default for ClientConfig {
fn default() -> Self {
Self::new(StdioConfig::default())
}
}
pub struct StdioClient<DW> {
child: Child,
source: StdioSource<ChildStdout>,
sink: StdioSink<ChildStdin>,
diagnostics: Arc<Diagnostics<DW>>,
forwarder: Option<JoinHandle<()>>,
config: ClientConfig,
next_id: AtomicI64,
}
impl<DW> core::fmt::Debug for StdioClient<DW> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("StdioClient")
.field("child_id", &self.child_id())
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl<DW> StdioClient<DW> {
#[must_use]
pub fn child_id(&self) -> Option<u32> {
self.child.id()
}
}
impl<DW> StdioClient<DW>
where
DW: AsyncWrite + Unpin + Send + 'static,
{
#[expect(
clippy::unused_async,
clippy::unused_async_trait_impl,
reason = "the public constructor is async on purpose: it is the caller's \
first await point in a client whose every other method is \
async, and dropping the `async` would change the shape of \
the API this module is meant to present. The body awaits \
nothing yet because the child is spawned synchronously and \
the only async work it starts (the stderr forwarder) is \
handed to a task."
)]
pub async fn spawn(
spec: &ChildSpec,
config: ClientConfig,
diagnostics: Diagnostics<DW>,
) -> Result<Self, SpawnError> {
let spawned = child::spawn(spec)?;
let Some(stdin) = spawned.stdin else {
return Err(SpawnError::NoPipe { stream: "stdin" });
};
let Some(stdout) = spawned.stdout else {
return Err(SpawnError::NoPipe { stream: "stdout" });
};
let Some(stderr) = spawned.stderr else {
return Err(SpawnError::NoPipe { stream: "stderr" });
};
let diagnostics = Arc::new(diagnostics);
let forwarder = child::forward_stderr(stderr, Arc::clone(&diagnostics));
Ok(Self {
child: spawned.child,
source: StdioSource::new(stdout, config.transport()),
sink: StdioSink::new(stdin, config.transport()),
diagnostics,
forwarder: Some(forwarder),
config,
next_id: AtomicI64::new(FIRST_REQUEST_ID),
})
}
#[must_use]
pub const fn config(&self) -> ClientConfig {
self.config
}
#[must_use]
pub fn diagnostics(&self) -> &Diagnostics<DW> {
&self.diagnostics
}
#[must_use]
pub const fn source(&self) -> &StdioSource<ChildStdout> {
&self.source
}
#[must_use]
pub const fn sink(&self) -> &StdioSink<ChildStdin> {
&self.sink
}
#[must_use]
pub const fn take_stderr_forwarder(&mut self) -> Option<JoinHandle<()>> {
self.forwarder.take()
}
pub async fn send_document(&self, document: &str) -> Result<(), StdioError> {
StdioSink::send(&self.sink, document).await
}
pub async fn next_frame(&self) -> Result<Option<String>, SourceError> {
StdioSource::next_frame(&self.source).await
}
#[must_use]
pub fn next_request_id(&self) -> RequestId {
RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed))
}
pub async fn call(
&self,
id: &RequestId,
method: &MethodName,
params: &JsonObject,
) -> Result<JsonObject, StdioClientError> {
let document = request_document(id, method, params).map_err(StdioClientError::Document)?;
self.exchange(id, method, &document.to_string()).await
}
async fn exchange(
&self,
id: &RequestId,
method: &MethodName,
document: &str,
) -> Result<JsonObject, StdioClientError> {
self.send_document(document).await?;
for _ in 0..FRAMES_A_REPLY_MAY_SIT_BEHIND {
let frame = self.read_reply(method).await?;
match reply_result(&frame, id) {
Ok(result) => return Ok(result),
Err(RequestError::UnreadableFrame | RequestError::IdMismatch { .. }) => {}
Err(error) => return Err(StdioClientError::Request(error)),
}
}
Err(StdioClientError::NoAnswer {
skipped: FRAMES_A_REPLY_MAY_SIT_BEHIND,
})
}
pub async fn notify(
&self,
method: &MethodName,
params: &JsonObject,
) -> Result<(), StdioClientError> {
let document = crate::application::jsonrpc::document::notification_document(method, params)
.map_err(StdioClientError::Document)?;
self.send_document(&document.to_string()).await?;
Ok(())
}
pub async fn terminate(&mut self) -> Result<Option<ExitStatus>, StdioClientError> {
let _started = self.child.start_kill();
self.child
.wait()
.await
.map(Some)
.map_err(|_| StdioClientError::Terminate)
}
async fn read_reply(&self, method: &MethodName) -> Result<String, StdioClientError> {
match self.config.request_timeout() {
Some(limit) => match tokio::time::timeout(limit, self.next_frame()).await {
Ok(Ok(Some(frame))) => Ok(frame),
Ok(Ok(None)) => Err(StdioClientError::PeerClosed {
method: method.clone(),
}),
Ok(Err(source)) => Err(StdioClientError::Source(source)),
Err(_elapsed) => Err(StdioClientError::TimedOut {
method: method.clone(),
after: limit,
}),
},
None => self.next_frame().await?.map_or_else(
|| {
Err(StdioClientError::PeerClosed {
method: method.clone(),
})
},
Ok,
),
}
}
}
impl<DW> ClientTransport for StdioClient<DW>
where
DW: AsyncWrite + Unpin + Send + 'static,
{
fn send(
&self,
method: &str,
params: &JsonObject,
) -> Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>> {
let prepared = (|| {
let name = MethodName::new(method).map_err(unavailable)?;
let id = self.next_request_id();
let document = request_document(&id, &name, params)
.map_err(|error| unavailable(StdioClientError::Document(error)))?
.to_string();
Ok::<_, TransportError>((id, name, document))
})();
Box::pin(async move {
let (id, name, document) = prepared?;
self.exchange(&id, &name, &document)
.await
.map_err(unavailable)
})
}
fn notify(
&self,
method: &str,
params: &JsonObject,
) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>> {
let prepared = (|| {
let name = MethodName::new(method).map_err(unavailable)?;
let document = notification_document(&name, params)
.map_err(|error| unavailable(StdioClientError::Document(error)))?
.to_string();
Ok::<_, TransportError>(document)
})();
Box::pin(async move {
let document = prepared?;
self.send_document(&document).await.map_err(unavailable)
})
}
}
fn unavailable(error: impl core::fmt::Display) -> TransportError {
TransportError::Unavailable(error.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum StdioClientError {
#[error("the request document could not be assembled: {0}")]
Document(crate::application::jsonrpc::document::RequestError),
#[error("the request could not be written to the child: {0}")]
Stdio(#[from] StdioError),
#[error("the child's answer could not be read: {0}")]
Source(#[from] SourceError),
#[error("the child closed its stream before answering {method}")]
PeerClosed {
method: MethodName,
},
#[error("the child did not answer {method} within {after:?}")]
TimedOut {
method: MethodName,
after: Duration,
},
#[error("the child's answer was not a result: {0}")]
Request(#[from] crate::application::jsonrpc::document::RequestError),
#[error("{skipped} frames went by and none of them was the answer")]
NoAnswer {
skipped: usize,
},
#[error("the child could not be shut down")]
Terminate,
}
#[cfg(test)]
mod tests {
use std::io;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use assert_matches::assert_matches;
use serde_json::Value;
use tokio::task::JoinError;
use super::{ClientConfig, SpawnError, StdioClient, StdioClientError, child};
use crate::adapters::primary::stdio::{Diagnostics, StdioConfig, StdioError};
use crate::application::client::{ClientTransport, TransportError};
use crate::application::jsonrpc::SourceError;
use crate::domain::RequestId;
use crate::domain::ir::{JsonObject, MethodName, MethodNameError};
use super::super::spec::{ChildSpec, ChildSpecError};
use super::super::testing::{Capture, Role, STDERR_MARKER, SpawnFailure, child_spec};
use crate::application::jsonrpc::document::{RequestError, reply_result, request_document};
const GUARD: Duration = Duration::from_secs(20);
const NO_ANSWER_DEADLINE: Duration = Duration::from_millis(300);
#[derive(Debug)]
#[expect(dead_code, reason = "the payloads are read by the derived Debug")]
enum Failure {
Io(io::Error),
ChildSpec(SpawnFailure),
Spawn(SpawnError),
Stdio(StdioError),
Source(SourceError),
Request(RequestError),
Client(StdioClientError),
Transport(TransportError),
Method(MethodNameError),
Spec(ChildSpecError),
UnreadableResult(serde_json::Error),
ChildOutlivedItsHandle,
ExitedEarly(std::process::ExitStatus),
ClosedEarly,
NoReplyInEightFrames,
}
impl From<io::Error> for Failure {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<SpawnFailure> for Failure {
fn from(error: SpawnFailure) -> Self {
Self::ChildSpec(error)
}
}
impl From<SpawnError> for Failure {
fn from(error: SpawnError) -> Self {
Self::Spawn(error)
}
}
impl From<StdioError> for Failure {
fn from(error: StdioError) -> Self {
Self::Stdio(error)
}
}
impl From<SourceError> for Failure {
fn from(error: SourceError) -> Self {
Self::Source(error)
}
}
impl From<RequestError> for Failure {
fn from(error: RequestError) -> Self {
Self::Request(error)
}
}
impl From<StdioClientError> for Failure {
fn from(error: StdioClientError) -> Self {
Self::Client(error)
}
}
impl From<TransportError> for Failure {
fn from(error: TransportError) -> Self {
Self::Transport(error)
}
}
impl From<MethodNameError> for Failure {
fn from(error: MethodNameError) -> Self {
Self::Method(error)
}
}
impl From<JoinError> for Failure {
fn from(error: JoinError) -> Self {
Self::Io(io::Error::other(error))
}
}
impl From<ChildSpecError> for Failure {
fn from(error: ChildSpecError) -> Self {
Self::Spec(error)
}
}
impl From<serde_json::Error> for Failure {
fn from(error: serde_json::Error) -> Self {
Self::UnreadableResult(error)
}
}
fn fields(result: &JsonObject) -> Result<Value, Failure> {
Ok(serde_json::from_str(result.as_str())?)
}
async fn next_reply(client: &StdioClient<Capture>, id: &RequestId) -> Result<String, Failure> {
for _ in 0..8 {
let Some(frame) = client.next_frame().await? else {
return Err(Failure::ClosedEarly);
};
match reply_result(&frame, id) {
Ok(_) => return Ok(frame),
Err(RequestError::UnreadableFrame) => {}
Err(error) => return Err(Failure::Request(error)),
}
}
Err(Failure::NoReplyInEightFrames)
}
async fn client_on(
role: Role,
timeout: Option<Duration>,
) -> Result<(StdioClient<Capture>, Capture), Failure> {
let spec = child_spec(role)?;
let mut config = ClientConfig::new(StdioConfig::default());
if let Some(limit) = timeout {
config = config.with_request_timeout(limit);
} else {
config = config.without_request_timeout();
}
let capture = Capture::shared();
let client = tokio::time::timeout(
GUARD,
StdioClient::spawn(&spec, config, Diagnostics::new(capture.clone())),
)
.await
.map_err(|_| Failure::Spawn(SpawnError::NotStarted))??;
Ok((client, capture))
}
#[test]
fn a_config_carries_the_transport_and_the_deadline() {
let config = ClientConfig::new(StdioConfig::default());
assert_eq!(config.transport(), StdioConfig::default());
assert_eq!(config.request_timeout(), Some(Duration::from_secs(30)));
assert_eq!(config, ClientConfig::default());
let quick = config.with_request_timeout(Duration::from_millis(5));
assert_eq!(quick.request_timeout(), Some(Duration::from_millis(5)));
assert_eq!(quick.transport(), StdioConfig::default());
}
#[test]
fn a_config_with_no_deadline_says_so() {
let config = ClientConfig::new(StdioConfig::default()).without_request_timeout();
assert_eq!(config.request_timeout(), None);
}
#[tokio::test]
async fn a_call_reaches_the_child_and_comes_back() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
let id = client.next_request_id();
let method = MethodName::new("tools/list")?;
let document = request_document(&id, &method, &JsonObject::default())?.to_string();
let frame = tokio::time::timeout(GUARD, async {
client.send_document(&document).await?;
next_reply(&client, &id).await
})
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)??;
let result = fields(&reply_result(&frame, &id)?)?;
assert_eq!(result.get("child"), Some(&Value::Bool(true)));
assert_eq!(result.get("method"), Some(&Value::from("tools/list")));
Ok(())
}
#[tokio::test]
async fn a_convenience_call_returns_the_same_object() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
let method = MethodName::new("tools/list")?;
let result = tokio::time::timeout(
GUARD,
client.call(&client.next_request_id(), &method, &JsonObject::default()),
)
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)??;
assert_eq!(fields(&result)?.get("role"), Some(&Value::from("echo")));
Ok(())
}
#[tokio::test]
async fn a_reply_split_across_two_writes_is_reassembled() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Split, Some(GUARD)).await?;
let method = MethodName::new("tools/list")?;
let result = tokio::time::timeout(
GUARD,
client.call(&client.next_request_id(), &method, &JsonObject::default()),
)
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)??;
assert_eq!(fields(&result)?.get("role"), Some(&Value::from("split")));
Ok(())
}
#[tokio::test]
async fn a_notification_is_written_and_never_awaited() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
let method = MethodName::new("notifications/initialized")?;
client.notify(&method, &JsonObject::default()).await?;
let id = client.next_request_id();
let listed = MethodName::new("tools/list")?;
let frame = tokio::time::timeout(GUARD, next_reply_after(&client, &id, &listed))
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)??;
let result = fields(&reply_result(&frame, &id)?)?;
assert_eq!(result.get("method"), Some(&Value::from("tools/list")));
Ok(())
}
#[tokio::test]
async fn a_child_that_never_answers_is_reported_as_a_timeout() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Silent, Some(NO_ANSWER_DEADLINE)).await?;
let method = MethodName::new("tools/list")?;
let outcome = tokio::time::timeout(
GUARD,
client.call(&client.next_request_id(), &method, &JsonObject::default()),
)
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)?;
assert_matches!(
outcome,
Err(StdioClientError::TimedOut { method: reported, after })
if reported == method && after == NO_ANSWER_DEADLINE
);
Ok(())
}
#[tokio::test]
async fn the_childs_stderr_never_reaches_the_protocol_stream() -> Result<(), Failure> {
let (client, capture) = client_on(Role::Stderr, Some(GUARD)).await?;
let id = client.next_request_id();
let method = MethodName::new("tools/list")?;
let frame = tokio::time::timeout(GUARD, next_reply_after(&client, &id, &method))
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)??;
let mut seen = String::new();
for _ in 0..200 {
seen = capture.contents();
if seen.contains(STDERR_MARKER) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(
seen.contains(STDERR_MARKER),
"the marker never reached the forwarder: {seen:?}"
);
assert!(
!frame.contains(STDERR_MARKER),
"the marker reached the protocol stream: {frame}"
);
Ok(())
}
#[tokio::test]
async fn a_caller_can_add_its_own_diagnostics_channel() -> Result<(), Failure> {
let (client, capture) = client_on(Role::Echo, Some(GUARD)).await?;
client
.diagnostics()
.write_all(b"a line the caller chose\n")
.await?;
assert!(capture.contents().contains("a line the caller chose"));
Ok(())
}
#[tokio::test]
async fn a_program_that_cannot_be_started_is_refused() -> Result<(), Failure> {
let spec = ChildSpec::new("protocol-droid-no-such-program-for-the-tests")?;
let config = ClientConfig::new(StdioConfig::default());
let outcome = StdioClient::spawn(&spec, config, Diagnostics::new(Capture::shared()))
.await
.map_err(Failure::Spawn);
assert_matches!(outcome, Err(Failure::Spawn(SpawnError::NotStarted)));
Ok(())
}
#[tokio::test]
async fn terminate_kills_the_child_and_reports_the_exit() -> Result<(), Failure> {
let (mut client, _capture) = client_on(Role::Silent, Some(GUARD)).await?;
let pid = client.child_id();
let status = tokio::time::timeout(GUARD, client.terminate())
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)??;
let Some(_status) = status else {
return Err(Failure::ClosedEarly);
};
assert!(pid.is_some(), "a running client has a process id");
Ok(())
}
#[tokio::test]
async fn request_ids_advance_and_never_repeat() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
let first = client.next_request_id();
let second = client.next_request_id();
assert_ne!(first, second);
Ok(())
}
#[tokio::test]
async fn the_port_sends_a_request_the_same_way_the_call_does() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
let result = tokio::time::timeout(GUARD, client.send("tools/list", &JsonObject::default()))
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)??;
assert_eq!(
fields(&result)?.get("method"),
Some(&Value::from("tools/list"))
);
Ok(())
}
#[tokio::test]
async fn the_port_refuses_a_method_the_ir_will_not_admit() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
let outcome = client.send("", &JsonObject::default()).await;
assert_matches!(outcome, Err(TransportError::Unavailable(reason)) if reason.contains("empty"));
Ok(())
}
#[tokio::test]
async fn a_client_names_its_child_and_its_configuration() -> Result<(), Failure> {
let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
assert!(
client.child_id().is_some(),
"a running client has a process id"
);
assert_eq!(client.config().request_timeout(), Some(GUARD));
assert!(format!("{client:?}").contains("StdioClient"));
let _source = client.source();
let _sink = client.sink();
Ok(())
}
#[tokio::test]
async fn dropping_the_child_handle_kills_the_process() -> Result<(), Failure> {
let spec = child_spec(Role::Silent)?;
let spawned = child::spawn(&spec)?;
let Some(stdin) = spawned.stdin else {
return Err(Failure::Spawn(SpawnError::NoPipe { stream: "stdin" }));
};
let Some(_stdout) = spawned.stdout else {
return Err(Failure::Spawn(SpawnError::NoPipe { stream: "stdout" }));
};
let Some(stderr) = spawned.stderr else {
return Err(Failure::Spawn(SpawnError::NoPipe { stream: "stderr" }));
};
let _stdin_held_open = stdin;
let forwarder =
child::forward_stderr(stderr, Arc::new(Diagnostics::new(Capture::shared())));
drop(spawned.child);
tokio::time::timeout(GUARD, forwarder)
.await
.map_err(|_| Failure::ChildOutlivedItsHandle)??;
Ok(())
}
#[tokio::test]
async fn a_child_that_was_not_killed_is_still_running() -> Result<(), Failure> {
let spec = child_spec(Role::Silent)?;
let mut command = spec.to_command();
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn()?;
let _stdin_held_open = child.stdin.take();
let _stdout = child.stdout.take();
let _stderr = child.stderr.take();
tokio::time::sleep(Duration::from_millis(500)).await;
match child.try_wait() {
Ok(None) => {}
Ok(Some(status)) => return Err(Failure::ExitedEarly(status)),
Err(error) => return Err(Failure::Io(error)),
}
let _killed = child.start_kill();
let _reaped = child.wait().await;
Ok(())
}
async fn next_reply_after(
client: &StdioClient<Capture>,
id: &RequestId,
method: &MethodName,
) -> Result<String, Failure> {
let document = request_document(id, method, &JsonObject::default())?.to_string();
client.send_document(&document).await?;
next_reply(client, id).await
}
}