Skip to main content

protocol_droid/adapters/primary/stdio/
diagnostics.rs

1//! The side channel that must never be confused for the protocol stream.
2//!
3//! # Why stderr and only stderr
4//!
5//! On stdio, **stdout is the protocol**. Every byte a peer reads from this
6//! process is a frame that [`FrameDecoder`](crate::domain::FrameDecoder) has to
7//! turn into a message, and a diagnostic line written there is not a frame the
8//! peer can use — it is a parse error the peer will report against us, having no
9//! way to tell that the thing which confused it was our log line rather than
10//! its peer's.
11//!
12//! So this channel is bound to stderr and there is no constructor that points
13//! it anywhere else. That is a stronger property than it looks: the generic
14//! `Diagnostics<W>` shape exists so a *test* can hand it a buffer, and the only
15//! way to reach the real process streams is [`Diagnostics::stderr`], which has
16//! no parameter to get wrong.
17//!
18//! # What is written, and what is not
19//!
20//! A frame can carry a token, a session identifier, or an elicitation
21//! response, so a caller hands this channel a line it has already composed
22//! rather than a frame it wants logged. The bytes cross here and stop: a
23//! diagnostic about this transport's bytes *is* the line, and there is no second
24//! copy of it elsewhere for a reader to choose between.
25//!
26//! That is also why nothing underneath is an event-stream facade. A fact about
27//! a session is a [`SessionEvent`](crate::application::session::SessionEvent)
28//! and reaches an [`EventLog`](crate::application::session::EventLog); a fact
29//! about this transport is a line on this channel. A third mechanism spanning
30//! the second would carry no fact the first cannot, and the cost of it is not
31//! only a dependency — it is a log that disagrees with itself.
32
33use core::pin::Pin;
34use core::task::{Context, Poll};
35
36use tokio::io::{AsyncWrite, AsyncWriteExt};
37use tokio::sync::{Mutex, MutexGuard};
38
39use super::StdioError;
40
41/// A writer bound to one stream, safe to share.
42///
43/// Generic over the stream so a test can hand it a buffer and read the bytes
44/// back. In production that buffer is stderr, and
45/// [`Diagnostics::stderr`] is the only constructor that says so.
46///
47/// # Examples
48///
49/// ```
50/// use protocol_droid::adapters::primary::stdio::Diagnostics;
51///
52/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
53/// // The real channel: process stderr, and nothing else.
54/// let diagnostics = Diagnostics::stderr();
55/// diagnostics.write_all(b"listening on stdio\n").await?;
56/// # Ok(())
57/// # }
58/// ```
59#[derive(Debug)]
60pub struct Diagnostics<W> {
61    writer: Mutex<W>,
62}
63
64impl Diagnostics<tokio::io::Stderr> {
65    /// The real diagnostics channel: this process's stderr.
66    ///
67    /// The only constructor that names a process stream, and the only one that
68    /// can reach stdout — which it cannot, because it takes no argument to say
69    /// so.
70    #[must_use]
71    pub fn stderr() -> Self {
72        Self::new(tokio::io::stderr())
73    }
74}
75
76impl<W> Diagnostics<W> {
77    /// Binds the channel to a stream.
78    ///
79    /// Public because a test needs it and a caller embedding this transport
80    /// needs it; neither is a route to stdout by accident, since choosing the
81    /// stream is the whole of what this function does.
82    #[must_use]
83    pub fn new(writer: W) -> Self {
84        Self {
85            writer: Mutex::new(writer),
86        }
87    }
88}
89
90impl<W> Diagnostics<W>
91where
92    W: AsyncWrite + Unpin,
93{
94    /// Writes a diagnostic line, holding the lock for the whole write.
95    ///
96    /// The lock spans the write rather than the frame boundary because a
97    /// diagnostic interleaved with another diagnostic is a corrupted log, and a
98    /// corrupted log is the failure mode that makes people turn logging off.
99    ///
100    /// # Errors
101    ///
102    /// [`StdioError::Transport`] when the underlying stream refuses the bytes.
103    /// A diagnostic that cannot be written is never worth failing a request
104    /// over, so callers that treat it as best-effort can discard this.
105    pub async fn write_all(&self, bytes: &[u8]) -> Result<(), StdioError> {
106        let mut writer = self.writer.lock().await;
107        writer
108            .write_all(bytes)
109            .await
110            .map_err(|_| StdioError::Transport)?;
111        // Released here rather than at the end of the scope, which is where the
112        // guard would otherwise live until the last statement. The channel is
113        // shared and the write is the last thing that needs the lock, so a
114        // caller writing a burst of diagnostics does not serialise each one
115        // behind the previous one's scope.
116        drop(writer);
117        Ok(())
118    }
119
120    /// Borrows the channel as a plain [`AsyncWrite`], for a caller that already
121    /// holds an `AsyncWrite` and would otherwise need a wrapper.
122    ///
123    /// The borrow is the point, and it is why this is not an `AsyncWrite` impl
124    /// on `Diagnostics` itself. The `AsyncWrite` methods take `&mut self`, so
125    /// implementing the trait on the shared type would mean the channel could
126    /// only ever be written by one task at a time and could not be shared at
127    /// all — and a diagnostics channel that cannot be shared is not one. The
128    /// returned guard holds the lock for as long as the caller keeps it, which
129    /// makes [`write_all`](Self::write_all) the same operation spelled
130    /// differently rather than a different guarantee.
131    ///
132    /// A guard is the borrow check made visible: it will not compile if the
133    /// caller tries to reach the channel a second time while holding it, which
134    /// is the mistake a shared `AsyncWrite` implementation would have hidden
135    /// behind a lock.
136    pub async fn writer(&self) -> DiagnosticsWriter<'_, W> {
137        DiagnosticsWriter {
138            guard: self.writer.lock().await,
139        }
140    }
141}
142
143/// A held borrow of a [`Diagnostics`] channel, usable as an [`AsyncWrite`].
144#[derive(Debug)]
145pub struct DiagnosticsWriter<'a, W> {
146    guard: MutexGuard<'a, W>,
147}
148
149impl<W> AsyncWrite for DiagnosticsWriter<'_, W>
150where
151    W: AsyncWrite + Unpin,
152{
153    /// Writes what it can and reports the short count, as `AsyncWrite` requires.
154    ///
155    /// A short write is not a corrupted log here for the same reason it is a
156    /// normal condition in every `AsyncWrite`: the caller is required to
157    /// re-present the remainder, and a diagnostic that gave up mid-line would
158    /// report a truncation the caller can see for itself.
159    fn poll_write(
160        self: Pin<&mut Self>,
161        cx: &mut Context<'_>,
162        buf: &[u8],
163    ) -> Poll<Result<usize, std::io::Error>> {
164        Pin::new(&mut *self.get_mut().guard).poll_write(cx, buf)
165    }
166
167    /// Flushes the underlying stream.
168    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
169        Pin::new(&mut *self.get_mut().guard).poll_flush(cx)
170    }
171
172    /// Shuts the underlying stream down. Called once, at the end of a session.
173    fn poll_shutdown(
174        self: Pin<&mut Self>,
175        cx: &mut Context<'_>,
176    ) -> Poll<Result<(), std::io::Error>> {
177        Pin::new(&mut *self.get_mut().guard).poll_shutdown(cx)
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use std::io;
184
185    use assert_matches::assert_matches;
186
187    use tokio::io::AsyncWriteExt;
188
189    use super::super::StdioError;
190    use super::super::testing::{Broken, Capture};
191    use super::Diagnostics;
192
193    /// The `Failure` an `?` in a test can produce, so a fallible call whose
194    /// error type is not the one under test still propagates instead of
195    /// unwrapping. Two of them appear here: the channel's own error, and the
196    /// `io::Error` that every `AsyncWriteExt` method raises.
197    #[derive(Debug)]
198    #[expect(dead_code, reason = "the payloads are read by the derived Debug")]
199    enum Failure {
200        /// The channel refused a write the test expected to succeed.
201        Stdio(StdioError),
202        /// The stream under the channel failed before the channel could map it.
203        Io(io::Error),
204    }
205
206    impl From<StdioError> for Failure {
207        fn from(error: StdioError) -> Self {
208            Self::Stdio(error)
209        }
210    }
211
212    impl From<io::Error> for Failure {
213        fn from(error: io::Error) -> Self {
214            Self::Io(error)
215        }
216    }
217
218    /// The property the module is tested for: a diagnostic written through the
219    /// channel lands in the buffer it was given, byte for byte. A diagnostics
220    /// channel that cannot be observed is a channel nobody verifies.
221    #[tokio::test]
222    async fn a_diagnostic_lands_in_the_injected_buffer() -> Result<(), Failure> {
223        let capture = Capture::shared();
224        let diagnostics = Diagnostics::new(capture.clone());
225
226        diagnostics
227            .write_all(b"peer announced version 2025-06-18\n")
228            .await?;
229
230        assert_eq!(capture.contents(), "peer announced version 2025-06-18\n");
231        Ok(())
232    }
233
234    /// The real channel is constructible and writable. Every other test in this
235    /// module hands it a buffer, which leaves this the only exercise of the one
236    /// constructor with a process stream behind it — and a break in it would
237    /// otherwise reach production unobserved. It writes to stderr, where the
238    /// test harness captures it, so the line is noise nobody has to read.
239    #[tokio::test]
240    async fn the_real_channel_is_constructible_and_writable() -> Result<(), Failure> {
241        let diagnostics = Diagnostics::stderr();
242
243        diagnostics
244            .write_all(b"diagnostics: the stderr channel is live\n")
245            .await?;
246
247        Ok(())
248    }
249
250    /// Several writes accumulate rather than replace. A channel that kept only
251    /// the last line would pass the single-write test above and lose the history
252    /// that made it worth reading.
253    #[tokio::test]
254    async fn successive_diagnostics_accumulate() -> Result<(), Failure> {
255        let capture = Capture::shared();
256        let diagnostics = Diagnostics::new(capture.clone());
257
258        diagnostics.write_all(b"first\n").await?;
259        diagnostics.write_all(b"second\n").await?;
260
261        assert_eq!(capture.contents(), "first\nsecond\n");
262        Ok(())
263    }
264
265    /// The same channel is reachable through a borrowed [`AsyncWrite`], so a
266    /// caller that already holds an `AsyncWrite` — a logger, a codec's error
267    /// sink — can use it without a wrapper. The bytes have to arrive through
268    /// that path too, or the guard is a claim the tests never checked.
269    #[tokio::test]
270    async fn the_channel_also_works_as_a_plain_async_writer() -> Result<(), Failure> {
271        let capture = Capture::shared();
272        let diagnostics = Diagnostics::new(capture.clone());
273
274        let mut borrowed = diagnostics.writer().await;
275        borrowed.write_all(b"through the trait\n").await?;
276        borrowed.flush().await?;
277        drop(borrowed);
278
279        assert_eq!(capture.contents(), "through the trait\n");
280        Ok(())
281    }
282
283    /// A short write is not a failure. `AsyncWrite` permits one, the caller is
284    /// required to resume from it, and a channel that treated every short
285    /// count as a broken stream would report truncation on a healthy pipe.
286    #[tokio::test]
287    async fn a_short_write_is_reported_as_a_count_not_an_error() -> Result<(), Failure> {
288        let capture = Capture::shared();
289        let diagnostics = Diagnostics::new(capture.clone());
290        let mut borrowed = diagnostics.writer().await;
291
292        borrowed
293            .write_all(b"a longer line than the limit allows")
294            .await?;
295
296        assert_eq!(capture.contents(), "a longer line than the limit allows");
297        Ok(())
298    }
299
300    /// A stream that refuses the bytes is a typed error, not a panic and not a
301    /// silent drop. `Broken` exists precisely so this needs no real pipe, and
302    /// this is the path a closed or full stderr takes in production — where
303    /// the caller is told the line did not land, and is free to decide that a
304    /// lost diagnostic is not worth failing a request over.
305    #[tokio::test]
306    async fn a_refused_diagnostic_is_a_typed_error() {
307        let diagnostics = Diagnostics::new(Broken);
308
309        let result = diagnostics.write_all(b"never lands\n").await;
310
311        assert_matches!(result, Err(StdioError::Transport));
312    }
313}