Skip to main content

protocol_droid/application/session/
frame_sink.rs

1//! Where a session's outbound frames go.
2//!
3//! # Why the engine does not build frames
4//!
5//! Encoding is the codec's job, and the codec is per-revision: the same
6//! `sampling/createMessage` is three different documents across three
7//! revisions. If the sink took a typed value the engine would have to know
8//! which codec to run, and the engine is exactly the module that is not
9//! allowed to know which protocol it is serving. So the boundary is drawn at
10//! the encoded frame: something downstream of the engine already lowered the
11//! value, and the sink's only job is to put the bytes on the wire and say
12//! whether they got there.
13//!
14//! That also makes the failure honest. A sink that fails is reporting a broken
15//! pipe, not a bad document, and the engine has nothing useful to add to that
16//! report.
17//!
18//! # Two traits, because one of them cannot be both
19//!
20//! [`FrameSink`] is a native `async fn`, which is a return-position impl trait
21//! and therefore **not** object-safe. A transport that is generic over its
22//! session gets it for free: the call monomorphises and costs nothing. The CLI
23//! bridge, which holds a bridge of mixed transports behind one handle, needs
24//! `Arc<dyn FrameSink>` and cannot have it from this trait.
25//!
26//! [`DynFrameSink`] is the object-safe spelling, written out by hand. The
27//! alternative — a blanket impl with `async-trait` — would put a boxed future
28//! on the generic path too, and one allocation per outbound frame is exactly
29//! what the `FrameSink` form exists to avoid. The box is confined to the
30//! callers that asked for it, which is the CLI bridge wiring and nowhere on a
31//! per-frame hot path.
32
33use core::future::Future;
34use core::pin::Pin;
35
36use thiserror::Error;
37
38/// A frame that did not reach the peer.
39///
40/// Deliberately carries nothing from the transport that failed. An adapter
41/// maps its own `io::Error` into this at the boundary, and the engine adds
42/// nothing, because the only fact available is "it did not go" — a half-written
43/// frame is a half-written frame regardless of which syscall noticed.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
45#[error("the frame could not be written to the peer")]
46pub struct SinkError;
47
48/// A session's outbound path.
49///
50/// Declared by the engine, which is its only consumer, and consumed through
51/// `impl FrameSink` so the call monomorphises. One method, for the same reason
52/// [`Clock`](crate::application::session::Clock) has one: a sink that also
53/// flushed, or negotiated, or reported back-pressure would be three adapters
54/// wearing one name.
55///
56/// # Examples
57///
58/// ```
59/// use protocol_droid::application::session::{FrameSink, SinkError};
60///
61/// /// A sink that keeps what it was given, for a test to inspect.
62/// #[derive(Default)]
63/// struct Recorded(Vec<String>);
64///
65/// impl FrameSink for Recorded {
66///     async fn send(&self, frame: &str) -> Result<(), SinkError> {
67///         // A real sink would not need this lock; a test that shares its sink
68///         // across tasks does, and holding the same shape here keeps the
69///         // example honest about what the borrow checker demands.
70///         let _ = self;
71///         Ok(())
72///     }
73/// }
74/// ```
75pub trait FrameSink {
76    /// Writes one encoded frame.
77    ///
78    /// `frame` is a document some codec has already produced. The engine
79    /// forwards it and never inspects it, which is what lets one engine serve
80    /// six revisions.
81    ///
82    /// # Errors
83    ///
84    /// [`SinkError`] when the frame did not reach the peer. A sink that has
85    /// already failed may return it for every later call; the engine does not
86    /// assume a failure is permanent.
87    ///
88    /// No `+ '_` on the return: this method has two borrowed inputs, and an
89    /// elided `'_` names only one of them. On edition 2024 the returned
90    /// `impl Future` already captures every in-scope lifetime, so spelling one
91    /// out is not merely redundant — it is a bound the implementor would fail
92    /// to satisfy.
93    fn send(&self, frame: &str) -> impl Future<Output = Result<(), SinkError>> + Send;
94}
95
96/// A [`FrameSink`] that can sit behind a trait object.
97///
98/// The hand-written spelling of the same method, and the only reason
99/// [`FrameSink`] does not use `async-trait`: the box belongs to the callers
100/// that need dynamic dispatch, not to the ones that do not.
101///
102/// # Examples
103///
104/// ```
105/// use std::sync::Arc;
106///
107/// use protocol_droid::application::session::{DynFrameSink, FrameSink, SinkError};
108///
109/// struct Null;
110///
111/// impl FrameSink for Null {
112///     async fn send(&self, _frame: &str) -> Result<(), SinkError> {
113///         Ok(())
114///     }
115/// }
116///
117/// // The bridge holds a sink of an unknown concrete type; the allocation here
118/// // is per outbound frame on a path that is already behind a mutex.
119/// let sink: Arc<dyn DynFrameSink> = Arc::new(Null);
120/// ```
121pub trait DynFrameSink: Send + Sync {
122    /// Writes one encoded frame.
123    ///
124    /// # Errors
125    ///
126    /// As [`FrameSink::send`].
127    ///
128    /// The two inputs share one lifetime because the box has to: a
129    /// `dyn Future + '_` names a single region, so an implementor that
130    /// returned a future borrowing the sink for longer than the frame could
131    /// not be spelled. Both are borrowed for the duration of the call anyway,
132    /// so nothing is lost.
133    fn send<'a>(
134        &'a self,
135        frame: &'a str,
136    ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + 'a>>;
137}
138
139impl<T> DynFrameSink for T
140where
141    T: FrameSink + Send + Sync,
142{
143    fn send<'a>(
144        &'a self,
145        frame: &'a str,
146    ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + 'a>> {
147        Box::pin(FrameSink::send(self, frame))
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use core::future::Future;
154    use core::pin::Pin;
155    use core::sync::atomic::{AtomicUsize, Ordering};
156    use core::task::{Context, Poll, Waker};
157    use std::sync::Arc;
158
159    use super::{DynFrameSink, FrameSink, SinkError};
160
161    /// Counts what it was asked to write, so the two spellings of the same
162    /// trait can be compared on what actually reached the peer.
163    #[derive(Default)]
164    struct Counting {
165        written: AtomicUsize,
166    }
167
168    impl Counting {
169        fn written(&self) -> usize {
170            self.written.load(Ordering::Relaxed)
171        }
172    }
173
174    impl FrameSink for Counting {
175        fn send(&self, _frame: &str) -> impl Future<Output = Result<(), SinkError>> + Send {
176            self.written.fetch_add(1, Ordering::Relaxed);
177            core::future::ready(Ok(()))
178        }
179    }
180
181    /// A sink that never writes, so the error path is reachable.
182    struct Broken;
183
184    impl FrameSink for Broken {
185        fn send(&self, _frame: &str) -> impl Future<Output = Result<(), SinkError>> + Send {
186            core::future::ready(Err(SinkError))
187        }
188    }
189
190    /// Drives a future to its first poll. `None` means it suspended, which
191    /// these sinks never do — a future that did would need a real executor,
192    /// and reporting `None` is more honest than pretending it completed.
193    fn drive<F: Future>(future: F) -> Option<F::Output> {
194        let mut context = Context::from_waker(Waker::noop());
195        let mut future = Box::pin(future);
196        match future.as_mut().poll(&mut context) {
197            Poll::Ready(value) => Some(value),
198            Poll::Pending => None,
199        }
200    }
201
202    #[test]
203    fn the_boxed_spelling_forwards_to_the_same_implementation() {
204        let concrete = Arc::new(Counting::default());
205        assert_eq!(drive(FrameSink::send(&*concrete, "{}")), Some(Ok(())));
206        let boxed: Arc<dyn DynFrameSink> = concrete.clone();
207        assert_eq!(drive(boxed.send("{}")), Some(Ok(())));
208        assert_eq!(
209            concrete.written(),
210            2,
211            "the blanket impl must not lose a frame"
212        );
213    }
214
215    #[test]
216    fn a_failing_sink_reports_the_error_through_both_spellings() {
217        let concrete = Broken;
218        assert_eq!(
219            drive(FrameSink::send(&concrete, "{}")),
220            Some(Err(SinkError))
221        );
222        let boxed: Arc<dyn DynFrameSink> = Arc::new(Broken);
223        assert_eq!(drive(boxed.send("{}")), Some(Err(SinkError)));
224    }
225
226    #[test]
227    fn a_boxed_sink_is_usable_from_behind_an_arc() {
228        fn write(
229            sink: &Arc<dyn DynFrameSink>,
230        ) -> Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send + '_>> {
231            sink.send("{\"jsonrpc\":\"2.0\"}")
232        }
233        let sink: Arc<dyn DynFrameSink> = Arc::new(Counting::default());
234        assert_eq!(drive(write(&sink)), Some(Ok(())));
235    }
236}