Skip to main content

subetha_cxc/
sens_unified.rs

1//! Unified Sens-O-Matic endpoint: one transport that carries BOTH erasure
2//! codes and switches between them mid-stream on the loss the receiver
3//! already measures and feeds back.
4//!
5//! Sens-O-Matic treats the erasure code as a swappable detail (like a cipher
6//! suite): the sliding-window Random Linear Code ([`crate::sens_rlc`]) and the
7//! block Cauchy Reed-Solomon code ([`crate::udp_bridge`]) deliver every item
8//! in order, differing only in HOW they recover loss. Their operating regimes
9//! are complementary, and the boundary is a measured loss level:
10//!
11//!  - **RLC wins at low-to-moderate loss** - incremental forward recovery from
12//!    the next repair (no block-wait, no retransmit round trip), so it holds a
13//!    low latency tail, and its sliding window carries less overhead than a
14//!    block code until loss is dense.
15//!  - **RS wins at high sustained loss** - a systematic MDS block code recovers
16//!    any `r` erasures per `k + r` shards, the most parity-efficient recovery
17//!    once loss is dense. Critically, RLC's adaptive redundancy hard-caps at
18//!    one repair per source symbol (50% redundancy, `STEP_MIN = 1` in
19//!    [`crate::rlc_control`]), so above the loss its rate law saturates at it
20//!    cannot provision enough and its goodput collapses; RS's `r` has no such
21//!    ceiling (`k + r <= 256`).
22//!
23//! The crossover sits at roughly **22-25% loss** when both codes are provisioned
24//! for the loss level (RLC's flow window sized to the path BDP, RS's parity
25//! provisioned per loss). It is lower on a high-RTT path because RLC's rate-law
26//! margin grows with the round trip and drives the code to its redundancy
27//! ceiling at a lower loss. The loss-driven switch moves UP to RS at the
28//! crossover (~23.5%, `q8 = 60`) and back DOWN to RLC at ~12% (a wide hysteresis
29//! band, so a loss level hovering at the boundary does not flap). A persistent
30//! RLC flow-block escapes to RS on its own, the backstop for a path whose
31//! crossover sits below the threshold, where RLC would stall before the loss
32//! reading crosses it.
33//!
34//! The switch is driven by the FEEDBACK frame's loss byte (`loss_q8`, the
35//! forward loss quantized to a `u8` as `loss * 256`), which both codes' senders
36//! already receive over the control plane. `CodeSwitchController` applies the
37//! threshold with immediate-up / conservative-down hysteresis (the same shape
38//! as [`crate::rlc_control::RlcController`]): it raises protection - switching
39//! to the stronger high-loss code - the instant the loss sustains above the up
40//! threshold, but only relaxes back to RLC after the loss sustains below the
41//! down threshold for `hold` ticks, since dropping the stronger code under a
42//! brief quiet spell risks a recovery gap.
43
44use std::collections::VecDeque;
45use std::io;
46use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
47use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
48use std::sync::{Arc, Mutex};
49use std::thread::JoinHandle;
50use std::time::{Duration, Instant};
51
52use crate::dgram::{new_demux_queue, DemuxQueue, DgramSock};
53use crate::sens_rlc::{SensOMaticRlcReceiver, SensOMaticRlcSender};
54use crate::udp_bridge::{ReliableUdpReceiver, ReliableUdpSender};
55
56/// Which erasure code the unified transport is currently carrying.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum SensCode {
59    /// Sliding-window Random Linear Code (low-to-moderate loss, low latency).
60    Rlc,
61    /// Block Cauchy Reed-Solomon (high sustained loss, parity-efficient).
62    Rs,
63}
64
65/// How the unified transport selects its erasure code.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum CodePolicy {
68    /// Loss-driven with hysteresis. `up_q8` / `down_q8` are forward-loss
69    /// thresholds (quantized `loss * 256`, matching the FEEDBACK frame):
70    /// switch RLC -> RS when loss sustains above `up_q8`, RS -> RLC when it
71    /// sustains below `down_q8`. `up_q8 > down_q8` is the hysteresis band.
72    Auto { up_q8: u8, down_q8: u8 },
73    /// Force the sliding-window RLC code regardless of loss (operator override).
74    ForceRlc,
75    /// Force the block Reed-Solomon code regardless of loss (operator override).
76    ForceRs,
77}
78
79impl CodePolicy {
80    /// The default loss-driven policy, thresholds set from the measured crossover
81    /// with RS provisioned to cover the loss: switch UP to RS at ~15%
82    /// (`q8 = CROSSOVER_LOSS_Q8 = 38`, where RS overtakes RLC on both throughput
83    /// and bounded tail latency) and back DOWN to RLC at ~10% (`q8 = 26`). RLC
84    /// keeps the sub-crossover regime for its lower TTFD / median; the ~5-point
85    /// hysteresis band keeps a loss level hovering at the boundary from flapping
86    /// the code.
87    pub fn default_auto() -> Self {
88        CodePolicy::Auto { up_q8: CROSSOVER_LOSS_Q8, down_q8: 26 }
89    }
90
91    /// The code this policy starts a connection on. Auto and ForceRlc start on
92    /// RLC (the low-latency primary); ForceRs starts on RS.
93    pub fn initial_code(&self) -> SensCode {
94        match self {
95            CodePolicy::ForceRs => SensCode::Rs,
96            CodePolicy::Auto { .. } | CodePolicy::ForceRlc => SensCode::Rlc,
97        }
98    }
99}
100
101/// Loss in q8 (the FEEDBACK frame's `loss * 256`) at the measured crossover
102/// where block-RS overtakes sliding-window RLC: ~15% (38/256). RS provisions
103/// parity to cover the loss (Encoder::set_parity_covering) and then wins both
104/// throughput and bounded tail latency from ~15% up; RLC keeps the low-loss
105/// edge (lower TTFD / median, incremental delivery). The earlier 23.5% pin was
106/// measured against RS capped at r=8 (33% recovery), which understated RS.
107pub const CROSSOVER_LOSS_Q8: u8 = 38;
108
109/// Immediate-up / conservative-down controller that turns a stream of fed-back
110/// `loss_q8` samples into code-switch decisions under a [`CodePolicy`].
111///
112/// Up-switches (to the stronger high-loss RS code) fire the instant the loss
113/// sustains above the up threshold for `up_hold` samples; down-switches (back
114/// to RLC) require `down_hold` sustained-below samples, a longer streak, so a
115/// brief lull does not strip the stronger code while loss is still bursty.
116#[derive(Debug, Clone)]
117pub struct CodeSwitchController {
118    policy: CodePolicy,
119    code: SensCode,
120    up_streak: u32,
121    down_streak: u32,
122    up_hold: u32,
123    down_hold: u32,
124    switches: u64,
125    /// Set when a flow-block ESCAPE (not a loss-threshold up-switch) moved to RS:
126    /// RLC stalled at this loss, so a down-switch back would just stall again and
127    /// flap. The latch suppresses the down-switch after a stall-escape (the loss
128    /// estimate at a stall-loss can sit below the down threshold, which would
129    /// otherwise pull straight back to a code that cannot keep up).
130    escape_latched: bool,
131}
132
133impl CodeSwitchController {
134    /// A controller under `policy`, starting on the policy's initial code.
135    /// `up_hold` consecutive over-threshold samples confirm an up-switch;
136    /// `down_hold` (typically larger) under-threshold samples confirm the
137    /// relax back to RLC.
138    pub fn new(policy: CodePolicy, up_hold: u32, down_hold: u32) -> Self {
139        Self {
140            policy,
141            code: policy.initial_code(),
142            up_streak: 0,
143            down_streak: 0,
144            up_hold: up_hold.max(1),
145            down_hold: down_hold.max(1),
146            switches: 0,
147            escape_latched: false,
148        }
149    }
150
151    /// A controller with sensible default holds: an up-switch confirms in 3
152    /// feedback intervals (loss spiked and held, robust to window noise), a
153    /// down-switch in 8 (loss must stay low a while before dropping the
154    /// stronger code).
155    pub fn with_policy(policy: CodePolicy) -> Self {
156        Self::new(policy, 3, 8)
157    }
158
159    /// The code currently selected.
160    pub fn code(&self) -> SensCode {
161        self.code
162    }
163
164    /// Total confirmed code switches so far (telemetry).
165    pub fn switches(&self) -> u64 {
166        self.switches
167    }
168
169    /// Feed one fed-back forward-loss sample (`loss_q8 = loss * 256`). Returns
170    /// `Some(new_code)` exactly on the sample that confirms a switch, else
171    /// `None`. A forced policy never switches.
172    pub fn observe(&mut self, loss_q8: u8) -> Option<SensCode> {
173        let (up_q8, down_q8) = match self.policy {
174            CodePolicy::ForceRlc | CodePolicy::ForceRs => return None,
175            CodePolicy::Auto { up_q8, down_q8 } => (up_q8, down_q8),
176        };
177        match self.code {
178            SensCode::Rlc => {
179                if loss_q8 >= up_q8 {
180                    self.up_streak += 1;
181                    self.down_streak = 0;
182                    if self.up_streak >= self.up_hold {
183                        self.code = SensCode::Rs;
184                        self.up_streak = 0;
185                        self.switches += 1;
186                        return Some(SensCode::Rs);
187                    }
188                } else {
189                    self.up_streak = 0;
190                }
191            }
192            SensCode::Rs => {
193                if !self.escape_latched && loss_q8 <= down_q8 {
194                    self.down_streak += 1;
195                    self.up_streak = 0;
196                    if self.down_streak >= self.down_hold {
197                        self.code = SensCode::Rlc;
198                        self.down_streak = 0;
199                        self.switches += 1;
200                        return Some(SensCode::Rlc);
201                    }
202                } else {
203                    self.down_streak = 0;
204                }
205            }
206        }
207        None
208    }
209
210    /// Align the controller to `to` for a switch driven OUTSIDE `observe` (the
211    /// flow-block escape), counting it and resetting the hysteresis streaks so the
212    /// band restarts from the new code. Returns whether it switched: a forced
213    /// policy stays put (returns `false`), as does an already-on-`to` controller.
214    pub fn force(&mut self, to: SensCode) -> bool {
215        if matches!(self.policy, CodePolicy::ForceRlc | CodePolicy::ForceRs) {
216            return false;
217        }
218        if self.code != to {
219            self.code = to;
220            self.switches += 1;
221            self.up_streak = 0;
222            self.down_streak = 0;
223            // A stall-escape to RS latches the code: RLC could not keep up at this
224            // loss, so suppress the down-switch that would flap straight back. A
225            // deliberate return to RLC (operator force) re-arms the down direction.
226            self.escape_latched = to == SensCode::Rs;
227            true
228        } else {
229            false
230        }
231    }
232}
233
234// ---------------------------------------------------------------------------
235// CODE_SWITCH control frame + first-byte demux
236// ---------------------------------------------------------------------------
237
238/// CODE_SWITCH control-frame type byte. Disjoint from RS data (1) / control
239/// (4), the RLC frames (10..=14), and QUIC (first byte has 0x40 set), so one
240/// socket demuxes all of them unambiguously by the first wire byte.
241pub const PKT_CODE_SWITCH: u8 = 9;
242
243/// Wire: `[9][boundary u64-le][to_code u8]`. `boundary` is the count of items
244/// the sender has delivered across both codes up to the switch; the receiver
245/// keeps draining the old decoder until its cumulative delivery reaches it,
246/// then activates `to_code`. 10 bytes.
247fn encode_code_switch(boundary: u64, to: SensCode) -> [u8; 10] {
248    let mut v = [0u8; 10];
249    v[0] = PKT_CODE_SWITCH;
250    v[1..9].copy_from_slice(&boundary.to_le_bytes());
251    v[9] = match to {
252        SensCode::Rlc => 0,
253        SensCode::Rs => 1,
254    };
255    v
256}
257
258fn decode_code_switch(buf: &[u8]) -> Option<(u64, SensCode)> {
259    if buf.len() < 10 || buf[0] != PKT_CODE_SWITCH {
260        return None;
261    }
262    let boundary = u64::from_le_bytes(buf[1..9].try_into().ok()?);
263    let to = if buf[9] == 0 { SensCode::Rlc } else { SensCode::Rs };
264    Some((boundary, to))
265}
266
267/// One CODE_SWITCH the demux reader observed (receiver side).
268pub(crate) type SwitchSignal = Arc<Mutex<Option<(u64, SensCode)>>>;
269
270/// Unified raw-loss feedback frame type byte. Disjoint from RS (1 / 4), RLC
271/// (10..=14), CODE_SWITCH (9), and QUIC (first byte 0x40 set).
272pub const PKT_UNIFIED_FB: u8 = 8;
273
274/// Wire: `[8][received u64-le]` - the receiver's cumulative count of forward
275/// data/repair datagrams seen. The sender pairs it with its own sent count to
276/// get the true raw channel loss, independent of either code's recovery.
277fn encode_unified_fb(received: u64) -> [u8; 9] {
278    let mut v = [0u8; 9];
279    v[0] = PKT_UNIFIED_FB;
280    v[1..9].copy_from_slice(&received.to_le_bytes());
281    v
282}
283
284fn decode_unified_fb(buf: &[u8]) -> Option<u64> {
285    if buf.len() < 9 || buf[0] != PKT_UNIFIED_FB {
286        return None;
287    }
288    Some(u64::from_le_bytes(buf[1..9].try_into().ok()?))
289}
290
291/// How often the receiver reports its cumulative received-datagram count.
292const UNIFIED_FB_PERIOD: Duration = Duration::from_millis(50);
293/// Minimum datagrams sent in a sample window before the raw-loss estimate is
294/// trusted (a tiny window is too noisy to switch on).
295const MIN_LOSS_SAMPLE: u64 = 30;
296
297/// Route one inbound Sens datagram (already classified as non-QUIC) to the
298/// matching per-code queue by its first byte, tallying forward data/repair for
299/// the raw-loss numerator and capturing CODE_SWITCH / UNIFIED_FB control. Shared
300/// by the standalone demux reader thread and the one-port QUIC demux socket.
301#[allow(clippy::too_many_arguments)]
302pub(crate) fn route_sens_inbound(
303    data: Vec<u8>,
304    from: SocketAddr,
305    kts: Option<i128>,
306    rlc_q: &DemuxQueue,
307    rs_q: &DemuxQueue,
308    switch_signal: Option<&SwitchSignal>,
309    fb_received: Option<&AtomicU64>,
310    recv_counter: Option<&AtomicU64>,
311    hs_q: Option<&DemuxQueue>,
312) {
313    let b0 = data.first().copied().unwrap_or(0);
314    if let Some(c) = recv_counter
315        && (b0 == 1 || b0 == 10 || b0 == 11)
316    {
317        c.fetch_add(1, Ordering::Relaxed);
318    }
319    if b0 == 1 || b0 == 4 {
320        rs_q.lock().unwrap().push_back((data, from, kts));
321    } else if (10..=14).contains(&b0)
322        || b0 == crate::sens_rlc::PKT_RLC_PATH_CHALLENGE
323        || b0 == crate::sens_rlc::PKT_RLC_PATH_RESPONSE
324    {
325        // The RLC data range plus the two path-validation frames. Named
326        // rather than folded into the range, which would swallow the crypto
327        // types the next arm routes to the handshake driver.
328        rlc_q.lock().unwrap().push_back((data, from, kts));
329    } else if (b0 == 15 || b0 == 16)
330        && let Some(hq) = hs_q
331    {
332        // PKT_RLC_CRYPTO (15) / PKT_RLC_CRYPTO_ACK (16): the one-port Sens TLS
333        // handshake. The standalone path completes its handshake before the demux
334        // reader starts, so it passes `None` and these never arrive there; the
335        // one-port path routes them to the handshake driver's queue.
336        hq.lock().unwrap().push_back((data, from, kts));
337    } else if b0 == PKT_UNIFIED_FB
338        && let (Some(fb), Some(v)) = (fb_received, decode_unified_fb(&data))
339    {
340        fb.store(v, Ordering::Relaxed);
341    } else if b0 == PKT_CODE_SWITCH
342        && let (Some(sig), Some(p)) = (switch_signal, decode_code_switch(&data))
343    {
344        *sig.lock().unwrap() = Some(p);
345    }
346}
347
348/// splitmix64 step: a cheap, seedable PRNG for the demux loss injector.
349fn next_rand(state: &mut u64) -> u64 {
350    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
351    let mut z = *state;
352    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
353    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
354    z ^ (z >> 31)
355}
356
357/// Spawn the demux reader: read the one real socket and route each datagram to
358/// the matching code's queue by its first byte. The classification is a single
359/// byte compare per datagram (the hot path stays branch-light; the per-code
360/// decoders carry their own GF(256) SIMD). A `switch_signal` (receiver side)
361/// captures CODE_SWITCH frames; on the sender side it is `None` and any stray
362/// CODE_SWITCH is dropped.
363#[allow(clippy::too_many_arguments)]
364fn spawn_demux(
365    sock: UdpSocket,
366    rlc_q: DemuxQueue,
367    rs_q: DemuxQueue,
368    switch_signal: Option<SwitchSignal>,
369    recv_counter: Option<Arc<AtomicU64>>,
370    fb_received: Option<Arc<AtomicU64>>,
371    loss_pct: u32,
372    seed: u64,
373    stop: Arc<AtomicBool>,
374    stats: Option<Arc<[AtomicU64; 5]>>,
375) -> JoinHandle<()> {
376    std::thread::spawn(move || {
377        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
378        if let Some(s) = &stats {
379            s[4].store(Arc::as_ptr(&rlc_q) as usize as u64, Ordering::Relaxed);
380        }
381        let mut buf = vec![0u8; 2048];
382        let mut last_from: Option<SocketAddr> = None;
383        let mut last_fb = Instant::now();
384        let mut rng = seed;
385        while !stop.load(Ordering::Relaxed) {
386            if let Some(s) = &stats {
387                s[0].fetch_add(1, Ordering::Relaxed);
388            }
389            match crate::dgram::udp_recv_with_kts(&sock, &mut buf) {
390                Ok((n, from, kts)) if n > 0 => {
391                    let b0 = buf[0];
392                    if let Some(s) = &stats {
393                        s[1].fetch_add(1, Ordering::Relaxed);
394                        if (10..=14).contains(&b0)
395                            || b0 == crate::sens_rlc::PKT_RLC_PATH_CHALLENGE
396                            || b0 == crate::sens_rlc::PKT_RLC_PATH_RESPONSE
397                        {
398                            s[3].fetch_add(1, Ordering::Relaxed);
399                        }
400                    }
401                    last_from = Some(from);
402                    // A path challenge is a verbatim echo needing no session
403                    // state, so it is answered here at wire latency and kept
404                    // out of the queue: admission completes in one round trip
405                    // whatever the pump's cadence, and a challenge burst
406                    // cannot pile ahead of control frames in FIFO order.
407                    if b0 == crate::sens_rlc::PKT_RLC_PATH_CHALLENGE
408                        && n >= crate::sens_rlc::PATH_FRAME_LEN
409                    {
410                        let mut resp = Vec::with_capacity(crate::sens_rlc::PATH_FRAME_LEN);
411                        resp.push(crate::sens_rlc::PKT_RLC_PATH_RESPONSE);
412                        resp.extend_from_slice(&buf[1..crate::sens_rlc::PATH_FRAME_LEN]);
413                        sock.send_to(&resp, from).ok();
414                        continue;
415                    }
416                    // Uniform link-loss injection on the forward data/repair
417                    // stream (RS data 1, RLC data 10 / repair 11): drop BEFORE
418                    // counting or routing, so the raw-loss estimate AND the codes
419                    // both see a realistic lossy link. Control frames pass.
420                    let is_fwd = b0 == 1 || b0 == 10 || b0 == 11;
421                    let dropped =
422                        loss_pct > 0 && is_fwd && (next_rand(&mut rng) % 100) < loss_pct as u64;
423                    if !dropped {
424                        // QUIC (0x40 bit set) and unknown first bytes are dropped
425                        // by route_sens_inbound; the one-port quinn demux consumes
426                        // QUIC separately.
427                        route_sens_inbound(
428                            buf[..n].to_vec(),
429                            from,
430                            kts,
431                            &rlc_q,
432                            &rs_q,
433                            switch_signal.as_ref(),
434                            fb_received.as_deref(),
435                            recv_counter.as_deref(),
436                            // Standalone path: the handshake completed before this
437                            // reader started, so no crypto frames arrive here.
438                            None,
439                        );
440                    }
441                }
442                Ok(_) => {}
443                Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
444                    if let Some(s) = &stats {
445                        s[2].fetch_add(1, Ordering::Relaxed);
446                    }
447                    std::thread::sleep(Duration::from_micros(100));
448                }
449                Err(e) if e.kind() == io::ErrorKind::TimedOut => {}
450                Err(_) => std::thread::sleep(Duration::from_micros(200)),
451            }
452            // Receiver: report the cumulative received-datagram count back so
453            // the sender derives the true raw channel loss (sent vs received),
454            // which neither code's post-recovery feedback reveals.
455            if let (Some(c), Some(dst)) = (&recv_counter, last_from)
456                && last_fb.elapsed() >= UNIFIED_FB_PERIOD
457            {
458                last_fb = Instant::now();
459                let frame = encode_unified_fb(c.load(Ordering::Relaxed));
460                sock.send_to(&frame, dst).ok();
461            }
462        }
463        }));
464        if let Err(p) = r {
465            let msg = p
466                .downcast_ref::<&str>()
467                .map(|s| s.to_string())
468                .or_else(|| p.downcast_ref::<String>().cloned())
469                .unwrap_or_else(|| "non-string panic payload".to_string());
470            eprintln!("subetha: demux reader panicked: {msg}");
471        }
472    })
473}
474
475/// How often the sender samples the fed-back loss and asks the controller for a
476/// switch. Time-based (not per-item) so the controller's hold counts track the
477/// receiver's ~10ms feedback cadence rather than the item rate.
478const SWITCH_SAMPLE_PERIOD: Duration = Duration::from_millis(50);
479/// Warmup before the switch is evaluated: the in-flight window ramps from 0 to
480/// the flow window at connection start, and that growth reads as loss; wait for
481/// it to stabilize so the ramp does not trip a spurious switch.
482const SWITCH_WARMUP: Duration = Duration::from_millis(1000);
483/// Feedback windows accumulated AFTER the warmup before the loss estimate is
484/// trusted to move the code. The decaying accumulator is cold at warmup-end (its
485/// first window's raw ratio dominates), so a start-of-stream retransmit burst
486/// reads as a spike that crosses the up threshold and flaps the code. Holding the
487/// switch until a few windows have decayed in lets the estimate mature first.
488const MIN_ACCUM_WINDOWS: u32 = 6;
489/// Drain deadline for a code handover (the in-flight tail of the old code must
490/// be delivered before the new code starts, for in-order delivery).
491const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
492/// How long RLC's DELIVERY FRONTIER may stay stuck (no item delivered while the
493/// send window is full) before the transport gives up on RLC and migrates to RS.
494/// This is the genuine-deadlock backstop: a frontier that does not advance for
495/// this long means RLC cannot decode the loss it is seeing (extreme loss past its
496/// redundancy ceiling), which the loss-driven `maybe_switch` cannot catch because
497/// a stalled sender produces no fresh loss sample. It is measured against frontier
498/// progress (the send loop resets the timer whenever a delivery lands), so a
499/// recoverable hard gap at sub-ceiling loss does NOT trip it - only a true stall.
500/// Measured against frontier progress, so it fires fast (the stalling unified RLC
501/// needs prompt rescue - a slower value starves it into a multi-second stall).
502const RLC_BLOCK_ESCAPE: Duration = Duration::from_millis(750);
503/// Drain deadline for the flow-block escape specifically: the stuck window's
504/// frontier is retransmitted (over a high-loss link, so each copy may also be
505/// lost) until fully delivered, so it must be generous enough to land every item
506/// before RS takes over (no gap = in-order delivery preserved).
507const ESCAPE_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
508/// Hard cap on the sender-side replay ring (items). The ring normally holds only
509/// the un-acked tail `[acked_through, items_total)` (evicted as RLC confirms
510/// delivery), but at extreme loss that tail can grow; this bounds the memory. If
511/// the un-acked tail ever exceeds the cap, the RLC->RS handover falls back to
512/// draining RLC so no item is dropped. 65536 * symbol covers the worst observed
513/// 30%-loss tail with headroom.
514const SENT_RING_CAP: usize = 65536;
515/// Recycled replay-ring buffers held for reuse. A trimmed (delivered) buffer is
516/// returned here instead of freed, and the next seal reuses it instead of
517/// allocating - so the per-item path does no heap alloc/free in steady state.
518/// Sized to the in-flight working set (a few flow-windows) rather than the full
519/// ring cap: the pool only needs to bridge trim-tail to send-head, and capping it
520/// keeps idle memory bounded when the ring shrinks. At small item sizes (where the
521/// item rate, and thus the alloc churn, is highest) this removes ~190k alloc/free
522/// pairs per second from the hot path.
523const RING_POOL_CAP: usize = 1024;
524/// CODE_SWITCH is a one-off control frame sent on the (drained, quiet) path at
525/// the switch point; send it a few times so a single drop does not strand the
526/// receiver on the old decoder.
527const CODE_SWITCH_REPEATS: usize = 6;
528
529// ---------------------------------------------------------------------------
530// Unified sender
531// ---------------------------------------------------------------------------
532
533/// Background reporter for the one-port path: periodically send the cumulative
534/// received-datagram count to the Sens peer (the raw-loss numerator). The QUIC
535/// demux socket feeds the receiver's queues, so there is no demux thread to do
536/// it; this small thread covers just the feedback send.
537fn spawn_fb_reporter(
538    sock: Arc<UdpSocket>,
539    recv_counter: Arc<AtomicU64>,
540    peer: Arc<Mutex<Option<SocketAddr>>>,
541    stop: Arc<AtomicBool>,
542) -> JoinHandle<()> {
543    std::thread::spawn(move || {
544        while !stop.load(Ordering::Relaxed) {
545            std::thread::sleep(UNIFIED_FB_PERIOD);
546            if let Some(dst) = *peer.lock().unwrap() {
547                let frame = encode_unified_fb(recv_counter.load(Ordering::Relaxed));
548                sock.send_to(&frame, dst).ok();
549            }
550        }
551    })
552}
553
554/// Construction parameters shared by the unified sender and receiver.
555#[derive(Debug, Clone, Copy)]
556pub struct UnifiedConfig {
557    /// Erasure-code selection policy (loss-driven Auto, or a forced code).
558    pub policy: CodePolicy,
559    /// Item / symbol size in bytes (matches the application's record size).
560    pub symbol_len: usize,
561    /// Reed-Solomon block geometry: `k` data shards.
562    pub k: usize,
563    /// Reed-Solomon base parity shards `r` (the receiver provisions per loss).
564    pub r: usize,
565    /// RLC sender flow window (outstanding source symbols); 0 = transport
566    /// default. Size it to the path BDP so RLC fills the pipe (the fair-A/B
567    /// config; the default caps RLC ~2x below its capability on a high-BDP path).
568    pub rlc_flow_window: u32,
569    /// Receiver-side diagnostic loss injection (percent, 0 = off) applied to
570    /// BOTH decoders, with `seed` for reproducibility. Drives the loss-based
571    /// switch without a real lossy link.
572    pub debug_loss: u32,
573    /// Seed for the reproducible `debug_loss` drop sequence.
574    pub seed: u64,
575    /// RLC repair cadence: one repair every `rlc_step` source symbols (redundancy
576    /// `1/(rlc_step+1)`). The starting value; the adaptive controller retunes it
577    /// per measured loss unless `rlc_static` pins it.
578    pub rlc_step: u16,
579    /// Pin the RLC coding parameters (disable the adaptive controller), holding a
580    /// fixed code rate instead of letting the sensing plane retune window / step /
581    /// density. The adaptive controller's disable-on-clean state drops coding
582    /// entirely on a quiet assessment and then pays an ARQ round trip on the next
583    /// loss; pinning trades that latency risk for a constant redundancy.
584    pub rlc_static: bool,
585}
586
587impl UnifiedConfig {
588    /// Defaults: loss-driven Auto policy, MTU-sized items, RS (8, 2), RLC flow
589    /// window sized for a filled BDP, no injected loss.
590    pub fn new(symbol_len: usize) -> Self {
591        Self {
592            policy: CodePolicy::default_auto(),
593            symbol_len,
594            k: 8,
595            r: 2,
596            rlc_flow_window: 4096,
597            debug_loss: 0,
598            seed: 1,
599            rlc_step: 4,
600            rlc_static: false,
601        }
602    }
603}
604
605/// Unified Sens-O-Matic sender: carries items over whichever erasure code the
606/// loss-driven controller selects, switching RLC <-> RS mid-stream via a
607/// drain-barrier handover. One real socket is shared by both codes through
608/// per-code demux queues fed by a background reader.
609pub struct UnifiedSensSender {
610    real: Arc<UdpSocket>,
611    peer: SocketAddr,
612    rlc: SensOMaticRlcSender,
613    rs: ReliableUdpSender,
614    active: SensCode,
615    ctrl: CodeSwitchController,
616    /// Cumulative items handed to the application across both codes (the switch
617    /// boundary the receiver keys on).
618    items_total: u64,
619    /// Cumulative entries into `send_item`, counted before any other
620    /// statement in the method.
621    send_item_calls: u64,
622    /// Demux reader loop counters: `[iterations, recv_ok, would_block,
623    /// rlc_frames_routed, rlc_queue_ptr]`, written by the reader
624    /// thread; the last slot holds the pushed-to queue's `Arc` address.
625    demux_stats: Arc<[AtomicU64; 5]>,
626    last_sample: Instant,
627    /// Connection start, for the switch-evaluation warmup.
628    started: Instant,
629    /// Datagrams sent through both codes' demux sockets (raw-loss numerator).
630    sent_counter: Arc<AtomicU64>,
631    /// Receiver's last-reported cumulative received-datagram count.
632    fb_received: Arc<AtomicU64>,
633    /// Sent / received baselines captured at the previous evaluated window.
634    prev_sent: u64,
635    prev_received: u64,
636    /// Size-weighted decaying raw-loss estimate (-1 = uninitialized). Decay the
637    /// lost / sent COUNTS (`loss_acc` / `sent_acc`) and take their ratio, rather
638    /// than EWMA-ing per-window ratios: a small feedback window with one drop
639    /// reads a spuriously high ratio, and an equal-weight EWMA of ratios over-
640    /// weights it, inflating the estimate at low loss (3% read as ~11%). Weighting
641    /// by datagram count makes the estimate track the true channel loss.
642    ewma_loss: f64,
643    /// Decaying sums of lost and sent forward datagrams (the size-weighted
644    /// estimate's numerator / denominator); their ratio is `ewma_loss`.
645    loss_acc: f64,
646    sent_acc: f64,
647    /// Feedback windows accumulated since the warmup ended. The switch is gated on
648    /// this reaching `MIN_ACCUM_WINDOWS` so a cold accumulator cannot flap the code.
649    post_warm_windows: u32,
650    /// Recently-sent item payloads, kept so a code switch can RESEND the un-acked
651    /// tail over the new code instead of slowly draining the old one. Holds the
652    /// global index range `[ring_base, items_total)`; the front is evicted once
653    /// RLC confirms delivery (its `acked_through`) and is hard-capped so a stalled
654    /// receiver cannot grow it without bound. This is the sender-side replay ring.
655    sent_ring: VecDeque<Vec<u8>>,
656    /// Global index of `sent_ring[0]` (the oldest retained item).
657    ring_base: u64,
658    /// Recycled wire-payload buffers (capacity retained, length reset). Trimmed
659    /// ring buffers land here; the next seal pops one instead of allocating.
660    ring_pool: Vec<Vec<u8>>,
661    /// Unified AEAD record layer (TLS feature). When set, every item payload is
662    /// sealed before it enters the replay ring and goes to either code, so the
663    /// RLC<->RS switch is crypto-transparent and the wire is confidential. The
664    /// seal packet number is the item's global index (sealed once, in order), so
665    /// a resend reuses it and the receiver opens by index.
666    #[cfg(feature = "tls")]
667    crypto: Option<crate::rlc_crypto::CryptoState>,
668    stop: Arc<AtomicBool>,
669    demux: Option<JoinHandle<()>>,
670}
671
672impl UnifiedSensSender {
673    /// Bind a local socket, connect to `peer`, and bring up both codes sharing
674    /// it. Starts on the policy's initial code (RLC for Auto / ForceRlc).
675    pub fn connect<A: ToSocketAddrs>(local: A, peer: SocketAddr, cfg: UnifiedConfig) -> io::Result<Self> {
676        let udp = UdpSocket::bind(local)?;
677        udp.set_nonblocking(true)?;
678        Self::assemble(udp, peer, cfg, 0)
679    }
680
681    /// Like [`connect`](Self::connect) but runs a TLS 1.3 handshake to `peer`
682    /// first and AEAD-seals every item: the auto-switching transport made
683    /// confidential for an untrusted WAN. The handshake completes before the
684    /// demux reader takes the socket, so its frames never reach the data path.
685    #[cfg(feature = "tls")]
686    pub fn connect_tls<A: ToSocketAddrs>(
687        local: A,
688        peer: SocketAddr,
689        cfg: UnifiedConfig,
690        tls: std::sync::Arc<rustls::ClientConfig>,
691    ) -> io::Result<Self> {
692        let udp = UdpSocket::bind(local)?;
693        udp.set_nonblocking(true)?;
694        let mut cs = crate::rlc_crypto::CryptoState::new_client(tls)
695            .map_err(io::Error::other)?;
696        let hs = DgramSock::from_udp(udp.try_clone()?);
697        crate::sens_rlc::drive_handshake(&hs, Some(peer), &mut cs, true)?;
698        let mut s = Self::assemble(udp, peer, cfg, crate::rlc_crypto::TAG_LEN)?;
699        s.crypto = Some(cs);
700        Ok(s)
701    }
702
703    /// Build the sender over an already-bound (and, for TLS, already-handshaked)
704    /// socket: bring up both codes sharing it and spawn the demux reader.
705    fn assemble(
706        udp: UdpSocket,
707        peer: SocketAddr,
708        cfg: UnifiedConfig,
709        seal_overhead: usize,
710    ) -> io::Result<Self> {
711        // Both codes carry the wire payload, which is the item plus the AEAD tag
712        // when TLS is on; size their symbols for the sealed width so pack_symbol
713        // and the RS shard split never overflow.
714        let wire_sym = cfg.symbol_len + seal_overhead;
715        // Left UNCONNECTED: the per-code demux sockets send via send_to(peer),
716        // and send_to on a connected socket is rejected on Windows. The demux
717        // reader still only ever hears from `peer` on this private socket.
718        // A clone for the demux thread: UdpSocket is Send, DgramSock is not
719        // (its io_uring variant is not Send), so the thread holds the raw socket.
720        let thread_sock = udp.try_clone()?;
721        thread_sock.set_nonblocking(true)?;
722        let real = Arc::new(udp);
723        let rlc_q = new_demux_queue();
724        let rs_q = new_demux_queue();
725        let sent_counter = Arc::new(AtomicU64::new(0));
726        let fb_received = Arc::new(AtomicU64::new(0));
727
728        let mut rlc = SensOMaticRlcSender::bind("0.0.0.0:0", peer, 32, cfg.rlc_step as usize, 15, wire_sym)?;
729        if cfg.rlc_flow_window > 0 {
730            rlc = rlc.with_flow_window(cfg.rlc_flow_window);
731        }
732        if cfg.rlc_static {
733            rlc = rlc.with_static_params();
734        } else {
735            // The RLC leg is the latency-priority code (the switch hands bulk /
736            // high-loss traffic to block-RS). Keep a light FEC floor on at all
737            // times so an isolated loss recovers in-window instead of falling to
738            // an ARQ round trip that head-of-line-stalls the in-order stream.
739            rlc = rlc.with_latency_priority();
740        }
741        let rlc_sock = DgramSock::demux_counted(
742            Arc::clone(&real),
743            Arc::clone(&rlc_q),
744            Arc::clone(&sent_counter),
745        );
746        rlc_sock.connect(peer).ok();
747        rlc.set_sock(rlc_sock);
748
749        let mut rs = ReliableUdpSender::bind("0.0.0.0:0", peer, cfg.k, cfg.r, wire_sym)?;
750        let rs_sock = DgramSock::demux_counted(
751            Arc::clone(&real),
752            Arc::clone(&rs_q),
753            Arc::clone(&sent_counter),
754        );
755        rs_sock.connect(peer).ok();
756        rs.set_sock(rs_sock);
757
758        let stop = Arc::new(AtomicBool::new(false));
759        let demux_stats: Arc<[AtomicU64; 5]> = Arc::new(std::array::from_fn(|_| AtomicU64::new(0)));
760        let demux = spawn_demux(
761            thread_sock,
762            rlc_q,
763            rs_q,
764            None,
765            None,
766            Some(Arc::clone(&fb_received)),
767            0,
768            1,
769            Arc::clone(&stop),
770            Some(Arc::clone(&demux_stats)),
771        );
772
773        Ok(Self {
774            real,
775            peer,
776            rlc,
777            rs,
778            demux_stats,
779            active: cfg.policy.initial_code(),
780            ctrl: CodeSwitchController::with_policy(cfg.policy),
781            items_total: 0,
782            send_item_calls: 0,
783            last_sample: Instant::now(),
784            started: Instant::now(),
785            sent_counter,
786            fb_received,
787            prev_sent: 0,
788            prev_received: 0,
789            ewma_loss: -1.0,
790            loss_acc: 0.0,
791            sent_acc: 0.0,
792            post_warm_windows: 0,
793            sent_ring: VecDeque::new(),
794            ring_base: 0,
795            ring_pool: Vec::new(),
796            #[cfg(feature = "tls")]
797            crypto: None,
798            stop,
799            demux: Some(demux),
800        })
801    }
802
803    /// Fill `buf` (cleared, capacity reused) with the wire payload for `item`:
804    /// AEAD-sealed in place (TLS) or the raw bytes. Sealed once, in send order, so
805    /// the packet number equals the item's global index. Reusing a pooled `buf`
806    /// keeps the per-item send path allocation-free in steady state.
807    fn seal_into(&self, item: &[u8], buf: &mut Vec<u8>) -> io::Result<()> {
808        buf.clear();
809        buf.extend_from_slice(item);
810        #[cfg(feature = "tls")]
811        if let Some(cs) = &self.crypto {
812            cs.seal(buf).map_err(io::Error::other)?;
813        }
814        Ok(())
815    }
816
817    /// The code currently transmitting.
818    pub fn active_code(&self) -> SensCode {
819        self.active
820    }
821
822    /// Confirmed code switches so far.
823    pub fn switches(&self) -> u64 {
824        self.ctrl.switches()
825    }
826
827    /// The RLC leg's live coding parameters `(window, step, dt, coding_on)`
828    /// (telemetry: shows what the adaptive controller settled at vs the baseline).
829    pub fn rlc_coding_params(&self) -> (u16, u16, u8, bool) {
830        self.rlc.coding_params()
831    }
832
833    /// Times the RLC leg's coding parameters changed under feedback (telemetry).
834    pub fn rlc_adapt_count(&self) -> u64 {
835        self.rlc.adapt_count()
836    }
837
838    /// The switch controller's current EWMA raw-loss estimate (sent-vs-received
839    /// datagrams), 0.0..1.0, or a negative value before the first sample. This is
840    /// the signal the up/down thresholds compare against, so it shows whether the
841    /// estimate tracks the true channel loss (telemetry).
842    pub fn raw_loss_estimate(&self) -> f64 {
843        self.ewma_loss
844    }
845
846    /// Cumulative (datagrams sent through both codes' demux sockets, receiver's
847    /// last-reported forward-received count). The raw inputs to the loss estimate;
848    /// `(sent - recv) / sent` should equal the channel loss if the counts are
849    /// clean (telemetry to find a sent-side over-count / recv-side under-count).
850    pub fn raw_sent_recv(&self) -> (u64, u64) {
851        (
852            self.sent_counter.load(Ordering::Relaxed),
853            self.fb_received.load(Ordering::Relaxed),
854        )
855    }
856
857    /// The RLC sender's transmit-side probe: `(last_sid,
858    /// wire_datagrams, acked_through, outstanding)`. Splits a stall
859    /// between "items never packed" (`last_sid` frozen), "packed but
860    /// never handed to the socket" (`wire_datagrams` frozen), and
861    /// "handed to the socket but never acknowledged" (`outstanding`
862    /// growing with `acked_through` frozen).
863    pub fn rlc_tx_probe(&self) -> (u32, u64, u32, usize) {
864        self.rlc.tx_probe()
865    }
866
867    /// Routing probe: `(active code, RS unacked blocks, items accepted
868    /// by send_item, send_item entries)`. `send_item_calls` counts
869    /// every entry into `send_item` before any other statement, so
870    /// `send_item_calls > items_total` measures Ok-returning exits
871    /// above the accept point, and `send_item_calls` frozen means the
872    /// method was never invoked on this instance.
873    pub fn route_probe(&self) -> (SensCode, usize, u64, u64) {
874        (self.active, self.rs.pending_len(), self.items_total, self.send_item_calls)
875    }
876
877    /// The RLC sender's control-plane arrivals: `(naks_seen, acks_seen,
878    /// feedback_recv)`, counted as the pump processes each frame.
879    pub fn rlc_ctrl_probe(&self) -> (u64, u64, u64) {
880        self.rlc.ctrl_probe()
881    }
882
883    /// The local address of the shared real socket - the port this
884    /// sender's datagrams leave from and its demux reads.
885    pub fn local_addr(&self) -> io::Result<SocketAddr> {
886        self.real.local_addr()
887    }
888
889    /// Whether the demux reader thread is still running. `false` means
890    /// no inbound frame reaches either code's queue or the feedback
891    /// counter again; a panic report is on stderr.
892    pub fn demux_alive(&self) -> bool {
893        self.demux.as_ref().is_some_and(|h| !h.is_finished())
894    }
895
896    /// Demux reader loop counters: `(iterations, recv_ok, would_block,
897    /// rlc_frames_routed)`. Iterations frozen with the thread alive is
898    /// a reader blocked inside the recv; iterations climbing with
899    /// recv_ok frozen is a socket no datagram reaches; recv_ok climbing
900    /// with routed frozen is a frame the routing arms refuse.
901    pub fn demux_probe(&self) -> (u64, u64, u64, u64) {
902        (
903            self.demux_stats[0].load(Ordering::Relaxed),
904            self.demux_stats[1].load(Ordering::Relaxed),
905            self.demux_stats[2].load(Ordering::Relaxed),
906            self.demux_stats[3].load(Ordering::Relaxed),
907        )
908    }
909
910    /// The push/pop seam across the RLC queue: `(push_side_queue_ptr,
911    /// pop_side)` where `pop_side` is the pump socket's
912    /// `(pop_attempts, pop_yields, queue_ptr, queue_len)`. The two
913    /// pointers differing is a construction fork - the reader pushes a
914    /// queue the pump never reads. `pop_attempts` frozen means the pump
915    /// never polls its socket; `queue_len` growing means frames pile
916    /// unread on one shared queue.
917    pub fn queue_seam_probe(&self) -> (u64, Option<(u64, u64, u64, u64)>) {
918        (
919            self.demux_stats[4].load(Ordering::Relaxed),
920            self.rlc.sock_probe(),
921        )
922    }
923
924    /// Frame types the RLC pump handled outside the counted control
925    /// arms: `(challenges_echoed, default_arm_drops,
926    /// last_dropped_byte)`. Pops that appear in neither ctrl_probe nor
927    /// here were sealed frames skipped under TLS.
928    pub fn rlc_pump_types(&self) -> (u64, u64, u8) {
929        self.rlc.pump_types()
930    }
931
932    /// Send one item over the active code, then periodically sample the fed-back
933    /// loss and switch codes if the controller calls for it. The item is recorded
934    /// in the replay ring so a switch can resend the un-acked tail over the new
935    /// code rather than draining the old one.
936    pub fn send_item(&mut self, item: &[u8]) -> io::Result<()> {
937        self.send_item_calls += 1;
938        // Env-gated entry trace (`SUBETHA_SEND_TRACE=1`): the compiled
939        // artifact reports its own execution on stderr, capped per
940        // instance so a hot sender cannot flood a log.
941        if self.send_item_calls <= 32 {
942            static TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
943            if *TRACE.get_or_init(|| std::env::var_os("SUBETHA_SEND_TRACE").is_some_and(|v| v == "1")) {
944                eprintln!(
945                    "subetha: send_item enter self={:p} len={} calls={}",
946                    self, item.len(), self.send_item_calls,
947                );
948            }
949        }
950        // Seal to the wire payload once (the packet number is this item's global
951        // index); both codes carry it and the replay ring stores it, so a resend
952        // reuses the same packet number and the switch is crypto-transparent. Seal
953        // into a recycled buffer so the hot path does no per-item heap alloc.
954        let mut payload = self.ring_pool.pop().unwrap_or_default();
955        self.seal_into(item, &mut payload)?;
956        match self.active {
957            SensCode::Rlc => {
958                // Own RLC's flow-window wait here (via the non-blocking
959                // try_send_item) instead of letting rlc.send_item block out of
960                // sight: when the window will not clear, RLC cannot decode the
961                // loss it is seeing (extreme loss past its redundancy ceiling), so
962                // a persistent block IS the trigger to migrate to RS. The loss-
963                // driven maybe_switch cannot catch this - a stalled sender emits no
964                // fresh loss sample, and the stall arrives inside the startup
965                // warmup. The handover resends the un-acked tail over RS (from the
966                // replay ring), so no slow RLC drain is needed.
967                // Progress-aware deadlock detection: escape only when RLC's
968                // delivery frontier is STUCK for RLC_BLOCK_ESCAPE, not merely when
969                // a single send flow-blocks while RLC is still delivering (slow but
970                // recovering). A blocked-but-advancing frontier is RLC working
971                // through loss at its own pace - that is the loss-threshold's job to
972                // switch on, not the deadlock backstop's; escaping there flaps the
973                // code (escape to RS, then the accurate loss estimate, being below
974                // the down threshold, switches straight back).
975                let mut escape_start = Instant::now();
976                let mut last_acked = self.rlc.acked_through();
977                loop {
978                    if self.rlc.try_send_item(&payload)? {
979                        break;
980                    }
981                    self.rlc.pump_once()?;
982                    let acked_now = self.rlc.acked_through();
983                    if acked_now > last_acked {
984                        last_acked = acked_now;
985                        escape_start = Instant::now();
986                    }
987                    if escape_start.elapsed() > RLC_BLOCK_ESCAPE {
988                        if self.ctrl.force(SensCode::Rs) {
989                            // Resend the un-acked tail [acked_through, items_total)
990                            // over RS, then this item.
991                            self.switch_rlc_to_rs()?;
992                            self.send_via_rs(&payload)?;
993                        } else {
994                            // A forced-RLC policy: honor it with the blocking send.
995                            self.rlc.send_item(&payload)?;
996                        }
997                        break;
998                    }
999                    std::thread::sleep(Duration::from_micros(50));
1000                }
1001            }
1002            SensCode::Rs => {
1003                self.send_via_rs(&payload)?;
1004            }
1005        }
1006        // Record in the replay ring (global index = items_total), advance, and
1007        // trim the delivered front + hard-cap.
1008        self.sent_ring.push_back(payload);
1009        self.items_total += 1;
1010        self.trim_sent_ring();
1011        if self.last_sample.elapsed() >= SWITCH_SAMPLE_PERIOD {
1012            self.last_sample = Instant::now();
1013            self.maybe_switch()?;
1014        }
1015        Ok(())
1016    }
1017
1018    /// Evict replay-ring items RLC has confirmed delivered (below its cumulative
1019    /// frontier) and hard-cap the ring length. Preserves the invariant
1020    /// `items_total == ring_base + sent_ring.len()`.
1021    fn trim_sent_ring(&mut self) {
1022        if self.active == SensCode::Rlc {
1023            let frontier = self.rlc.acked_through() as u64;
1024            while self.ring_base < frontier && !self.sent_ring.is_empty() {
1025                if let Some(buf) = self.sent_ring.pop_front() {
1026                    self.recycle(buf);
1027                }
1028                self.ring_base += 1;
1029            }
1030        }
1031        while self.sent_ring.len() > SENT_RING_CAP {
1032            if let Some(buf) = self.sent_ring.pop_front() {
1033                self.recycle(buf);
1034            }
1035            self.ring_base += 1;
1036        }
1037    }
1038
1039    /// Return a trimmed wire-payload buffer to the pool for reuse by the next
1040    /// seal, capped so a shrinking ring does not pin idle memory.
1041    fn recycle(&mut self, buf: Vec<u8>) {
1042        if self.ring_pool.len() < RING_POOL_CAP {
1043            self.ring_pool.push(buf);
1044        }
1045    }
1046
1047    /// RLC -> RS handover by RESEND (not drain): announce the boundary RLC has
1048    /// delivered to, switch, and resend the un-acked tail `[boundary,
1049    /// items_total)` over RS from the replay ring, in order. RS is reliable, so
1050    /// it recovers the tail fast at any loss - no waiting on RLC's slow frontier
1051    /// recovery. Falls back to draining RLC only if the cap evicted un-acked
1052    /// items (so nothing is ever dropped).
1053    fn switch_rlc_to_rs(&mut self) -> io::Result<()> {
1054        let boundary = self.rlc.acked_through() as u64;
1055        let frame = encode_code_switch(boundary, SensCode::Rs);
1056        for _ in 0..CODE_SWITCH_REPEATS {
1057            self.real.send_to(&frame, self.peer).ok();
1058            std::thread::sleep(Duration::from_millis(2));
1059        }
1060        self.active = SensCode::Rs;
1061        if boundary >= self.ring_base {
1062            let start = (boundary - self.ring_base) as usize;
1063            let end = self.sent_ring.len();
1064            for i in start..end {
1065                let item = self.sent_ring[i].clone();
1066                self.send_via_rs(&item)?;
1067            }
1068        } else {
1069            // Un-acked tail underflowed the cap: drain RLC so nothing is lost.
1070            let target = self.rlc.next_source_id();
1071            self.rlc.drain_until_acked(target, ESCAPE_DRAIN_TIMEOUT)?;
1072        }
1073        Ok(())
1074    }
1075
1076    /// Send one item over RS, waiting out RS flow-control back-pressure (RS's ARQ
1077    /// guarantees the window clears, so this wait is bounded by delivery, not by a
1078    /// decode cliff). Shared by the RS steady state and the RLC escape handover.
1079    fn send_via_rs(&mut self, item: &[u8]) -> io::Result<()> {
1080        while self.rs.flow_blocked() {
1081            self.rs.pump_feedback().ok();
1082            if self.rs.flow_blocked() {
1083                std::thread::sleep(Duration::from_micros(50));
1084            }
1085        }
1086        self.rs.send_item(item)
1087    }
1088
1089    /// Sample the active code's fed-back loss and switch codes if the controller
1090    /// confirms a crossing of the configured thresholds.
1091    fn maybe_switch(&mut self) -> io::Result<()> {
1092        // The raw channel loss from sent-vs-received datagram counts: code-
1093        // agnostic, so it does not collapse when the active code recovers the
1094        // loss (which is what made the active code's own feedback flap).
1095        let sent = self.sent_counter.load(Ordering::Relaxed);
1096        let recv = self.fb_received.load(Ordering::Relaxed);
1097        if recv == 0 {
1098            return Ok(()); // no raw-loss report from the receiver yet
1099        }
1100        // Warmup: the in-flight window ramps 0 -> flow window at start, and that
1101        // growth reads as loss; track the baseline but do not evaluate until it
1102        // stabilizes, so the ramp does not trip a spurious switch.
1103        if self.started.elapsed() < SWITCH_WARMUP {
1104            self.prev_sent = sent;
1105            self.prev_received = recv;
1106            return Ok(());
1107        }
1108        if self.prev_received == 0 {
1109            // First report: set the baseline, evaluate from the next window.
1110            self.prev_sent = sent;
1111            self.prev_received = recv;
1112            return Ok(());
1113        }
1114        // Align the window to FEEDBACK arrivals: skip ticks with no new report,
1115        // so a tick landing between reports does not read a spurious 100% loss
1116        // (sent advanced, received not yet updated this window).
1117        if recv <= self.prev_received {
1118            return Ok(());
1119        }
1120        let sent_d = sent.saturating_sub(self.prev_sent);
1121        if sent_d < MIN_LOSS_SAMPLE {
1122            return Ok(()); // window too small to trust; keep accumulating
1123        }
1124        let recv_d = recv.saturating_sub(self.prev_received);
1125        self.prev_sent = sent;
1126        self.prev_received = recv;
1127        let lost_d = sent_d.saturating_sub(recv_d) as f64;
1128        // Size-weighted decaying loss: decay the lost / sent COUNTS and take their
1129        // ratio, NOT an equal-weight EWMA of per-window ratios. A small feedback
1130        // window with one drop reads a spuriously high ratio, and equal-weight
1131        // averaging over-read low loss ~3.5x (3% measured as ~11%); weighting by
1132        // datagram count makes large windows dominate so the estimate tracks the
1133        // true channel loss. The 0.95 decay (effective window ~20 feedback samples)
1134        // keeps it recent yet smooths the retransmit-burst windows that a tighter
1135        // decay let spike across the up threshold and flap the code.
1136        self.loss_acc = 0.95 * self.loss_acc + lost_d;
1137        self.sent_acc = 0.95 * self.sent_acc + sent_d as f64;
1138        self.ewma_loss = if self.sent_acc > 0.0 {
1139            self.loss_acc / self.sent_acc
1140        } else {
1141            0.0
1142        };
1143        // Gate the switch until the accumulator has matured past its cold start: at
1144        // warmup-end loss_acc/sent_acc are near-empty, so the first post-warmup
1145        // window's raw ratio (a start-of-stream burst) would otherwise dominate the
1146        // estimate and trip a spurious up-switch. Keep accumulating, just do not act
1147        // on it yet.
1148        if self.post_warm_windows < MIN_ACCUM_WINDOWS {
1149            self.post_warm_windows += 1;
1150            return Ok(());
1151        }
1152        let loss_q8 = (self.ewma_loss * 256.0).clamp(0.0, 255.0) as u8;
1153        if let Some(to) = self.ctrl.observe(loss_q8) {
1154            self.do_switch(to)?;
1155        }
1156        Ok(())
1157    }
1158
1159    /// Code handover. RLC -> RS RESENDS the un-acked tail over RS (RS is reliable
1160    /// and fast at any loss, so it never waits on RLC's slow frontier recovery).
1161    /// RS -> RLC drains RS first (RS's ARQ clears its window quickly), then starts
1162    /// RLC from the fully-delivered boundary. In-order delivery holds either way.
1163    fn do_switch(&mut self, to: SensCode) -> io::Result<()> {
1164        match (self.active, to) {
1165            (SensCode::Rlc, SensCode::Rs) => self.switch_rlc_to_rs(),
1166            _ => self.do_switch_with_drain(to, DRAIN_TIMEOUT),
1167        }
1168    }
1169
1170    /// `do_switch` with an explicit drain deadline. The flow-block escape passes a
1171    /// generous one ([`ESCAPE_DRAIN_TIMEOUT`]) because draining a stuck window
1172    /// over a high-loss link (retransmitting its frontier, each copy itself
1173    /// lossy) takes far longer than a healthy handover.
1174    fn do_switch_with_drain(&mut self, to: SensCode, drain_timeout: Duration) -> io::Result<()> {
1175        match self.active {
1176            SensCode::Rlc => {
1177                let target = self.rlc.next_source_id();
1178                self.rlc.drain_until_acked(target, drain_timeout)?;
1179            }
1180            SensCode::Rs => {
1181                self.rs.flush()?;
1182                self.rs.drain_until_acked(drain_timeout)?;
1183            }
1184        }
1185        let frame = encode_code_switch(self.items_total, to);
1186        for _ in 0..CODE_SWITCH_REPEATS {
1187            self.real.send_to(&frame, self.peer).ok();
1188            std::thread::sleep(Duration::from_millis(2));
1189        }
1190        self.active = to;
1191        // Returning to RLC: another code carried [old RLC frontier, items_total),
1192        // so RLC's source-id stream diverged from the global index. Re-base it to
1193        // the global boundary so the resumed stream's source ids equal the global
1194        // item indices the receiver expects (it re-bases in lockstep on the same
1195        // boundary), instead of stalling on holes RLC will never resend or
1196        // replaying its stale pre-switch buffer.
1197        if to == SensCode::Rlc {
1198            self.rlc.skip_to(self.items_total as u32);
1199        }
1200        Ok(())
1201    }
1202
1203    /// Flush and drain the active code so the final items are delivered. Returns
1204    /// whether everything was acked before the deadline.
1205    pub fn finish(&mut self) -> io::Result<bool> {
1206        match self.active {
1207            SensCode::Rlc => {
1208                let target = self.rlc.next_source_id();
1209                self.rlc.drain_until_acked(target, Duration::from_secs(120))
1210            }
1211            SensCode::Rs => {
1212                self.rs.flush()?;
1213                self.rs.drain_until_acked(Duration::from_secs(120))
1214            }
1215        }
1216    }
1217
1218    /// Force the active code to `to` now (operator override), via the same
1219    /// handover an automatic switch uses (RLC->RS resend / RS->RLC drain), and
1220    /// keep the controller in sync so it does not immediately switch back. No-op
1221    /// if already on `to`.
1222    pub fn force_switch(&mut self, to: SensCode) -> io::Result<()> {
1223        if to != self.active {
1224            self.ctrl.force(to);
1225            self.do_switch(to)?;
1226        }
1227        Ok(())
1228    }
1229}
1230
1231impl Drop for UnifiedSensSender {
1232    fn drop(&mut self) {
1233        self.stop.store(true, Ordering::Relaxed);
1234        if let Some(h) = self.demux.take() {
1235            h.join().ok();
1236        }
1237    }
1238}
1239
1240// ---------------------------------------------------------------------------
1241// Unified receiver
1242// ---------------------------------------------------------------------------
1243
1244/// Unified Sens-O-Matic receiver: demuxes both codes off one socket and
1245/// delivers items in order across mid-stream code switches. The sender's
1246/// drain-barrier guarantees the old code is fully delivered before the new code
1247/// starts, so the receiver simply runs the active decoder and switches at the
1248/// announced boundary.
1249pub struct UnifiedSensReceiver {
1250    real: Arc<UdpSocket>,
1251    rlc: SensOMaticRlcReceiver,
1252    rs: ReliableUdpReceiver,
1253    active: SensCode,
1254    switch_signal: SwitchSignal,
1255    pending_switch: Option<(u64, SensCode)>,
1256    delivered_total: u64,
1257    /// Global index of the next item the RS decoder will deliver. RS delivers in
1258    /// its own local order; this maps that to the global stream so the un-acked
1259    /// tail an RLC->RS handover resends over RS can be deduped against what RLC
1260    /// already delivered. Set to the handover boundary on RLC->RS; advances per RS
1261    /// item thereafter.
1262    rs_next_global: u64,
1263    switches: u64,
1264    /// Unified AEAD record layer (TLS feature). When set, each item a decoder
1265    /// delivers is opened with its global index as the packet number before it
1266    /// reaches the application; duplicates (the resend overlap) are skipped before
1267    /// opening, so the packet number always matches the seal. A `OnceLock` shared
1268    /// with the handshake driver: the one-port server completes its handshake on a
1269    /// thread (the QUIC endpoint owns the socket, so the Sens handshake rides the
1270    /// demux queue) and publishes the keys here once; `bind_tls` sets it inline.
1271    #[cfg(feature = "tls")]
1272    crypto: Arc<std::sync::OnceLock<crate::rlc_crypto::CryptoState>>,
1273    /// TLS is expected on this receiver (set by `bind_tls` / `from_shared_tls`):
1274    /// `poll` withholds delivery until `crypto` is published, so a data frame that
1275    /// races ahead of the handshake completion is never opened with absent keys.
1276    #[cfg(feature = "tls")]
1277    expect_tls: bool,
1278    stop: Arc<AtomicBool>,
1279    demux: Option<JoinHandle<()>>,
1280}
1281
1282impl UnifiedSensReceiver {
1283    /// Bind `local` and bring up both decoders sharing it.
1284    pub fn bind<A: ToSocketAddrs>(local: A, cfg: UnifiedConfig) -> io::Result<Self> {
1285        let udp = UdpSocket::bind(local)?;
1286        udp.set_nonblocking(true)?;
1287        Self::assemble(udp, cfg, 0)
1288    }
1289
1290    /// Like [`bind`](Self::bind) but runs a TLS 1.3 server handshake first and
1291    /// AEAD-opens every delivered item: the WAN-confidential counterpart to
1292    /// [`UnifiedSensSender::connect_tls`]. The handshake completes before the
1293    /// demux reader takes the socket.
1294    #[cfg(feature = "tls")]
1295    pub fn bind_tls<A: ToSocketAddrs>(
1296        local: A,
1297        cfg: UnifiedConfig,
1298        tls: std::sync::Arc<rustls::ServerConfig>,
1299    ) -> io::Result<Self> {
1300        let udp = UdpSocket::bind(local)?;
1301        udp.set_nonblocking(true)?;
1302        let mut cs = crate::rlc_crypto::CryptoState::new_server(tls)
1303            .map_err(io::Error::other)?;
1304        let hs = DgramSock::from_udp(udp.try_clone()?);
1305        crate::sens_rlc::drive_handshake(&hs, None, &mut cs, false)?;
1306        let mut s = Self::assemble(udp, cfg, crate::rlc_crypto::TAG_LEN)?;
1307        s.crypto.set(cs).ok();
1308        s.expect_tls = true;
1309        Ok(s)
1310    }
1311
1312    /// Build the receiver over an already-bound (and, for TLS, already-handshaked)
1313    /// socket: bring up both decoders sharing it and spawn the demux reader.
1314    fn assemble(udp: UdpSocket, cfg: UnifiedConfig, seal_overhead: usize) -> io::Result<Self> {
1315        // The decoder must accept the sealed wire width (item + AEAD tag under
1316        // TLS); the RS decoder learns its shard width from the wire header, so
1317        // only the RLC decoder's symbol size needs widening here.
1318        let wire_sym = cfg.symbol_len + seal_overhead;
1319        let thread_sock = udp.try_clone()?;
1320        thread_sock.set_nonblocking(true)?;
1321        let real = Arc::new(udp);
1322        let rlc_q = new_demux_queue();
1323        let rs_q = new_demux_queue();
1324
1325        // No per-code debug loss: the unified path injects loss uniformly at the
1326        // demux (below), modelling a real lossy link AND letting the raw-loss
1327        // estimate see it (a sub-receiver drop would be invisible to the demux
1328        // count).
1329        let mut rlc = SensOMaticRlcReceiver::bind("0.0.0.0:0", wire_sym)?;
1330        rlc.set_sock(DgramSock::demux(Arc::clone(&real), Arc::clone(&rlc_q)));
1331
1332        let mut rs = ReliableUdpReceiver::bind("0.0.0.0:0")?;
1333        rs.set_sock(DgramSock::demux(Arc::clone(&real), Arc::clone(&rs_q)));
1334
1335        let switch_signal: SwitchSignal = Arc::new(Mutex::new(None));
1336        let recv_counter = Arc::new(AtomicU64::new(0));
1337        let stop = Arc::new(AtomicBool::new(false));
1338        let demux = spawn_demux(
1339            thread_sock,
1340            rlc_q,
1341            rs_q,
1342            Some(Arc::clone(&switch_signal)),
1343            Some(recv_counter),
1344            None,
1345            cfg.debug_loss,
1346            cfg.seed,
1347            Arc::clone(&stop),
1348            None,
1349        );
1350
1351        Ok(Self {
1352            real,
1353            rlc,
1354            rs,
1355            active: cfg.policy.initial_code(),
1356            switch_signal,
1357            pending_switch: None,
1358            delivered_total: 0,
1359            rs_next_global: 0,
1360            switches: 0,
1361            #[cfg(feature = "tls")]
1362            crypto: Arc::new(std::sync::OnceLock::new()),
1363            #[cfg(feature = "tls")]
1364            expect_tls: false,
1365            stop,
1366            demux: Some(demux),
1367        })
1368    }
1369
1370    /// Build a receiver fed by an EXTERNAL demux (the one-port QUIC endpoint's
1371    /// socket routes Sens datagrams into `rlc_q` / `rs_q` / `switch_signal` and
1372    /// tallies `recv_counter`). `send_sock` is a clone of the shared socket for
1373    /// control + raw-loss feedback. No demux thread is spawned (the QUIC socket
1374    /// feeds the queues); a small reporter thread sends the feedback to the peer
1375    /// the QUIC socket records in `sens_peer`.
1376    #[allow(clippy::too_many_arguments)]
1377    pub fn from_shared(
1378        send_sock: Arc<UdpSocket>,
1379        rlc_q: DemuxQueue,
1380        rs_q: DemuxQueue,
1381        switch_signal: SwitchSignal,
1382        recv_counter: Arc<AtomicU64>,
1383        sens_peer: Arc<Mutex<Option<SocketAddr>>>,
1384        cfg: UnifiedConfig,
1385        seal_overhead: usize,
1386    ) -> io::Result<Self> {
1387        // The RLC decoder must accept the sealed wire width (item + AEAD tag under
1388        // TLS) so it frames the symbols the sender shipped; the RS decoder learns
1389        // its shard width from the wire header, so only the RLC width needs it.
1390        let mut rlc = SensOMaticRlcReceiver::bind("0.0.0.0:0", cfg.symbol_len + seal_overhead)?;
1391        rlc.set_sock(DgramSock::demux(Arc::clone(&send_sock), rlc_q));
1392        let mut rs = ReliableUdpReceiver::bind("0.0.0.0:0")?;
1393        rs.set_sock(DgramSock::demux(Arc::clone(&send_sock), rs_q));
1394        let stop = Arc::new(AtomicBool::new(false));
1395        let demux = spawn_fb_reporter(Arc::clone(&send_sock), recv_counter, sens_peer, Arc::clone(&stop));
1396        Ok(Self {
1397            real: send_sock,
1398            rlc,
1399            rs,
1400            active: cfg.policy.initial_code(),
1401            switch_signal,
1402            pending_switch: None,
1403            delivered_total: 0,
1404            rs_next_global: 0,
1405            switches: 0,
1406            #[cfg(feature = "tls")]
1407            crypto: Arc::new(std::sync::OnceLock::new()),
1408            #[cfg(feature = "tls")]
1409            expect_tls: false,
1410            stop,
1411            demux: Some(demux),
1412        })
1413    }
1414
1415    /// Like [`from_shared`](Self::from_shared) but runs a TLS 1.3 server handshake
1416    /// over the demux'd `hs_q`. The one-port QUIC endpoint owns the socket, so the
1417    /// Sens handshake cannot own a recv loop; it rides the same demux queue as data
1418    /// (the demux routes `PKT_RLC_CRYPTO` frames into `hs_q`). The handshake runs
1419    /// on a thread and publishes the 1-RTT keys to the shared `crypto` cell once
1420    /// complete; `poll` withholds delivery until then. Returns immediately so the
1421    /// caller can start the QUIC + Sens clients that drive the handshake.
1422    #[cfg(feature = "tls")]
1423    #[allow(clippy::too_many_arguments)]
1424    pub fn from_shared_tls(
1425        send_sock: Arc<UdpSocket>,
1426        rlc_q: DemuxQueue,
1427        rs_q: DemuxQueue,
1428        hs_q: DemuxQueue,
1429        switch_signal: SwitchSignal,
1430        recv_counter: Arc<AtomicU64>,
1431        sens_peer: Arc<Mutex<Option<SocketAddr>>>,
1432        cfg: UnifiedConfig,
1433        tls: std::sync::Arc<rustls::ServerConfig>,
1434    ) -> io::Result<Self> {
1435        let mut s = Self::from_shared(
1436            Arc::clone(&send_sock),
1437            rlc_q,
1438            rs_q,
1439            switch_signal,
1440            recv_counter,
1441            sens_peer,
1442            cfg,
1443            crate::rlc_crypto::TAG_LEN,
1444        )?;
1445        s.expect_tls = true;
1446        let crypto = Arc::clone(&s.crypto);
1447        let stop = Arc::clone(&s.stop);
1448        let hs_sock = DgramSock::demux(send_sock, hs_q);
1449        std::thread::spawn(move || {
1450            let mut cs = match crate::rlc_crypto::CryptoState::new_server(tls) {
1451                Ok(c) => c,
1452                Err(_) => return,
1453            };
1454            // Drive the server handshake over the demux'd queue (peer learned from
1455            // the first flight); publish the keys once the 1-RTT secrets derive.
1456            if !stop.load(Ordering::Relaxed)
1457                && crate::sens_rlc::drive_handshake(&hs_sock, None, &mut cs, false).is_ok()
1458            {
1459                crypto.set(cs).ok();
1460            }
1461        });
1462        Ok(s)
1463    }
1464
1465    /// The decoder currently delivering.
1466    pub fn active_code(&self) -> SensCode {
1467        self.active
1468    }
1469
1470    /// Code switches the receiver has followed.
1471    pub fn switches(&self) -> u64 {
1472        self.switches
1473    }
1474
1475    /// Whether either decoder adopted a replacement session since this was
1476    /// last called, clearing the flag. Edge-triggered: one report per
1477    /// adoption.
1478    pub fn take_session_changed(&mut self) -> bool {
1479        let rlc = self.rlc.take_session_changed();
1480        let rs = self.rs.take_session_changed();
1481        rlc || rs
1482    }
1483
1484    /// `(adopted, challenges_that_went_unanswered)` for replacement
1485    /// The RLC connection ids holding a decode window, in first-seen order.
1486    /// Empty before any peer is seen.
1487    pub fn live_rlc_sessions(&self) -> Vec<u64> {
1488        self.rlc.live_sessions()
1489    }
1490
1491    /// The block-RS session epochs holding a decode window, in first-seen
1492    /// order.
1493    pub fn live_rs_sessions(&self) -> Vec<u32> {
1494        self.rs.live_sessions()
1495    }
1496
1497    /// Peers refused a decode window on either code.
1498    pub fn session_refusals(&self) -> u64 {
1499        self.rlc.session_refusals() + self.rs.session_refusals()
1500    }
1501
1502    /// One RLC session's delivery position: `(delivered_through,
1503    /// highest_seen)`, or `None` for an id with no window.
1504    pub fn rlc_session_frontier(&self, cid: u64) -> Option<(u32, u32)> {
1505        self.rlc.session_frontier(cid)
1506    }
1507
1508    /// One RLC session's control plane: `(naks_sent, acks_sent,
1509    /// sends_skipped, peer_validated)`, or `None` for an id with no
1510    /// window.
1511    pub fn rlc_session_control(&self, cid: u64) -> Option<(u64, u64, u64, bool)> {
1512        self.rlc.session_control(cid)
1513    }
1514
1515    /// The address one RLC session's control sends target, or `None`
1516    /// for an id with no window or no recorded peer.
1517    pub fn rlc_session_peer(&self, cid: u64) -> Option<SocketAddr> {
1518        self.rlc.session_admissions_for(cid)
1519    }
1520
1521    /// Successful path validations, summed over every RLC session.
1522    pub fn rlc_path_validations(&self) -> u64 {
1523        self.rlc.path_validations()
1524    }
1525
1526    /// Path-validation timeouts, summed over every RLC session. A
1527    /// count climbing without bound is a session re-challenging an
1528    /// address that never answers inside the window.
1529    pub fn rlc_path_validation_failures(&self) -> u64 {
1530        self.rlc.path_validation_failures()
1531    }
1532
1533    /// sessions, summed over both codes. A refused forgery raises the
1534    /// second without the first.
1535    pub fn session_adoption_counts(&self) -> (u64, u64) {
1536        let (ra, rf) = self.rlc.session_adoption_counts();
1537        let (sa, sf) = self.rs.session_adoption_counts();
1538        (ra + sa, rf + sf)
1539    }
1540
1541    /// The bound local address.
1542    pub fn local_addr(&self) -> io::Result<SocketAddr> {
1543        self.real.local_addr()
1544    }
1545
1546    /// Recover an item from a delivered wire payload: AEAD-open (TLS) with `pn`
1547    /// the item's global index, or pass the bytes through. A failed open (a
1548    /// tampered datagram) surfaces as an error rather than delivering bad data.
1549    #[cfg_attr(not(feature = "tls"), allow(unused_variables, unused_mut))]
1550    fn open_payload(&self, mut payload: Vec<u8>, pn: u64) -> io::Result<Vec<u8>> {
1551        #[cfg(feature = "tls")]
1552        if let Some(cs) = self.crypto.get() {
1553            let n = cs
1554                .open(pn, &mut payload)
1555                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1556            payload.truncate(n);
1557            return Ok(payload);
1558        }
1559        Ok(payload)
1560    }
1561
1562    /// Drive the active decoder and return the items it delivered this call,
1563    /// each tagged with the identity of the peer that sent it: the RLC
1564    /// connection id, or the block-RS session epoch widened to `u64`.
1565    ///
1566    /// Both codes decode a window per peer. The code-switch layer above them
1567    /// does not: the delivery frontier, the switch boundary and the TLS packet
1568    /// number are per endpoint. A mesh node pins a code and leaves TLS off, or
1569    /// drives [`SensOMaticRlcReceiver`] / [`ReliableUdpReceiver`] directly.
1570    pub fn poll_from(&mut self) -> io::Result<Vec<(u64, Vec<u8>)>> {
1571        self.poll_tagged()
1572    }
1573
1574    /// Drive the active decoder and return the items it delivered this call.
1575    /// Honors a pending CODE_SWITCH once the active decoder has delivered every
1576    /// item up to the announced boundary.
1577    pub fn poll(&mut self) -> io::Result<Vec<Vec<u8>>> {
1578        Ok(self.poll_tagged()?.into_iter().map(|(_, item)| item).collect())
1579    }
1580
1581    /// The one drain both public forms share, carrying each item's peer tag
1582    /// from the decoder that delivered it rather than reconstructing it after.
1583    fn poll_tagged(&mut self) -> io::Result<Vec<(u64, Vec<u8>)>> {
1584        // One-port TLS: the handshake completes asynchronously on a thread (the
1585        // QUIC endpoint owns the socket), so until the keys are published, withhold
1586        // delivery. The decoders keep buffering inbound frames; the peer only sends
1587        // data after ITS handshake finished, so the backlog is at most a few frames
1588        // and they open correctly once the keys land. (bind_tls sets the keys
1589        // inline before returning, so this gate is already clear there.)
1590        #[cfg(feature = "tls")]
1591        if self.expect_tls && self.crypto.get().is_none() {
1592            return Ok(Vec::new());
1593        }
1594        if self.pending_switch.is_none() {
1595            self.pending_switch = self.switch_signal.lock().unwrap().take();
1596        }
1597        let out = match self.active {
1598            SensCode::Rlc => {
1599                // Open each payload with its global index as the packet number.
1600                // The tag rides from the decoder, so an item is attributed to the
1601                // peer that actually sent it rather than to whoever spoke last.
1602                let raw = self.rlc.poll_from()?;
1603                let mut d = Vec::with_capacity(raw.len());
1604                for (cid, payload) in raw {
1605                    let item = self.open_payload(payload, self.delivered_total)?;
1606                    self.delivered_total += 1;
1607                    d.push((cid, item));
1608                }
1609                d
1610            }
1611            SensCode::Rs => {
1612                // RS delivers in its own local order; map each to its global index
1613                // (rs_next_global, advancing per item). After an RLC->RS resend
1614                // handover the leading items overlap what RLC already delivered, so
1615                // drop any whose global index is below the delivery frontier
1616                // (before opening, so the packet number always matches the seal).
1617                //
1618                // The tag is the sending peer's session epoch, widened.
1619                let raw = self.rs.poll_from()?;
1620                let mut d = Vec::with_capacity(raw.len());
1621                for (epoch, payload) in raw {
1622                    if self.rs_next_global >= self.delivered_total {
1623                        let item = self.open_payload(payload, self.rs_next_global)?;
1624                        self.delivered_total += 1;
1625                        d.push((u64::from(epoch), item));
1626                    }
1627                    self.rs_next_global += 1;
1628                }
1629                d
1630            }
1631        };
1632        if let Some((boundary, to)) = self.pending_switch
1633            && self.delivered_total >= boundary
1634        {
1635            // The sender repeats CODE_SWITCH for reliability; only act (and
1636            // count) when the target differs from the active code, so the
1637            // repeats do not inflate the switch tally or re-switch.
1638            if to != self.active {
1639                match to {
1640                    SensCode::Rs => {
1641                        // The RS stream resumes at the boundary (RLC's delivery
1642                        // frontier); index its local order from there.
1643                        self.rs_next_global = boundary;
1644                    }
1645                    SensCode::Rlc => {
1646                        // Returning to RLC: re-base the decoder to the boundary so
1647                        // it delivers the resumed stream from there (whose source
1648                        // ids the sender re-aligned to the global index) and does
1649                        // not replay its stale pre-switch buffer or stall on holes
1650                        // the other code already delivered.
1651                        self.rlc.skip_to(boundary as u32);
1652                    }
1653                }
1654                self.active = to;
1655                self.switches += 1;
1656            }
1657            self.pending_switch = None;
1658        }
1659        Ok(out)
1660    }
1661}
1662
1663impl Drop for UnifiedSensReceiver {
1664    fn drop(&mut self) {
1665        self.stop.store(true, Ordering::Relaxed);
1666        if let Some(h) = self.demux.take() {
1667            h.join().ok();
1668        }
1669    }
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674    use super::*;
1675
1676    #[test]
1677    fn forced_policies_never_switch() {
1678        for policy in [CodePolicy::ForceRlc, CodePolicy::ForceRs] {
1679            let mut c = CodeSwitchController::with_policy(policy);
1680            let start = c.code();
1681            for q in [0u8, 80, 200, 255, 10, 0] {
1682                assert_eq!(c.observe(q), None, "forced policy must not switch");
1683            }
1684            assert_eq!(c.code(), start);
1685            assert_eq!(c.switches(), 0);
1686        }
1687    }
1688
1689    #[test]
1690    fn force_rs_starts_on_rs() {
1691        let c = CodeSwitchController::with_policy(CodePolicy::ForceRs);
1692        assert_eq!(c.code(), SensCode::Rs);
1693    }
1694
1695    #[test]
1696    fn auto_starts_on_rlc_then_up_switches_when_loss_sustains() {
1697        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1698        assert_eq!(c.code(), SensCode::Rlc);
1699        // 12% loss (q8 ~30) is below the ~15% up threshold (q8 38): no switch.
1700        assert_eq!(c.observe(30), None);
1701        assert_eq!(c.observe(30), None);
1702        assert_eq!(c.code(), SensCode::Rlc);
1703        // 18% loss (q8 46) above the up threshold: one sample arms, the second
1704        // (up_hold = 2) confirms the switch to RS.
1705        assert_eq!(c.observe(46), None, "first over-threshold sample only arms");
1706        assert_eq!(c.observe(46), Some(SensCode::Rs), "second confirms up-switch");
1707        assert_eq!(c.code(), SensCode::Rs);
1708        assert_eq!(c.switches(), 1);
1709    }
1710
1711    #[test]
1712    fn stall_escape_latches_rs_and_does_not_flap() {
1713        // A flow-block escape to RS (RLC stalled at this loss) must NOT down-switch
1714        // back even when the loss estimate sits below the down threshold: returning
1715        // to a code that just stalled flaps, and the RS->RLC handover then corrupts
1716        // in-order delivery. The latch holds RS after a stall-escape.
1717        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 4);
1718        assert!(c.force(SensCode::Rs), "stall-escape forces to RS");
1719        assert_eq!(c.code(), SensCode::Rs);
1720        for i in 0..20 {
1721            assert_eq!(c.observe(5), None, "latched RS must not down-switch at tick {i}");
1722        }
1723        assert_eq!(c.code(), SensCode::Rs);
1724        assert_eq!(c.switches(), 1, "no flap: only the one escape switch");
1725    }
1726
1727    #[test]
1728    fn a_single_loss_spike_does_not_flap_the_code() {
1729        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1730        // One isolated spike over the threshold then back down: up_hold = 2 is
1731        // not met, so no switch (the streak resets on the low sample).
1732        assert_eq!(c.observe(200), None);
1733        assert_eq!(c.observe(10), None);
1734        assert_eq!(c.observe(200), None);
1735        assert_eq!(c.code(), SensCode::Rlc, "an isolated spike must not switch");
1736        assert_eq!(c.switches(), 0);
1737    }
1738
1739    #[test]
1740    fn down_switch_needs_a_longer_sustained_low_streak() {
1741        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1742        // Drive up to RS first.
1743        c.observe(80);
1744        assert_eq!(c.observe(80), Some(SensCode::Rs));
1745        // Loss drops below the 10% down threshold (q8 26). It must SUSTAIN for
1746        // down_hold = 8 samples; a brief low spell does not relax the code.
1747        for _ in 0..7 {
1748            assert_eq!(c.observe(10), None, "down-switch must not fire early");
1749        }
1750        assert_eq!(c.observe(10), Some(SensCode::Rlc), "8th low sample relaxes to RLC");
1751        assert_eq!(c.code(), SensCode::Rlc);
1752        assert_eq!(c.switches(), 2);
1753    }
1754
1755    #[test]
1756    fn hysteresis_band_holds_rs_between_thresholds() {
1757        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1758        c.observe(80);
1759        c.observe(80); // now on RS
1760        assert_eq!(c.code(), SensCode::Rs);
1761        // Loss in the band (down_q8=26 < q8=32 < up_q8=38): neither relaxes nor
1762        // re-arms; RS holds across the whole band (no flapping).
1763        for _ in 0..20 {
1764            assert_eq!(c.observe(32), None);
1765        }
1766        assert_eq!(c.code(), SensCode::Rs, "RS holds inside the hysteresis band");
1767    }
1768
1769    // A real two-socket loopback round trip that forces an RLC -> RS handover
1770    // mid-stream and asserts every item is delivered exactly once, in order,
1771    // across the switch. Exercises the demux sockets, the drain-barrier, the
1772    // CODE_SWITCH frame, and the receiver's boundary merge end to end.
1773    /// Two concurrent senders through the unified endpoint, pinned to RLC (the
1774    /// mesh shape, and the code Auto runs at low loss). Every item of both
1775    /// streams must arrive, and `poll_from` must attribute each to the peer
1776    /// that actually sent it.
1777    ///
1778    /// The tag assertion is the point. Delivery alone passes even when every
1779    /// item is labelled with whoever spoke last, which is the misattribution a
1780    /// mesh node cannot detect from its own side.
1781    /// The same two-peer shape pinned to block-RS. The unified endpoint hands
1782    /// its RS half a demux socket, which is shared and fed by a reader that
1783    /// takes every source address, so that receiver has to route by session
1784    /// epoch rather than serve one peer.
1785    /// Three peers through the unified endpoint on block-RS. Two is not enough
1786    /// to exercise admission: one peer always takes the free first-admission
1787    /// slot, so a broken challenge path still delivers both. Three forces two
1788    /// separate challenges, and the challenge answer travels back over the
1789    /// sender's demux socket.
1790    /// Two peers on DIFFERENT codes through one receiver. Under `Auto` each
1791    /// sender runs its own switch controller, so a mesh whose links see
1792    /// different loss can have peers disagree about which code is live.
1793    ///
1794    /// The receiver holds one `active` code and polls only that decoder, so a
1795    /// peer sending the other code is never drained. This is the endpoint-wide
1796    /// switch boundary meeting a per-peer topology.
1797    #[test]
1798    #[ignore = "subetha-11: one active code per endpoint; peers on different codes are not both drained"]
1799    fn unified_peers_on_different_codes_both_deliver() {
1800        use std::sync::mpsc;
1801        let sym = 64usize;
1802        let base = UnifiedConfig {
1803            policy: CodePolicy::default_auto(),
1804            symbol_len: sym,
1805            k: 8,
1806            r: 2,
1807            rlc_flow_window: 256,
1808            debug_loss: 0,
1809            seed: 1,
1810            rlc_step: 4,
1811            rlc_static: false,
1812        };
1813        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", base).unwrap();
1814        let addr = recv.local_addr().unwrap();
1815        let per_peer: u64 = 40;
1816        let total = per_peer * 2;
1817
1818        let (tx, rx) = mpsc::channel();
1819        let rh = std::thread::spawn(move || {
1820            let mut recv = recv;
1821            let mut got: Vec<u64> = Vec::new();
1822            let start = Instant::now();
1823            while (got.len() as u64) < total && start.elapsed() < Duration::from_secs(20) {
1824                let items = recv.poll().unwrap_or_default();
1825                let empty = items.is_empty();
1826                for it in items {
1827                    let mut s = [0u8; 8];
1828                    s.copy_from_slice(&it[..8]);
1829                    got.push(u64::from_le_bytes(s));
1830                }
1831                if empty {
1832                    std::thread::sleep(Duration::from_micros(200));
1833                }
1834            }
1835            tx.send(got).ok();
1836        });
1837
1838        // One peer pinned to each code, which is the steady state a divergent
1839        // Auto switch reaches.
1840        let mut handles = Vec::new();
1841        for (p, policy) in [CodePolicy::ForceRlc, CodePolicy::ForceRs].into_iter().enumerate() {
1842            let mut cfg = base;
1843            cfg.policy = policy;
1844            handles.push(std::thread::spawn(move || {
1845                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1846                let mut buf = vec![0u8; 8];
1847                for i in 0..per_peer {
1848                    buf[..8].copy_from_slice(&(((p as u64) << 56) | i).to_le_bytes());
1849                    if send.send_item(&buf).is_err() {
1850                        break;
1851                    }
1852                }
1853                send.finish().ok();
1854            }));
1855        }
1856        for h in handles {
1857            h.join().ok();
1858        }
1859
1860        let got = rx.recv_timeout(Duration::from_secs(25)).unwrap();
1861        rh.join().ok();
1862        for p in 0..2u64 {
1863            let mine: Vec<u64> = got
1864                .iter()
1865                .filter(|v| (*v >> 56) == p)
1866                .map(|v| v & 0x00FF_FFFF_FFFF_FFFF)
1867                .collect();
1868            assert_eq!(
1869                mine,
1870                (0..per_peer).collect::<Vec<_>>(),
1871                "peer {p} was not drained; the receiver polls one active code",
1872            );
1873        }
1874    }
1875
1876    /// poll() must return promptly whether or not traffic is flowing: a mesh
1877    /// consumer polls one receiver per node in a loop, and a poll that blocks
1878    /// for seconds starves every other duty on that loop. Measured on a
1879    /// four-node mesh: a strict 1Hz log printed ~6 samples in ~40s.
1880    #[test]
1881    fn unified_poll_returns_promptly_under_sparse_traffic() {
1882        use std::sync::mpsc;
1883        let sym = 64usize;
1884        let cfg = UnifiedConfig {
1885            policy: CodePolicy::ForceRlc,
1886            symbol_len: sym,
1887            k: 8,
1888            r: 2,
1889            rlc_flow_window: 256,
1890            debug_loss: 0,
1891            seed: 1,
1892            rlc_step: 4,
1893            rlc_static: false,
1894        };
1895        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
1896        let addr = recv.local_addr().unwrap();
1897
1898        // Three peers on heartbeat-shaped traffic, one dying early: the mesh
1899        // shape where the seconds-scale poll was measured.
1900        let (done_tx, done_rx) = mpsc::channel::<()>();
1901        let done_rx = std::sync::Arc::new(std::sync::Mutex::new(done_rx));
1902        let mut senders = Vec::new();
1903        for p in 0..3u64 {
1904            let done_rx = std::sync::Arc::clone(&done_rx);
1905            senders.push(std::thread::spawn(move || {
1906                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1907                let buf = vec![7u8; 8];
1908                std::thread::sleep(Duration::from_millis(150 * p));
1909                let n = if p == 1 { 2 } else { 8 };
1910                for _ in 0..n {
1911                    if send.send_item(&buf).is_err() {
1912                        break;
1913                    }
1914                    std::thread::sleep(Duration::from_millis(400));
1915                }
1916                if p == 1 {
1917                    return;
1918                }
1919                done_rx.lock().unwrap().recv_timeout(Duration::from_secs(20)).ok();
1920            }));
1921        }
1922
1923        let mut recv = recv;
1924        let mut worst = Duration::ZERO;
1925        let start = Instant::now();
1926        while start.elapsed() < Duration::from_secs(6) {
1927            let t = Instant::now();
1928            recv.poll().ok();
1929            worst = worst.max(t.elapsed());
1930        }
1931        done_tx.send(()).ok();
1932        done_tx.send(()).ok();
1933        for s in senders {
1934            s.join().ok();
1935        }
1936        assert!(
1937            worst < Duration::from_millis(500),
1938            "a single poll() blocked for {worst:?} under sparse traffic",
1939        );
1940    }
1941
1942    /// Three peers through the unified endpoint on ForceRlc, sending SPARSELY -
1943    /// one small item every 300ms - with one going silent partway. The
1944    /// consumer's topology: a heartbeat mesh where a node dies.
1945    ///
1946    /// Combines what the other multi-peer tests each cover separately: the
1947    /// demux socket, sparse traffic that lets the receiver's timers run between
1948    /// frames, and a peer that stops.
1949    #[test]
1950    fn unified_three_sparse_peers_survive_one_going_silent() {
1951        use std::sync::mpsc;
1952        let sym = 64usize;
1953        let cfg = UnifiedConfig {
1954            policy: CodePolicy::ForceRlc,
1955            symbol_len: sym,
1956            k: 8,
1957            r: 2,
1958            rlc_flow_window: 256,
1959            debug_loss: 0,
1960            seed: 1,
1961            rlc_step: 4,
1962            rlc_static: false,
1963        };
1964        let rounds: u64 = 10;
1965        let silent_after: u64 = 3;
1966        let peers: u64 = 3;
1967
1968        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
1969        let addr = recv.local_addr().unwrap();
1970        let (stop_tx, stop_rx) = mpsc::channel::<()>();
1971        let rh = std::thread::spawn(move || {
1972            let mut recv = recv;
1973            let mut got: Vec<(u64, u64)> = Vec::new();
1974            let start = Instant::now();
1975            while start.elapsed() < Duration::from_secs(15) && stop_rx.try_recv().is_err() {
1976                let batch: Vec<(u64, Vec<u8>)> = recv.poll_from().unwrap_or_default();
1977                for (tag, it) in batch {
1978                    let mut s = [0u8; 8];
1979                    s.copy_from_slice(&it[..8]);
1980                    let v = u64::from_le_bytes(s);
1981                    got.push((tag, v));
1982                }
1983                std::thread::sleep(Duration::from_millis(2));
1984            }
1985            got
1986        });
1987
1988        let mut handles = Vec::new();
1989        for p in 0..peers {
1990            handles.push(std::thread::spawn(move || {
1991                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1992                let mut buf = vec![0u8; 8];
1993                let n = if p == 2 { silent_after } else { rounds };
1994                for i in 0..n {
1995                    buf[..8].copy_from_slice(&((p << 56) | i).to_le_bytes());
1996                    if send.send_item(&buf).is_err() {
1997                        break;
1998                    }
1999                    std::thread::sleep(Duration::from_millis(300));
2000                }
2001                if p != 2 {
2002                    std::thread::sleep(Duration::from_secs(2));
2003                }
2004                send.finish().ok();
2005            }));
2006        }
2007        for h in handles {
2008            h.join().ok();
2009        }
2010        stop_tx.send(()).ok();
2011        let got: Vec<(u64, u64)> = rh.join().expect("collector thread");
2012
2013        let tags: std::collections::BTreeSet<u64> = got.iter().map(|(t, _)| *t).collect();
2014        for p in 0..2u64 {
2015            let mine: Vec<u64> = got
2016                .iter()
2017                .filter(|(_, v)| (*v >> 56) == p)
2018                .map(|(_, v)| v & 0x00FF_FFFF_FFFF_FFFF)
2019                .collect();
2020            assert_eq!(
2021                mine,
2022                (0..rounds).collect::<Vec<_>>(),
2023                "surviving peer {p} stopped being delivered; got {} of {rounds}, \
2024                 tags seen {tags:?}",
2025                mine.len(),
2026            );
2027        }
2028    }
2029
2030    #[test]
2031    fn unified_three_peers_on_block_rs_all_deliver() {
2032        use std::sync::mpsc;
2033        let sym = 64usize;
2034        let cfg = UnifiedConfig {
2035            policy: CodePolicy::ForceRs,
2036            symbol_len: sym,
2037            k: 8,
2038            r: 2,
2039            rlc_flow_window: 256,
2040            debug_loss: 0,
2041            seed: 1,
2042            rlc_step: 4,
2043            rlc_static: false,
2044        };
2045        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
2046        let addr = recv.local_addr().unwrap();
2047        let per_peer: u64 = 50;
2048        let peers: u64 = 3;
2049        let total = per_peer * peers;
2050
2051        let (tx, rx) = mpsc::channel();
2052        let rh = std::thread::spawn(move || {
2053            let mut recv = recv;
2054            let mut got: Vec<u64> = Vec::with_capacity(total as usize);
2055            let start = Instant::now();
2056            while (got.len() as u64) < total && start.elapsed() < Duration::from_secs(30) {
2057                let items = recv.poll().unwrap_or_default();
2058                let empty = items.is_empty();
2059                for it in items {
2060                    let mut s = [0u8; 8];
2061                    s.copy_from_slice(&it[..8]);
2062                    got.push(u64::from_le_bytes(s));
2063                }
2064                if empty {
2065                    std::thread::sleep(Duration::from_micros(200));
2066                }
2067            }
2068            tx.send(got).ok();
2069        });
2070
2071        let gate = Arc::new(std::sync::Barrier::new(peers as usize));
2072        let mut handles = Vec::new();
2073        for p in 0..peers {
2074            let gate = Arc::clone(&gate);
2075            handles.push(std::thread::spawn(move || {
2076                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
2077                let mut buf = vec![0u8; 8];
2078                gate.wait();
2079                let start = Instant::now();
2080                for i in 0..per_peer {
2081                    if start.elapsed() > Duration::from_secs(20) {
2082                        break;
2083                    }
2084                    buf[..8].copy_from_slice(&((p << 56) | i).to_le_bytes());
2085                    if send.send_item(&buf).is_err() {
2086                        break;
2087                    }
2088                }
2089                send.finish().ok();
2090            }));
2091        }
2092        for h in handles {
2093            h.join().ok();
2094        }
2095
2096        let got = rx.recv_timeout(Duration::from_secs(35)).unwrap();
2097        rh.join().ok();
2098        for p in 0..peers {
2099            let mine: Vec<u64> = got
2100                .iter()
2101                .filter(|v| (*v >> 56) == p)
2102                .map(|v| v & 0x00FF_FFFF_FFFF_FFFF)
2103                .collect();
2104            assert_eq!(
2105                mine,
2106                (0..per_peer).collect::<Vec<_>>(),
2107                "peer {p} of {peers} did not deliver through the unified block-RS path",
2108            );
2109        }
2110    }
2111
2112    #[test]
2113    fn unified_two_peers_on_block_rs_both_deliver() {
2114        use std::sync::mpsc;
2115        let sym = 64usize;
2116        let cfg = UnifiedConfig {
2117            policy: CodePolicy::ForceRs,
2118            symbol_len: sym,
2119            k: 8,
2120            r: 2,
2121            rlc_flow_window: 256,
2122            debug_loss: 0,
2123            seed: 1,
2124            rlc_step: 4,
2125            rlc_static: false,
2126        };
2127        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
2128        let addr = recv.local_addr().unwrap();
2129        let per_peer: u64 = 60;
2130        let peers: u64 = 2;
2131        let total = per_peer * peers;
2132
2133        let (tx, rx) = mpsc::channel();
2134        let rh = std::thread::spawn(move || {
2135            let mut recv = recv;
2136            let mut got: Vec<u64> = Vec::with_capacity(total as usize);
2137            let start = Instant::now();
2138            while (got.len() as u64) < total && start.elapsed() < Duration::from_secs(25) {
2139                let items = recv.poll().unwrap_or_default();
2140                let empty = items.is_empty();
2141                for it in items {
2142                    let mut s = [0u8; 8];
2143                    s.copy_from_slice(&it[..8]);
2144                    got.push(u64::from_le_bytes(s));
2145                }
2146                if empty {
2147                    std::thread::sleep(Duration::from_micros(200));
2148                }
2149            }
2150            tx.send(got).ok();
2151        });
2152
2153        let gate = Arc::new(std::sync::Barrier::new(peers as usize));
2154        let mut handles = Vec::new();
2155        for p in 0..peers {
2156            let gate = Arc::clone(&gate);
2157            handles.push(std::thread::spawn(move || {
2158                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
2159                let mut buf = vec![0u8; 8];
2160                gate.wait();
2161                let start = Instant::now();
2162                for i in 0..per_peer {
2163                    if start.elapsed() > Duration::from_secs(15) {
2164                        break;
2165                    }
2166                    buf[..8].copy_from_slice(&((p << 56) | i).to_le_bytes());
2167                    if send.send_item(&buf).is_err() {
2168                        break;
2169                    }
2170                }
2171                send.finish().ok();
2172            }));
2173        }
2174        for h in handles {
2175            h.join().ok();
2176        }
2177
2178        let got = rx.recv_timeout(Duration::from_secs(30)).unwrap();
2179        rh.join().ok();
2180        for p in 0..peers {
2181            let mine: Vec<u64> = got
2182                .iter()
2183                .filter(|v| (*v >> 56) == p)
2184                .map(|v| v & 0x00FF_FFFF_FFFF_FFFF)
2185                .collect();
2186            assert_eq!(
2187                mine,
2188                (0..per_peer).collect::<Vec<_>>(),
2189                "block-RS peer {p} must deliver every item alongside the other peer",
2190            );
2191        }
2192    }
2193
2194    #[test]
2195    fn unified_two_peers_deliver_and_are_attributed_separately() {
2196        use std::sync::mpsc;
2197        let sym = 64usize;
2198        let cfg = UnifiedConfig {
2199            policy: CodePolicy::ForceRlc,
2200            symbol_len: sym,
2201            k: 8,
2202            r: 2,
2203            rlc_flow_window: 256,
2204            debug_loss: 0,
2205            seed: 1,
2206            rlc_step: 4,
2207            rlc_static: false,
2208        };
2209        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
2210        let addr = recv.local_addr().unwrap();
2211        let per_peer: u64 = 150;
2212        let peers: u64 = 2;
2213        let total = per_peer * peers;
2214
2215        let (tx, rx) = mpsc::channel();
2216        let rh = std::thread::spawn(move || {
2217            let mut recv = recv;
2218            let mut got: Vec<(u64, u64)> = Vec::with_capacity(total as usize);
2219            let start = Instant::now();
2220            while (got.len() as u64) < total && start.elapsed() < Duration::from_secs(25) {
2221                let items = recv.poll_from().unwrap_or_default();
2222                let empty = items.is_empty();
2223                for (tag, it) in items {
2224                    let mut s = [0u8; 8];
2225                    s.copy_from_slice(&it[..8]);
2226                    got.push((tag, u64::from_le_bytes(s)));
2227                }
2228                if empty {
2229                    std::thread::sleep(Duration::from_micros(200));
2230                }
2231            }
2232            tx.send(got).ok();
2233        });
2234
2235        let mut handles = Vec::new();
2236        for p in 0..peers {
2237            handles.push(std::thread::spawn(move || {
2238                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
2239                let mut buf = vec![0u8; 8];
2240                let start = Instant::now();
2241                for i in 0..per_peer {
2242                    if start.elapsed() > Duration::from_secs(15) {
2243                        break;
2244                    }
2245                    buf[..8].copy_from_slice(&((p << 56) | i).to_le_bytes());
2246                    if send.send_item(&buf).is_err() {
2247                        break;
2248                    }
2249                }
2250                send.finish().ok();
2251            }));
2252        }
2253        for h in handles {
2254            h.join().ok();
2255        }
2256
2257        let got = rx.recv_timeout(Duration::from_secs(30)).unwrap();
2258        rh.join().ok();
2259
2260        for p in 0..peers {
2261            let mine: Vec<u64> = got
2262                .iter()
2263                .filter(|(_, v)| (v >> 56) == p)
2264                .map(|(_, v)| v & 0x00FF_FFFF_FFFF_FFFF)
2265                .collect();
2266            assert_eq!(
2267                mine,
2268                (0..per_peer).collect::<Vec<_>>(),
2269                "peer {p} must deliver every item in order alongside the other peer",
2270            );
2271            // Every item a peer sent must carry ONE tag, and the two peers'
2272            // tags must differ - otherwise the attribution is a label, not a
2273            // routing fact.
2274            let tags: std::collections::BTreeSet<u64> =
2275                got.iter().filter(|(_, v)| (v >> 56) == p).map(|(t, _)| *t).collect();
2276            assert_eq!(tags.len(), 1, "peer {p} items must all carry one tag, got {tags:?}");
2277        }
2278        let all_tags: std::collections::BTreeSet<u64> = got.iter().map(|(t, _)| *t).collect();
2279        assert_eq!(all_tags.len(), 2, "the two peers must be attributed distinctly");
2280    }
2281
2282    #[test]
2283    fn unified_delivers_in_order_across_a_forced_switch() {
2284        use std::sync::mpsc;
2285        let sym = 64usize;
2286        let cfg = UnifiedConfig {
2287            policy: CodePolicy::default_auto(),
2288            symbol_len: sym,
2289            k: 8,
2290            r: 2,
2291            rlc_flow_window: 256,
2292            debug_loss: 0,
2293            seed: 1,
2294            rlc_step: 4,
2295            rlc_static: false,
2296        };
2297        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
2298        let addr = recv.local_addr().unwrap();
2299        let n: u64 = 4000;
2300
2301        let (tx, rx) = mpsc::channel();
2302        let rh = std::thread::spawn(move || {
2303            let mut recv = recv;
2304            let mut got: Vec<u64> = Vec::with_capacity(n as usize);
2305            let start = Instant::now();
2306            while (got.len() as u64) < n && start.elapsed() < Duration::from_secs(25) {
2307                let items = recv.poll().unwrap_or_default();
2308                let empty = items.is_empty();
2309                for it in items {
2310                    let mut s = [0u8; 8];
2311                    s.copy_from_slice(&it[..8]);
2312                    got.push(u64::from_le_bytes(s));
2313                }
2314                if empty {
2315                    std::thread::sleep(Duration::from_micros(200));
2316                }
2317            }
2318            tx.send((got, recv.switches())).ok();
2319        });
2320
2321        let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
2322        // Items must leave room for the RLC symbol's length prefix
2323        // (item.len() + LEN_PREFIX <= symbol_len), so ship the 8-byte seq.
2324        let mut buf = vec![0u8; 8];
2325        for seq in 0..n / 2 {
2326            buf[..8].copy_from_slice(&seq.to_le_bytes());
2327            send.send_item(&buf).unwrap();
2328        }
2329        send.force_switch(SensCode::Rs).unwrap();
2330        assert_eq!(send.active_code(), SensCode::Rs);
2331        for seq in n / 2..n {
2332            buf[..8].copy_from_slice(&seq.to_le_bytes());
2333            send.send_item(&buf).unwrap();
2334        }
2335        send.finish().unwrap();
2336
2337        let (got, rswitches) = rx.recv_timeout(Duration::from_secs(30)).unwrap();
2338        rh.join().ok();
2339        assert_eq!(got.len() as u64, n, "every item delivered exactly once");
2340        for (i, &v) in got.iter().enumerate() {
2341            assert_eq!(v, i as u64, "delivery in order across the switch at index {i}");
2342        }
2343        assert!(rswitches >= 1, "receiver followed the code switch");
2344    }
2345}