Skip to main content

ruststream_sea_file/
stdio.rs

1//! Standard input and output as one stream: a service that is a stage of a shell pipeline.
2//!
3//! [`StdioBroker`] records nothing and attaches the process's own pipes on
4//! [`Broker::connect`]. Standard input is the subscription, standard output the publisher, so
5//! `producer | service | consumer` works with ordinary command-line tools. A service on a
6//! pipeline globs this form's [`prelude`] and names its policy [`Publish`].
7//!
8//! - [StdioBroker][stdio-broker]: [`StdioBroker`], and [`loopback`](StdioBroker::loopback) for a
9//!   stage that reads its own output ([loopback][loopback]).
10//! - [Lines][lines]: the client's `[timestamp | stream_key | sequence | shard_id] payload` line
11//!   format, read by a subscription on its stream key through [`StdioSubscriber`].
12//! - [StdioPublish][stdio-publish]: [`Publish`] and its live [`StdioPublisher`], one line per
13//!   message on standard output.
14//! - [Payloads][payloads]: binary, multi-line and padded payloads through the envelope.
15//! - [out_retry][out-retry]: where a deferred copy goes, named at the mount site.
16//! - [Shutdown][shutdown]: a stdio shutdown ends every stdio stream of the process.
17//!
18//! [stdio-broker]: https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/stdio-broker/
19//! [loopback]: https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/loopback/
20//! [lines]: https://powersemmi.github.io/ruststream-sea-file/latest/components/subscriptions/lines/
21//! [stdio-publish]: https://powersemmi.github.io/ruststream-sea-file/latest/components/publishing/stdio-publish/
22//! [payloads]: https://powersemmi.github.io/ruststream-sea-file/latest/components/headers/payloads/
23//! [out-retry]: https://powersemmi.github.io/ruststream-sea-file/latest/components/settlement/out-retry/
24//! [shutdown]: https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/shutdown/
25
26// Without the `testing` feature a link has one variant, so a `match` on it has a single arm; the
27// matches stay so that the in-process arm has its place when the feature is on.
28#![cfg_attr(
29    not(feature = "testing"),
30    allow(clippy::infallible_destructuring_match)
31)]
32
33use std::future::{Future, ready};
34use std::io;
35use std::num::NonZeroUsize;
36use std::pin::pin;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, Ordering};
39
40use futures::{FutureExt as _, Stream};
41#[cfg(feature = "testing")]
42use ruststream::RawMessage;
43#[cfg(feature = "testing")]
44use ruststream::testing::{Coordinator, InProcess, TestableBroker};
45use ruststream::{
46    BatchSubscriber, Broker, BufferedSubscriber, ConnectedBroker, DefaultPublish, DescribeServer,
47    Lend, NamedCopies, OutgoingMessage, PairError, PublishPolicy, Publisher, ServerSpec, Subscribe,
48    Subscriber,
49};
50use sea_streamer_stdio::{
51    StdioConnectOptions, StdioConsumer, StdioConsumerOptions, StdioProducer, StdioProducerOptions,
52    StdioResult, StdioStreamer,
53};
54use sea_streamer_types::{
55    Consumer as _, ConsumerMode, ConsumerOptions as _, Producer as _, StreamKey, Streamer as _,
56    StreamerUri,
57};
58use tokio::runtime::Handle;
59use tokio::sync::{OnceCell, mpsc};
60
61use crate::batching::BATCH_MAX_WAIT;
62use crate::error::{SeaFileError, box_err};
63#[cfg(feature = "testing")]
64use crate::in_process::{MemoryPipe, PipeQueue};
65use crate::message::SeaMessage;
66use crate::wire;
67
68/// Whether a subscription in this process has started the client's standard-input reader.
69///
70/// Why a static: the reader is the client's, and it is process-wide. The first consumer starts
71/// it, and it then holds standard input while it waits for a line, so only the subscription that
72/// starts it can hold standard input itself (see `open_consumer`); a later one would wait for
73/// the next line.
74static READER_STARTED: AtomicBool = AtomicBool::new(false);
75
76pub(crate) struct StdioCore {
77    pub(crate) link: StdioLink,
78    pub(crate) closed: AtomicBool,
79    /// The broker's own setting, which the test harness reads to know where a publish goes. The
80    /// field is there only with the `testing` feature.
81    #[cfg(feature = "testing")]
82    loopback: bool,
83}
84
85/// What a connected broker and every handle paired off it speak over: the process's own pipes,
86/// or, under the `testing` feature, the in-memory ones the test harness connected instead.
87///
88/// Without the feature there is one variant, so the type is the client's streamer itself and every
89/// `match` on it is irrefutable: a production build carries no second transport and no branch to
90/// it.
91pub(crate) enum StdioLink {
92    Stdio(Pipes),
93    #[cfg(feature = "testing")]
94    InProcess(Arc<MemoryPipe>),
95}
96
97/// The process's own pipes as the client opened them, and the runtime `connect` ran on: the
98/// reader task of every subscription runs there, whichever runtime opens it.
99pub(crate) struct Pipes {
100    streamer: StdioStreamer,
101    runtime: Handle,
102}
103
104// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives the
105// link exactly the size of the pipes it wraps.
106#[cfg(not(feature = "testing"))]
107const _: () = assert!(size_of::<StdioLink>() == size_of::<Pipes>());
108
109impl StdioCore {
110    fn ensure_open(&self) -> Result<(), SeaFileError> {
111        if self.closed.load(Ordering::Acquire) {
112            return Err(SeaFileError::NotConnected);
113        }
114        Ok(())
115    }
116}
117
118impl std::fmt::Debug for StdioCore {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.debug_struct("StdioCore")
121            .field("closed", &self.closed.load(Ordering::Relaxed))
122            .finish_non_exhaustive()
123    }
124}
125
126type StdioCell = Arc<OnceCell<Arc<StdioCore>>>;
127
128/// Standard input and output as one stream: consume lines from stdin, publish lines to
129/// stdout, in the client's `[timestamp | stream_key | seq] payload` line format.
130///
131/// Shutting this broker down ends every stdio subscription and publisher in the process, after
132/// the lines each subscription has already read: the client's transport is process-wide. The
133/// [StdioBroker](https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/stdio-broker/)
134/// page shows it in a service.
135///
136/// # Examples
137///
138/// ```
139/// use ruststream_sea_file::stdio::prelude::*;
140/// use serde::{Deserialize, Serialize};
141///
142/// #[derive(Debug, Deserialize)]
143/// struct Line {
144///     text: String,
145/// }
146///
147/// #[derive(Debug, Outgoing, Serialize)]
148/// #[outgoing(name = "upper")]
149/// struct Upper {
150///     text: String,
151/// }
152///
153/// #[subscriber("lines", reply)]
154/// async fn shout(line: &Line) -> Upper {
155///     Upper { text: line.text.to_uppercase() }
156/// }
157///
158/// #[ruststream::app]
159/// fn app() -> impl App {
160///     RustStream::new(AppInfo::new("shout", "0.1.0")).with_broker(StdioBroker::new(), |b| {
161///         b.include(shout).out_retry(Publish).to("lines.retry");
162///     })
163/// }
164/// ```
165#[derive(Debug, Clone, Default)]
166#[must_use]
167pub struct StdioBroker {
168    loopback: bool,
169    cell: StdioCell,
170}
171
172impl StdioBroker {
173    /// Records configuration only. No I/O.
174    pub fn new() -> Self {
175        Self::default()
176    }
177
178    /// Loops published messages back to this process's own subscribers (for tests).
179    pub fn loopback(mut self) -> Self {
180        self.loopback = true;
181        self
182    }
183
184    /// A publisher sharing this broker's connection cell; buildable before `connect`.
185    #[must_use]
186    pub fn publisher(&self) -> StdioPublisher {
187        StdioPublisher {
188            cell: Arc::clone(&self.cell),
189            producer: Arc::new(OnceCell::new()),
190        }
191    }
192}
193
194impl Broker for StdioBroker {
195    type Error = SeaFileError;
196    type Connected = ConnectedStdioBroker;
197
198    async fn connect(self) -> Result<Self::Connected, Self::Error> {
199        let core = self
200            .cell
201            .get_or_try_init(async || {
202                let mut options = StdioConnectOptions::default();
203                options.set_loopback(self.loopback);
204                let streamer = StdioStreamer::connect(StreamerUri::zero(), options)
205                    .await
206                    .map_err(|e| SeaFileError::Connect {
207                        target: "stdio".to_owned(),
208                        source: box_err(e),
209                    })?;
210                Ok::<_, SeaFileError>(Arc::new(StdioCore {
211                    link: StdioLink::Stdio(Pipes {
212                        streamer,
213                        runtime: Handle::current(),
214                    }),
215                    closed: AtomicBool::new(false),
216                    #[cfg(feature = "testing")]
217                    loopback: self.loopback,
218                }))
219            })
220            .await?
221            .clone();
222        Ok(ConnectedStdioBroker {
223            core,
224            cell: self.cell,
225        })
226    }
227}
228
229/// The in-process mode: the connected form a test runs the production app against, over a pipe
230/// held in memory in place of the process's own standard input and output.
231///
232/// The pipe belongs to this broker and every clone of it. What the test injects arrives on its
233/// standard input; what the service publishes is written to its standard output, and reaches the
234/// service's own subscriptions only under [`loopback`](Self::loopback), as on a real pipe.
235#[cfg(feature = "testing")]
236impl InProcess for StdioBroker {
237    async fn connect_in_process(self) -> Result<Self::Connected, Self::Error> {
238        let core = self
239            .cell
240            .get_or_init(async || {
241                Arc::new(StdioCore {
242                    link: StdioLink::InProcess(MemoryPipe::new(self.loopback)),
243                    closed: AtomicBool::new(false),
244                    loopback: self.loopback,
245                })
246            })
247            .await
248            .clone();
249        Ok(ConnectedStdioBroker {
250            core,
251            cell: self.cell,
252        })
253    }
254}
255
256#[cfg(feature = "testing")]
257ruststream::register_testable_broker!(StdioBroker);
258
259/// Opens a consumer of `key` on standard input.
260///
261/// The client starts its reader inside the first consumer and only then registers that consumer,
262/// so a line already waiting on the pipe could be read in between and handed to no one: a stage
263/// started with its input ready lost its first line. The first subscription therefore holds
264/// standard input while the consumer is created, and the reader takes its first line only once
265/// the consumer is there. Creating a consumer completes without waiting, so standard input is
266/// held for that one poll and never across an await.
267async fn open_consumer(streamer: &StdioStreamer, key: StreamKey) -> StdioResult<StdioConsumer> {
268    let keys = [key];
269    let mut created =
270        pin!(streamer.create_consumer(&keys, StdioConsumerOptions::new(ConsumerMode::RealTime)));
271    if READER_STARTED.swap(true, Ordering::AcqRel) {
272        return created.await;
273    }
274    let first = {
275        let _input = io::stdin().lock();
276        created.as_mut().now_or_never()
277    };
278    match first {
279        Some(consumer) => consumer,
280        None => created.await,
281    }
282}
283
284impl DescribeServer for StdioBroker {
285    fn describe_server(&self) -> ServerSpec {
286        ServerSpec::in_process("stdio")
287    }
288}
289
290/// The typed witness that `connect` succeeded.
291#[derive(Debug)]
292pub struct ConnectedStdioBroker {
293    core: Arc<StdioCore>,
294    // Keeps the cell of publishers handed out before connect alive and filled.
295    cell: StdioCell,
296}
297
298impl ConnectedStdioBroker {
299    /// A publisher from the connected form.
300    #[must_use]
301    pub fn publisher(&self) -> StdioPublisher {
302        StdioPublisher {
303            cell: Arc::clone(&self.cell),
304            producer: Arc::new(OnceCell::new()),
305        }
306    }
307}
308
309impl ConnectedBroker for ConnectedStdioBroker {
310    type Error = SeaFileError;
311    type Closed = ();
312
313    async fn shutdown(self) -> Result<(), Self::Error> {
314        self.core.closed.store(true, Ordering::Release);
315        let streamer = match &self.core.link {
316            StdioLink::Stdio(pipes) => &pipes.streamer,
317            // The in-memory pipe belongs to this broker alone, so its shutdown ends only the
318            // subscriptions on it.
319            #[cfg(feature = "testing")]
320            StdioLink::InProcess(pipe) => {
321                pipe.finish();
322                return Ok(());
323            }
324        };
325        // Globally destructive by the client's design: every stdio consumer and producer in
326        // the process ends. That is the honest meaning of shutting down a process-wide
327        // transport.
328        streamer
329            .clone()
330            .disconnect()
331            .await
332            .map_err(|e| SeaFileError::Connect {
333                target: "stdio".to_owned(),
334                source: box_err(e),
335            })
336    }
337}
338
339impl Subscribe for ConnectedStdioBroker {
340    type Subscriber = StdioSubscriber;
341    /// The mount site names where a deferred copy goes, because nothing on this transport
342    /// addresses the subscription: a publish writes to standard output and a subscription reads
343    /// standard input, so the process downstream of the pipe is not the one that sent the
344    /// message.
345    ///
346    /// A registration over stdio therefore names a destination - `.out_retry(Publish).to(name)`,
347    /// or a transform that names one per delivery - and one that names neither refuses to start,
348    /// which is the right answer for a pipeline stage: a delayed message that went nowhere is
349    /// worse than a service that does not come up. ([`loopback`](StdioBroker::loopback) does
350    /// route a publish back to this process, but it is a test aid, and an address that only held
351    /// under it would break in the shape a service ships.)
352    type Copies = NamedCopies;
353
354    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
355        self.core.ensure_open()?;
356        let key =
357            StreamKey::new(name).map_err(|e| SeaFileError::Invalid(format!("'{name}': {e}")))?;
358        let pipes = match &self.core.link {
359            StdioLink::Stdio(pipes) => pipes,
360            #[cfg(feature = "testing")]
361            StdioLink::InProcess(pipe) => {
362                return Ok(StdioSubscriber {
363                    stream: name.to_owned(),
364                    inner: BufferedSubscriber::new(StdioDeliveries::InProcess(
365                        pipe.subscribe(key.name()),
366                    ))
367                    .max_wait(BATCH_MAX_WAIT),
368                });
369            }
370        };
371        let consumer =
372            open_consumer(&pipes.streamer, key)
373                .await
374                .map_err(|e| SeaFileError::Subscribe {
375                    stream: name.to_owned(),
376                    source: box_err(e),
377                })?;
378
379        let (tx, rx) = mpsc::channel(64);
380        pipes.runtime.spawn(async move {
381            loop {
382                tokio::select! {
383                    () = tx.closed() => break,
384                    next = consumer.next() => match next {
385                        Ok(message) => {
386                            if tx.send(Ok(SeaMessage::new(&message))).await.is_err() {
387                                break;
388                            }
389                        }
390                        // The client fails a read only once its consumers are disconnected,
391                        // which is what the shutdown does: the subscription ends there.
392                        Err(_) => break,
393                    },
394                }
395            }
396        });
397        Ok(StdioSubscriber {
398            stream: name.to_owned(),
399            inner: BufferedSubscriber::new(StdioDeliveries::Reader(rx)).max_wait(BATCH_MAX_WAIT),
400        })
401    }
402}
403
404impl DefaultPublish for ConnectedStdioBroker {
405    type Policy = StdioPublish;
406}
407
408/// The harness's view of the in-process transport: what it feeds to standard input, what it
409/// reads back, and the coordinator it counts the in-flight deliveries with.
410///
411/// An injected message is a line arriving on standard input, so every subscription on its stream
412/// key reads it. A publish is a line on standard output, which reaches the process downstream:
413/// [`routes`](TestableBroker::routes) answers that it reaches this service's subscriptions of the
414/// same stream key under [`loopback`](StdioBroker::loopback), and none of them otherwise.
415///
416/// # Panics
417///
418/// `inject` and `published` panic on a broker connected with `connect`: the harness drives only
419/// the pipe `connect_in_process` produces. `inject` also panics on a line a pipe cannot carry: an
420/// invalid stream key, or a message with no payload and no headers.
421#[cfg(feature = "testing")]
422impl TestableBroker for ConnectedStdioBroker {
423    fn install_coordinator(&self, coordinator: Coordinator) {
424        if let StdioLink::InProcess(pipe) = &self.core.link {
425            pipe.install(coordinator);
426        }
427    }
428
429    fn inject(&self, message: OutgoingMessage<'_>) {
430        let pipe = self.memory_pipe("inject");
431        let key = StreamKey::new(message.name()).unwrap_or_else(|err| {
432            panic!(
433                "the injected message to {:?} is not one a pipe carries: {err}",
434                message.name()
435            )
436        });
437        assert!(
438            !(message.payload().is_empty() && message.headers().is_empty()),
439            "the injected message to {:?} is empty, and a pipe drops an empty line",
440            message.name(),
441        );
442        // The line an upstream stage of the pipeline writes for this message.
443        pipe.feed(
444            key,
445            wire::encode(message.headers(), message.payload(), true),
446        );
447    }
448
449    fn published(&self, name: &str) -> Vec<RawMessage> {
450        self.memory_pipe("published").published(name)
451    }
452
453    fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
454        if !self.core.loopback {
455            return Vec::new();
456        }
457        subscriptions
458            .iter()
459            .enumerate()
460            .filter(|(_, name)| **name == destination)
461            .map(|(position, _)| position)
462            .collect()
463    }
464}
465
466#[cfg(feature = "testing")]
467impl ConnectedStdioBroker {
468    /// The in-memory pipe, which is all the harness drives.
469    fn memory_pipe(&self, what: &str) -> &MemoryPipe {
470        match &self.core.link {
471            StdioLink::InProcess(pipe) => pipe,
472            StdioLink::Stdio(_) => panic!(
473                "TestableBroker::{what} reached a broker connected with `connect`; the harness \
474                 drives the pipe `connect_in_process` produces"
475            ),
476        }
477    }
478}
479
480/// A subscription to one stream key on standard input; yields [`SeaMessage`]s.
481///
482/// Standard input has no retained log: there is no acknowledgement and no repositioning, and
483/// both are reported as unsupported rather than pretended. Batches it does serve: the client
484/// reads one line at a time, so they are assembled here, capped at the size the mount site
485/// named.
486pub struct StdioSubscriber {
487    stream: String,
488    inner: BufferedSubscriber<StdioDeliveries>,
489}
490
491impl std::fmt::Debug for StdioSubscriber {
492    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493        f.debug_struct("StdioSubscriber")
494            .field("stream", &self.stream)
495            .finish_non_exhaustive()
496    }
497}
498
499impl Subscriber for StdioSubscriber {
500    type Message = SeaMessage;
501    type Error = SeaFileError;
502
503    fn stream(&mut self) -> impl Stream<Item = Result<SeaMessage, SeaFileError>> + Send + '_ {
504        self.inner.stream()
505    }
506}
507
508impl BatchSubscriber for StdioSubscriber {
509    type Batch = Vec<SeaMessage>;
510
511    fn batches(
512        &mut self,
513        size: NonZeroUsize,
514    ) -> impl Stream<Item = Result<Self::Batch, SeaFileError>> + Send + '_ {
515        self.inner.batches(size)
516    }
517}
518
519/// A subscription's lines, before batching: one line per poll, in arrival order.
520///
521/// Without the `testing` feature there is one variant, so the type is the reader task's channel
522/// itself and every `match` on it is irrefutable.
523enum StdioDeliveries {
524    /// The reader task's channel, fed from standard input.
525    Reader(mpsc::Receiver<Result<SeaMessage, SeaFileError>>),
526    /// The in-process pipe's queue.
527    #[cfg(feature = "testing")]
528    InProcess(PipeQueue),
529}
530
531// The zero-cost promise of the in-process mode, held by the compiler.
532#[cfg(not(feature = "testing"))]
533const _: () = assert!(
534    size_of::<StdioDeliveries>() == size_of::<mpsc::Receiver<Result<SeaMessage, SeaFileError>>>()
535);
536
537impl Subscriber for StdioDeliveries {
538    type Message = SeaMessage;
539    type Error = SeaFileError;
540
541    fn stream(&mut self) -> impl Stream<Item = Result<SeaMessage, SeaFileError>> + Send + '_ {
542        // Poll the channel in place rather than wrapping it in an owning stream, so `stream`
543        // can be called again after the returned stream is dropped (the runtime and the
544        // conformance helpers re-enter it per call).
545        futures::stream::poll_fn(move |cx| match self {
546            Self::Reader(rx) => rx.poll_recv(cx),
547            #[cfg(feature = "testing")]
548            Self::InProcess(queue) => queue.poll_next(cx).map(|next| next.map(Ok)),
549        })
550    }
551}
552
553/// Publishes messages to standard output.
554///
555/// The line format is the client's own. A line is text, ends at a newline, and the reader trims
556/// it, so a payload that is not UTF-8, holds a newline or begins or ends with whitespace (and any
557/// message with headers) travels in the text-safe envelope. The client silently drops
558/// empty lines, so an empty payload is rejected here instead.
559///
560/// A publish carries no per-message settings: a line on standard output takes a key and a
561/// payload and nothing else, so [`Publisher::Options`] is the unit type and the publish builder
562/// gains no step from this crate.
563///
564/// Standard output reaches the next process in the pipeline and never this process's own
565/// standard input, so a registration that defers a delivery names where the copy goes, with
566/// `.out_retry(Publish).to(key)` or a transform that names one per delivery; one that names
567/// neither refuses to start. The
568/// [StdioPublish](https://powersemmi.github.io/ruststream-sea-file/latest/components/publishing/stdio-publish/)
569/// page shows it in a service.
570///
571/// # Examples
572///
573/// ```
574/// use ruststream_sea_file::stdio::prelude::*;
575/// use serde::{Deserialize, Serialize};
576///
577/// #[derive(Debug, Deserialize)]
578/// struct Job {
579///     id: u64,
580/// }
581///
582/// #[derive(Debug, Outgoing, Serialize)]
583/// #[outgoing(name = "results")]
584/// struct Done {
585///     id: u64,
586/// }
587///
588/// #[subscriber("jobs", reply)]
589/// async fn work(job: &Job) -> Done {
590///     tracing::info!(job.id, "done");
591///     Done { id: job.id }
592/// }
593///
594/// #[ruststream::app]
595/// fn app() -> impl App {
596///     RustStream::new(AppInfo::new("pipeline", "0.1.0")).with_broker(StdioBroker::new(), |b| {
597///         b.include(work).out_retry(Publish).to("jobs.retry");
598///     })
599/// }
600/// ```
601#[derive(Clone)]
602pub struct StdioPublisher {
603    cell: StdioCell,
604    producer: Arc<OnceCell<StdioProducer>>,
605}
606
607impl std::fmt::Debug for StdioPublisher {
608    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609        f.debug_struct("StdioPublisher").finish_non_exhaustive()
610    }
611}
612
613impl Publisher for StdioPublisher {
614    /// A line on standard output is written from the bytes and keeps nothing: the client's
615    /// `send_to` takes a slice, and the text-safe envelope is a buffer of this crate's own.
616    type Payload = Lend;
617
618    type Error = SeaFileError;
619    type Options = ();
620
621    async fn publish(
622        &self,
623        msg: OutgoingMessage<'_, &[u8]>,
624        _options: Option<&()>,
625    ) -> Result<(), Self::Error> {
626        let core = self.cell.get().ok_or(SeaFileError::NotConnected)?;
627        core.ensure_open()?;
628        if msg.payload().is_empty() && msg.headers().is_empty() {
629            return Err(SeaFileError::Invalid(
630                "stdio drops empty lines; an empty message cannot be transmitted".into(),
631            ));
632        }
633        let streamer = match &core.link {
634            StdioLink::Stdio(pipes) => &pipes.streamer,
635            #[cfg(feature = "testing")]
636            StdioLink::InProcess(pipe) => {
637                let key = StreamKey::new(msg.name())
638                    .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", msg.name())))?;
639                pipe.write(key, wire::encode(msg.headers(), msg.payload(), true));
640                return Ok(());
641            }
642        };
643        let producer = self
644            .producer
645            .get_or_try_init(async || {
646                streamer
647                    .create_generic_producer(StdioProducerOptions::default())
648                    .await
649                    .map_err(|e| SeaFileError::Publish {
650                        stream: msg.name().to_owned(),
651                        source: box_err(e),
652                    })
653            })
654            .await?;
655        let key = StreamKey::new(msg.name())
656            .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", msg.name())))?;
657        // force_text: the stdio line format rejects non-UTF-8 payloads.
658        let payload = wire::encode(msg.headers(), msg.payload(), true);
659        producer
660            .send_to(&key, payload.as_slice())
661            .map_err(|e| SeaFileError::Publish {
662                stream: msg.name().to_owned(),
663                source: box_err(e),
664            })?
665            .await
666            .map(|_| ())
667            .map_err(|e| SeaFileError::Publish {
668                stream: msg.name().to_owned(),
669                source: box_err(e),
670            })
671    }
672}
673
674/// The publish policy for [`StdioPublisher`].
675///
676/// # Examples
677///
678/// ```
679/// use ruststream_sea_file::prelude::*;
680/// use serde::{Deserialize, Serialize};
681///
682/// #[derive(Debug, Deserialize)]
683/// struct Order {
684///     id: u64,
685/// }
686///
687/// #[derive(Debug, Outgoing, Serialize)]
688/// struct Receipt {
689///     order: u64,
690/// }
691///
692/// #[subscriber("orders", reply("receipts"))]
693/// async fn confirm(order: &Order) -> Receipt {
694///     Receipt { order: order.id }
695/// }
696///
697/// #[ruststream::app]
698/// fn app() -> impl App {
699///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(StdioBroker::new(), |b| {
700///         b.include(confirm)
701///             .out_reply(StdioPublish)
702///             .out_retry(StdioPublish)
703///             .to("orders.retry");
704///     })
705/// }
706/// ```
707#[derive(Debug, Clone, Copy, Default)]
708#[must_use]
709pub struct StdioPublish;
710
711impl PublishPolicy<ConnectedStdioBroker> for StdioPublish {
712    type Live = StdioPublisher;
713
714    fn pair(
715        self,
716        connected: &ConnectedStdioBroker,
717    ) -> impl Future<Output = Result<Self::Live, PairError>> {
718        ready(Ok(connected.publisher()))
719    }
720}
721
722/// The publish policy of this form, under the name every form uses.
723///
724/// A mount site names the concept, never the transport: moving a service from one form to
725/// another changes the prelude it globs and leaves the composition root alone. The prefixed
726/// [`StdioPublish`] stays at the crate root, for a service that mixes both forms.
727///
728/// # Examples
729///
730/// ```
731/// use ruststream_sea_file::stdio::prelude::*;
732/// use serde::{Deserialize, Serialize};
733///
734/// #[derive(Debug, Deserialize)]
735/// struct Order {
736///     id: u64,
737/// }
738///
739/// #[derive(Debug, Outgoing, Serialize)]
740/// struct Receipt {
741///     order: u64,
742/// }
743///
744/// #[subscriber("orders", reply("receipts"))]
745/// async fn confirm(order: &Order) -> Receipt {
746///     Receipt { order: order.id }
747/// }
748///
749/// #[ruststream::app]
750/// fn app() -> impl App {
751///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(StdioBroker::new(), |b| {
752///         b.include(confirm)
753///             .out_reply(Publish)
754///             .out_retry(Publish)
755///             .to("orders.retry");
756///     })
757/// }
758/// ```
759pub use StdioPublish as Publish;
760
761pub mod prelude {
762    //! The imports a service on a shell pipeline writes every time, in one glob.
763    //!
764    //! The framework's prelude, this form's broker, and [`Publish`]. A subscription here is a
765    //! plain stream key, so this form has no descriptor type.
766    //!
767    //! This is the routes-side vocabulary: a mount site globs it and names policies by concept.
768    //! A handler body globs `ruststream::prelude` instead and bounds an injected publisher by
769    //! the framework's capability traits, so the two vocabularies never meet in one file.
770    //!
771    //! # Examples
772    //!
773    //! ```
774    //! use ruststream_sea_file::stdio::prelude::*;
775    //! use serde::{Deserialize, Serialize};
776    //!
777    //! #[derive(Debug, Deserialize)]
778    //! struct Job {
779    //!     id: u64,
780    //! }
781    //!
782    //! #[derive(Debug, Outgoing, Serialize)]
783    //! #[outgoing(name = "results")]
784    //! struct Done {
785    //!     id: u64,
786    //! }
787    //!
788    //! #[subscriber("jobs", reply)]
789    //! async fn work(job: &Job) -> Done {
790    //!     Done { id: job.id }
791    //! }
792    //!
793    //! #[ruststream::app]
794    //! fn app() -> impl App {
795    //!     RustStream::new(AppInfo::new("pipeline", "0.1.0"))
796    //!         .with_broker(StdioBroker::new(), |b| {
797    //!             b.include(work).out_retry(Publish).to("jobs.retry");
798    //!         })
799    //! }
800    //! ```
801
802    pub use ruststream::prelude::*;
803
804    // No capability traits: `SeaMessage` implements `Positioned`, but a pipe cannot seek back to
805    // one - do not add it here.
806    pub use crate::stdio::{Publish, StdioBroker};
807}