Skip to main content

camel_component_stream/
lib.rs

1//! Stream (stdio) component for rust-camel — routes exchange bodies between
2//! the process's standard streams and the integration runtime.
3//!
4//! Main types: `StreamComponent`, `StreamEndpoint`, `StreamConfig`,
5//! `StreamTarget`, `StreamFrame`.
6//! URI format: `stream:out|err|in?appendNewline=true&charset=utf-8&frame=line`.
7//!
8//! # Targets
9//!
10//! - **out** / **err**: producers that write exchange bodies to stdout/stderr.
11//! - **in**: consumer that reads exchange bodies from stdin.
12//!
13//! UTF-8 is the only supported charset in v1.
14
15use std::fmt;
16use std::future::Future;
17use std::pin::Pin;
18use std::str::FromStr;
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::task::{Context, Poll};
22
23use async_trait::async_trait;
24use bytes::BytesMut;
25use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
26use tower::Service;
27use tracing::debug;
28
29use camel_component_api::parse_uri;
30use camel_component_api::{BoxProcessor, CamelError, Exchange, Message};
31use camel_component_api::{
32    Component, ComponentMetadata, Consumer, ConsumerContext, Endpoint, ProducerContext,
33    RuntimeObservability, UriConfig,
34};
35
36// ---------------------------------------------------------------------------
37// StreamTarget
38// ---------------------------------------------------------------------------
39
40/// The standard stream an endpoint is bound to.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum StreamTarget {
43    /// Standard output (producer).
44    Out,
45    /// Standard error (producer).
46    Err,
47    /// Standard input (consumer).
48    In,
49}
50
51impl fmt::Display for StreamTarget {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            StreamTarget::Out => write!(f, "out"),
55            StreamTarget::Err => write!(f, "err"),
56            StreamTarget::In => write!(f, "in"),
57        }
58    }
59}
60
61impl FromStr for StreamTarget {
62    type Err = String;
63
64    // Explicit `String`: `Self::Err` is ambiguous here because the enum has
65    // an `Err` variant (ambiguous_associated_items).
66    fn from_str(s: &str) -> Result<Self, String> {
67        match s {
68            "out" => Ok(StreamTarget::Out),
69            "err" => Ok(StreamTarget::Err),
70            "in" => Ok(StreamTarget::In),
71            _ => Err(format!(
72                "unknown stream target: '{}'. Valid: out, err, in",
73                s
74            )),
75        }
76    }
77}
78
79// ---------------------------------------------------------------------------
80// StreamFrame
81// ---------------------------------------------------------------------------
82
83/// How the body bytes are framed on the stream.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum StreamFrame {
86    /// One exchange per line (newline-delimited). Default.
87    Line,
88    /// Bytes written/read verbatim, no framing.
89    Raw,
90    /// Fixed-size frames of `size` bytes.
91    Fixed,
92}
93
94impl fmt::Display for StreamFrame {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            StreamFrame::Line => write!(f, "line"),
98            StreamFrame::Raw => write!(f, "raw"),
99            StreamFrame::Fixed => write!(f, "fixed"),
100        }
101    }
102}
103
104impl FromStr for StreamFrame {
105    type Err = String;
106
107    fn from_str(s: &str) -> Result<Self, Self::Err> {
108        match s {
109            "line" => Ok(StreamFrame::Line),
110            "raw" => Ok(StreamFrame::Raw),
111            "fixed" => Ok(StreamFrame::Fixed),
112            _ => Err(format!(
113                "unknown stream frame: '{}'. Valid: line, raw, fixed",
114                s
115            )),
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// StreamConfig
122// ---------------------------------------------------------------------------
123
124/// Configuration parsed from a stream URI.
125///
126/// Format: `stream:out|err|in?appendNewline=true&charset=utf-8&frame=line[&size=N]`
127#[derive(Debug, Clone, UriConfig)]
128#[uri_scheme = "stream"]
129#[uri_config(
130    skip_impl,
131    metadata(
132        scheme = "stream",
133        description = "stdio data-plane adapter: out/err producers, in consumer",
134        producer,
135        consumer
136    ),
137    crate = "camel_component_api"
138)]
139pub struct StreamConfig {
140    /// Stream target (the path portion of the URI).
141    pub target: StreamTarget,
142
143    /// When true (default), append a trailing newline to each body written
144    /// to `out`/`err`.
145    #[uri_param(name = "appendNewline", default = "true")]
146    pub append_newline: bool,
147
148    /// Charset for the stream. Only `utf-8` is supported in v1.
149    #[uri_param(name = "charset", default = "utf-8")]
150    pub charset: String,
151
152    /// Framing mode. Default: `line`.
153    #[uri_param(name = "frame", kind = "enum:line,raw,fixed", default = "line")]
154    pub frame: StreamFrame,
155
156    /// Fixed frame size in bytes. Required (and must be greater than 0)
157    /// when `frame = fixed`.
158    #[uri_param(name = "size")]
159    pub size: Option<u64>,
160}
161
162// Inherent validate — callable as StreamConfig::validate(&self)
163impl StreamConfig {
164    /// Validate the configuration without consuming self.
165    ///
166    /// The target needs no check here: `StreamTarget` is a closed enum whose
167    /// `FromStr` rejects unknown paths, so any constructed config already
168    /// holds a valid target by construction.
169    pub fn validate(&self) -> Result<(), CamelError> {
170        if self.charset != "utf-8" {
171            return Err(CamelError::Config(format!(
172                "unsupported charset '{}': utf-8 is the only charset supported in v1",
173                self.charset
174            )));
175        }
176        if self.frame == StreamFrame::Fixed {
177            match self.size {
178                None => {
179                    return Err(CamelError::InvalidUri(
180                        "frame=fixed requires the size parameter".to_string(),
181                    ));
182                }
183                Some(0) => {
184                    return Err(CamelError::InvalidUri(
185                        "frame=fixed requires a size greater than 0".to_string(),
186                    ));
187                }
188                // The chunk buffer is allocated per frame, so `size` doubles
189                // as a per-frame allocation from a URI parameter. Cap it at
190                // the crate's max-materialization limit (100 MiB).
191                Some(n) if n > MAX_MATERIALIZE_BYTES as u64 => {
192                    return Err(CamelError::InvalidUri(format!(
193                        "frame=fixed size {n} exceeds the maximum frame size of {MAX_MATERIALIZE_BYTES} bytes (100 MiB)"
194                    )));
195                }
196                Some(_) => {}
197            }
198        }
199        Ok(())
200    }
201}
202
203impl UriConfig for StreamConfig {
204    fn scheme() -> &'static str {
205        "stream"
206    }
207
208    fn from_uri(uri: &str) -> Result<Self, CamelError> {
209        let parts = parse_uri(uri)?;
210        Self::from_components(parts)
211    }
212
213    fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
214        let config = Self::parse_uri_components(parts)?;
215        StreamConfig::validate(&config)?;
216        Ok(config)
217    }
218
219    fn validate(self) -> Result<Self, CamelError> {
220        // Delegate to the inherent validate(&self)
221        StreamConfig::validate(&self)?;
222        Ok(self)
223    }
224}
225
226// ---------------------------------------------------------------------------
227// StreamComponent
228// ---------------------------------------------------------------------------
229
230/// The Stream component routes exchange bodies to/from the process's
231/// standard streams.
232pub struct StreamComponent;
233
234impl StreamComponent {
235    pub fn new() -> Self {
236        Self
237    }
238}
239
240impl Default for StreamComponent {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246impl Component for StreamComponent {
247    fn scheme(&self) -> &str {
248        "stream"
249    }
250
251    fn metadata(&self) -> ComponentMetadata {
252        StreamConfig::metadata()
253    }
254
255    fn create_endpoint(
256        &self,
257        uri: &str,
258        _ctx: &dyn camel_component_api::ComponentContext,
259    ) -> Result<Box<dyn Endpoint>, CamelError> {
260        let config = StreamConfig::from_uri(uri)?;
261        Ok(Box::new(StreamEndpoint {
262            uri: uri.to_string(),
263            config,
264        }))
265    }
266}
267
268// ---------------------------------------------------------------------------
269// StreamEndpoint
270// ---------------------------------------------------------------------------
271
272struct StreamEndpoint {
273    uri: String,
274    config: StreamConfig,
275}
276
277impl Endpoint for StreamEndpoint {
278    fn uri(&self) -> &str {
279        &self.uri
280    }
281
282    fn create_consumer(
283        &self,
284        rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
285    ) -> Result<Box<dyn Consumer>, CamelError> {
286        match self.config.target {
287            StreamTarget::In => Ok(Box::new(StreamConsumer::new(
288                self.config.clone(),
289                Arc::clone(&rt),
290            ))),
291            // `stream:out`/`stream:err` are producer targets; consuming from
292            // them has no meaning on the data plane.
293            StreamTarget::Out | StreamTarget::Err => Err(CamelError::EndpointCreationFailed(
294                "stream:out and stream:err do not support consumers; use stream:in".into(),
295            )),
296        }
297    }
298
299    fn create_producer(
300        &self,
301        _rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
302        _ctx: &ProducerContext,
303    ) -> Result<BoxProcessor, CamelError> {
304        match self.config.target {
305            StreamTarget::Out | StreamTarget::Err => {
306                Ok(BoxProcessor::new(StreamProducer::new(self.config.clone())))
307            }
308            // `stream:in` is a consumer target (Phase 2); producing to it
309            // has no meaning on the data plane.
310            StreamTarget::In => Err(CamelError::EndpointCreationFailed(
311                "stream:in is a consumer target; producers require stream:out or stream:err".into(),
312            )),
313        }
314    }
315}
316
317// ---------------------------------------------------------------------------
318// StreamProducer
319// ---------------------------------------------------------------------------
320
321/// Write end of a stdio stream, so tests can capture bytes instead of
322/// touching the real process file descriptors.
323#[async_trait]
324trait Sink: Send + Sync {
325    async fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()>;
326    async fn flush(&mut self) -> std::io::Result<()>;
327}
328
329struct StdoutSink(tokio::io::Stdout);
330
331#[async_trait]
332impl Sink for StdoutSink {
333    async fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
334        self.0.write_all(buf).await
335    }
336
337    async fn flush(&mut self) -> std::io::Result<()> {
338        self.0.flush().await
339    }
340}
341
342struct StderrSink(tokio::io::Stderr);
343
344#[async_trait]
345impl Sink for StderrSink {
346    async fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
347        self.0.write_all(buf).await
348    }
349
350    async fn flush(&mut self) -> std::io::Result<()> {
351        self.0.flush().await
352    }
353}
354
355/// One lock per file descriptor. This is THE serialization mechanism: the
356/// whole write+flush of a single concatenated buffer (body + optional
357/// newline) happens under the guard, so two `stream:out` endpoints can never
358/// interleave a body and its trailing newline on fd 1 (nor on fd 2).
359///
360/// The `Arc<tokio::sync::Mutex<dyn Sink>>` on the producer is NOT a
361/// serialization mechanism — it only gives `Clone` producers `&mut` access
362/// to the shared sink.
363static FD1_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
364static FD2_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
365
366/// Per-exchange materialization limit (100 MiB), the same data-plane limit
367/// the file component enforces.
368const MAX_MATERIALIZE_BYTES: usize = 100 * 1024 * 1024;
369
370/// Producer that writes exchange bodies to stdout/stderr as raw data.
371///
372/// Semantics (binding): NO log levels, NO formatting, NO redaction — the
373/// body is DATA. Nothing partial is written on error. Flush after every
374/// exchange.
375#[derive(Clone)]
376struct StreamProducer {
377    config: StreamConfig,
378    sink: Arc<tokio::sync::Mutex<dyn Sink>>,
379}
380
381impl StreamProducer {
382    /// Build a producer with the real process stream for `config.target`.
383    pub(crate) fn new(config: StreamConfig) -> Self {
384        let sink: Arc<tokio::sync::Mutex<dyn Sink>> = match config.target {
385            StreamTarget::Out => Arc::new(tokio::sync::Mutex::new(StdoutSink(tokio::io::stdout()))),
386            StreamTarget::Err => Arc::new(tokio::sync::Mutex::new(StderrSink(tokio::io::stderr()))),
387            // Unreachable through `create_producer` (which rejects `In`);
388            // fd 0 is stdin, so `StdoutSink` is never a silent fallback for
389            // real output — it can only surface through direct construction.
390            StreamTarget::In => Arc::new(tokio::sync::Mutex::new(StdoutSink(tokio::io::stdout()))),
391        };
392        Self { config, sink }
393    }
394
395    /// Build a producer around an injected sink (test seam).
396    #[cfg(test)]
397    pub(crate) fn with_sink(config: StreamConfig, sink: Arc<tokio::sync::Mutex<dyn Sink>>) -> Self {
398        Self { config, sink }
399    }
400
401    /// File descriptor this producer's target maps to (1 = stdout,
402    /// 2 = stderr, 0 = stdin for the consumer-only `In` target).
403    #[cfg(test)]
404    pub(crate) fn target_fd(&self) -> i32 {
405        match self.config.target {
406            StreamTarget::Out => 1,
407            StreamTarget::Err => 2,
408            StreamTarget::In => 0,
409        }
410    }
411}
412
413impl Service<Exchange> for StreamProducer {
414    type Response = Exchange;
415    type Error = CamelError;
416    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
417
418    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
419        Poll::Ready(Ok(()))
420    }
421
422    fn call(&mut self, exchange: Exchange) -> Self::Future {
423        let config = self.config.clone();
424        let sink = self.sink.clone();
425        Box::pin(async move {
426            // The body is DATA: materialize verbatim — no formatting, no
427            // redaction. `into_bytes` serializes structured bodies to their
428            // canonical byte form; the exchange itself is left untouched.
429            let bytes = exchange
430                .input
431                .body
432                .clone()
433                .into_bytes(MAX_MATERIALIZE_BYTES)
434                .await?;
435
436            // One logical write: newline is appended to the SAME buffer
437            // before the single `write_all` — never two separate writes.
438            let mut buf = BytesMut::from(bytes);
439            if config.append_newline {
440                buf.extend_from_slice(b"\n");
441            }
442
443            let fd_guard = if config.target == StreamTarget::Err {
444                FD2_LOCK.lock().await
445            } else {
446                FD1_LOCK.lock().await
447            };
448
449            let mut sink = sink.lock().await;
450            sink.write_all(&buf).await?;
451            sink.flush().await?;
452            drop(sink);
453            drop(fd_guard);
454
455            Ok(exchange)
456        })
457    }
458}
459
460// ---------------------------------------------------------------------------
461// StreamConsumer
462// ---------------------------------------------------------------------------
463
464/// Loud bound check for consumer-side frame accumulation: `len` is the
465/// frame length an accumulation step would reach. Past the crate's
466/// `MAX_MATERIALIZE_BYTES` limit the frame fails with the same
467/// `CamelError::StreamLimitExceeded` variant the producer's
468/// `Body::into_bytes` bound uses — a loud route failure, never a silent
469/// skip and never an unbounded buffer (`cat /dev/zero | camel run`
470/// must not OOM the process).
471fn ensure_frame_bound(len: usize) -> Result<(), CamelError> {
472    if len > MAX_MATERIALIZE_BYTES {
473        return Err(CamelError::StreamLimitExceeded(MAX_MATERIALIZE_BYTES));
474    }
475    Ok(())
476}
477
478/// Async byte-source seam behind the consumer so tests can inject buffered
479/// readers instead of the real stdin. The three methods mirror the three
480/// framing modes.
481///
482/// All accumulation is bounded: a frame that would grow past the crate's
483/// per-frame materialization limit fails with
484/// `CamelError::StreamLimitExceeded` before the buffer grows past the
485/// bound. EOF is `Ok(0)`, never an error (ruling R2).
486#[async_trait]
487pub(crate) trait StreamReader: Send + Sync {
488    /// Line framing: read bytes up to and including the next `\n`, appending
489    /// them to `buf`. Returns the byte count (`0` = EOF); an unterminated
490    /// final line is still returned when EOF follows it. Byte-oriented on
491    /// purpose (never `read_line`): input need not be valid UTF-8.
492    async fn read_until_newline(&mut self, buf: &mut Vec<u8>) -> Result<usize, CamelError>;
493
494    /// Fixed framing: read up to `n` bytes, appending them to `buf`.
495    /// Returns the byte count (`0` = EOF); a short frame is a smaller count.
496    async fn read_exact_chunk(&mut self, n: usize, buf: &mut Vec<u8>) -> Result<usize, CamelError>;
497
498    /// Raw framing: read until EOF, appending everything to `buf`. Returns
499    /// the byte count.
500    async fn read_to_end(&mut self, buf: &mut Vec<u8>) -> Result<usize, CamelError>;
501}
502
503/// Production reader over the process stdin. Line and raw framing
504/// accumulate through bounded `fill_buf` chunks — never unbounded
505/// `read_until`/`read_to_end` growth. Each chunk is bound-checked
506/// BEFORE it extends the frame, so an endless source (`cat /dev/zero`)
507/// fails the route instead of OOMing. The internal buffer is bounded
508/// (8 KiB) and owned by this reader, so framing state is consistent
509/// across all three modes.
510struct StdinReader {
511    stdin: BufReader<tokio::io::Stdin>,
512}
513
514#[async_trait]
515impl StreamReader for StdinReader {
516    async fn read_until_newline(&mut self, buf: &mut Vec<u8>) -> Result<usize, CamelError> {
517        let mut appended = 0;
518        loop {
519            let chunk = self
520                .stdin
521                .fill_buf()
522                .await
523                .map_err(|e| CamelError::Io(e.to_string()))?;
524            if chunk.is_empty() {
525                return Ok(appended);
526            }
527            // One logical `read_until` step: up to and including the first
528            // `\n` in this chunk, or the whole chunk when the line spans
529            // chunks. Bound-checked before the frame grows.
530            let upto = chunk
531                .iter()
532                .position(|&b| b == b'\n')
533                .map_or(chunk.len(), |i| i + 1);
534            ensure_frame_bound(buf.len() + upto)?;
535            let hit_newline = chunk[upto - 1] == b'\n';
536            buf.extend_from_slice(&chunk[..upto]);
537            self.stdin.consume(upto);
538            appended += upto;
539            if hit_newline {
540                return Ok(appended);
541            }
542        }
543    }
544
545    async fn read_exact_chunk(&mut self, n: usize, buf: &mut Vec<u8>) -> Result<usize, CamelError> {
546        // One read, capped at `n`: stdin is not seekable, so capping the
547        // window at the remaining frame bytes is what keeps BufReader from
548        // stealing a byte past the frame boundary (over-read bytes stay in
549        // its internal buffer for the next call). Accumulation across short
550        // reads is `read_frame`'s job — it holds for every StreamReader
551        // implementation, not just this one. `n` is config-validated to be
552        // within `MAX_MATERIALIZE_BYTES`, so this window is bounded.
553        let mut chunk = vec![0u8; n];
554        let read = self
555            .stdin
556            .read(&mut chunk)
557            .await
558            .map_err(|e| CamelError::Io(e.to_string()))?;
559        buf.extend_from_slice(&chunk[..read]);
560        Ok(read)
561    }
562
563    async fn read_to_end(&mut self, buf: &mut Vec<u8>) -> Result<usize, CamelError> {
564        let mut appended = 0;
565        loop {
566            let chunk = self
567                .stdin
568                .fill_buf()
569                .await
570                .map_err(|e| CamelError::Io(e.to_string()))?;
571            if chunk.is_empty() {
572                return Ok(appended);
573            }
574            // Bound-checked before the frame grows: an endless raw source
575            // fails loudly instead of OOMing.
576            ensure_frame_bound(buf.len() + chunk.len())?;
577            let len = chunk.len();
578            buf.extend_from_slice(chunk);
579            self.stdin.consume(len);
580            appended += len;
581        }
582    }
583}
584
585/// One decoded frame handed from the read loop to the send path.
586enum Frame {
587    /// Line mode: decoded UTF-8 text, terminators stripped.
588    Line(String),
589    /// Raw/fixed mode: verbatim bytes.
590    Bytes(Vec<u8>),
591    /// Line mode: frame bytes are not valid UTF-8 — skip without sending.
592    InvalidUtf8,
593    /// End of stream: no further frames will arrive.
594    Eof,
595}
596
597/// Read the next frame from `reader` according to the configured framing.
598///
599/// EOF is a [`Frame::Eof`] outcome, never an error (ruling R2). IO
600/// failures and bound violations (`CamelError::StreamLimitExceeded`)
601/// propagate as loud route failures.
602async fn read_frame(
603    reader: &mut dyn StreamReader,
604    mode: StreamFrame,
605    size: Option<u64>,
606) -> Result<Frame, CamelError> {
607    let mut buf = Vec::new();
608    match mode {
609        StreamFrame::Line => {
610            if reader.read_until_newline(&mut buf).await? == 0 {
611                return Ok(Frame::Eof);
612            }
613            // Strip the trailing `\n`, then one preceding `\r` — but only
614            // strip the `\r` when a `\n` was actually stripped: a lone
615            // trailing `\r` is data, not a terminator. Line mode terminates
616            // on `\n` only (Apache Camel's stream: also splits on `\r`;
617            // documented divergence on the component page).
618            if buf.last() == Some(&b'\n') {
619                buf.pop();
620                if buf.last() == Some(&b'\r') {
621                    buf.pop();
622                }
623            }
624            // Validates and constructs in one step; an invalid frame takes
625            // the decode-skip path.
626            match String::from_utf8(buf) {
627                Ok(text) => Ok(Frame::Line(text)),
628                Err(_) => Ok(Frame::InvalidUtf8),
629            }
630        }
631        StreamFrame::Raw => {
632            // `read_to_end` is bound-checked inside the reader: it fails
633            // with `StreamLimitExceeded` instead of growing past the cap.
634            reader.read_to_end(&mut buf).await?;
635            if buf.is_empty() {
636                Ok(Frame::Eof)
637            } else {
638                Ok(Frame::Bytes(buf))
639            }
640        }
641        StreamFrame::Fixed => {
642            // `size` is validated present, non-zero, and within
643            // `MAX_MATERIALIZE_BYTES` by `StreamConfig::validate`, so the
644            // frame buffer is bounded by construction and this conversion
645            // cannot fail on any supported platform.
646            let n = usize::try_from(size.unwrap_or(0)).unwrap_or(0);
647            // Accumulate across short reads until exactly `n` bytes are in
648            // the frame: a trickling source may deliver fewer bytes per
649            // read call. Only EOF (a read returning 0) may yield a partial
650            // frame — a mid-stream short read never does.
651            let start = buf.len();
652            while buf.len() - start < n {
653                let read = reader
654                    .read_exact_chunk(n - (buf.len() - start), &mut buf)
655                    .await?;
656                if read == 0 {
657                    break;
658                }
659            }
660            if buf.len() == start {
661                Ok(Frame::Eof)
662            } else {
663                Ok(Frame::Bytes(buf))
664            }
665        }
666    }
667}
668
669/// Consumer reading exchange bodies from stdin (`stream:in`).
670///
671/// Binding semantics:
672/// - EOF is graceful: `start` returns `Ok(())`, never an error (ruling R2).
673/// - Sequential backpressure: the next frame is read only after the previous
674///   send completes — never read ahead, no buffering queue.
675/// - Frames are bounded at the crate's materialization limit
676///   (`MAX_MATERIALIZE_BYTES`): a frame past the bound fails `start`
677///   loudly with `CamelError::StreamLimitExceeded` — same posture as an
678///   IO error, never a silent skip.
679/// - Terminal send failures (`b-prime:stream:fire-send`) and invalid UTF-8
680///   lines (`b-prime:stream:decode`) are counted per ADR-0012.
681pub(crate) struct StreamConsumer {
682    config: StreamConfig,
683    /// Guard against double-start (TimerConsumer pattern).
684    started: AtomicBool,
685    /// `rt.metrics().increment_errors(...)` sink per ADR-0012.
686    runtime: Arc<dyn RuntimeObservability>,
687    reader: Box<dyn StreamReader>,
688}
689
690impl StreamConsumer {
691    /// Build a consumer reading the process stdin (production constructor).
692    pub(crate) fn new(config: StreamConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
693        Self::with_reader(
694            config,
695            runtime,
696            Box::new(StdinReader {
697                stdin: BufReader::new(tokio::io::stdin()),
698            }),
699        )
700    }
701
702    /// Build a consumer around an injected reader (test seam).
703    pub(crate) fn with_reader(
704        config: StreamConfig,
705        runtime: Arc<dyn RuntimeObservability>,
706        reader: Box<dyn StreamReader>,
707    ) -> Self {
708        Self {
709            config,
710            started: AtomicBool::new(false),
711            runtime,
712            reader,
713        }
714    }
715
716    /// Test helper: pre-set the started flag to simulate an already-running
717    /// consumer (TimerConsumer pattern).
718    #[cfg(test)]
719    pub(crate) fn mark_started_for_test(&self) {
720        self.started.store(true, Ordering::SeqCst);
721    }
722}
723
724#[async_trait]
725impl Consumer for StreamConsumer {
726    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
727        self.started
728            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
729            .map_err(|_| {
730                CamelError::EndpointCreationFailed("stream consumer already started".to_string())
731            })?;
732
733        let cancel_token = context.cancel_token();
734        let frame_mode = self.config.frame;
735        let frame_size = self.config.size;
736        // Field-level borrow: the loop reads through `reader` while the
737        // per-frame body touches `runtime` — disjoint fields.
738        let reader = &mut *self.reader;
739
740        loop {
741            tokio::select! {
742                _ = cancel_token.cancelled() => {
743                    debug!(stream_target = %self.config.target, "stream consumer cancelled, stopping");
744                    break;
745                }
746                frame = read_frame(reader, frame_mode, frame_size) => {
747                    let message = match frame {
748                        Ok(Frame::Line(text)) => Message::new(text),
749                        Ok(Frame::Bytes(bytes)) => Message::new(bytes),
750                        Ok(Frame::InvalidUtf8) => {
751                            self.runtime
752                                .metrics()
753                                .increment_errors(context.route_id(), "b-prime:stream:decode");
754                            continue;
755                        }
756                        Ok(Frame::Eof) => break,
757                        Err(error) => {
758                            // An IO error is NOT EOF (only EOF is
759                            // graceful), and neither is a frame past the
760                            // materialization bound
761                            // (`StreamLimitExceeded`). Fail loudly like a
762                            // genuine source failure: the runtime treats a
763                            // `start` Err as an error event.
764                            self.started.store(false, Ordering::SeqCst);
765                            return Err(error);
766                        }
767                    };
768
769                    if context.send(Exchange::new(message)).await.is_err() {
770                        // b-prime: locally terminal fire-send — the route
771                        // channel is closed and the loop exits, so this
772                        // metric is the only signal (ADR-0012).
773                        self.runtime
774                            .metrics()
775                            .increment_errors(context.route_id(), "b-prime:stream:fire-send");
776                        break;
777                    }
778                    // Sequential backpressure: the next read starts only
779                    // after this send completed — never read ahead.
780                }
781            }
782        }
783
784        // Reset so the consumer can be restarted after stop.
785        self.started.store(false, Ordering::SeqCst);
786        Ok(())
787    }
788
789    async fn stop(&mut self) -> Result<(), CamelError> {
790        self.started.store(false, Ordering::SeqCst);
791        debug!(stream_target = %self.config.target, "stream consumer stopped");
792        Ok(())
793    }
794}
795
796// ---------------------------------------------------------------------------
797// Tests
798// ---------------------------------------------------------------------------
799
800#[cfg(test)]
801mod stream_producer_tests {
802    use super::*;
803    use camel_component_api::{Body, Message, StreamBody};
804    use serde_json::json;
805
806    /// Captures every logical write so tests can assert on exact bytes
807    /// without touching the real process streams.
808    #[derive(Default, Clone)]
809    struct VecSink(Arc<std::sync::Mutex<Vec<u8>>>);
810
811    impl VecSink {
812        fn bytes(&self) -> Vec<u8> {
813            self.0.lock().unwrap().clone()
814        }
815    }
816
817    #[async_trait::async_trait]
818    impl Sink for VecSink {
819        async fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
820            self.0.lock().unwrap().extend_from_slice(buf);
821            Ok(())
822        }
823
824        async fn flush(&mut self) -> std::io::Result<()> {
825            Ok(())
826        }
827    }
828
829    /// Test sink that splits every logical write into two chunks with a
830    /// `yield_now` between them. A per-chunk atomic append (like `VecSink`)
831    /// would let two producers interleave at chunk granularity; only a guard
832    /// held across the WHOLE logical write (the fd statics) prevents that.
833    /// Two `ChunkSink`s sharing one inner buffer model two endpoints writing
834    /// to the same fd through separate sink mutexes.
835    struct ChunkSink(Arc<std::sync::Mutex<Vec<u8>>>);
836
837    #[async_trait::async_trait]
838    impl Sink for ChunkSink {
839        async fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
840            let mid = buf.len() / 2;
841            self.0.lock().unwrap().extend_from_slice(&buf[..mid]);
842            tokio::task::yield_now().await;
843            self.0.lock().unwrap().extend_from_slice(&buf[mid..]);
844            Ok(())
845        }
846
847        async fn flush(&mut self) -> std::io::Result<()> {
848            Ok(())
849        }
850    }
851
852    /// Build the erased test sink plus a reader handle over the same
853    /// internal buffer (clones of `VecSink` share one `Vec<u8>`).
854    fn test_sink() -> (Arc<tokio::sync::Mutex<dyn Sink>>, VecSink) {
855        let vec_sink = VecSink::default();
856        (
857            Arc::new(tokio::sync::Mutex::new(vec_sink.clone())),
858            vec_sink,
859        )
860    }
861
862    #[tokio::test]
863    async fn line_mode_appends_single_newline() {
864        let (sink, reader) = test_sink();
865        let config = StreamConfig::from_uri("stream:out").unwrap();
866        let mut producer = StreamProducer::with_sink(config, sink);
867
868        let exchange = Exchange::new(Message::new("hello"));
869        let result = producer.call(exchange).await.unwrap();
870
871        assert_eq!(result.input.body.as_text(), Some("hello"));
872        assert_eq!(reader.bytes(), b"hello\n");
873    }
874
875    #[tokio::test]
876    async fn raw_mode_writes_verbatim() {
877        let (sink, reader) = test_sink();
878        let config = StreamConfig::from_uri("stream:out?appendNewline=false").unwrap();
879        let mut producer = StreamProducer::with_sink(config, sink);
880
881        let exchange = Exchange::new(Message::new(b"a,b,c".to_vec()));
882        producer.call(exchange).await.unwrap();
883
884        assert_eq!(reader.bytes(), b"a,b,c");
885    }
886
887    #[tokio::test]
888    async fn err_target_dispatches_stderr() {
889        let config = StreamConfig::from_uri("stream:err").unwrap();
890        assert_eq!(StreamProducer::new(config).target_fd(), 2);
891
892        let config = StreamConfig::from_uri("stream:out").unwrap();
893        assert_eq!(StreamProducer::new(config).target_fd(), 1);
894    }
895
896    #[tokio::test]
897    async fn structured_body_materializes_to_bytes() {
898        let (sink, reader) = test_sink();
899        let config = StreamConfig::from_uri("stream:out?appendNewline=false").unwrap();
900        let mut producer = StreamProducer::with_sink(config, sink);
901
902        let value = json!({"name": "café", "count": 3, "nested": {"ok": true}});
903        let mut exchange = Exchange::new(Message::new(""));
904        exchange.input.body = Body::Json(value.clone());
905        producer.call(exchange).await.unwrap();
906
907        let expected = Body::Json(value)
908            .into_bytes(100 * 1024 * 1024)
909            .await
910            .unwrap();
911        assert_eq!(reader.bytes(), expected.as_ref());
912    }
913
914    #[tokio::test]
915    async fn credential_body_passes_verbatim() {
916        let (sink, reader) = test_sink();
917        let config = StreamConfig::from_uri("stream:out?appendNewline=false").unwrap();
918        let mut producer = StreamProducer::with_sink(config, sink);
919
920        let exchange = Exchange::new(Message::new("password=hunter2"));
921        producer.call(exchange).await.unwrap();
922
923        // Body is DATA: no redaction, no formatting — byte-exact passthrough.
924        assert_eq!(reader.bytes(), b"password=hunter2");
925    }
926
927    #[tokio::test]
928    async fn empty_body_line_mode_writes_just_newline() {
929        let (sink, reader) = test_sink();
930        let config = StreamConfig::from_uri("stream:out").unwrap();
931        let mut producer = StreamProducer::with_sink(config, sink);
932
933        let exchange = Exchange::new(Message::default());
934        producer.call(exchange).await.unwrap();
935
936        assert_eq!(reader.bytes(), b"\n");
937    }
938
939    #[tokio::test]
940    async fn concurrent_producers_serialize_on_fd() {
941        // Two producers with SEPARATE sink mutexes over ChunkSinks that
942        // share one buffer: the only cross-producer serializer left is the
943        // fd static guard. ChunkSink splits each logical write and yields
944        // mid-write, so without the guard the two writes interleave at
945        // chunk granularity and the assertion below fails.
946        let shared = Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
947        let sink1: Arc<tokio::sync::Mutex<dyn Sink>> =
948            Arc::new(tokio::sync::Mutex::new(ChunkSink(shared.clone())));
949        let sink2: Arc<tokio::sync::Mutex<dyn Sink>> =
950            Arc::new(tokio::sync::Mutex::new(ChunkSink(shared.clone())));
951
952        let config = StreamConfig::from_uri("stream:out").unwrap();
953        let mut p1 = StreamProducer::with_sink(config.clone(), sink1);
954        let mut p2 = StreamProducer::with_sink(config, sink2);
955
956        let t1 = tokio::spawn(async move { p1.call(Exchange::new(Message::new("aa\naa"))).await });
957        let t2 = tokio::spawn(async move { p2.call(Exchange::new(Message::new("bb\nbb"))).await });
958        t1.await.unwrap().unwrap();
959        t2.await.unwrap().unwrap();
960
961        // Each multi-line body must land as ONE intact write; the two
962        // logical writes may appear in either order but never interleave.
963        let out = shared.lock().unwrap().clone();
964        let ok = out.as_slice() == b"aa\naa\nbb\nbb\n" || out.as_slice() == b"bb\nbb\naa\naa\n";
965        assert!(ok, "interleaved or corrupted writes: {out:?}");
966    }
967
968    #[tokio::test]
969    async fn materialization_error_writes_nothing() {
970        let (sink, reader) = test_sink();
971        let config = StreamConfig::from_uri("stream:out").unwrap();
972        let mut producer = StreamProducer::with_sink(config, sink);
973
974        // A consumed stream body fails materialization cheaply (no 100 MiB
975        // allocation needed): `into_bytes` returns AlreadyConsumed at once.
976        let body = Body::Stream(StreamBody {
977            stream: Arc::new(tokio::sync::Mutex::new(None)),
978            metadata: Default::default(),
979        });
980        let exchange = Exchange::new(Message::new(body));
981
982        let result = producer.call(exchange).await;
983        assert!(
984            matches!(result, Err(CamelError::AlreadyConsumed)),
985            "expected AlreadyConsumed, got: {result:?}"
986        );
987        assert!(
988            reader.bytes().is_empty(),
989            "nothing may be written when materialization fails"
990        );
991    }
992}
993
994// ---------------------------------------------------------------------------
995// StreamConsumer tests
996// ---------------------------------------------------------------------------
997
998#[cfg(test)]
999#[path = "consumer_tests.rs"]
1000mod stream_consumer_tests;
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    #[test]
1007    fn config_parses_out_default() {
1008        let config = StreamConfig::from_uri("stream:out").unwrap();
1009        assert_eq!(config.target, StreamTarget::Out);
1010        assert!(config.append_newline);
1011        assert_eq!(config.charset, "utf-8");
1012        assert_eq!(config.frame, StreamFrame::Line);
1013    }
1014
1015    #[test]
1016    fn config_parses_in_raw() {
1017        let config = StreamConfig::from_uri("stream:in?frame=raw").unwrap();
1018        assert_eq!(config.target, StreamTarget::In);
1019        assert_eq!(config.frame, StreamFrame::Raw);
1020    }
1021
1022    #[test]
1023    fn config_rejects_unknown_path() {
1024        let result = StreamConfig::from_uri("stream:logfile");
1025        assert!(
1026            matches!(result, Err(CamelError::InvalidUri(_))),
1027            "unknown path must be rejected with InvalidUri, got: {result:?}"
1028        );
1029    }
1030
1031    #[test]
1032    fn config_rejects_non_utf8_charset() {
1033        let result = StreamConfig::from_uri("stream:out?charset=latin-1");
1034        match result {
1035            Err(CamelError::Config(msg)) => {
1036                assert!(msg.contains("utf-8"), "error must name utf-8, got: {msg}")
1037            }
1038            other => panic!("expected CamelError::Config, got: {other:?}"),
1039        }
1040    }
1041
1042    #[test]
1043    fn config_rejects_fixed_without_size() {
1044        let result = StreamConfig::from_uri("stream:in?frame=fixed");
1045        assert!(
1046            matches!(result, Err(CamelError::InvalidUri(_))),
1047            "frame=fixed without size must be rejected with InvalidUri, got: {result:?}"
1048        );
1049        let result = StreamConfig::from_uri("stream:in?frame=fixed&size=0");
1050        assert!(
1051            matches!(result, Err(CamelError::InvalidUri(_))),
1052            "frame=fixed with size=0 must be rejected with InvalidUri, got: {result:?}"
1053        );
1054    }
1055
1056    /// `size` drives a per-frame allocation from a URI parameter, so an
1057    /// absurd value must be rejected up front instead of OOMing the process.
1058    #[test]
1059    fn config_rejects_huge_fixed_size() {
1060        let result = StreamConfig::from_uri("stream:in?frame=fixed&size=99999999999");
1061        match result {
1062            Err(CamelError::InvalidUri(msg)) => {
1063                assert!(
1064                    msg.contains("104857600"),
1065                    "error must name the size bound, got: {msg}"
1066                );
1067            }
1068            other => panic!("expected CamelError::InvalidUri, got: {other:?}"),
1069        }
1070    }
1071
1072    #[test]
1073    fn component_scheme_is_stream() {
1074        assert_eq!(StreamComponent::new().scheme(), "stream");
1075    }
1076
1077    /// At-cap frames are allowed: `Body::into_bytes` rejects strictly
1078    /// past the bound, and the consumer must match that posture.
1079    #[test]
1080    fn ensure_frame_bound_allows_up_to_cap() {
1081        assert!(ensure_frame_bound(MAX_MATERIALIZE_BYTES - 1).is_ok());
1082        assert!(ensure_frame_bound(MAX_MATERIALIZE_BYTES).is_ok());
1083    }
1084
1085    #[test]
1086    fn ensure_frame_bound_rejects_past_cap() {
1087        match ensure_frame_bound(MAX_MATERIALIZE_BYTES + 1) {
1088            Err(CamelError::StreamLimitExceeded(max)) => {
1089                assert_eq!(max, MAX_MATERIALIZE_BYTES);
1090            }
1091            other => panic!("expected StreamLimitExceeded, got: {other:?}"),
1092        }
1093    }
1094}