Skip to main content

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}