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}