protocol_droid/adapters/primary/stdio/mod.rs
1//! The stdio transport's byte layer.
2//!
3//! # What this is
4//!
5//! Three adapters and a shared error type, and none of them knows what a
6//! JSON-RPC message is. [`StdioSource`] turns a byte stream into whole frames,
7//! [`StdioSink`] turns documents into framed bytes, and [`Diagnostics`] is the
8//! side channel that must never be confused for either. The framing itself is
9//! [`crate::domain::FrameDecoder`] and [`crate::domain::encode_frame`] — this
10//! module supplies the stream, the ceiling, and the errors, and delegates the
11//! rest, so the two halves of the transport cannot disagree about where a
12//! frame ends or how long one may be.
13//!
14//! # The ceiling is one value, not two
15//!
16//! [`StdioConfig`] is handed to both halves. That is the point: a limit applied
17//! only to reads protects the server from a hostile peer but not from a
18//! handler that answers with more than the peer will read, and a limit applied
19//! only to writes is the same argument with the roles swapped. One
20//! [`MaxFrameSize`], both directions, so a reply is refused with a typed error
21//! instead of being written as a line the peer's own decoder has to treat as a
22//! malformed message.
23//!
24//! # Why the transport is generic over its stream
25//!
26//! [`StdioSource<R>`] and [`StdioSink<W>`] are generic rather than hard-wired to
27//! a process's stdin and stdout. The transport boundary is where a
28//! `tokio::process::Child` becomes a byte stream, and everything below that line
29//! is identical whether the bytes arrive from a child, a pipe, a socket, or a
30//! test. Naming the concrete stream here would make every one of those cases a
31//! second implementation, and the framing logic — the part with the security
32//! properties — would be duplicated along with it.
33//!
34//! # Where the conversation is
35//!
36//! The three adapters above are a byte layer and stay one. The loop that reads
37//! a frame, routes it and answers it is [`serve::ServeLoop`], and the reply
38//! document it writes is [`reply`](crate::domain::jsonrpc::reply). Both sit in this module because they are
39//! still stdio: the loop's central rule is that this transport's stdout carries
40//! MCP messages and nothing else, and that rule is about this process's
41//! streams rather than about MCP.
42//!
43//! # What is deliberately not here
44//!
45//! No spawning and no lifecycle. The loop runs against whatever byte streams it
46//! is given, and the process management that gives it a child's stdin and
47//! stdout in the first place is a separate concern. A single unterminated line
48//! has no framing error either, because the framing layer cannot know whether
49//! the peer is mid-message or has gone away.
50
51mod diagnostics;
52mod exchange;
53mod serve;
54mod sink;
55mod source;
56
57#[cfg(test)]
58mod testing;
59
60pub use crate::domain::jsonrpc::reply::ReplyError;
61pub use diagnostics::{Diagnostics, DiagnosticsWriter};
62pub use serve::{ServeError, ServeLoop, ServeReport};
63pub use sink::StdioSink;
64pub use source::StdioSource;
65
66use core::num::NonZeroUsize;
67
68use thiserror::Error;
69
70use crate::domain::MaxFrameSize;
71
72/// How many bytes a single read from the peer's stream is allowed to ask for.
73///
74/// A pipe hands over whatever happens to be in the buffer, so this is an upper
75/// bound rather than a promise: a large frame is still assembled from as many
76/// reads as it takes. It is sized for a pipe rather than a socket because a
77/// pipe read cannot be made to wait for a full buffer, so an 8 KiB ask is
78/// satisfied quickly and keeps the syscall count down without holding a frame's
79/// worth of memory per connection.
80const DEFAULT_READ_CHUNK: NonZeroUsize = match NonZeroUsize::new(8 * 1024) {
81 Some(chunk) => chunk,
82 None => NonZeroUsize::MIN,
83};
84
85/// The settings both halves of the transport are built from.
86///
87/// Cloned into each adapter rather than shared by reference, so a
88/// [`StdioSource`] and a [`StdioSink`] on the same session cannot be pointed at
89/// different ceilings after the fact. The value is three words; the invariant is
90/// worth more than the copy.
91///
92/// # Examples
93///
94/// ```
95/// use protocol_droid::adapters::primary::stdio::StdioConfig;
96/// use protocol_droid::domain::MaxFrameSize;
97///
98/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
99/// // The default ceiling, with a smaller read buffer.
100/// let config = StdioConfig::new(MaxFrameSize::new(64 * 1024)?).with_read_chunk(4096);
101/// assert_eq!(config.read_chunk().get(), 4096);
102/// # Ok(())
103/// # }
104/// ```
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct StdioConfig {
107 limit: MaxFrameSize,
108 read_chunk: NonZeroUsize,
109}
110
111impl StdioConfig {
112 /// The default configuration: a [`MaxFrameSize::DEFAULT`] ceiling and a
113 /// `DEFAULT_READ_CHUNK` read buffer.
114 #[must_use]
115 pub const fn new(limit: MaxFrameSize) -> Self {
116 Self {
117 limit,
118 read_chunk: DEFAULT_READ_CHUNK,
119 }
120 }
121
122 /// Caps how many bytes one read from the peer's stream may ask for.
123 ///
124 /// Zero is treated as one byte rather than refused. A zero-length buffer
125 /// would make `read` return `Ok(0)`, which every reader in the `AsyncRead`
126 /// contract reserves for end-of-stream — so honouring it would silently
127 /// turn the peer into a closed stream on the first poll. Coercing to one
128 /// byte keeps the loop making progress and costs nothing but syscalls.
129 #[must_use]
130 pub const fn with_read_chunk(mut self, bytes: usize) -> Self {
131 self.read_chunk = match NonZeroUsize::new(bytes) {
132 Some(chunk) => chunk,
133 None => NonZeroUsize::MIN,
134 };
135 self
136 }
137
138 /// The largest frame this transport will read or write.
139 #[must_use]
140 pub const fn limit(self) -> MaxFrameSize {
141 self.limit
142 }
143
144 /// How many bytes a single read may ask for.
145 #[must_use]
146 pub const fn read_chunk(self) -> NonZeroUsize {
147 self.read_chunk
148 }
149}
150
151impl Default for StdioConfig {
152 /// The 4 MiB [`MaxFrameSize::DEFAULT`] ceiling.
153 ///
154 /// Not a const impl, because `Default::default` is not a const fn and the
155 /// project's rules prefer the trait over a hand-written constructor that
156 /// only exists to be called from another one.
157 fn default() -> Self {
158 Self::new(MaxFrameSize::DEFAULT)
159 }
160}
161
162/// Why the stdio transport could not carry a frame.
163///
164/// Deliberately not [`crate::application::jsonrpc::SourceError`] or
165/// [`crate::application::session::SinkError`]. Those are the *ports*, and they
166/// carry no detail on purpose: the application layer is protocol-neutral and
167/// must not learn that a broken pipe and an oversized reply are different
168/// failures. This is the transport's own account, and it is what a caller
169/// driving [`StdioSource`] or [`StdioSink`] directly actually learns.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
171pub enum StdioError {
172 /// The peer's stream refused a read or a write.
173 ///
174 /// Carries no `io::Error` on purpose, for the reason the ports do not: a
175 /// caller in a position to do something about *which* failure it was
176 /// inspects its own transport, and one that is not has nothing useful to do
177 /// with the difference.
178 #[error("the peer's stream could not be read or written")]
179 Transport,
180
181 /// A reply was larger than the transport is configured to write.
182 ///
183 /// Terminal, and not a truncated write. The document does not fit a frame,
184 /// so there is no frame to write, and writing the bytes anyway would put a
185 /// line on the wire that the peer's decoder must then reject as malformed —
186 /// a protocol error caused by our own answer, reported as a protocol error
187 /// from the peer's side, with nothing in this process to connect the two.
188 #[error("a reply of {len} bytes exceeds the configured maximum of {limit}")]
189 ReplyTooLarge {
190 /// The ceiling the caller configured.
191 limit: MaxFrameSize,
192 /// The length of the reply that was refused, in bytes.
193 len: usize,
194 },
195}