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}