protocol_droid/adapters/primary/stdio/sink.rs
1//! Writing whole frames to a peer's stream.
2//!
3//! # The write half of the same ceiling
4//!
5//! A limit on what a server will *read* is half a protection. A handler that
6//! answers with more than the peer is willing to receive is the mirror image of
7//! a peer that sends more than the server will accept, and
8//! [`StdioSink::send`] refuses it with
9//! [`StdioError::ReplyTooLarge`](super::StdioError::ReplyTooLarge) rather than
10//! writing a line the reader on the other end has to treat as malformed.
11//!
12//! The refusal happens before a single byte is written. That is the part worth
13//! being explicit about: a write that fails halfway is a *truncated frame* on
14//! the wire, and a truncated frame in a newline protocol is not an error the
15//! peer can even detect, because the terminator simply never arrives and the
16//! peer waits for more of a message that was already truncated. A ceiling
17//! checked before the write turns that silent corruption into a typed error in
18//! this process, where something can be done about it.
19//!
20//! # Why the framing is the domain's job
21//!
22//! [`encode_frame`] is the same function the read side's decoder is paired with,
23//! so the terminator and the size check cannot drift between the two halves.
24//! This module supplies the limit, the stream, and the mapping into
25//! [`StdioError`] — and nothing about what a reply *says*, which belongs above
26//! it.
27//!
28//! # Why a lock rather than a borrow
29//!
30//! [`FrameSink::send`] takes `&self`, so two replies can be in flight at once.
31//! A line interleaved with another line is a corrupted stream, and a corrupted
32//! stream on a transport whose whole framing is one byte of delimiter is
33//! indistinguishable from a protocol bug. The lock spans the whole write for
34//! that reason alone.
35
36use tokio::io::{AsyncWrite, AsyncWriteExt};
37use tokio::sync::Mutex;
38
39use crate::application::session::{FrameSink, SinkError};
40use crate::domain::{FramingError, MaxFrameSize, encode_frame};
41
42use super::{StdioConfig, StdioError};
43
44/// Writes framed documents to the peer, and to nothing else.
45///
46/// # Examples
47///
48/// ```
49/// use protocol_droid::adapters::primary::stdio::{StdioConfig, StdioSink};
50/// use protocol_droid::domain::MaxFrameSize;
51///
52/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
53/// let config = StdioConfig::new(MaxFrameSize::new(1024)?);
54/// let sink = StdioSink::new(tokio::io::sink(), config);
55///
56/// // One line, one terminator, and nothing else on the stream.
57/// sink.send(r#"{"jsonrpc":"2.0","id":1,"result":null}"#).await?;
58/// # Ok(())
59/// # }
60/// ```
61#[derive(Debug)]
62pub struct StdioSink<W> {
63 stream: Mutex<W>,
64 limit: MaxFrameSize,
65}
66
67impl<W> StdioSink<W> {
68 /// Wraps a stream, refusing replies above the configured ceiling.
69 #[must_use]
70 pub fn new(stream: W, config: StdioConfig) -> Self {
71 Self {
72 stream: Mutex::new(stream),
73 limit: config.limit(),
74 }
75 }
76
77 /// The ceiling this sink refuses replies above.
78 #[must_use]
79 pub const fn limit(&self) -> MaxFrameSize {
80 self.limit
81 }
82}
83
84impl<W> StdioSink<W>
85where
86 W: AsyncWrite + Unpin,
87{
88 /// Frames `document` and writes it as one line.
89 ///
90 /// # Errors
91 ///
92 /// [`StdioError::ReplyTooLarge`] when the document is longer than the
93 /// ceiling, in which case *nothing* is written — not a prefix, not a
94 /// truncated line. [`StdioError::Transport`] when the stream refuses the
95 /// bytes.
96 pub async fn send(&self, document: &str) -> Result<(), StdioError> {
97 // Encoded into a local first, so the size check and the terminator
98 // happen before the stream is touched at all.
99 let mut framed = Vec::with_capacity(document.len() + 1);
100 encode_frame(document, &mut framed, self.limit).map_err(stdio_error)?;
101
102 let mut stream = self.stream.lock().await;
103 stream
104 .write_all(&framed)
105 .await
106 .map_err(|_| StdioError::Transport)?;
107 stream.flush().await.map_err(|_| StdioError::Transport)
108 }
109}
110
111/// The domain's framing error, in the transport's vocabulary.
112///
113/// [`encode_frame`] only refuses on size — a `&str` is text by construction — so
114/// the `NotUtf8` arm is unreachable from here. It is mapped rather than
115/// `unreachable!()`d because the enum does not promise that to this caller, and
116/// because both arms report the same thing: nothing reached the wire. A panic
117/// on a path that runs for every reply a server ever sends is the wrong place
118/// to be defending a hypothesis.
119const fn stdio_error(error: FramingError) -> StdioError {
120 match error {
121 FramingError::FrameTooLarge { limit, at_least } => StdioError::ReplyTooLarge {
122 limit,
123 len: at_least,
124 },
125 FramingError::NotUtf8 { .. } => StdioError::Transport,
126 }
127}
128
129impl<W> FrameSink for StdioSink<W>
130where
131 W: AsyncWrite + Unpin + Send,
132{
133 /// Writes one encoded frame.
134 ///
135 /// Collapses [`StdioError`] into the port's unit [`SinkError`], because the
136 /// session engine is protocol-neutral and must not learn which transport it
137 /// happens to be driving. That collapse is the mapping this boundary exists
138 /// to perform — the specific reason was already decided by the time the
139 /// engine asked, and a caller that needs it drives
140 /// [`StdioSink::send`] directly.
141 ///
142 /// # Errors
143 ///
144 /// [`SinkError`] when the frame did not reach the peer.
145 async fn send(&self, frame: &str) -> Result<(), SinkError> {
146 Self::send(self, frame).await.map_err(|_| SinkError)
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use std::sync::Arc;
153
154 use assert_matches::assert_matches;
155
156 use super::super::{StdioConfig, testing::Capture};
157 use super::{StdioError, StdioSink, stdio_error};
158 use crate::application::session::{DynFrameSink, FrameSink, SinkError};
159 use crate::domain::{FramingError, MaxFrameSize, MaxFrameSizeError};
160
161 /// The `Failure` an `?` in a test can produce, so a fallible call whose
162 /// error type is not the one under test still propagates instead of
163 /// unwrapping.
164 #[derive(Debug)]
165 #[expect(dead_code, reason = "the payloads are read by the derived Debug")]
166 enum Failure {
167 /// The sink refused a write the test expected to succeed.
168 Stdio(StdioError),
169 /// A limit the test tried to build was refused.
170 Limit(MaxFrameSizeError),
171 /// The port's unit error, for the call that goes through a trait object.
172 Sink(SinkError),
173 }
174
175 impl From<StdioError> for Failure {
176 fn from(error: StdioError) -> Self {
177 Self::Stdio(error)
178 }
179 }
180
181 impl From<MaxFrameSizeError> for Failure {
182 fn from(error: MaxFrameSizeError) -> Self {
183 Self::Limit(error)
184 }
185 }
186
187 impl From<SinkError> for Failure {
188 fn from(error: SinkError) -> Self {
189 Self::Sink(error)
190 }
191 }
192
193 /// A sink over a capture, so a test can assert on the bytes and not only on
194 /// the return value. The "nothing was written" assertions are the point: a
195 /// sink that returned the right error *and* leaked a truncated prefix would
196 /// pass a test that only looked at the `Result`.
197 fn capture_sink(limit: u32) -> Result<(StdioSink<Capture>, Capture), Failure> {
198 let capture = Capture::shared();
199 let config = StdioConfig::new(MaxFrameSize::new(limit)?);
200 Ok((StdioSink::new(capture.clone(), config), capture))
201 }
202
203 /// A ceiling that is definitely not zero, for assertions that compare
204 /// against a limit the test set itself.
205 fn ceiling(bytes: u32) -> MaxFrameSize {
206 MaxFrameSize::new(bytes).unwrap_or(MaxFrameSize::DEFAULT)
207 }
208
209 /// The happy path writes exactly one line: the document, then one `\n`.
210 /// Anything else — two terminators, a trailing newline of its own, no
211 /// terminator — produces a stream the peer's decoder reads differently.
212 #[tokio::test]
213 async fn a_reply_is_written_as_exactly_one_line() -> Result<(), Failure> {
214 let (sink, capture) = capture_sink(1024)?;
215 let document = r#"{"jsonrpc":"2.0","id":1,"result":null}"#;
216
217 sink.send(document).await?;
218
219 assert_eq!(capture.bytes(), format!("{document}\n").into_bytes());
220 Ok(())
221 }
222
223 /// An oversized reply is a typed error, and the stream is untouched.
224 ///
225 /// This is the test the module exists for. Returning the right error while
226 /// writing a truncated line would corrupt the stream in a way the peer
227 /// cannot detect: in a newline protocol a truncated frame has no
228 /// terminator, so the peer does not see a bad message, it sees the first
229 /// half of one and then waits forever for the rest.
230 #[tokio::test]
231 async fn an_oversized_reply_is_typed_and_writes_nothing() -> Result<(), Failure> {
232 let (sink, capture) = capture_sink(16)?;
233
234 let result = sink
235 .send("a reply well over the sixteen byte ceiling")
236 .await;
237
238 assert_matches!(
239 result,
240 Err(StdioError::ReplyTooLarge { limit, len }) => {
241 assert_eq!(limit, ceiling(16));
242 assert!(len > 16, "the reported length is the document's, not the limit's");
243 }
244 );
245 assert!(
246 capture.bytes().is_empty(),
247 "an oversized reply must not put a single byte on the wire"
248 );
249 Ok(())
250 }
251
252 /// The ceiling counts the document, not the terminator, so a document of
253 /// exactly `limit` bytes is legal on both halves of the transport. If it
254 /// were not, the read side would accept a frame the write side refuses.
255 #[tokio::test]
256 async fn a_reply_of_exactly_the_ceiling_is_written() -> Result<(), Failure> {
257 let (sink, capture) = capture_sink(16)?;
258 let document = "0123456789abcdef";
259
260 sink.send(document).await?;
261
262 assert_eq!(
263 capture.bytes().len(),
264 17,
265 "sixteen document bytes plus one terminator"
266 );
267 Ok(())
268 }
269
270 /// The port's spelling collapses the detail, and the frame still does not
271 /// reach the stream. The collapse is deliberate — the session engine is
272 /// protocol-neutral — so this asserts both halves of it.
273 #[tokio::test]
274 async fn the_port_trait_turns_an_oversized_reply_into_a_sink_error() -> Result<(), Failure> {
275 let (sink, capture) = capture_sink(16)?;
276
277 let result = FrameSink::send(&sink, "a reply well over the ceiling").await;
278
279 assert_matches!(result, Err(SinkError));
280 assert!(capture.bytes().is_empty());
281 Ok(())
282 }
283
284 /// Two replies in flight at once must not interleave. The lock spans the
285 /// whole write, so each line lands whole; without it a scheduler switch
286 /// between the document and its terminator would put `half` and `whole` on
287 /// the wire as two separate frames, which the peer's decoder reads as two
288 /// malformed messages rather than one good reply and one bad one.
289 ///
290 /// The documents carry an embedded newline on purpose: without one, a
291 /// mis-ordered pair of writes would still produce two well-formed frames
292 /// and the test would pass against a broken sink.
293 #[tokio::test]
294 async fn concurrent_replies_do_not_interleave() -> Result<(), Failure> {
295 let (sink, capture) = capture_sink(1024)?;
296 let first = Arc::new(sink);
297 let second = Arc::clone(&first);
298
299 let (left, right) = tokio::join!(
300 async move { first.send("{\"id\":1}\nembedded").await },
301 async move { second.send("{\"id\":2}").await }
302 );
303 left?;
304 right?;
305
306 let written = capture.contents();
307 for document in ["{\"id\":1}\nembedded", "{\"id\":2}"] {
308 assert!(
309 written.contains(&format!("{document}\n")),
310 "each reply must land whole; the stream was {written:?}"
311 );
312 }
313 Ok(())
314 }
315
316 /// The `FrameSink` impl needs no `DynFrameSink` impl: the session module
317 /// already provides a blanket one for every `T: FrameSink + Send + Sync`,
318 /// and a second here would collide with it at the coherence check. This
319 /// test is the proof that the blanket impl really does apply.
320 ///
321 /// The call goes through [`DynFrameSink`], not [`FrameSink`], because the
322 /// two are separate traits and the blanket impl runs in one direction only.
323 /// A `dyn DynFrameSink` is not itself a `FrameSink`; asking for one is the
324 /// mistake this test exists to make impossible.
325 #[tokio::test]
326 async fn the_sink_is_usable_behind_a_trait_object() -> Result<(), Failure> {
327 let (sink, capture) = capture_sink(1024)?;
328 let boxed: Arc<dyn DynFrameSink> = Arc::new(sink);
329
330 DynFrameSink::send(boxed.as_ref(), "boxed").await?;
331
332 assert_eq!(capture.contents(), "boxed\n");
333 Ok(())
334 }
335
336 /// The unreachable arm of the mapping is mapped rather than panicked on,
337 /// and it reports the same thing the reachable one does: nothing was
338 /// written. A `&str` is text by construction, so the arm cannot be reached
339 /// from `send` — but the enum does not promise that to a future caller, and
340 /// a panic on the path that runs for every reply a server ever sends is the
341 /// wrong place to be defending a hypothesis.
342 #[test]
343 fn a_not_utf8_framing_error_maps_rather_than_panics() {
344 assert_eq!(
345 stdio_error(FramingError::NotUtf8 { valid_up_to: 3 }),
346 StdioError::Transport
347 );
348 assert_matches!(
349 stdio_error(FramingError::FrameTooLarge {
350 limit: ceiling(64),
351 at_least: 65,
352 }),
353 StdioError::ReplyTooLarge { len: 65, .. }
354 );
355 }
356}