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}