Skip to main content

flowcat_core/pipeline/
source_pump.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! [`SourcePump`] — the standard, ordering-safe way for a **source** processor (a
4//! transport/socket reader that has no upstream frame to react to) to inject frames
5//! into a running [`PipelineTask`](super::PipelineTask)'s head.
6//!
7//! ## Why a helper and not a trait hook (the source-emit ruling, §10 Q6)
8//!
9//! A [`FrameProcessor`](crate::processor::FrameProcessor)'s
10//! [`start`](crate::processor::FrameProcessor::start) hook receives no
11//! [`Link`](crate::processor::Link), so a *source* (a `recv()` loop) cannot
12//! self-emit from the frozen trait. Rather than grow the just-frozen trait with a
13//! runtime-spawned `run_source`, the framework **codifies the external-pump
14//! pattern**: a source reads its transport in its own task and feeds the pipeline
15//! head via [`PipelineTask::queue_sender`](super::PipelineTask::queue_sender). This
16//! is exactly pipecat's `BaseInputTransport` reader-task model — the source's
17//! reader is hosted *beside* the pipeline, not inside a lifecycle method.
18//!
19//! Feeding the **head** (not a mid-chain `push`) is what preserves the
20//! Start→ready ordering guarantee every processor relies on:
21//! [`PipelineTask::run`](super::PipelineTask::run) injects `Start` and **blocks on
22//! the Start→Sink handshake before it drains the head queue**, so a pumped
23//! `InputAudio` can never reach any processor's `process_frame` before that
24//! processor's `start()` ran (the invariant the code-reviewer flagged for
25//! `.expect()`-in-`process_frame` processors). `SourcePump` is the one-liner that
26//! gives every transport author this behaviour without re-deriving the
27//! spawn + abort + ordering dance.
28//!
29//! ## Backpressure
30//!
31//! The head queue is unbounded (a source must never block delivering capture —
32//! input audio is wall-clock-rate-limited, PROCESSOR-DESIGN §2.2). Backpressure
33//! lives one hop later: the head's bounded *normal* channel to the first real
34//! processor `await`s when full, so a stalled downstream still applies natural
35//! backpressure to the producer via that channel — the source pump itself does not
36//! buffer unboundedly in practice because each `emit` returns immediately and the
37//! `PipelineTask` head pump forwards into the bounded channel.
38//!
39//! ## Usage
40//!
41//! ```ignore
42//! let task = PipelineTask::new(pipeline, params, vec![]);
43//! // Spawn a reader that drives a transport's recv() into the head:
44//! let pump = SourcePump::spawn(task.queue_sender(), |head| async move {
45//!     while let Some(media) = transport.recv().await {
46//!         match media {
47//!             MediaIn::Audio(chunk) => {
48//!                 if head.emit(Frame::InputAudio(Arc::new((&chunk).into()))).is_err() {
49//!                     break; // pipeline gone
50//!                 }
51//!             }
52//!             MediaIn::Stop | /* exhausted */ _ => { head.end(); break; }
53//!         }
54//!     }
55//! });
56//! task.run().await?;       // pump is aborted on drop after run returns
57//! ```
58
59use tokio::sync::mpsc::UnboundedSender;
60use tokio::task::JoinHandle;
61
62use crate::processor::frame::Frame;
63
64/// A clonable handle a source's reader task uses to inject frames into the pipeline
65/// head. Thin wrapper over the [`PipelineTask`](super::PipelineTask) head queue so
66/// transport authors call `emit`/`end` instead of touching the raw channel.
67#[derive(Clone)]
68pub struct SourceHandle {
69    head: UnboundedSender<Frame>,
70}
71
72impl SourceHandle {
73    /// Wrap a [`PipelineTask::queue_sender`](super::PipelineTask::queue_sender)
74    /// handle.
75    pub fn new(head: UnboundedSender<Frame>) -> Self {
76        Self { head }
77    }
78
79    /// Inject one frame at the pipeline head (flows downstream). Returns `Err` if
80    /// the pipeline has shut down (the reader should then stop). Mirrors pipecat
81    /// `BaseInputTransport.push_frame` from the reader task.
82    pub fn emit(&self, frame: Frame) -> Result<(), Frame> {
83        self.head.send(frame).map_err(|e| e.0)
84    }
85
86    /// Request a graceful drain: inject a downstream `End` at the head so every
87    /// processor (incl. the internal `Source`) flushes and the task winds down.
88    /// Idempotent and a no-op once the pipeline is gone.
89    pub fn end(&self) {
90        let _ = self.head.send(Frame::End { reason: None });
91    }
92
93    /// Request an immediate, no-flush teardown: inject a downstream `Cancel`.
94    pub fn cancel(&self, reason: Option<String>) {
95        let _ = self.head.send(Frame::Cancel { reason });
96    }
97}
98
99/// Owns a source's spawned reader task and aborts it on drop. Holds the pump+abort
100/// lifecycle so a transport author never re-derives it (the source-emit
101/// ruling — see the module docs / PROCESSOR-DESIGN §10 Q6).
102pub struct SourcePump {
103    handle: JoinHandle<()>,
104}
105
106impl SourcePump {
107    /// Spawn a source reader. `f` receives a [`SourceHandle`] and runs the
108    /// transport's `recv()` loop, `emit`ing frames into the pipeline head until the
109    /// transport is exhausted (then it should call [`SourceHandle::end`]). The
110    /// returned [`SourcePump`] aborts the reader on drop, so dropping it after
111    /// [`PipelineTask::run`](super::PipelineTask::run) returns cleans the reader up.
112    pub fn spawn<F, Fut>(head: UnboundedSender<Frame>, f: F) -> Self
113    where
114        F: FnOnce(SourceHandle) -> Fut + Send + 'static,
115        Fut: std::future::Future<Output = ()> + Send + 'static,
116    {
117        let handle = tokio::spawn(f(SourceHandle::new(head)));
118        Self { handle }
119    }
120
121    /// Wrap an already-spawned reader `handle` (for sources that build their own
122    /// task — e.g. one that needs the [`JoinHandle`] before the loop body). The
123    /// reader is still expected to feed the head via a [`SourceHandle`].
124    pub fn from_handle(handle: JoinHandle<()>) -> Self {
125        Self { handle }
126    }
127
128    /// Stop the reader task immediately (used after `run()` returns).
129    pub fn abort(&self) {
130        self.handle.abort();
131    }
132}
133
134impl Drop for SourcePump {
135    fn drop(&mut self) {
136        self.handle.abort();
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::pipeline::{Pipeline, PipelineTask, PipelineTaskParams};
144    use crate::processor::frame::{Frame, StartParams};
145    use crate::processor::{Envelope, FrameProcessor, Link, ProcessorSetup};
146    use crate::Result;
147    use async_trait::async_trait;
148    use std::sync::atomic::{AtomicUsize, Ordering};
149    use std::sync::Arc;
150
151    /// A processor that asserts its `start()` ran before any data frame, and counts
152    /// the `Text` frames the source pump fed it — proving the Start→ready handshake
153    /// holds for pump-injected frames (the ordering invariant the helper preserves).
154    struct Guard {
155        started: Arc<AtomicUsize>,
156        data_before_start: Arc<AtomicUsize>,
157        data_after_start: Arc<AtomicUsize>,
158    }
159    #[async_trait]
160    impl FrameProcessor for Guard {
161        fn name(&self) -> &str {
162            "Guard"
163        }
164        async fn start(&mut self, _s: &ProcessorSetup, _p: &StartParams) -> Result<()> {
165            self.started.fetch_add(1, Ordering::SeqCst);
166            Ok(())
167        }
168        async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
169            if matches!(env.frame, Frame::Text(_)) {
170                if self.started.load(Ordering::SeqCst) == 0 {
171                    self.data_before_start.fetch_add(1, Ordering::SeqCst);
172                } else {
173                    self.data_after_start.fetch_add(1, Ordering::SeqCst);
174                }
175            }
176            link.push(env.meta, env.frame, env.direction).await;
177            Ok(())
178        }
179    }
180
181    #[tokio::test]
182    async fn source_pump_feeds_head_and_respects_start_handshake() {
183        let started = Arc::new(AtomicUsize::new(0));
184        let before = Arc::new(AtomicUsize::new(0));
185        let after = Arc::new(AtomicUsize::new(0));
186        let pipeline = Pipeline::new(vec![Box::new(Guard {
187            started: started.clone(),
188            data_before_start: before.clone(),
189            data_after_start: after.clone(),
190        })]);
191        let task = PipelineTask::new(pipeline, PipelineTaskParams::default(), vec![]);
192
193        // A source reader that emits three Text frames then ends the call.
194        let pump = SourcePump::spawn(task.queue_sender(), |head| async move {
195            for i in 0..3 {
196                if head.emit(Frame::Text(format!("m{i}"))).is_err() {
197                    return;
198                }
199            }
200            head.end();
201        });
202
203        tokio::time::timeout(std::time::Duration::from_secs(5), task.run())
204            .await
205            .expect("task timed out")
206            .expect("run ok");
207        drop(pump); // aborts the (already-finished) reader
208
209        assert_eq!(started.load(Ordering::SeqCst), 1, "start ran once");
210        assert_eq!(
211            before.load(Ordering::SeqCst),
212            0,
213            "no pumped data reached process_frame before start()"
214        );
215        assert_eq!(
216            after.load(Ordering::SeqCst),
217            3,
218            "all 3 pumped frames processed"
219        );
220    }
221
222    #[tokio::test]
223    async fn source_pump_aborts_reader_on_drop() {
224        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<Frame>();
225        let ticks = Arc::new(AtomicUsize::new(0));
226        let t2 = ticks.clone();
227        let pump = SourcePump::spawn(tx, |_head| async move {
228            // Loop forever until aborted.
229            loop {
230                t2.fetch_add(1, Ordering::SeqCst);
231                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
232            }
233        });
234        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
235        drop(pump);
236        let seen = ticks.load(Ordering::SeqCst);
237        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
238        assert_eq!(
239            ticks.load(Ordering::SeqCst),
240            seen,
241            "reader task stopped ticking after the pump was dropped"
242        );
243    }
244}