Skip to main content

flowcat_core/processor/
runtime.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! The framework-owned per-processor task loop (PROCESSOR-DESIGN §2.2/§2.3/§2.5).
4//!
5//! Two channels per processor so `System` frames jump ahead of `Data`/`Control`
6//! (pipecat does this with a `PriorityQueue`, `frame_processor.py:119`; a second
7//! channel is the cheaper, branch-free Rust equivalent):
8//!
9//! - **bounded** normal channel (Data/Control) → natural backpressure for media;
10//! - **unbounded** system channel (Start/Cancel/Interruption/Error/…) → an
11//!   interruption can never block on a full queue.
12//!
13//! The loop biases the system channel, runs the processor's lifecycle hooks on
14//! lifecycle frames, drains interruptible frames on [`Frame::Interruption`], and
15//! converts a `process_frame` `Err` into an upstream [`Frame::Error`].
16
17use tokio::sync::mpsc;
18
19use super::frame::{Direction, Frame, FrameClass};
20use super::{Envelope, FrameProcessor, Link, ProcessorSetup, StopReason};
21
22/// Default capacity of the bounded normal (Data/Control) channel — the
23/// `bench-rs` value (~1.3 s of audio headroom @ 50 fps). PROCESSOR-DESIGN §2.2.
24pub const NORMAL_CHAN_CAP: usize = 64;
25
26/// The receive half held by a processor task: a bounded normal channel and an
27/// unbounded system channel.
28pub struct ProcessorRx {
29    pub(crate) system: mpsc::UnboundedReceiver<Envelope>,
30    pub(crate) normal: mpsc::Receiver<Envelope>,
31}
32
33/// The send half: routes a frame by [`Frame::class`] (System → the unbounded
34/// system channel, Data/Control → the bounded normal channel). Cheaply cloned
35/// into each neighbour's [`Link`].
36#[derive(Clone)]
37pub struct ProcessorTx {
38    system: mpsc::UnboundedSender<Envelope>,
39    normal: mpsc::Sender<Envelope>,
40    name: std::sync::Arc<str>,
41}
42
43impl ProcessorTx {
44    /// The destination processor's name (for observer push events).
45    pub fn name(&self) -> &str {
46        &self.name
47    }
48
49    /// Enqueue `env` onto the right channel for its class. Backpressure (`await`)
50    /// only on the bounded normal channel; the system channel never blocks. A
51    /// closed channel is a no-op (the consumer task already exited).
52    pub async fn send(&self, env: Envelope) {
53        match env.frame.class() {
54            FrameClass::System => {
55                let _ = self.system.send(env);
56            }
57            FrameClass::Data | FrameClass::Control => {
58                let _ = self.normal.send(env).await;
59            }
60        }
61    }
62}
63
64/// `Link`'s sender alias for a neighbour (PROCESSOR-DESIGN §2.2).
65pub type EnvelopeSender = ProcessorTx;
66
67/// Build a processor channel pair (the bounded normal + unbounded system halves)
68/// tagged with the owning processor's `name`.
69pub fn channel(name: std::sync::Arc<str>, normal_cap: usize) -> (ProcessorTx, ProcessorRx) {
70    let (sys_tx, sys_rx) = mpsc::unbounded_channel();
71    let (norm_tx, norm_rx) = mpsc::channel(normal_cap);
72    (
73        ProcessorTx {
74            system: sys_tx,
75            normal: norm_tx,
76            name,
77        },
78        ProcessorRx {
79            system: sys_rx,
80            normal: norm_rx,
81        },
82    )
83}
84
85fn stop_reason(frame: &Frame) -> StopReason {
86    match frame {
87        Frame::End { .. } => StopReason::EndOfTask,
88        Frame::Stop => StopReason::Stopped,
89        Frame::Cancel { .. } => StopReason::Cancelled,
90        _ => StopReason::Stopped,
91    }
92}
93
94/// The framework-owned per-processor loop. Runs until the input channels close or
95/// a terminal frame (`End`/`Cancel`) is forwarded.
96///
97/// PROCESSOR-DESIGN §2.2 pseudocode, made real:
98/// - `biased` select drains the **system** channel first (priority);
99/// - `Start` runs `start()` then forwards;
100/// - `Interruption` drains the normal queue of *interruptible* frames (keeping
101///   uninterruptible ones — End/Stop/FunctionCallResult/UpdateSettings) then
102///   forwards;
103/// - `End`/`Stop`/`Cancel` run `stop()`, forward, and (End/Cancel) break;
104/// - any other frame goes to `process_frame`; an `Err` becomes an upstream
105///   `Error{fatal:false}`.
106pub async fn run_processor(
107    mut p: Box<dyn FrameProcessor>,
108    mut rx: ProcessorRx,
109    link: Link,
110    setup: ProcessorSetup,
111) {
112    loop {
113        let env = tokio::select! {
114            biased;
115            Some(e) = rx.system.recv() => e,
116            Some(e) = rx.normal.recv() => e,
117            else => break,
118        };
119
120        // Observer `on_process` hook (zero-cost when no observer registered).
121        if let Some(o) = &setup.observer {
122            let ev = crate::observer::FrameEvent {
123                processor: &link.name,
124                frame: &env.frame,
125                meta: &env.meta,
126                direction: env.direction,
127                timestamp_ns: setup.clock.now_ns(),
128            };
129            o.on_process(&ev).await;
130        }
131
132        match &env.frame {
133            Frame::Start(params) => {
134                let params = params.clone();
135                if let Err(e) = p.start(&setup, &params).await {
136                    link.push_error(e.to_string(), false).await;
137                }
138                link.push(env.meta, env.frame, env.direction).await;
139            }
140            Frame::Interruption => {
141                // Drain the normal queue: drop interruptible frames, keep
142                // uninterruptible ones. A kept *downstream terminal* (End/Stop) is
143                // returned rather than blindly forwarded — see below.
144                let kept_terminal = drain_on_interruption(&mut rx, &link).await;
145                // Forward the interruption in the direction it arrived.
146                link.push(env.meta, env.frame, env.direction).await;
147                // A downstream End/Stop that was buffered when the interruption hit
148                // must go through the normal terminal handling so a `Sink` taps it via
149                // `stop()` (its `next` is `None`, so a raw `link.push` would silently
150                // drop it and the task would hang waiting for a terminal that never
151                // surfaces). This was a latent interruption-vs-End race.
152                if let Some(term) = kept_terminal {
153                    let reason = stop_reason(&term.frame);
154                    let is_terminal = !matches!(&term.frame, Frame::Stop);
155                    if let Err(e) = p.stop(reason).await {
156                        link.push_error(e.to_string(), false).await;
157                    }
158                    link.push(term.meta, term.frame, term.direction).await;
159                    if is_terminal {
160                        break;
161                    }
162                }
163            }
164            // Framework lifecycle handling applies only to **downstream** lifecycle
165            // frames (the actual drain signal). An *upstream* End/Stop/Cancel is a
166            // "request to end" from an inner processor — it must fall through to
167            // `process_frame` so the `Source` can convert it into a downstream
168            // lifecycle frame (PROCESSOR-DESIGN §4.1; pipecat keeps these distinct as
169            // EndTaskFrame vs EndFrame). Intercepting it here would strand the request
170            // and the task would never terminate.
171            Frame::Cancel { .. } | Frame::End { .. } | Frame::Stop
172                if env.direction == Direction::Downstream =>
173            {
174                let reason = stop_reason(&env.frame);
175                let terminal = !matches!(&env.frame, Frame::Stop);
176                if let Err(e) = p.stop(reason).await {
177                    link.push_error(e.to_string(), false).await;
178                }
179                link.push(env.meta, env.frame, env.direction).await;
180                if terminal {
181                    break;
182                }
183            }
184            _ => {
185                if let Err(e) = p.process_frame(env, &link).await {
186                    link.push_error(e.to_string(), false).await;
187                }
188            }
189        }
190    }
191}
192
193/// On an interruption, drain the **normal** channel of currently-queued frames,
194/// dropping interruptible ones and forwarding uninterruptible ones (End/Stop/
195/// FunctionCallResult/UpdateSettings) so they are never lost (pipecat
196/// `_start_interruption`, `frame_processor.py:828`).
197///
198/// Returns a kept **downstream terminal** (End/Stop) if one was buffered, so the
199/// caller can run it through the normal terminal handling (`stop()` + break) — at a
200/// `Sink` that hook is what taps the terminal for `PipelineTask`; forwarding it via
201/// `link.push` here would drop it (Sink `next` is `None`) and hang the task.
202///
203/// Ordering note: non-terminal kept frames (UpdateSettings/FunctionCallResult, or
204/// upstream terminals) are forwarded here, *before* the caller forwards the
205/// `Interruption` marker. Since the marker is a `System` frame (unbounded,
206/// biased-first) and those frames are Data/Control, the marker may overtake them at
207/// the next processor. That is intentional and safe: they are exactly the frames
208/// that *survive* an interruption by definition, so the next hop keeps them
209/// regardless of arrival order.
210async fn drain_on_interruption(rx: &mut ProcessorRx, link: &Link) -> Option<Envelope> {
211    // `try_recv` only the frames already queued — do not await new ones.
212    while let Ok(env) = rx.normal.try_recv() {
213        if env.frame.uninterruptible() {
214            // A downstream terminal goes back to the caller for normal terminal
215            // handling (so a Sink taps it via `stop()`); stop draining (nothing
216            // queued after a terminal matters — the task is ending).
217            if env.direction == Direction::Downstream && env.frame.is_terminal() {
218                return Some(env);
219            }
220            // Other uninterruptible frames forward in their direction.
221            link.push(env.meta, env.frame, env.direction).await;
222        }
223        // else: drop the interruptible frame.
224    }
225    None
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::error::Result;
232    use crate::processor::{Clock, FrameProcessor};
233    use async_trait::async_trait;
234    use std::sync::atomic::{AtomicBool, Ordering};
235    use std::sync::Arc;
236
237    /// A tail/sink-like processor: `next` is `None`, and it records whether the
238    /// framework ran its `stop()` hook (the hook a `Sink` uses to tap a terminal).
239    struct TapSink {
240        stopped: Arc<AtomicBool>,
241    }
242    #[async_trait]
243    impl FrameProcessor for TapSink {
244        fn name(&self) -> &str {
245            "TapSink"
246        }
247        async fn stop(&mut self, _reason: StopReason) -> Result<()> {
248            self.stopped.store(true, Ordering::Relaxed);
249            Ok(())
250        }
251    }
252
253    fn sink_link(name: Arc<str>, clock: Clock) -> Link {
254        // A tail Link: no `next` (the condition that made the bug drop the End).
255        Link {
256            next: None,
257            prev: None,
258            name,
259            clock,
260            observer: None,
261            enable_metrics: false,
262            enable_usage_metrics: false,
263            ttfb_start: Arc::new(std::sync::atomic::AtomicI64::new(0)),
264            processing_start: Arc::new(std::sync::atomic::AtomicI64::new(0)),
265        }
266    }
267
268    /// Regression: a downstream `End` buffered on the normal channel when an
269    /// `Interruption` arrives at a `Sink` (no `next`) must still terminate the task
270    /// via the `stop()` hook — not be silently dropped by the interruption drain
271    /// (which would hang `PipelineTask::run`).
272    #[tokio::test]
273    async fn interruption_does_not_lose_a_buffered_downstream_end_at_a_sink() {
274        let name: Arc<str> = Arc::from("TapSink");
275        let (tx, rx) = channel(name.clone(), 8);
276        let clock = Clock::new();
277        let setup = ProcessorSetup {
278            clock: clock.clone(),
279            observer: None,
280            cancel: tokio_util::sync::CancellationToken::new(),
281            enable_metrics: false,
282            enable_usage_metrics: false,
283        };
284        let stopped = Arc::new(AtomicBool::new(false));
285        let p = Box::new(TapSink {
286            stopped: stopped.clone(),
287        });
288
289        // The race: a downstream End is already buffered (Control → normal channel)
290        // when the Interruption (System → system channel, biased-first) is handled.
291        tx.send(Envelope::new(
292            Frame::End { reason: None },
293            Direction::Downstream,
294        ))
295        .await;
296        tx.send(Envelope::new(Frame::Interruption, Direction::Downstream))
297            .await;
298        drop(tx); // close channels so the loop can't block awaiting more input
299
300        let h = tokio::spawn(run_processor(p, rx, sink_link(name, clock), setup));
301        let res = tokio::time::timeout(std::time::Duration::from_secs(2), h).await;
302        assert!(
303            res.is_ok(),
304            "run_processor hung on the interruption-vs-buffered-End race"
305        );
306        assert!(
307            stopped.load(Ordering::Relaxed),
308            "the Sink's stop() hook must run for the buffered End (else PipelineTask hangs)"
309        );
310    }
311}