Skip to main content

ruststream_sea_file/
stdio.rs

1//! The stdio transport: [`StdioBroker`], standard input and output as one stream - a service
2//! that composes with ordinary command-line tools.
3
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use futures::Stream;
8use ruststream::{
9    Broker, ConnectedBroker, DefaultPublish, DescribeServer, OutgoingMessage, PairError,
10    PublishPolicy, Publisher, ServerSpec, Subscribe, Subscriber,
11};
12use sea_streamer_stdio::{StdioConnectOptions, StdioProducer, StdioProducerOptions, StdioStreamer};
13use sea_streamer_types::{
14    Consumer as _, ConsumerMode, ConsumerOptions as _, Producer as _, StreamKey, Streamer as _,
15    StreamerUri,
16};
17use tokio::sync::{OnceCell, mpsc};
18
19use crate::error::{SeaFileError, box_err};
20use crate::message::SeaMessage;
21use crate::wire;
22
23pub(crate) struct StdioCore {
24    pub(crate) streamer: StdioStreamer,
25    pub(crate) closed: AtomicBool,
26}
27
28impl StdioCore {
29    fn ensure_open(&self) -> Result<(), SeaFileError> {
30        if self.closed.load(Ordering::Acquire) {
31            return Err(SeaFileError::NotConnected);
32        }
33        Ok(())
34    }
35}
36
37impl std::fmt::Debug for StdioCore {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("StdioCore")
40            .field("closed", &self.closed.load(Ordering::Relaxed))
41            .finish_non_exhaustive()
42    }
43}
44
45type StdioCell = Arc<OnceCell<Arc<StdioCore>>>;
46
47/// Standard input and output as one stream: consume lines from stdin, publish lines to
48/// stdout, in the client's `[timestamp | stream_key | seq] payload` line format.
49///
50/// # Examples
51///
52/// ```
53/// use ruststream_sea_file::StdioBroker;
54///
55/// let broker = StdioBroker::new();
56/// # let _ = broker;
57/// ```
58#[derive(Debug, Clone, Default)]
59#[must_use]
60pub struct StdioBroker {
61    loopback: bool,
62    cell: StdioCell,
63}
64
65impl StdioBroker {
66    /// Records configuration only. No I/O.
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Loops published messages back to this process's own subscribers (for tests).
72    pub fn loopback(mut self) -> Self {
73        self.loopback = true;
74        self
75    }
76
77    /// A publisher sharing this broker's connection cell; buildable before `connect`.
78    #[must_use]
79    pub fn publisher(&self) -> StdioPublisher {
80        StdioPublisher {
81            cell: Arc::clone(&self.cell),
82            producer: Arc::new(OnceCell::new()),
83        }
84    }
85}
86
87impl Broker for StdioBroker {
88    type Error = SeaFileError;
89    type Connected = ConnectedStdioBroker;
90
91    async fn connect(self) -> Result<Self::Connected, Self::Error> {
92        let core = self
93            .cell
94            .get_or_try_init(async || {
95                let mut options = StdioConnectOptions::default();
96                options.set_loopback(self.loopback);
97                let streamer = StdioStreamer::connect(StreamerUri::zero(), options)
98                    .await
99                    .map_err(|e| SeaFileError::Connect {
100                        target: "stdio".to_owned(),
101                        source: box_err(e),
102                    })?;
103                Ok::<_, SeaFileError>(Arc::new(StdioCore {
104                    streamer,
105                    closed: AtomicBool::new(false),
106                }))
107            })
108            .await?
109            .clone();
110        Ok(ConnectedStdioBroker {
111            core,
112            cell: self.cell,
113        })
114    }
115}
116
117impl DescribeServer for StdioBroker {
118    fn describe_server(&self) -> ServerSpec {
119        ServerSpec::in_process("stdio")
120    }
121}
122
123/// The typed witness that `connect` succeeded.
124#[derive(Debug)]
125pub struct ConnectedStdioBroker {
126    core: Arc<StdioCore>,
127    // Keeps the cell of publishers handed out before connect alive and filled.
128    cell: StdioCell,
129}
130
131impl ConnectedStdioBroker {
132    /// A publisher from the connected form.
133    #[must_use]
134    pub fn publisher(&self) -> StdioPublisher {
135        StdioPublisher {
136            cell: Arc::clone(&self.cell),
137            producer: Arc::new(OnceCell::new()),
138        }
139    }
140}
141
142impl ConnectedBroker for ConnectedStdioBroker {
143    type Error = SeaFileError;
144    type Closed = ();
145
146    async fn shutdown(self) -> Result<(), Self::Error> {
147        self.core.closed.store(true, Ordering::Release);
148        // Globally destructive by the client's design: every stdio consumer and producer in
149        // the process ends. That is the honest meaning of shutting down a process-wide
150        // transport.
151        self.core
152            .streamer
153            .clone()
154            .disconnect()
155            .await
156            .map_err(|e| SeaFileError::Connect {
157                target: "stdio".to_owned(),
158                source: box_err(e),
159            })
160    }
161}
162
163impl Subscribe for ConnectedStdioBroker {
164    type Subscriber = StdioSubscriber;
165
166    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
167        self.core.ensure_open()?;
168        let key =
169            StreamKey::new(name).map_err(|e| SeaFileError::Invalid(format!("'{name}': {e}")))?;
170        let consumer = self
171            .core
172            .streamer
173            .create_consumer(
174                &[key],
175                sea_streamer_stdio::StdioConsumerOptions::new(ConsumerMode::RealTime),
176            )
177            .await
178            .map_err(|e| SeaFileError::Subscribe {
179                stream: name.to_owned(),
180                source: box_err(e),
181            })?;
182
183        let (tx, rx) = mpsc::channel(64);
184        let stream_name = name.to_owned();
185        tokio::spawn(async move {
186            loop {
187                tokio::select! {
188                    () = tx.closed() => break,
189                    next = consumer.next() => match next {
190                        Ok(message) => {
191                            if tx.send(Ok(SeaMessage::new(&message))).await.is_err() {
192                                break;
193                            }
194                        }
195                        Err(err) => {
196                            let _ = tx
197                                .send(Err(SeaFileError::Receive {
198                                    stream: stream_name.clone(),
199                                    source: box_err(err),
200                                }))
201                                .await;
202                            break;
203                        }
204                    },
205                }
206            }
207        });
208        Ok(StdioSubscriber {
209            stream: name.to_owned(),
210            rx,
211        })
212    }
213}
214
215impl DefaultPublish for ConnectedStdioBroker {
216    type Policy = StdioPublish;
217}
218
219/// A subscription to one stream key on standard input; yields [`SeaMessage`]s.
220///
221/// Standard input has no retained log: there is no acknowledgement and no repositioning, and
222/// both are reported as unsupported rather than pretended.
223pub struct StdioSubscriber {
224    stream: String,
225    rx: mpsc::Receiver<Result<SeaMessage, SeaFileError>>,
226}
227
228impl std::fmt::Debug for StdioSubscriber {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        f.debug_struct("StdioSubscriber")
231            .field("stream", &self.stream)
232            .finish_non_exhaustive()
233    }
234}
235
236impl Subscriber for StdioSubscriber {
237    type Message = SeaMessage;
238    type Error = SeaFileError;
239
240    fn stream(&mut self) -> impl Stream<Item = Result<SeaMessage, SeaFileError>> + Send + '_ {
241        // Poll the channel in place rather than wrapping it in an owning stream, so `stream`
242        // can be called again after the returned stream is dropped (the runtime and the
243        // conformance helpers re-enter it per call).
244        futures::stream::poll_fn(move |cx| self.rx.poll_recv(cx))
245    }
246}
247
248/// Publishes messages to standard output.
249///
250/// The line format is the client's own; payloads must be text, so a non-UTF-8 payload (and
251/// any message with headers) travels in the text-safe envelope. The client silently drops
252/// empty lines, so an empty payload is rejected here instead.
253#[derive(Clone)]
254pub struct StdioPublisher {
255    cell: StdioCell,
256    producer: Arc<OnceCell<StdioProducer>>,
257}
258
259impl std::fmt::Debug for StdioPublisher {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        f.debug_struct("StdioPublisher").finish_non_exhaustive()
262    }
263}
264
265impl Publisher for StdioPublisher {
266    type Error = SeaFileError;
267
268    async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
269        let core = self.cell.get().ok_or(SeaFileError::NotConnected)?;
270        core.ensure_open()?;
271        if msg.payload().is_empty() && msg.headers().is_empty() {
272            return Err(SeaFileError::Invalid(
273                "stdio drops empty lines; an empty message cannot be transmitted".into(),
274            ));
275        }
276        let producer = self
277            .producer
278            .get_or_try_init(async || {
279                core.streamer
280                    .create_generic_producer(StdioProducerOptions::default())
281                    .await
282                    .map_err(|e| SeaFileError::Publish {
283                        stream: msg.name().to_owned(),
284                        source: box_err(e),
285                    })
286            })
287            .await?;
288        let key = StreamKey::new(msg.name())
289            .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", msg.name())))?;
290        // force_text: the stdio line format rejects non-UTF-8 payloads.
291        let payload = wire::encode(msg.headers(), msg.payload(), true);
292        producer
293            .send_to(&key, payload.as_slice())
294            .map_err(|e| SeaFileError::Publish {
295                stream: msg.name().to_owned(),
296                source: box_err(e),
297            })?
298            .await
299            .map(|_| ())
300            .map_err(|e| SeaFileError::Publish {
301                stream: msg.name().to_owned(),
302                source: box_err(e),
303            })
304    }
305}
306
307/// The publish policy for [`StdioPublisher`].
308///
309/// # Examples
310///
311/// ```
312/// use ruststream_sea_file::StdioPublish;
313///
314/// let policy = StdioPublish::default();
315/// # let _ = policy;
316/// ```
317#[derive(Debug, Clone, Copy, Default)]
318#[must_use]
319pub struct StdioPublish;
320
321impl PublishPolicy<ConnectedStdioBroker> for StdioPublish {
322    type Live = StdioPublisher;
323
324    async fn pair(self, connected: &ConnectedStdioBroker) -> Result<Self::Live, PairError> {
325        Ok(connected.publisher())
326    }
327}