pipecrab_runtime/inbound.rs
1//! Each stage has an Inbound mailbox with two typed lanes:
2//! `sys` — the priority lane, drains first, carries `(Direction, SystemFrame)`.
3//! `data` — the data lane, carries bare `DataFrame` (downstream only).
4//!
5//! Keeping the lanes typed prevents misrouting a media frame onto the system
6//! lane and removes the per-frame is-system check from the hot path.
7//!
8//! Every frame crosses its link carrying a per-link sequence stamp (see
9//! [`Stamped`]), which makes the interrupt flush *causal*: a flush drops only
10//! frames queued before the system frame it flushes against, so a barge-in
11//! utterance sent behind its own `Interrupt` is never destroyed by it.
12
13use futures::channel::mpsc::Receiver;
14use futures::stream::StreamExt;
15use pipecrab_core::{DataFrame, Direction, SystemFrame};
16
17/// A frame paired with the sequence stamp its link's
18/// [`Outbound`](crate::Outbound) applied.
19///
20/// Both lanes of one link share a single monotonic counter, so stamps order
21/// frames *across* lanes: [`Inbound::flush_data`] keeps any data frame stamped
22/// at or after the system frame being flushed against.
23#[derive(Debug)]
24pub(crate) struct Stamped<T> {
25 /// Per-link monotonic sequence number; `0` is never issued.
26 pub(crate) seq: u64,
27 /// The carried frame.
28 pub(crate) frame: T,
29}
30
31/// A frame received from [`Inbound::recv`]: either a system frame (with its
32/// travel direction) or a data frame (always downstream).
33#[derive(Debug)]
34pub enum Received {
35 /// A system frame and the direction it is travelling.
36 Sys(Direction, SystemFrame),
37 /// A data frame, implicitly travelling downstream.
38 Data(DataFrame),
39}
40
41/// The receive surface of a stage: a preempting system lane and the data lane.
42///
43/// Within a lane, frames keep FIFO order. Across lanes, `sys` always wins, so a
44/// system frame is taken even when `data` is backed up.
45///
46/// Constructed only by [`link`](crate::link); the lanes are private so every
47/// receive goes through [`recv`](Self::recv), which maintains the flush floor
48/// [`flush_data`](Self::flush_data) relies on.
49pub struct Inbound {
50 /// System-tier frames (lifecycle, interruption, errors). Drained first.
51 /// `Error` rides this lane *upstream*; `Interrupt`/`Start`/`Stop` ride it
52 /// downstream. Sparse and latency-critical.
53 pub(crate) sys: Receiver<Stamped<(Direction, SystemFrame)>>,
54 /// Data-tier frames (media, transcripts), in FIFO order, downstream only.
55 pub(crate) data: Receiver<Stamped<DataFrame>>,
56 /// Stamp of the most recent system frame taken off `sys` — the floor
57 /// [`flush_data`](Self::flush_data) flushes up to.
58 pub(crate) flush_floor: u64,
59}
60
61impl Inbound {
62 /// Receive the next frame, draining the system lane before the data lane.
63 ///
64 /// Returns [`Received::Sys`] or [`Received::Data`], or `None` once *both*
65 /// lanes are closed — the run-loop's shutdown signal.
66 ///
67 /// [`futures::select_biased`] polls `sys` first, so a system frame preempts
68 /// any data backlog deterministically. When a lane closes, its receiver
69 /// (a [`FusedStream`]) yields `None`; the `loop` swallows that first `None`
70 /// so the next iteration just skips the dead lane instead of treating it as
71 /// shutdown. This is so the sys lane can keep draining even after the data
72 /// lane shuts down — `None` is returned only once *both* lanes have closed.
73 ///
74 /// [`FusedStream`]: futures::stream::FusedStream
75 pub async fn recv(&mut self) -> Option<Received> {
76 loop {
77 futures::select_biased! {
78 sys = self.sys.next() => {
79 if let Some(Stamped { seq, frame: (dir, f) }) = sys {
80 self.flush_floor = seq;
81 return Some(Received::Sys(dir, f));
82 }
83 }
84 data = self.data.next() => {
85 if let Some(Stamped { frame, .. }) = data {
86 return Some(Received::Data(frame));
87 }
88 }
89 complete => return None,
90 }
91 }
92 }
93
94 /// Drain everything currently queued on the data lane. A frame queued
95 /// *before* the most recently received system frame is kept only if
96 /// `survives_flush()`; a frame queued at or after it is always kept.
97 /// Keepers are returned in arrival order, for the caller to re-process.
98 /// Does not block and does not touch the sys lane.
99 ///
100 /// Only meaningful straight after receiving the system frame to flush
101 /// against — receiving another system frame moves the floor.
102 pub fn flush_data(&mut self) -> Vec<DataFrame> {
103 self.flush_data_stamped()
104 .into_iter()
105 .map(|stamped| stamped.frame)
106 .collect()
107 }
108
109 /// [`flush_data`](Self::flush_data), keeping the stamps: the run loop holds
110 /// keepers across a later interrupt, whose flush must re-judge them by seq.
111 pub(crate) fn flush_data_stamped(&mut self) -> Vec<Stamped<DataFrame>> {
112 let mut kept = Vec::new();
113 while let Ok(stamped) = self.data.try_recv() {
114 if stamped.seq >= self.flush_floor || stamped.frame.survives_flush() {
115 kept.push(stamped);
116 }
117 }
118 kept
119 }
120
121 /// Await the next system frame, ignoring the data lane and maintaining the
122 /// flush floor exactly as [`recv`](Self::recv) does.
123 ///
124 /// `None` once the system lane closes, and immediately so from then on — a
125 /// caller racing this against other work must stop polling it at that
126 /// point. Cancellation-safe: a dropped future takes nothing off the lane.
127 ///
128 /// This is what keeps an application's output pump preemptible while it is
129 /// busy with a data frame. A stage gets sys priority from the run loop's
130 /// own race; the tail lane belongs to the application, so its pump must run
131 /// the same race itself (see the e2e examples' `pump_out`).
132 pub async fn recv_sys(&mut self) -> Option<(Direction, SystemFrame)> {
133 let Stamped {
134 seq,
135 frame: (dir, frame),
136 } = self.sys.next().await?;
137 self.flush_floor = seq;
138 Some((dir, frame))
139 }
140
141 /// Take one already-queued system frame without blocking, maintaining the
142 /// flush floor exactly as [`recv`](Self::recv) would. `None` when the sys
143 /// lane is empty or closed. Lets the run loop keep sys priority while it
144 /// replays flush keepers instead of awaiting `recv`.
145 pub(crate) fn try_recv_sys(&mut self) -> Option<(Direction, SystemFrame)> {
146 match self.sys.try_recv() {
147 Ok(Stamped {
148 seq,
149 frame: (dir, frame),
150 }) => {
151 self.flush_floor = seq;
152 Some((dir, frame))
153 }
154 Err(_) => None,
155 }
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 //! Lane-close semantics need one lane to close while the other stays open,
162 //! which the public [`link`](crate::link) surface cannot express (one
163 //! `Outbound` owns both senders) — so these live here, on the raw lanes.
164
165 use futures::FutureExt;
166 use futures::channel::mpsc;
167 use futures::executor::block_on;
168 use pipecrab_core::Transcript;
169
170 use super::*;
171
172 #[allow(clippy::type_complexity)]
173 fn lanes() -> (
174 mpsc::Sender<Stamped<(Direction, SystemFrame)>>,
175 mpsc::Sender<Stamped<DataFrame>>,
176 Inbound,
177 ) {
178 let (sys_tx, sys) = mpsc::channel(16);
179 let (data_tx, data) = mpsc::channel(16);
180 (
181 sys_tx,
182 data_tx,
183 Inbound {
184 sys,
185 data,
186 flush_floor: 0,
187 },
188 )
189 }
190
191 #[test]
192 fn both_lanes_closed_yields_none() {
193 block_on(async {
194 let (sys_tx, data_tx, mut inb) = lanes();
195 drop(sys_tx);
196 drop(data_tx);
197 assert!(
198 inb.recv().await.is_none(),
199 "closed lanes must signal shutdown via None"
200 );
201 });
202 }
203
204 #[test]
205 fn one_closed_lane_does_not_signal_shutdown() {
206 block_on(async {
207 let (sys_tx, data_tx, mut inb) = lanes();
208 // Data lane closes while sys is still open but empty.
209 drop(data_tx);
210 // recv must NOT resolve to None — a single closed lane is not
211 // shutdown. `now_or_never` yields `None` while still pending.
212 assert!(
213 inb.recv().now_or_never().is_none(),
214 "a still-open sys lane must keep recv pending, not report shutdown",
215 );
216
217 // The other lane closing too is what finally yields `None`.
218 drop(sys_tx);
219 assert!(
220 matches!(inb.recv().now_or_never(), Some(None)),
221 "both lanes closed must resolve immediately to None",
222 );
223 });
224 }
225
226 #[test]
227 fn closed_sys_lane_still_serves_buffered_data() {
228 block_on(async {
229 let (sys_tx, mut data_tx, mut inb) = lanes();
230 data_tx
231 .try_send(Stamped {
232 seq: 1,
233 frame: Transcript::user_final("after sys closed").into(),
234 })
235 .unwrap();
236 // Sys lane closes, but a buffered data frame must still be
237 // delivered.
238 drop(sys_tx);
239
240 match inb.recv().await.unwrap() {
241 Received::Data(DataFrame::Transcript(s)) => {
242 assert_eq!(s.text, "after sys closed".into())
243 }
244 other => panic!("closed sys lane must not block the data lane, got {other:?}"),
245 }
246 });
247 }
248
249 #[test]
250 fn recv_sys_takes_the_system_frame_past_a_backed_up_data_lane() {
251 block_on(async {
252 let (mut sys_tx, mut data_tx, mut inb) = lanes();
253 data_tx
254 .try_send(Stamped {
255 seq: 1,
256 frame: Transcript::user_final("stale").into(),
257 })
258 .unwrap();
259 sys_tx
260 .try_send(Stamped {
261 seq: 2,
262 frame: (Direction::Down, SystemFrame::Interrupt),
263 })
264 .unwrap();
265 data_tx
266 .try_send(Stamped {
267 seq: 3,
268 frame: Transcript::user_final("barge-in").into(),
269 })
270 .unwrap();
271
272 assert!(
273 matches!(
274 inb.recv_sys().await,
275 Some((Direction::Down, SystemFrame::Interrupt))
276 ),
277 "recv_sys must reach the system frame without draining data first",
278 );
279 // The floor moved, so the causal flush still discriminates.
280 let kept = inb.flush_data();
281 assert_eq!(kept.len(), 1, "only the post-Interrupt frame survives");
282 match &kept[0] {
283 DataFrame::Transcript(s) => assert_eq!(s.text, "barge-in".into()),
284 other => panic!("wrong survivor: {other:?}"),
285 }
286 });
287 }
288
289 #[test]
290 fn recv_sys_reports_a_closed_sys_lane_and_leaves_data_alone() {
291 block_on(async {
292 let (sys_tx, mut data_tx, mut inb) = lanes();
293 data_tx
294 .try_send(Stamped {
295 seq: 1,
296 frame: Transcript::user_final("still here").into(),
297 })
298 .unwrap();
299 drop(sys_tx);
300
301 assert!(
302 matches!(inb.recv_sys().now_or_never(), Some(None)),
303 "a closed sys lane must resolve immediately, so a racing caller can stop polling",
304 );
305 match inb.recv().await.unwrap() {
306 Received::Data(DataFrame::Transcript(s)) => {
307 assert_eq!(s.text, "still here".into())
308 }
309 other => panic!("recv_sys must not consume data frames, got {other:?}"),
310 }
311 });
312 }
313}