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, ¶ms).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}