protocol_droid/adapters/secondary/stdio/mod.rs
1//! The stdio **client**: a child MCP server, spawned and supervised.
2//!
3//! # What this is
4//!
5//! [`primary::stdio`](crate::adapters::primary::stdio) is the byte layer of a
6//! stdio conversation, and it is deliberately agnostic about where the bytes
7//! come from: [`StdioSource<R>`](crate::adapters::primary::stdio::StdioSource) and [`StdioSink<W>`](crate::adapters::primary::stdio::StdioSink) are generic over
8//! `AsyncRead` and `AsyncWrite` precisely so that a process's pipes can be
9//! dropped into them. This module supplies what the byte layer was missing —
10//! the other end of the conversation. [`StdioClient`] starts a child server,
11//! hands its stdout to the source and its stdin to the sink, drains its stderr
12//! somewhere that is not the protocol, and takes the process down with it.
13//!
14//! There is no second framing and no second encoder here, and that is the
15//! whole argument for reusing those two types rather than writing a client
16//! transport of one's own. Framing is where the security properties live — the
17//! ceiling, the single delimiter, the decision that a frame the peer wrote and
18//! did not terminate is dropped rather than reported — and a second
19//! implementation would be a second set of those properties to get right. The
20//! request and reply *documents* in `engine::request` are the exception, and they are
21//! documents rather than framing: the shapes [`reply`](crate::domain::jsonrpc::reply)
22//! writes and this reads are one wire format with one serialiser, not two.
23//!
24//! # The two things that are easy to get wrong
25//!
26//! **Kill-on-drop is opt-in.** `tokio::process::Child` does *not* kill the
27//! process it is dropped with. Without an explicit `kill_on_drop(true)`, a
28//! client that returns an error half way through a handshake leaves a server
29//! running with nobody reading its stdout — an orphan holding a pipe, holding
30//! whatever the user asked it to do. `child::spawn` is where the flag is set,
31//! and it is set on the one path that can spawn a child.
32//!
33//! **The child's stderr is not the protocol.** [`Diagnostics`](crate::adapters::primary::stdio::Diagnostics) is bound to
34//! stderr and has no constructor that can be pointed at stdout, for the same
35//! reason the server's does not: on stdio, stdout is the conversation. The
36//! forwarder in `child::forward_stderr` moves those bytes across one line at
37//! a time and nowhere else, so a server that logs to stderr cannot corrupt a
38//! frame — and, just as importantly, a log line cannot be mistaken for a
39//! frame the peer failed to parse.
40//!
41//! # The two `None`s
42//!
43//! [`StdioSource::next_frame`](crate::adapters::primary::stdio::StdioSource::next_frame) answers `Ok(None)` when the peer has closed, and
44//! the decoder underneath it answers `Ok(None)` when a line has no terminator
45//! *yet* — opposites, and forwarding the second up would turn "keep reading"
46//! into "the child died" and "the child died" into "read again". The source
47//! already gets this right; this module passes its answer through unaltered
48//! and names the distinction at every call site, because the two failures it
49//! produces (a child that exited early, and a child that has not answered yet)
50//! are the two a caller most needs told apart.
51//!
52//! # Where the conversation is
53//!
54//! The bytes stay in the byte layer. What this module adds is the lifecycle —
55//! [`spec::ChildSpec`] for the program, [`child`] for the process, and
56//! [`client::StdioClient`] for the exchange — plus the request documents in
57//! `engine::request`, which are the client's mirror of the server's
58//! [`reply`](crate::domain::jsonrpc::reply) module.
59
60pub mod bridge_peer;
61pub mod child;
62pub mod client;
63pub mod spec;
64
65#[cfg(test)]
66mod testing;
67
68pub use bridge_peer::StdioPeer;
69pub use child::SpawnError;
70pub use client::{ClientConfig, StdioClient, StdioClientError};
71pub use spec::{ChildSpec, ChildSpecError};