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}