Skip to main content

proxima_primitives/pipe/
drain_source.rs

1//! `DrainSource` / `DrainFanIn` — zero-copy N→1 merge via the visitor-push model.
2//!
3//! The zero-copy counterpart to the owned [`crate::pipe::fan_in::FanIn`]: the merged
4//! item *borrows* into the producing source's ring slot (`&[u8]`) instead of
5//! being copied out. The borrow is scoped to a visitor closure call, so it
6//! never escapes the merge loop.
7//!
8//! ## Why push, not a GAT lending pull
9//!
10//! The obvious zero-copy design is a GAT lending source
11//! (`type Item<'a>; poll_next<'a>(&'a mut self) -> Poll<Option<Item<'a>>>`).
12//! It was tried and **empirically falsified**: the array-FSM
13//! `match self.sources[i].poll_next(cx) { Ready(Some(item)) => return … }`
14//! fails to compile (`E0499` — the returned `Item<'a>` keeps `self.sources[i]`
15//! borrowed for `'a`, conflicting with the next loop iteration re-polling it).
16//! That is the classic lending-iterator-in-a-loop borrow that needs Polonius,
17//! which is not stable. The visitor-push model sidesteps it entirely: the
18//! `&Item` borrow lives only for the `visitor(item)` call inside
19//! `drain_ready`, never crossing the loop boundary, so it compiles on stable
20//! and stays zero-copy.
21//!
22//! ## Execution model
23//!
24//! This is the **poll-mode / spin-drain** surface (no `Waker`, no `Context`) —
25//! the \*DK shape: a source drains the items ready *now* and returns; the caller
26//! re-drives. It is distinct from the waker-registered async
27//! [`crate::pipe::fan_in::FanIn`] (an `UnpinPipe` merge); the two coexist
28//! (sync-zero-copy vs async-owned), bridged by a batching adapter where an
29//! async consumer needs owned items.
30//!
31//! TIER: T0 — no_std + no-alloc. `&mut dyn FnMut` is a stack fat-pointer (no
32//! heap); the consumer-facing `drain_each` takes `impl FnMut` (monomorphised),
33//! so the only vtable hop is once per source-batch, not per item.
34
35use core::ops::ControlFlow;
36
37/// The outcome of one [`DrainSource::drain_ready`] pass.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum DrainState {
40    /// Items may be available on the next call — the source is not exhausted,
41    /// it just ran out of ready items this pass (or the visitor stopped it).
42    More,
43    /// The source is permanently exhausted; it will not be polled again.
44    Drained,
45}
46
47/// A poll-mode source that pushes borrowed items into a visitor.
48///
49/// The source applies `visitor` to each ready item while holding the borrow
50/// internally — so the item can be a `?Sized` slice view (`[u8]`) into a ring
51/// slot with zero copy. The visitor returns [`ControlFlow`]: `Continue` keeps
52/// draining, `Break` stops (backpressure), in which case `drain_ready` returns
53/// [`DrainState::More`] (the source is not exhausted, the consumer stopped it).
54pub trait DrainSource {
55    /// The item lent to the visitor. `?Sized` so it can be a `[u8]` slot view.
56    type Item: ?Sized;
57
58    /// Drain all currently-ready items, calling `visitor` on each (borrowed)
59    /// item in order. No alloc, no syscall, no waker on the hot path.
60    fn drain_ready(
61        &mut self,
62        visitor: &mut dyn FnMut(&Self::Item) -> ControlFlow<()>,
63    ) -> DrainState;
64}
65
66/// Fixed-arity N→1 zero-copy merge over `[S; N]`. Round-robin fair; ends when
67/// every source has drained. Shares the FSM shape of [`crate::pipe::fan_in::FanIn`]
68/// (cursor + live-set + remaining) but drives sources push-style so items
69/// borrow source slots without copying. No_std + no-alloc.
70pub struct DrainFanIn<S, const N: usize> {
71    sources: [S; N],
72    live: [bool; N],
73    remaining: usize,
74    cursor: usize,
75}
76
77impl<S, const N: usize> DrainFanIn<S, N> {
78    /// Merge `sources`. All start live.
79    #[must_use]
80    pub fn new(sources: [S; N]) -> Self {
81        Self {
82            sources,
83            live: [true; N],
84            remaining: N,
85            cursor: 0,
86        }
87    }
88
89    /// Sources not yet permanently drained.
90    #[must_use]
91    pub fn live_count(&self) -> usize {
92        self.remaining
93    }
94
95    /// Visit the live sources once each, round-robin from the cursor, draining
96    /// each fully (the burst pattern: a source emits its ready items per visit,
97    /// then the next source). Items are read in-place — zero copy. Returns when
98    /// the visitor breaks (backpressure → [`DrainState::More`], resuming at the
99    /// same source next call) or every source has drained.
100    ///
101    /// Cross-call fairness: the cursor advances by one each call so a perpetually
102    /// busy source can't permanently win the first slot. `impl FnMut`
103    /// monomorphises the consumer; the per-source `drain_ready` takes the
104    /// `&mut dyn FnMut`, so the only vtable hop is once per source, not per item.
105    pub fn drain_each<F>(&mut self, mut visitor: F) -> DrainState
106    where
107        S: DrainSource,
108        F: FnMut(&S::Item) -> ControlFlow<()>,
109    {
110        if self.remaining == 0 {
111            return DrainState::Drained;
112        }
113        // capture a STABLE start: mutating self.cursor mid-loop would corrupt
114        // the index calc and skip sources. visitor + broke are non-self
115        // captures, so they don't conflict with the &mut self.sources[index].
116        let start = self.cursor;
117        let mut any_more = false;
118        for step in 0..N {
119            let index = (start + step) % N;
120            if !self.live[index] {
121                continue;
122            }
123            let mut broke = false;
124            let state = self.sources[index].drain_ready(&mut |item| {
125                let flow = visitor(item);
126                if flow.is_break() {
127                    broke = true;
128                }
129                flow
130            });
131            match state {
132                DrainState::Drained => {
133                    self.live[index] = false;
134                    self.remaining -= 1;
135                    if self.remaining == 0 {
136                        return DrainState::Drained;
137                    }
138                }
139                DrainState::More => any_more = true,
140            }
141            if broke {
142                self.cursor = index; // resume this source next call
143                return DrainState::More;
144            }
145        }
146        self.cursor = (start + 1) % N; // advance once for cross-call fairness
147        if any_more {
148            DrainState::More
149        } else {
150            DrainState::Drained
151        }
152    }
153}
154
155// Nesting: a merge is itself a source, so `DrainFanIn<DrainFanIn<R, 4>, 3>`
156// composes with no extra machinery.
157impl<S: DrainSource, const N: usize> DrainSource for DrainFanIn<S, N> {
158    type Item = S::Item;
159
160    fn drain_ready(
161        &mut self,
162        visitor: &mut dyn FnMut(&Self::Item) -> ControlFlow<()>,
163    ) -> DrainState {
164        self.drain_each(|item| visitor(item))
165    }
166}
167
168/// A fixed-capacity ring of byte frames over a stack arena — the zero-copy \*DK
169/// source (DPDK mbuf ring / NVMe CQ view / per-core telemetry ring). Items lent
170/// to the visitor borrow directly into `arena`; no heap, no copy on the read.
171pub struct RingSource<const SLOTS: usize, const SLOT: usize> {
172    arena: [[u8; SLOT]; SLOTS],
173    lengths: [usize; SLOTS],
174    read: usize,
175    write: usize,
176    count: usize,
177    closed: bool,
178    // AsyncRead's byte-stream cursor into the CURRENT oldest frame — how far
179    // a `poll_read` call has already delivered when the caller's buffer is
180    // smaller than the frame. `DrainSource::drain_ready` never touches this
181    // (it always hands back a whole frame per visitor call); the two
182    // consumption models are not meant to interleave on the same instance —
183    // pick one per `RingSource` (see the `io-async` impl below). Only exists
184    // under `io-async` so the T0 floor build carries no dead field.
185    #[cfg(feature = "io-async")]
186    frame_offset: usize,
187}
188
189impl<const SLOTS: usize, const SLOT: usize> Default for RingSource<SLOTS, SLOT> {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195impl<const SLOTS: usize, const SLOT: usize> RingSource<SLOTS, SLOT> {
196    #[must_use]
197    pub const fn new() -> Self {
198        Self {
199            arena: [[0u8; SLOT]; SLOTS],
200            lengths: [0usize; SLOTS],
201            read: 0,
202            write: 0,
203            count: 0,
204            closed: false,
205            #[cfg(feature = "io-async")]
206            frame_offset: 0,
207        }
208    }
209
210    /// Copy one frame into the next slot. `false` if full or oversized (the
211    /// producer's concern — the ring never allocates).
212    pub fn push(&mut self, frame: &[u8]) -> bool {
213        if self.count == SLOTS || frame.len() > SLOT {
214            return false;
215        }
216        let slot = self.write % SLOTS;
217        self.arena[slot][..frame.len()].copy_from_slice(frame);
218        self.lengths[slot] = frame.len();
219        self.write = self.write.wrapping_add(1);
220        self.count += 1;
221        true
222    }
223
224    /// No more frames will arrive — the next fully-drained pass returns
225    /// [`DrainState::Drained`].
226    pub fn close(&mut self) {
227        self.closed = true;
228    }
229}
230
231impl<const SLOTS: usize, const SLOT: usize> DrainSource for RingSource<SLOTS, SLOT> {
232    type Item = [u8];
233
234    fn drain_ready(&mut self, visitor: &mut dyn FnMut(&[u8]) -> ControlFlow<()>) -> DrainState {
235        while self.count > 0 {
236            let slot = self.read % SLOTS;
237            // zero-copy: borrow straight into the arena; valid for the visitor call.
238            let view: &[u8] = &self.arena[slot][..self.lengths[slot]];
239            self.read = self.read.wrapping_add(1);
240            self.count -= 1;
241            if visitor(view).is_break() {
242                return DrainState::More;
243            }
244        }
245        if self.closed {
246            DrainState::Drained
247        } else {
248            DrainState::More
249        }
250    }
251}
252
253// direct impl, not a wrapper: `RingSource` already IS the byte-stream state
254// (arena + read cursor), so it composes `proxima_core::io::AsyncRead`
255// straight, per the LOCKED part-source-sink-design ruling (no blanket
256// unifying `Pipe`/streaming io — `docs/proxima-pipe/part-source-sink-design.md`).
257// This is the FLOOR form (canonical at no_std/no-alloc, where
258// `futures::io` cannot compile at all — see `proxima_core::io`'s module
259// doc); the std-tier sibling immediately below forwards to it.
260#[cfg(feature = "io-async")]
261impl<const SLOTS: usize, const SLOT: usize> proxima_core::io::AsyncRead
262    for RingSource<SLOTS, SLOT>
263{
264    // a read never truly fails here — the worst case is a short read (normal
265    // `AsyncRead` behaviour), so there is no error to carry.
266    type Error = core::convert::Infallible;
267
268    /// Poll-mode caveat (`docs/pipe-to-metal/edges.md`'s reshape ruling): this
269    /// ring has no waker registration, so an empty-but-open ring returns
270    /// `Pending` WITHOUT arming a wake — busy-poll, not wake-driven, matching
271    /// every other T0 source in this crate ([`crate::pipe::FanIn`],
272    /// [`DrainFanIn`]).
273    fn poll_read(
274        self: core::pin::Pin<&mut Self>,
275        _cx: &mut core::task::Context<'_>,
276        buf: &mut [u8],
277    ) -> core::task::Poll<Result<usize, Self::Error>> {
278        let this = self.get_mut();
279        if this.count == 0 {
280            return if this.closed {
281                core::task::Poll::Ready(Ok(0))
282            } else {
283                core::task::Poll::Pending
284            };
285        }
286        let slot = this.read % SLOTS;
287        let frame = &this.arena[slot][this.frame_offset..this.lengths[slot]];
288        let take = frame.len().min(buf.len());
289        buf[..take].copy_from_slice(&frame[..take]);
290        this.frame_offset += take;
291        if this.frame_offset == this.lengths[slot] {
292            this.frame_offset = 0;
293            this.read = this.read.wrapping_add(1);
294            this.count -= 1;
295        }
296        core::task::Poll::Ready(Ok(take))
297    }
298}
299
300// std-tier ecosystem bridge: `futures::io::AsyncRead` is the workspace's
301// canonical std-tier trait (`prime::os::net::TcpStream` implements it, and
302// every real std transport binds it — see `proxima_core::io`'s own module
303// doc for the full tier rule), so a std caller holding a `RingSource`
304// reaches for THIS impl, not the floor one above. No new logic: it forwards
305// straight into the already-tested floor `poll_read` (the `Infallible`
306// error can never fire, so the conversion is an exhaustive match, not a
307// guess) — one ring, one read loop, two trait doors for two tiers.
308#[cfg(all(feature = "io-async", feature = "std"))]
309impl<const SLOTS: usize, const SLOT: usize> futures::io::AsyncRead for RingSource<SLOTS, SLOT> {
310    fn poll_read(
311        self: core::pin::Pin<&mut Self>,
312        cx: &mut core::task::Context<'_>,
313        buf: &mut [u8],
314    ) -> core::task::Poll<std::io::Result<usize>> {
315        match <Self as proxima_core::io::AsyncRead>::poll_read(self, cx, buf) {
316            core::task::Poll::Ready(Ok(count)) => core::task::Poll::Ready(Ok(count)),
317            core::task::Poll::Ready(Err(never)) => match never {},
318            core::task::Poll::Pending => core::task::Poll::Pending,
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    #![allow(clippy::unwrap_used, clippy::expect_used)]
326
327    use super::*;
328
329    // OTLP-shaped frames: tag 0x0a (field 1, length-delimited) + len + body.
330    fn ring<const SLOTS: usize, const SLOT: usize>(frames: &[&[u8]]) -> RingSource<SLOTS, SLOT> {
331        let mut source = RingSource::new();
332        for frame in frames {
333            assert!(source.push(frame), "test ring overflow");
334        }
335        source.close();
336        source
337    }
338
339    #[test]
340    fn merges_two_rings_round_robin_zero_copy() {
341        let core0 = ring::<4, 8>(&[&[0x0a, 0x01, 0x41], &[0x0a, 0x01, 0x42]]);
342        let core1 = ring::<4, 8>(&[&[0x0a, 0x01, 0x43]]);
343        let mut fan = DrainFanIn::new([core0, core1]);
344
345        // collect the payload byte of each frame (in-place read, no owned copy
346        // beyond the single byte we pull for the assertion).
347        let mut got = [0u8; 8];
348        let mut count = 0;
349        let state = fan.drain_each(|frame: &[u8]| {
350            got[count] = frame[2];
351            count += 1;
352            ControlFlow::Continue(())
353        });
354
355        assert_eq!(state, DrainState::Drained);
356        // source-at-a-time burst drain from cursor 0: core0 fully (A,B) then core1 (C).
357        assert_eq!(&got[..count], b"ABC");
358    }
359
360    #[test]
361    fn visitor_break_is_backpressure_not_drain() {
362        let core0 = ring::<4, 8>(&[&[1], &[2]]);
363        let core1 = ring::<4, 8>(&[&[3]]);
364        let mut fan = DrainFanIn::new([core0, core1]);
365        let mut seen = 0;
366        let state = fan.drain_each(|_frame: &[u8]| {
367            seen += 1;
368            ControlFlow::Break(())
369        });
370        assert_eq!(state, DrainState::More, "break = source not exhausted");
371        assert_eq!(seen, 1, "stopped after the first item");
372        assert_eq!(fan.live_count(), 2, "both sources still live");
373    }
374
375    #[test]
376    fn open_empty_ring_is_more_closed_empty_is_drained() {
377        let mut open: RingSource<2, 4> = RingSource::new();
378        assert_eq!(
379            open.drain_ready(&mut |_| ControlFlow::Continue(())),
380            DrainState::More,
381            "open empty ring may get more"
382        );
383        open.close();
384        assert_eq!(
385            open.drain_ready(&mut |_| ControlFlow::Continue(())),
386            DrainState::Drained,
387            "closed empty ring is drained"
388        );
389    }
390
391    #[test]
392    fn nested_merge_composes_without_new_types() {
393        let level_a = DrainFanIn::new([ring::<2, 4>(&[&[1]]), ring::<2, 4>(&[&[2]])]);
394        let level_b = DrainFanIn::new([ring::<2, 4>(&[&[3]]), ring::<2, 4>(&[&[4]])]);
395        let mut root = DrainFanIn::new([level_a, level_b]);
396        let mut count = 0;
397        root.drain_each(|_| {
398            count += 1;
399            ControlFlow::Continue(())
400        });
401        assert_eq!(count, 4, "all four leaf frames reach the root visitor");
402    }
403
404    // real captured-shaped HTTP/1 request bytes, split across frames smaller
405    // than the read buffer AND a read buffer smaller than a frame — exercises
406    // both the whole-frame and the partial-frame (`frame_offset`) paths.
407    #[cfg(feature = "io-async")]
408    #[test]
409    fn ring_source_async_read_reassembles_a_captured_http_request() {
410        use core::pin::Pin;
411        use core::task::{Context, Poll, Waker};
412
413        use proxima_core::io::AsyncRead as _;
414
415        const REQUEST_LINE: &[u8] = b"GET /orders?id=42 HTTP/1.1\r\n";
416        const HOST_HEADER: &[u8] = b"Host: api.example.internal\r\n";
417
418        let mut source: RingSource<4, 64> = RingSource::new();
419        assert!(source.push(REQUEST_LINE));
420        assert!(source.push(HOST_HEADER));
421        source.close();
422
423        let waker = Waker::noop();
424        let mut cx = Context::from_waker(waker);
425        let mut collected = [0u8; 128];
426        let mut total = 0;
427        // a 5-byte buffer forces the request line to be delivered across
428        // several `poll_read` calls, proving `frame_offset` resumes correctly.
429        loop {
430            let mut chunk = [0u8; 5];
431            match Pin::new(&mut source).poll_read(&mut cx, &mut chunk) {
432                Poll::Ready(Ok(0)) => break,
433                Poll::Ready(Ok(count)) => {
434                    collected[total..total + count].copy_from_slice(&chunk[..count]);
435                    total += count;
436                }
437                Poll::Ready(Err(never)) => match never {},
438                Poll::Pending => panic!("closed ring with ready frames must not report Pending"),
439            }
440        }
441
442        let mut expected = [0u8; 128];
443        let mut expected_len = 0;
444        expected[..REQUEST_LINE.len()].copy_from_slice(REQUEST_LINE);
445        expected_len += REQUEST_LINE.len();
446        expected[expected_len..expected_len + HOST_HEADER.len()].copy_from_slice(HOST_HEADER);
447        expected_len += HOST_HEADER.len();
448
449        assert_eq!(&collected[..total], &expected[..expected_len]);
450    }
451}