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) -> JoinHandle<()> {
375    std::thread::spawn(move || {
376        let mut buf = vec![0u8; 2048];
377        let mut last_from: Option<SocketAddr> = None;
378        let mut last_fb = Instant::now();
379        let mut rng = seed;
380        while !stop.load(Ordering::Relaxed) {
381            match crate::dgram::udp_recv_with_kts(&sock, &mut buf) {
382                Ok((n, from, kts)) if n > 0 => {
383                    let b0 = buf[0];
384                    last_from = Some(from);
385                    // Uniform link-loss injection on the forward data/repair
386                    // stream (RS data 1, RLC data 10 / repair 11): drop BEFORE
387                    // counting or routing, so the raw-loss estimate AND the codes
388                    // both see a realistic lossy link. Control frames pass.
389                    let is_fwd = b0 == 1 || b0 == 10 || b0 == 11;
390                    let dropped =
391                        loss_pct > 0 && is_fwd && (next_rand(&mut rng) % 100) < loss_pct as u64;
392                    if !dropped {
393                        // QUIC (0x40 bit set) and unknown first bytes are dropped
394                        // by route_sens_inbound; the one-port quinn demux consumes
395                        // QUIC separately.
396                        route_sens_inbound(
397                            buf[..n].to_vec(),
398                            from,
399                            kts,
400                            &rlc_q,
401                            &rs_q,
402                            switch_signal.as_ref(),
403                            fb_received.as_deref(),
404                            recv_counter.as_deref(),
405                            // Standalone path: the handshake completed before this
406                            // reader started, so no crypto frames arrive here.
407                            None,
408                        );
409                    }
410                }
411                Ok(_) => {}
412                Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
413                    std::thread::sleep(Duration::from_micros(100));
414                }
415                Err(e) if e.kind() == io::ErrorKind::TimedOut => {}
416                Err(_) => std::thread::sleep(Duration::from_micros(200)),
417            }
418            // Receiver: report the cumulative received-datagram count back so
419            // the sender derives the true raw channel loss (sent vs received),
420            // which neither code's post-recovery feedback reveals.
421            if let (Some(c), Some(dst)) = (&recv_counter, last_from)
422                && last_fb.elapsed() >= UNIFIED_FB_PERIOD
423            {
424                last_fb = Instant::now();
425                let frame = encode_unified_fb(c.load(Ordering::Relaxed));
426                sock.send_to(&frame, dst).ok();
427            }
428        }
429    })
430}
431
432/// How often the sender samples the fed-back loss and asks the controller for a
433/// switch. Time-based (not per-item) so the controller's hold counts track the
434/// receiver's ~10ms feedback cadence rather than the item rate.
435const SWITCH_SAMPLE_PERIOD: Duration = Duration::from_millis(50);
436/// Warmup before the switch is evaluated: the in-flight window ramps from 0 to
437/// the flow window at connection start, and that growth reads as loss; wait for
438/// it to stabilize so the ramp does not trip a spurious switch.
439const SWITCH_WARMUP: Duration = Duration::from_millis(1000);
440/// Feedback windows accumulated AFTER the warmup before the loss estimate is
441/// trusted to move the code. The decaying accumulator is cold at warmup-end (its
442/// first window's raw ratio dominates), so a start-of-stream retransmit burst
443/// reads as a spike that crosses the up threshold and flaps the code. Holding the
444/// switch until a few windows have decayed in lets the estimate mature first.
445const MIN_ACCUM_WINDOWS: u32 = 6;
446/// Drain deadline for a code handover (the in-flight tail of the old code must
447/// be delivered before the new code starts, for in-order delivery).
448const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
449/// How long RLC's DELIVERY FRONTIER may stay stuck (no item delivered while the
450/// send window is full) before the transport gives up on RLC and migrates to RS.
451/// This is the genuine-deadlock backstop: a frontier that does not advance for
452/// this long means RLC cannot decode the loss it is seeing (extreme loss past its
453/// redundancy ceiling), which the loss-driven `maybe_switch` cannot catch because
454/// a stalled sender produces no fresh loss sample. It is measured against frontier
455/// progress (the send loop resets the timer whenever a delivery lands), so a
456/// recoverable hard gap at sub-ceiling loss does NOT trip it - only a true stall.
457/// Measured against frontier progress, so it fires fast (the stalling unified RLC
458/// needs prompt rescue - a slower value starves it into a multi-second stall).
459const RLC_BLOCK_ESCAPE: Duration = Duration::from_millis(750);
460/// Drain deadline for the flow-block escape specifically: the stuck window's
461/// frontier is retransmitted (over a high-loss link, so each copy may also be
462/// lost) until fully delivered, so it must be generous enough to land every item
463/// before RS takes over (no gap = in-order delivery preserved).
464const ESCAPE_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
465/// Hard cap on the sender-side replay ring (items). The ring normally holds only
466/// the un-acked tail `[acked_through, items_total)` (evicted as RLC confirms
467/// delivery), but at extreme loss that tail can grow; this bounds the memory. If
468/// the un-acked tail ever exceeds the cap, the RLC->RS handover falls back to
469/// draining RLC so no item is dropped. 65536 * symbol covers the worst observed
470/// 30%-loss tail with headroom.
471const SENT_RING_CAP: usize = 65536;
472/// Recycled replay-ring buffers held for reuse. A trimmed (delivered) buffer is
473/// returned here instead of freed, and the next seal reuses it instead of
474/// allocating - so the per-item path does no heap alloc/free in steady state.
475/// Sized to the in-flight working set (a few flow-windows) rather than the full
476/// ring cap: the pool only needs to bridge trim-tail to send-head, and capping it
477/// keeps idle memory bounded when the ring shrinks. At small item sizes (where the
478/// item rate, and thus the alloc churn, is highest) this removes ~190k alloc/free
479/// pairs per second from the hot path.
480const RING_POOL_CAP: usize = 1024;
481/// CODE_SWITCH is a one-off control frame sent on the (drained, quiet) path at
482/// the switch point; send it a few times so a single drop does not strand the
483/// receiver on the old decoder.
484const CODE_SWITCH_REPEATS: usize = 6;
485
486// ---------------------------------------------------------------------------
487// Unified sender
488// ---------------------------------------------------------------------------
489
490/// Background reporter for the one-port path: periodically send the cumulative
491/// received-datagram count to the Sens peer (the raw-loss numerator). The QUIC
492/// demux socket feeds the receiver's queues, so there is no demux thread to do
493/// it; this small thread covers just the feedback send.
494fn spawn_fb_reporter(
495    sock: Arc<UdpSocket>,
496    recv_counter: Arc<AtomicU64>,
497    peer: Arc<Mutex<Option<SocketAddr>>>,
498    stop: Arc<AtomicBool>,
499) -> JoinHandle<()> {
500    std::thread::spawn(move || {
501        while !stop.load(Ordering::Relaxed) {
502            std::thread::sleep(UNIFIED_FB_PERIOD);
503            if let Some(dst) = *peer.lock().unwrap() {
504                let frame = encode_unified_fb(recv_counter.load(Ordering::Relaxed));
505                sock.send_to(&frame, dst).ok();
506            }
507        }
508    })
509}
510
511/// Construction parameters shared by the unified sender and receiver.
512#[derive(Debug, Clone, Copy)]
513pub struct UnifiedConfig {
514    /// Erasure-code selection policy (loss-driven Auto, or a forced code).
515    pub policy: CodePolicy,
516    /// Item / symbol size in bytes (matches the application's record size).
517    pub symbol_len: usize,
518    /// Reed-Solomon block geometry: `k` data shards.
519    pub k: usize,
520    /// Reed-Solomon base parity shards `r` (the receiver provisions per loss).
521    pub r: usize,
522    /// RLC sender flow window (outstanding source symbols); 0 = transport
523    /// default. Size it to the path BDP so RLC fills the pipe (the fair-A/B
524    /// config; the default caps RLC ~2x below its capability on a high-BDP path).
525    pub rlc_flow_window: u32,
526    /// Receiver-side diagnostic loss injection (percent, 0 = off) applied to
527    /// BOTH decoders, with `seed` for reproducibility. Drives the loss-based
528    /// switch without a real lossy link.
529    pub debug_loss: u32,
530    /// Seed for the reproducible `debug_loss` drop sequence.
531    pub seed: u64,
532    /// RLC repair cadence: one repair every `rlc_step` source symbols (redundancy
533    /// `1/(rlc_step+1)`). The starting value; the adaptive controller retunes it
534    /// per measured loss unless `rlc_static` pins it.
535    pub rlc_step: u16,
536    /// Pin the RLC coding parameters (disable the adaptive controller), holding a
537    /// fixed code rate instead of letting the sensing plane retune window / step /
538    /// density. The adaptive controller's disable-on-clean state drops coding
539    /// entirely on a quiet assessment and then pays an ARQ round trip on the next
540    /// loss; pinning trades that latency risk for a constant redundancy.
541    pub rlc_static: bool,
542}
543
544impl UnifiedConfig {
545    /// Defaults: loss-driven Auto policy, MTU-sized items, RS (8, 2), RLC flow
546    /// window sized for a filled BDP, no injected loss.
547    pub fn new(symbol_len: usize) -> Self {
548        Self {
549            policy: CodePolicy::default_auto(),
550            symbol_len,
551            k: 8,
552            r: 2,
553            rlc_flow_window: 4096,
554            debug_loss: 0,
555            seed: 1,
556            rlc_step: 4,
557            rlc_static: false,
558        }
559    }
560}
561
562/// Unified Sens-O-Matic sender: carries items over whichever erasure code the
563/// loss-driven controller selects, switching RLC <-> RS mid-stream via a
564/// drain-barrier handover. One real socket is shared by both codes through
565/// per-code demux queues fed by a background reader.
566pub struct UnifiedSensSender {
567    real: Arc<UdpSocket>,
568    peer: SocketAddr,
569    rlc: SensOMaticRlcSender,
570    rs: ReliableUdpSender,
571    active: SensCode,
572    ctrl: CodeSwitchController,
573    /// Cumulative items handed to the application across both codes (the switch
574    /// boundary the receiver keys on).
575    items_total: u64,
576    last_sample: Instant,
577    /// Connection start, for the switch-evaluation warmup.
578    started: Instant,
579    /// Datagrams sent through both codes' demux sockets (raw-loss numerator).
580    sent_counter: Arc<AtomicU64>,
581    /// Receiver's last-reported cumulative received-datagram count.
582    fb_received: Arc<AtomicU64>,
583    /// Sent / received baselines captured at the previous evaluated window.
584    prev_sent: u64,
585    prev_received: u64,
586    /// Size-weighted decaying raw-loss estimate (-1 = uninitialized). Decay the
587    /// lost / sent COUNTS (`loss_acc` / `sent_acc`) and take their ratio, rather
588    /// than EWMA-ing per-window ratios: a small feedback window with one drop
589    /// reads a spuriously high ratio, and an equal-weight EWMA of ratios over-
590    /// weights it, inflating the estimate at low loss (3% read as ~11%). Weighting
591    /// by datagram count makes the estimate track the true channel loss.
592    ewma_loss: f64,
593    /// Decaying sums of lost and sent forward datagrams (the size-weighted
594    /// estimate's numerator / denominator); their ratio is `ewma_loss`.
595    loss_acc: f64,
596    sent_acc: f64,
597    /// Feedback windows accumulated since the warmup ended. The switch is gated on
598    /// this reaching `MIN_ACCUM_WINDOWS` so a cold accumulator cannot flap the code.
599    post_warm_windows: u32,
600    /// Recently-sent item payloads, kept so a code switch can RESEND the un-acked
601    /// tail over the new code instead of slowly draining the old one. Holds the
602    /// global index range `[ring_base, items_total)`; the front is evicted once
603    /// RLC confirms delivery (its `acked_through`) and is hard-capped so a stalled
604    /// receiver cannot grow it without bound. This is the sender-side replay ring.
605    sent_ring: VecDeque<Vec<u8>>,
606    /// Global index of `sent_ring[0]` (the oldest retained item).
607    ring_base: u64,
608    /// Recycled wire-payload buffers (capacity retained, length reset). Trimmed
609    /// ring buffers land here; the next seal pops one instead of allocating.
610    ring_pool: Vec<Vec<u8>>,
611    /// Unified AEAD record layer (TLS feature). When set, every item payload is
612    /// sealed before it enters the replay ring and goes to either code, so the
613    /// RLC<->RS switch is crypto-transparent and the wire is confidential. The
614    /// seal packet number is the item's global index (sealed once, in order), so
615    /// a resend reuses it and the receiver opens by index.
616    #[cfg(feature = "tls")]
617    crypto: Option<crate::rlc_crypto::CryptoState>,
618    stop: Arc<AtomicBool>,
619    demux: Option<JoinHandle<()>>,
620}
621
622impl UnifiedSensSender {
623    /// Bind a local socket, connect to `peer`, and bring up both codes sharing
624    /// it. Starts on the policy's initial code (RLC for Auto / ForceRlc).
625    pub fn connect<A: ToSocketAddrs>(local: A, peer: SocketAddr, cfg: UnifiedConfig) -> io::Result<Self> {
626        let udp = UdpSocket::bind(local)?;
627        udp.set_nonblocking(true)?;
628        Self::assemble(udp, peer, cfg, 0)
629    }
630
631    /// Like [`connect`](Self::connect) but runs a TLS 1.3 handshake to `peer`
632    /// first and AEAD-seals every item: the auto-switching transport made
633    /// confidential for an untrusted WAN. The handshake completes before the
634    /// demux reader takes the socket, so its frames never reach the data path.
635    #[cfg(feature = "tls")]
636    pub fn connect_tls<A: ToSocketAddrs>(
637        local: A,
638        peer: SocketAddr,
639        cfg: UnifiedConfig,
640        tls: std::sync::Arc<rustls::ClientConfig>,
641    ) -> io::Result<Self> {
642        let udp = UdpSocket::bind(local)?;
643        udp.set_nonblocking(true)?;
644        let mut cs = crate::rlc_crypto::CryptoState::new_client(tls)
645            .map_err(io::Error::other)?;
646        let hs = DgramSock::from_udp(udp.try_clone()?);
647        crate::sens_rlc::drive_handshake(&hs, Some(peer), &mut cs, true)?;
648        let mut s = Self::assemble(udp, peer, cfg, crate::rlc_crypto::TAG_LEN)?;
649        s.crypto = Some(cs);
650        Ok(s)
651    }
652
653    /// Build the sender over an already-bound (and, for TLS, already-handshaked)
654    /// socket: bring up both codes sharing it and spawn the demux reader.
655    fn assemble(
656        udp: UdpSocket,
657        peer: SocketAddr,
658        cfg: UnifiedConfig,
659        seal_overhead: usize,
660    ) -> io::Result<Self> {
661        // Both codes carry the wire payload, which is the item plus the AEAD tag
662        // when TLS is on; size their symbols for the sealed width so pack_symbol
663        // and the RS shard split never overflow.
664        let wire_sym = cfg.symbol_len + seal_overhead;
665        // Left UNCONNECTED: the per-code demux sockets send via send_to(peer),
666        // and send_to on a connected socket is rejected on Windows. The demux
667        // reader still only ever hears from `peer` on this private socket.
668        // A clone for the demux thread: UdpSocket is Send, DgramSock is not
669        // (its io_uring variant is not Send), so the thread holds the raw socket.
670        let thread_sock = udp.try_clone()?;
671        thread_sock.set_nonblocking(true)?;
672        let real = Arc::new(udp);
673        let rlc_q = new_demux_queue();
674        let rs_q = new_demux_queue();
675        let sent_counter = Arc::new(AtomicU64::new(0));
676        let fb_received = Arc::new(AtomicU64::new(0));
677
678        let mut rlc = SensOMaticRlcSender::bind("0.0.0.0:0", peer, 32, cfg.rlc_step as usize, 15, wire_sym)?;
679        if cfg.rlc_flow_window > 0 {
680            rlc = rlc.with_flow_window(cfg.rlc_flow_window);
681        }
682        if cfg.rlc_static {
683            rlc = rlc.with_static_params();
684        } else {
685            // The RLC leg is the latency-priority code (the switch hands bulk /
686            // high-loss traffic to block-RS). Keep a light FEC floor on at all
687            // times so an isolated loss recovers in-window instead of falling to
688            // an ARQ round trip that head-of-line-stalls the in-order stream.
689            rlc = rlc.with_latency_priority();
690        }
691        let rlc_sock = DgramSock::demux_counted(
692            Arc::clone(&real),
693            Arc::clone(&rlc_q),
694            Arc::clone(&sent_counter),
695        );
696        rlc_sock.connect(peer).ok();
697        rlc.set_sock(rlc_sock);
698
699        let mut rs = ReliableUdpSender::bind("0.0.0.0:0", peer, cfg.k, cfg.r, wire_sym)?;
700        let rs_sock = DgramSock::demux_counted(
701            Arc::clone(&real),
702            Arc::clone(&rs_q),
703            Arc::clone(&sent_counter),
704        );
705        rs_sock.connect(peer).ok();
706        rs.set_sock(rs_sock);
707
708        let stop = Arc::new(AtomicBool::new(false));
709        let demux = spawn_demux(
710            thread_sock,
711            rlc_q,
712            rs_q,
713            None,
714            None,
715            Some(Arc::clone(&fb_received)),
716            0,
717            1,
718            Arc::clone(&stop),
719        );
720
721        Ok(Self {
722            real,
723            peer,
724            rlc,
725            rs,
726            active: cfg.policy.initial_code(),
727            ctrl: CodeSwitchController::with_policy(cfg.policy),
728            items_total: 0,
729            last_sample: Instant::now(),
730            started: Instant::now(),
731            sent_counter,
732            fb_received,
733            prev_sent: 0,
734            prev_received: 0,
735            ewma_loss: -1.0,
736            loss_acc: 0.0,
737            sent_acc: 0.0,
738            post_warm_windows: 0,
739            sent_ring: VecDeque::new(),
740            ring_base: 0,
741            ring_pool: Vec::new(),
742            #[cfg(feature = "tls")]
743            crypto: None,
744            stop,
745            demux: Some(demux),
746        })
747    }
748
749    /// Fill `buf` (cleared, capacity reused) with the wire payload for `item`:
750    /// AEAD-sealed in place (TLS) or the raw bytes. Sealed once, in send order, so
751    /// the packet number equals the item's global index. Reusing a pooled `buf`
752    /// keeps the per-item send path allocation-free in steady state.
753    fn seal_into(&self, item: &[u8], buf: &mut Vec<u8>) -> io::Result<()> {
754        buf.clear();
755        buf.extend_from_slice(item);
756        #[cfg(feature = "tls")]
757        if let Some(cs) = &self.crypto {
758            cs.seal(buf).map_err(io::Error::other)?;
759        }
760        Ok(())
761    }
762
763    /// The code currently transmitting.
764    pub fn active_code(&self) -> SensCode {
765        self.active
766    }
767
768    /// Confirmed code switches so far.
769    pub fn switches(&self) -> u64 {
770        self.ctrl.switches()
771    }
772
773    /// The RLC leg's live coding parameters `(window, step, dt, coding_on)`
774    /// (telemetry: shows what the adaptive controller settled at vs the baseline).
775    pub fn rlc_coding_params(&self) -> (u16, u16, u8, bool) {
776        self.rlc.coding_params()
777    }
778
779    /// Times the RLC leg's coding parameters changed under feedback (telemetry).
780    pub fn rlc_adapt_count(&self) -> u64 {
781        self.rlc.adapt_count()
782    }
783
784    /// The switch controller's current EWMA raw-loss estimate (sent-vs-received
785    /// datagrams), 0.0..1.0, or a negative value before the first sample. This is
786    /// the signal the up/down thresholds compare against, so it shows whether the
787    /// estimate tracks the true channel loss (telemetry).
788    pub fn raw_loss_estimate(&self) -> f64 {
789        self.ewma_loss
790    }
791
792    /// Cumulative (datagrams sent through both codes' demux sockets, receiver's
793    /// last-reported forward-received count). The raw inputs to the loss estimate;
794    /// `(sent - recv) / sent` should equal the channel loss if the counts are
795    /// clean (telemetry to find a sent-side over-count / recv-side under-count).
796    pub fn raw_sent_recv(&self) -> (u64, u64) {
797        (
798            self.sent_counter.load(Ordering::Relaxed),
799            self.fb_received.load(Ordering::Relaxed),
800        )
801    }
802
803    /// Send one item over the active code, then periodically sample the fed-back
804    /// loss and switch codes if the controller calls for it. The item is recorded
805    /// in the replay ring so a switch can resend the un-acked tail over the new
806    /// code rather than draining the old one.
807    pub fn send_item(&mut self, item: &[u8]) -> io::Result<()> {
808        // Seal to the wire payload once (the packet number is this item's global
809        // index); both codes carry it and the replay ring stores it, so a resend
810        // reuses the same packet number and the switch is crypto-transparent. Seal
811        // into a recycled buffer so the hot path does no per-item heap alloc.
812        let mut payload = self.ring_pool.pop().unwrap_or_default();
813        self.seal_into(item, &mut payload)?;
814        match self.active {
815            SensCode::Rlc => {
816                // Own RLC's flow-window wait here (via the non-blocking
817                // try_send_item) instead of letting rlc.send_item block out of
818                // sight: when the window will not clear, RLC cannot decode the
819                // loss it is seeing (extreme loss past its redundancy ceiling), so
820                // a persistent block IS the trigger to migrate to RS. The loss-
821                // driven maybe_switch cannot catch this - a stalled sender emits no
822                // fresh loss sample, and the stall arrives inside the startup
823                // warmup. The handover resends the un-acked tail over RS (from the
824                // replay ring), so no slow RLC drain is needed.
825                // Progress-aware deadlock detection: escape only when RLC's
826                // delivery frontier is STUCK for RLC_BLOCK_ESCAPE, not merely when
827                // a single send flow-blocks while RLC is still delivering (slow but
828                // recovering). A blocked-but-advancing frontier is RLC working
829                // through loss at its own pace - that is the loss-threshold's job to
830                // switch on, not the deadlock backstop's; escaping there flaps the
831                // code (escape to RS, then the accurate loss estimate, being below
832                // the down threshold, switches straight back).
833                let mut escape_start = Instant::now();
834                let mut last_acked = self.rlc.acked_through();
835                loop {
836                    if self.rlc.try_send_item(&payload)? {
837                        break;
838                    }
839                    self.rlc.pump_once()?;
840                    let acked_now = self.rlc.acked_through();
841                    if acked_now > last_acked {
842                        last_acked = acked_now;
843                        escape_start = Instant::now();
844                    }
845                    if escape_start.elapsed() > RLC_BLOCK_ESCAPE {
846                        if self.ctrl.force(SensCode::Rs) {
847                            // Resend the un-acked tail [acked_through, items_total)
848                            // over RS, then this item.
849                            self.switch_rlc_to_rs()?;
850                            self.send_via_rs(&payload)?;
851                        } else {
852                            // A forced-RLC policy: honor it with the blocking send.
853                            self.rlc.send_item(&payload)?;
854                        }
855                        break;
856                    }
857                    std::thread::sleep(Duration::from_micros(50));
858                }
859            }
860            SensCode::Rs => {
861                self.send_via_rs(&payload)?;
862            }
863        }
864        // Record in the replay ring (global index = items_total), advance, and
865        // trim the delivered front + hard-cap.
866        self.sent_ring.push_back(payload);
867        self.items_total += 1;
868        self.trim_sent_ring();
869        if self.last_sample.elapsed() >= SWITCH_SAMPLE_PERIOD {
870            self.last_sample = Instant::now();
871            self.maybe_switch()?;
872        }
873        Ok(())
874    }
875
876    /// Evict replay-ring items RLC has confirmed delivered (below its cumulative
877    /// frontier) and hard-cap the ring length. Preserves the invariant
878    /// `items_total == ring_base + sent_ring.len()`.
879    fn trim_sent_ring(&mut self) {
880        if self.active == SensCode::Rlc {
881            let frontier = self.rlc.acked_through() as u64;
882            while self.ring_base < frontier && !self.sent_ring.is_empty() {
883                if let Some(buf) = self.sent_ring.pop_front() {
884                    self.recycle(buf);
885                }
886                self.ring_base += 1;
887            }
888        }
889        while self.sent_ring.len() > SENT_RING_CAP {
890            if let Some(buf) = self.sent_ring.pop_front() {
891                self.recycle(buf);
892            }
893            self.ring_base += 1;
894        }
895    }
896
897    /// Return a trimmed wire-payload buffer to the pool for reuse by the next
898    /// seal, capped so a shrinking ring does not pin idle memory.
899    fn recycle(&mut self, buf: Vec<u8>) {
900        if self.ring_pool.len() < RING_POOL_CAP {
901            self.ring_pool.push(buf);
902        }
903    }
904
905    /// RLC -> RS handover by RESEND (not drain): announce the boundary RLC has
906    /// delivered to, switch, and resend the un-acked tail `[boundary,
907    /// items_total)` over RS from the replay ring, in order. RS is reliable, so
908    /// it recovers the tail fast at any loss - no waiting on RLC's slow frontier
909    /// recovery. Falls back to draining RLC only if the cap evicted un-acked
910    /// items (so nothing is ever dropped).
911    fn switch_rlc_to_rs(&mut self) -> io::Result<()> {
912        let boundary = self.rlc.acked_through() as u64;
913        let frame = encode_code_switch(boundary, SensCode::Rs);
914        for _ in 0..CODE_SWITCH_REPEATS {
915            self.real.send_to(&frame, self.peer).ok();
916            std::thread::sleep(Duration::from_millis(2));
917        }
918        self.active = SensCode::Rs;
919        if boundary >= self.ring_base {
920            let start = (boundary - self.ring_base) as usize;
921            let end = self.sent_ring.len();
922            for i in start..end {
923                let item = self.sent_ring[i].clone();
924                self.send_via_rs(&item)?;
925            }
926        } else {
927            // Un-acked tail underflowed the cap: drain RLC so nothing is lost.
928            let target = self.rlc.next_source_id();
929            self.rlc.drain_until_acked(target, ESCAPE_DRAIN_TIMEOUT)?;
930        }
931        Ok(())
932    }
933
934    /// Send one item over RS, waiting out RS flow-control back-pressure (RS's ARQ
935    /// guarantees the window clears, so this wait is bounded by delivery, not by a
936    /// decode cliff). Shared by the RS steady state and the RLC escape handover.
937    fn send_via_rs(&mut self, item: &[u8]) -> io::Result<()> {
938        while self.rs.flow_blocked() {
939            self.rs.pump_feedback().ok();
940            if self.rs.flow_blocked() {
941                std::thread::sleep(Duration::from_micros(50));
942            }
943        }
944        self.rs.send_item(item)
945    }
946
947    /// Sample the active code's fed-back loss and switch codes if the controller
948    /// confirms a crossing of the configured thresholds.
949    fn maybe_switch(&mut self) -> io::Result<()> {
950        // The raw channel loss from sent-vs-received datagram counts: code-
951        // agnostic, so it does not collapse when the active code recovers the
952        // loss (which is what made the active code's own feedback flap).
953        let sent = self.sent_counter.load(Ordering::Relaxed);
954        let recv = self.fb_received.load(Ordering::Relaxed);
955        if recv == 0 {
956            return Ok(()); // no raw-loss report from the receiver yet
957        }
958        // Warmup: the in-flight window ramps 0 -> flow window at start, and that
959        // growth reads as loss; track the baseline but do not evaluate until it
960        // stabilizes, so the ramp does not trip a spurious switch.
961        if self.started.elapsed() < SWITCH_WARMUP {
962            self.prev_sent = sent;
963            self.prev_received = recv;
964            return Ok(());
965        }
966        if self.prev_received == 0 {
967            // First report: set the baseline, evaluate from the next window.
968            self.prev_sent = sent;
969            self.prev_received = recv;
970            return Ok(());
971        }
972        // Align the window to FEEDBACK arrivals: skip ticks with no new report,
973        // so a tick landing between reports does not read a spurious 100% loss
974        // (sent advanced, received not yet updated this window).
975        if recv <= self.prev_received {
976            return Ok(());
977        }
978        let sent_d = sent.saturating_sub(self.prev_sent);
979        if sent_d < MIN_LOSS_SAMPLE {
980            return Ok(()); // window too small to trust; keep accumulating
981        }
982        let recv_d = recv.saturating_sub(self.prev_received);
983        self.prev_sent = sent;
984        self.prev_received = recv;
985        let lost_d = sent_d.saturating_sub(recv_d) as f64;
986        // Size-weighted decaying loss: decay the lost / sent COUNTS and take their
987        // ratio, NOT an equal-weight EWMA of per-window ratios. A small feedback
988        // window with one drop reads a spuriously high ratio, and equal-weight
989        // averaging over-read low loss ~3.5x (3% measured as ~11%); weighting by
990        // datagram count makes large windows dominate so the estimate tracks the
991        // true channel loss. The 0.95 decay (effective window ~20 feedback samples)
992        // keeps it recent yet smooths the retransmit-burst windows that a tighter
993        // decay let spike across the up threshold and flap the code.
994        self.loss_acc = 0.95 * self.loss_acc + lost_d;
995        self.sent_acc = 0.95 * self.sent_acc + sent_d as f64;
996        self.ewma_loss = if self.sent_acc > 0.0 {
997            self.loss_acc / self.sent_acc
998        } else {
999            0.0
1000        };
1001        // Gate the switch until the accumulator has matured past its cold start: at
1002        // warmup-end loss_acc/sent_acc are near-empty, so the first post-warmup
1003        // window's raw ratio (a start-of-stream burst) would otherwise dominate the
1004        // estimate and trip a spurious up-switch. Keep accumulating, just do not act
1005        // on it yet.
1006        if self.post_warm_windows < MIN_ACCUM_WINDOWS {
1007            self.post_warm_windows += 1;
1008            return Ok(());
1009        }
1010        let loss_q8 = (self.ewma_loss * 256.0).clamp(0.0, 255.0) as u8;
1011        if let Some(to) = self.ctrl.observe(loss_q8) {
1012            self.do_switch(to)?;
1013        }
1014        Ok(())
1015    }
1016
1017    /// Code handover. RLC -> RS RESENDS the un-acked tail over RS (RS is reliable
1018    /// and fast at any loss, so it never waits on RLC's slow frontier recovery).
1019    /// RS -> RLC drains RS first (RS's ARQ clears its window quickly), then starts
1020    /// RLC from the fully-delivered boundary. In-order delivery holds either way.
1021    fn do_switch(&mut self, to: SensCode) -> io::Result<()> {
1022        match (self.active, to) {
1023            (SensCode::Rlc, SensCode::Rs) => self.switch_rlc_to_rs(),
1024            _ => self.do_switch_with_drain(to, DRAIN_TIMEOUT),
1025        }
1026    }
1027
1028    /// `do_switch` with an explicit drain deadline. The flow-block escape passes a
1029    /// generous one ([`ESCAPE_DRAIN_TIMEOUT`]) because draining a stuck window
1030    /// over a high-loss link (retransmitting its frontier, each copy itself
1031    /// lossy) takes far longer than a healthy handover.
1032    fn do_switch_with_drain(&mut self, to: SensCode, drain_timeout: Duration) -> io::Result<()> {
1033        match self.active {
1034            SensCode::Rlc => {
1035                let target = self.rlc.next_source_id();
1036                self.rlc.drain_until_acked(target, drain_timeout)?;
1037            }
1038            SensCode::Rs => {
1039                self.rs.flush()?;
1040                self.rs.drain_until_acked(drain_timeout)?;
1041            }
1042        }
1043        let frame = encode_code_switch(self.items_total, to);
1044        for _ in 0..CODE_SWITCH_REPEATS {
1045            self.real.send_to(&frame, self.peer).ok();
1046            std::thread::sleep(Duration::from_millis(2));
1047        }
1048        self.active = to;
1049        // Returning to RLC: another code carried [old RLC frontier, items_total),
1050        // so RLC's source-id stream diverged from the global index. Re-base it to
1051        // the global boundary so the resumed stream's source ids equal the global
1052        // item indices the receiver expects (it re-bases in lockstep on the same
1053        // boundary), instead of stalling on holes RLC will never resend or
1054        // replaying its stale pre-switch buffer.
1055        if to == SensCode::Rlc {
1056            self.rlc.skip_to(self.items_total as u32);
1057        }
1058        Ok(())
1059    }
1060
1061    /// Flush and drain the active code so the final items are delivered. Returns
1062    /// whether everything was acked before the deadline.
1063    pub fn finish(&mut self) -> io::Result<bool> {
1064        match self.active {
1065            SensCode::Rlc => {
1066                let target = self.rlc.next_source_id();
1067                self.rlc.drain_until_acked(target, Duration::from_secs(120))
1068            }
1069            SensCode::Rs => {
1070                self.rs.flush()?;
1071                self.rs.drain_until_acked(Duration::from_secs(120))
1072            }
1073        }
1074    }
1075
1076    /// Force the active code to `to` now (operator override), via the same
1077    /// handover an automatic switch uses (RLC->RS resend / RS->RLC drain), and
1078    /// keep the controller in sync so it does not immediately switch back. No-op
1079    /// if already on `to`.
1080    pub fn force_switch(&mut self, to: SensCode) -> io::Result<()> {
1081        if to != self.active {
1082            self.ctrl.force(to);
1083            self.do_switch(to)?;
1084        }
1085        Ok(())
1086    }
1087}
1088
1089impl Drop for UnifiedSensSender {
1090    fn drop(&mut self) {
1091        self.stop.store(true, Ordering::Relaxed);
1092        if let Some(h) = self.demux.take() {
1093            h.join().ok();
1094        }
1095    }
1096}
1097
1098// ---------------------------------------------------------------------------
1099// Unified receiver
1100// ---------------------------------------------------------------------------
1101
1102/// Unified Sens-O-Matic receiver: demuxes both codes off one socket and
1103/// delivers items in order across mid-stream code switches. The sender's
1104/// drain-barrier guarantees the old code is fully delivered before the new code
1105/// starts, so the receiver simply runs the active decoder and switches at the
1106/// announced boundary.
1107pub struct UnifiedSensReceiver {
1108    real: Arc<UdpSocket>,
1109    rlc: SensOMaticRlcReceiver,
1110    rs: ReliableUdpReceiver,
1111    active: SensCode,
1112    switch_signal: SwitchSignal,
1113    pending_switch: Option<(u64, SensCode)>,
1114    delivered_total: u64,
1115    /// Global index of the next item the RS decoder will deliver. RS delivers in
1116    /// its own local order; this maps that to the global stream so the un-acked
1117    /// tail an RLC->RS handover resends over RS can be deduped against what RLC
1118    /// already delivered. Set to the handover boundary on RLC->RS; advances per RS
1119    /// item thereafter.
1120    rs_next_global: u64,
1121    switches: u64,
1122    /// Unified AEAD record layer (TLS feature). When set, each item a decoder
1123    /// delivers is opened with its global index as the packet number before it
1124    /// reaches the application; duplicates (the resend overlap) are skipped before
1125    /// opening, so the packet number always matches the seal. A `OnceLock` shared
1126    /// with the handshake driver: the one-port server completes its handshake on a
1127    /// thread (the QUIC endpoint owns the socket, so the Sens handshake rides the
1128    /// demux queue) and publishes the keys here once; `bind_tls` sets it inline.
1129    #[cfg(feature = "tls")]
1130    crypto: Arc<std::sync::OnceLock<crate::rlc_crypto::CryptoState>>,
1131    /// TLS is expected on this receiver (set by `bind_tls` / `from_shared_tls`):
1132    /// `poll` withholds delivery until `crypto` is published, so a data frame that
1133    /// races ahead of the handshake completion is never opened with absent keys.
1134    #[cfg(feature = "tls")]
1135    expect_tls: bool,
1136    stop: Arc<AtomicBool>,
1137    demux: Option<JoinHandle<()>>,
1138}
1139
1140impl UnifiedSensReceiver {
1141    /// Bind `local` and bring up both decoders sharing it.
1142    pub fn bind<A: ToSocketAddrs>(local: A, cfg: UnifiedConfig) -> io::Result<Self> {
1143        let udp = UdpSocket::bind(local)?;
1144        udp.set_nonblocking(true)?;
1145        Self::assemble(udp, cfg, 0)
1146    }
1147
1148    /// Like [`bind`](Self::bind) but runs a TLS 1.3 server handshake first and
1149    /// AEAD-opens every delivered item: the WAN-confidential counterpart to
1150    /// [`UnifiedSensSender::connect_tls`]. The handshake completes before the
1151    /// demux reader takes the socket.
1152    #[cfg(feature = "tls")]
1153    pub fn bind_tls<A: ToSocketAddrs>(
1154        local: A,
1155        cfg: UnifiedConfig,
1156        tls: std::sync::Arc<rustls::ServerConfig>,
1157    ) -> io::Result<Self> {
1158        let udp = UdpSocket::bind(local)?;
1159        udp.set_nonblocking(true)?;
1160        let mut cs = crate::rlc_crypto::CryptoState::new_server(tls)
1161            .map_err(io::Error::other)?;
1162        let hs = DgramSock::from_udp(udp.try_clone()?);
1163        crate::sens_rlc::drive_handshake(&hs, None, &mut cs, false)?;
1164        let mut s = Self::assemble(udp, cfg, crate::rlc_crypto::TAG_LEN)?;
1165        s.crypto.set(cs).ok();
1166        s.expect_tls = true;
1167        Ok(s)
1168    }
1169
1170    /// Build the receiver over an already-bound (and, for TLS, already-handshaked)
1171    /// socket: bring up both decoders sharing it and spawn the demux reader.
1172    fn assemble(udp: UdpSocket, cfg: UnifiedConfig, seal_overhead: usize) -> io::Result<Self> {
1173        // The decoder must accept the sealed wire width (item + AEAD tag under
1174        // TLS); the RS decoder learns its shard width from the wire header, so
1175        // only the RLC decoder's symbol size needs widening here.
1176        let wire_sym = cfg.symbol_len + seal_overhead;
1177        let thread_sock = udp.try_clone()?;
1178        thread_sock.set_nonblocking(true)?;
1179        let real = Arc::new(udp);
1180        let rlc_q = new_demux_queue();
1181        let rs_q = new_demux_queue();
1182
1183        // No per-code debug loss: the unified path injects loss uniformly at the
1184        // demux (below), modelling a real lossy link AND letting the raw-loss
1185        // estimate see it (a sub-receiver drop would be invisible to the demux
1186        // count).
1187        let mut rlc = SensOMaticRlcReceiver::bind("0.0.0.0:0", wire_sym)?;
1188        rlc.set_sock(DgramSock::demux(Arc::clone(&real), Arc::clone(&rlc_q)));
1189
1190        let mut rs = ReliableUdpReceiver::bind("0.0.0.0:0")?;
1191        rs.set_sock(DgramSock::demux(Arc::clone(&real), Arc::clone(&rs_q)));
1192
1193        let switch_signal: SwitchSignal = Arc::new(Mutex::new(None));
1194        let recv_counter = Arc::new(AtomicU64::new(0));
1195        let stop = Arc::new(AtomicBool::new(false));
1196        let demux = spawn_demux(
1197            thread_sock,
1198            rlc_q,
1199            rs_q,
1200            Some(Arc::clone(&switch_signal)),
1201            Some(recv_counter),
1202            None,
1203            cfg.debug_loss,
1204            cfg.seed,
1205            Arc::clone(&stop),
1206        );
1207
1208        Ok(Self {
1209            real,
1210            rlc,
1211            rs,
1212            active: cfg.policy.initial_code(),
1213            switch_signal,
1214            pending_switch: None,
1215            delivered_total: 0,
1216            rs_next_global: 0,
1217            switches: 0,
1218            #[cfg(feature = "tls")]
1219            crypto: Arc::new(std::sync::OnceLock::new()),
1220            #[cfg(feature = "tls")]
1221            expect_tls: false,
1222            stop,
1223            demux: Some(demux),
1224        })
1225    }
1226
1227    /// Build a receiver fed by an EXTERNAL demux (the one-port QUIC endpoint's
1228    /// socket routes Sens datagrams into `rlc_q` / `rs_q` / `switch_signal` and
1229    /// tallies `recv_counter`). `send_sock` is a clone of the shared socket for
1230    /// control + raw-loss feedback. No demux thread is spawned (the QUIC socket
1231    /// feeds the queues); a small reporter thread sends the feedback to the peer
1232    /// the QUIC socket records in `sens_peer`.
1233    #[allow(clippy::too_many_arguments)]
1234    pub fn from_shared(
1235        send_sock: Arc<UdpSocket>,
1236        rlc_q: DemuxQueue,
1237        rs_q: DemuxQueue,
1238        switch_signal: SwitchSignal,
1239        recv_counter: Arc<AtomicU64>,
1240        sens_peer: Arc<Mutex<Option<SocketAddr>>>,
1241        cfg: UnifiedConfig,
1242        seal_overhead: usize,
1243    ) -> io::Result<Self> {
1244        // The RLC decoder must accept the sealed wire width (item + AEAD tag under
1245        // TLS) so it frames the symbols the sender shipped; the RS decoder learns
1246        // its shard width from the wire header, so only the RLC width needs it.
1247        let mut rlc = SensOMaticRlcReceiver::bind("0.0.0.0:0", cfg.symbol_len + seal_overhead)?;
1248        rlc.set_sock(DgramSock::demux(Arc::clone(&send_sock), rlc_q));
1249        let mut rs = ReliableUdpReceiver::bind("0.0.0.0:0")?;
1250        rs.set_sock(DgramSock::demux(Arc::clone(&send_sock), rs_q));
1251        let stop = Arc::new(AtomicBool::new(false));
1252        let demux = spawn_fb_reporter(Arc::clone(&send_sock), recv_counter, sens_peer, Arc::clone(&stop));
1253        Ok(Self {
1254            real: send_sock,
1255            rlc,
1256            rs,
1257            active: cfg.policy.initial_code(),
1258            switch_signal,
1259            pending_switch: None,
1260            delivered_total: 0,
1261            rs_next_global: 0,
1262            switches: 0,
1263            #[cfg(feature = "tls")]
1264            crypto: Arc::new(std::sync::OnceLock::new()),
1265            #[cfg(feature = "tls")]
1266            expect_tls: false,
1267            stop,
1268            demux: Some(demux),
1269        })
1270    }
1271
1272    /// Like [`from_shared`](Self::from_shared) but runs a TLS 1.3 server handshake
1273    /// over the demux'd `hs_q`. The one-port QUIC endpoint owns the socket, so the
1274    /// Sens handshake cannot own a recv loop; it rides the same demux queue as data
1275    /// (the demux routes `PKT_RLC_CRYPTO` frames into `hs_q`). The handshake runs
1276    /// on a thread and publishes the 1-RTT keys to the shared `crypto` cell once
1277    /// complete; `poll` withholds delivery until then. Returns immediately so the
1278    /// caller can start the QUIC + Sens clients that drive the handshake.
1279    #[cfg(feature = "tls")]
1280    #[allow(clippy::too_many_arguments)]
1281    pub fn from_shared_tls(
1282        send_sock: Arc<UdpSocket>,
1283        rlc_q: DemuxQueue,
1284        rs_q: DemuxQueue,
1285        hs_q: DemuxQueue,
1286        switch_signal: SwitchSignal,
1287        recv_counter: Arc<AtomicU64>,
1288        sens_peer: Arc<Mutex<Option<SocketAddr>>>,
1289        cfg: UnifiedConfig,
1290        tls: std::sync::Arc<rustls::ServerConfig>,
1291    ) -> io::Result<Self> {
1292        let mut s = Self::from_shared(
1293            Arc::clone(&send_sock),
1294            rlc_q,
1295            rs_q,
1296            switch_signal,
1297            recv_counter,
1298            sens_peer,
1299            cfg,
1300            crate::rlc_crypto::TAG_LEN,
1301        )?;
1302        s.expect_tls = true;
1303        let crypto = Arc::clone(&s.crypto);
1304        let stop = Arc::clone(&s.stop);
1305        let hs_sock = DgramSock::demux(send_sock, hs_q);
1306        std::thread::spawn(move || {
1307            let mut cs = match crate::rlc_crypto::CryptoState::new_server(tls) {
1308                Ok(c) => c,
1309                Err(_) => return,
1310            };
1311            // Drive the server handshake over the demux'd queue (peer learned from
1312            // the first flight); publish the keys once the 1-RTT secrets derive.
1313            if !stop.load(Ordering::Relaxed)
1314                && crate::sens_rlc::drive_handshake(&hs_sock, None, &mut cs, false).is_ok()
1315            {
1316                crypto.set(cs).ok();
1317            }
1318        });
1319        Ok(s)
1320    }
1321
1322    /// The decoder currently delivering.
1323    pub fn active_code(&self) -> SensCode {
1324        self.active
1325    }
1326
1327    /// Code switches the receiver has followed.
1328    pub fn switches(&self) -> u64 {
1329        self.switches
1330    }
1331
1332    /// Whether either decoder adopted a replacement session since this was
1333    /// last called, clearing the flag. Edge-triggered: one report per
1334    /// adoption.
1335    pub fn take_session_changed(&mut self) -> bool {
1336        let rlc = self.rlc.take_session_changed();
1337        let rs = self.rs.take_session_changed();
1338        rlc || rs
1339    }
1340
1341    /// `(adopted, challenges_that_went_unanswered)` for replacement
1342    /// The RLC connection ids holding a decode window, in first-seen order.
1343    /// Empty before any peer is seen.
1344    pub fn live_rlc_sessions(&self) -> Vec<u64> {
1345        self.rlc.live_sessions()
1346    }
1347
1348    /// The block-RS session epochs holding a decode window, in first-seen
1349    /// order.
1350    pub fn live_rs_sessions(&self) -> Vec<u32> {
1351        self.rs.live_sessions()
1352    }
1353
1354    /// Peers refused a decode window on either code.
1355    pub fn session_refusals(&self) -> u64 {
1356        self.rlc.session_refusals() + self.rs.session_refusals()
1357    }
1358
1359    /// One RLC session's delivery position: `(delivered_through,
1360    /// highest_seen)`, or `None` for an id with no window.
1361    pub fn rlc_session_frontier(&self, cid: u64) -> Option<(u32, u32)> {
1362        self.rlc.session_frontier(cid)
1363    }
1364
1365    /// sessions, summed over both codes. A refused forgery raises the
1366    /// second without the first.
1367    pub fn session_adoption_counts(&self) -> (u64, u64) {
1368        let (ra, rf) = self.rlc.session_adoption_counts();
1369        let (sa, sf) = self.rs.session_adoption_counts();
1370        (ra + sa, rf + sf)
1371    }
1372
1373    /// The bound local address.
1374    pub fn local_addr(&self) -> io::Result<SocketAddr> {
1375        self.real.local_addr()
1376    }
1377
1378    /// Recover an item from a delivered wire payload: AEAD-open (TLS) with `pn`
1379    /// the item's global index, or pass the bytes through. A failed open (a
1380    /// tampered datagram) surfaces as an error rather than delivering bad data.
1381    #[cfg_attr(not(feature = "tls"), allow(unused_variables, unused_mut))]
1382    fn open_payload(&self, mut payload: Vec<u8>, pn: u64) -> io::Result<Vec<u8>> {
1383        #[cfg(feature = "tls")]
1384        if let Some(cs) = self.crypto.get() {
1385            let n = cs
1386                .open(pn, &mut payload)
1387                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1388            payload.truncate(n);
1389            return Ok(payload);
1390        }
1391        Ok(payload)
1392    }
1393
1394    /// Drive the active decoder and return the items it delivered this call,
1395    /// each tagged with the identity of the peer that sent it: the RLC
1396    /// connection id, or the block-RS session epoch widened to `u64`.
1397    ///
1398    /// Both codes decode a window per peer. The code-switch layer above them
1399    /// does not: the delivery frontier, the switch boundary and the TLS packet
1400    /// number are per endpoint. A mesh node pins a code and leaves TLS off, or
1401    /// drives [`SensOMaticRlcReceiver`] / [`ReliableUdpReceiver`] directly.
1402    pub fn poll_from(&mut self) -> io::Result<Vec<(u64, Vec<u8>)>> {
1403        self.poll_tagged()
1404    }
1405
1406    /// Drive the active decoder and return the items it delivered this call.
1407    /// Honors a pending CODE_SWITCH once the active decoder has delivered every
1408    /// item up to the announced boundary.
1409    pub fn poll(&mut self) -> io::Result<Vec<Vec<u8>>> {
1410        Ok(self.poll_tagged()?.into_iter().map(|(_, item)| item).collect())
1411    }
1412
1413    /// The one drain both public forms share, carrying each item's peer tag
1414    /// from the decoder that delivered it rather than reconstructing it after.
1415    fn poll_tagged(&mut self) -> io::Result<Vec<(u64, Vec<u8>)>> {
1416        // One-port TLS: the handshake completes asynchronously on a thread (the
1417        // QUIC endpoint owns the socket), so until the keys are published, withhold
1418        // delivery. The decoders keep buffering inbound frames; the peer only sends
1419        // data after ITS handshake finished, so the backlog is at most a few frames
1420        // and they open correctly once the keys land. (bind_tls sets the keys
1421        // inline before returning, so this gate is already clear there.)
1422        #[cfg(feature = "tls")]
1423        if self.expect_tls && self.crypto.get().is_none() {
1424            return Ok(Vec::new());
1425        }
1426        if self.pending_switch.is_none() {
1427            self.pending_switch = self.switch_signal.lock().unwrap().take();
1428        }
1429        let out = match self.active {
1430            SensCode::Rlc => {
1431                // Open each payload with its global index as the packet number.
1432                // The tag rides from the decoder, so an item is attributed to the
1433                // peer that actually sent it rather than to whoever spoke last.
1434                let raw = self.rlc.poll_from()?;
1435                let mut d = Vec::with_capacity(raw.len());
1436                for (cid, payload) in raw {
1437                    let item = self.open_payload(payload, self.delivered_total)?;
1438                    self.delivered_total += 1;
1439                    d.push((cid, item));
1440                }
1441                d
1442            }
1443            SensCode::Rs => {
1444                // RS delivers in its own local order; map each to its global index
1445                // (rs_next_global, advancing per item). After an RLC->RS resend
1446                // handover the leading items overlap what RLC already delivered, so
1447                // drop any whose global index is below the delivery frontier
1448                // (before opening, so the packet number always matches the seal).
1449                //
1450                // The tag is the sending peer's session epoch, widened.
1451                let raw = self.rs.poll_from()?;
1452                let mut d = Vec::with_capacity(raw.len());
1453                for (epoch, payload) in raw {
1454                    if self.rs_next_global >= self.delivered_total {
1455                        let item = self.open_payload(payload, self.rs_next_global)?;
1456                        self.delivered_total += 1;
1457                        d.push((u64::from(epoch), item));
1458                    }
1459                    self.rs_next_global += 1;
1460                }
1461                d
1462            }
1463        };
1464        if let Some((boundary, to)) = self.pending_switch
1465            && self.delivered_total >= boundary
1466        {
1467            // The sender repeats CODE_SWITCH for reliability; only act (and
1468            // count) when the target differs from the active code, so the
1469            // repeats do not inflate the switch tally or re-switch.
1470            if to != self.active {
1471                match to {
1472                    SensCode::Rs => {
1473                        // The RS stream resumes at the boundary (RLC's delivery
1474                        // frontier); index its local order from there.
1475                        self.rs_next_global = boundary;
1476                    }
1477                    SensCode::Rlc => {
1478                        // Returning to RLC: re-base the decoder to the boundary so
1479                        // it delivers the resumed stream from there (whose source
1480                        // ids the sender re-aligned to the global index) and does
1481                        // not replay its stale pre-switch buffer or stall on holes
1482                        // the other code already delivered.
1483                        self.rlc.skip_to(boundary as u32);
1484                    }
1485                }
1486                self.active = to;
1487                self.switches += 1;
1488            }
1489            self.pending_switch = None;
1490        }
1491        Ok(out)
1492    }
1493}
1494
1495impl Drop for UnifiedSensReceiver {
1496    fn drop(&mut self) {
1497        self.stop.store(true, Ordering::Relaxed);
1498        if let Some(h) = self.demux.take() {
1499            h.join().ok();
1500        }
1501    }
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506    use super::*;
1507
1508    #[test]
1509    fn forced_policies_never_switch() {
1510        for policy in [CodePolicy::ForceRlc, CodePolicy::ForceRs] {
1511            let mut c = CodeSwitchController::with_policy(policy);
1512            let start = c.code();
1513            for q in [0u8, 80, 200, 255, 10, 0] {
1514                assert_eq!(c.observe(q), None, "forced policy must not switch");
1515            }
1516            assert_eq!(c.code(), start);
1517            assert_eq!(c.switches(), 0);
1518        }
1519    }
1520
1521    #[test]
1522    fn force_rs_starts_on_rs() {
1523        let c = CodeSwitchController::with_policy(CodePolicy::ForceRs);
1524        assert_eq!(c.code(), SensCode::Rs);
1525    }
1526
1527    #[test]
1528    fn auto_starts_on_rlc_then_up_switches_when_loss_sustains() {
1529        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1530        assert_eq!(c.code(), SensCode::Rlc);
1531        // 12% loss (q8 ~30) is below the ~15% up threshold (q8 38): no switch.
1532        assert_eq!(c.observe(30), None);
1533        assert_eq!(c.observe(30), None);
1534        assert_eq!(c.code(), SensCode::Rlc);
1535        // 18% loss (q8 46) above the up threshold: one sample arms, the second
1536        // (up_hold = 2) confirms the switch to RS.
1537        assert_eq!(c.observe(46), None, "first over-threshold sample only arms");
1538        assert_eq!(c.observe(46), Some(SensCode::Rs), "second confirms up-switch");
1539        assert_eq!(c.code(), SensCode::Rs);
1540        assert_eq!(c.switches(), 1);
1541    }
1542
1543    #[test]
1544    fn stall_escape_latches_rs_and_does_not_flap() {
1545        // A flow-block escape to RS (RLC stalled at this loss) must NOT down-switch
1546        // back even when the loss estimate sits below the down threshold: returning
1547        // to a code that just stalled flaps, and the RS->RLC handover then corrupts
1548        // in-order delivery. The latch holds RS after a stall-escape.
1549        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 4);
1550        assert!(c.force(SensCode::Rs), "stall-escape forces to RS");
1551        assert_eq!(c.code(), SensCode::Rs);
1552        for i in 0..20 {
1553            assert_eq!(c.observe(5), None, "latched RS must not down-switch at tick {i}");
1554        }
1555        assert_eq!(c.code(), SensCode::Rs);
1556        assert_eq!(c.switches(), 1, "no flap: only the one escape switch");
1557    }
1558
1559    #[test]
1560    fn a_single_loss_spike_does_not_flap_the_code() {
1561        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1562        // One isolated spike over the threshold then back down: up_hold = 2 is
1563        // not met, so no switch (the streak resets on the low sample).
1564        assert_eq!(c.observe(200), None);
1565        assert_eq!(c.observe(10), None);
1566        assert_eq!(c.observe(200), None);
1567        assert_eq!(c.code(), SensCode::Rlc, "an isolated spike must not switch");
1568        assert_eq!(c.switches(), 0);
1569    }
1570
1571    #[test]
1572    fn down_switch_needs_a_longer_sustained_low_streak() {
1573        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1574        // Drive up to RS first.
1575        c.observe(80);
1576        assert_eq!(c.observe(80), Some(SensCode::Rs));
1577        // Loss drops below the 10% down threshold (q8 26). It must SUSTAIN for
1578        // down_hold = 8 samples; a brief low spell does not relax the code.
1579        for _ in 0..7 {
1580            assert_eq!(c.observe(10), None, "down-switch must not fire early");
1581        }
1582        assert_eq!(c.observe(10), Some(SensCode::Rlc), "8th low sample relaxes to RLC");
1583        assert_eq!(c.code(), SensCode::Rlc);
1584        assert_eq!(c.switches(), 2);
1585    }
1586
1587    #[test]
1588    fn hysteresis_band_holds_rs_between_thresholds() {
1589        let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1590        c.observe(80);
1591        c.observe(80); // now on RS
1592        assert_eq!(c.code(), SensCode::Rs);
1593        // Loss in the band (down_q8=26 < q8=32 < up_q8=38): neither relaxes nor
1594        // re-arms; RS holds across the whole band (no flapping).
1595        for _ in 0..20 {
1596            assert_eq!(c.observe(32), None);
1597        }
1598        assert_eq!(c.code(), SensCode::Rs, "RS holds inside the hysteresis band");
1599    }
1600
1601    // A real two-socket loopback round trip that forces an RLC -> RS handover
1602    // mid-stream and asserts every item is delivered exactly once, in order,
1603    // across the switch. Exercises the demux sockets, the drain-barrier, the
1604    // CODE_SWITCH frame, and the receiver's boundary merge end to end.
1605    /// Two concurrent senders through the unified endpoint, pinned to RLC (the
1606    /// mesh shape, and the code Auto runs at low loss). Every item of both
1607    /// streams must arrive, and `poll_from` must attribute each to the peer
1608    /// that actually sent it.
1609    ///
1610    /// The tag assertion is the point. Delivery alone passes even when every
1611    /// item is labelled with whoever spoke last, which is the misattribution a
1612    /// mesh node cannot detect from its own side.
1613    /// The same two-peer shape pinned to block-RS. The unified endpoint hands
1614    /// its RS half a demux socket, which is shared and fed by a reader that
1615    /// takes every source address, so that receiver has to route by session
1616    /// epoch rather than serve one peer.
1617    /// Three peers through the unified endpoint on block-RS. Two is not enough
1618    /// to exercise admission: one peer always takes the free first-admission
1619    /// slot, so a broken challenge path still delivers both. Three forces two
1620    /// separate challenges, and the challenge answer travels back over the
1621    /// sender's demux socket.
1622    /// Two peers on DIFFERENT codes through one receiver. Under `Auto` each
1623    /// sender runs its own switch controller, so a mesh whose links see
1624    /// different loss can have peers disagree about which code is live.
1625    ///
1626    /// The receiver holds one `active` code and polls only that decoder, so a
1627    /// peer sending the other code is never drained. This is the endpoint-wide
1628    /// switch boundary meeting a per-peer topology.
1629    #[test]
1630    #[ignore = "subetha-11: one active code per endpoint; peers on different codes are not both drained"]
1631    fn unified_peers_on_different_codes_both_deliver() {
1632        use std::sync::mpsc;
1633        let sym = 64usize;
1634        let base = UnifiedConfig {
1635            policy: CodePolicy::default_auto(),
1636            symbol_len: sym,
1637            k: 8,
1638            r: 2,
1639            rlc_flow_window: 256,
1640            debug_loss: 0,
1641            seed: 1,
1642            rlc_step: 4,
1643            rlc_static: false,
1644        };
1645        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", base).unwrap();
1646        let addr = recv.local_addr().unwrap();
1647        let per_peer: u64 = 40;
1648        let total = per_peer * 2;
1649
1650        let (tx, rx) = mpsc::channel();
1651        let rh = std::thread::spawn(move || {
1652            let mut recv = recv;
1653            let mut got: Vec<u64> = Vec::new();
1654            let start = Instant::now();
1655            while (got.len() as u64) < total && start.elapsed() < Duration::from_secs(20) {
1656                let items = recv.poll().unwrap_or_default();
1657                let empty = items.is_empty();
1658                for it in items {
1659                    let mut s = [0u8; 8];
1660                    s.copy_from_slice(&it[..8]);
1661                    got.push(u64::from_le_bytes(s));
1662                }
1663                if empty {
1664                    std::thread::sleep(Duration::from_micros(200));
1665                }
1666            }
1667            tx.send(got).ok();
1668        });
1669
1670        // One peer pinned to each code, which is the steady state a divergent
1671        // Auto switch reaches.
1672        let mut handles = Vec::new();
1673        for (p, policy) in [CodePolicy::ForceRlc, CodePolicy::ForceRs].into_iter().enumerate() {
1674            let mut cfg = base;
1675            cfg.policy = policy;
1676            handles.push(std::thread::spawn(move || {
1677                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1678                let mut buf = vec![0u8; 8];
1679                for i in 0..per_peer {
1680                    buf[..8].copy_from_slice(&(((p as u64) << 56) | i).to_le_bytes());
1681                    if send.send_item(&buf).is_err() {
1682                        break;
1683                    }
1684                }
1685                send.finish().ok();
1686            }));
1687        }
1688        for h in handles {
1689            h.join().ok();
1690        }
1691
1692        let got = rx.recv_timeout(Duration::from_secs(25)).unwrap();
1693        rh.join().ok();
1694        for p in 0..2u64 {
1695            let mine: Vec<u64> = got
1696                .iter()
1697                .filter(|v| (*v >> 56) == p)
1698                .map(|v| v & 0x00FF_FFFF_FFFF_FFFF)
1699                .collect();
1700            assert_eq!(
1701                mine,
1702                (0..per_peer).collect::<Vec<_>>(),
1703                "peer {p} was not drained; the receiver polls one active code",
1704            );
1705        }
1706    }
1707
1708    /// poll() must return promptly whether or not traffic is flowing: a mesh
1709    /// consumer polls one receiver per node in a loop, and a poll that blocks
1710    /// for seconds starves every other duty on that loop. Measured on a
1711    /// four-node mesh: a strict 1Hz log printed ~6 samples in ~40s.
1712    #[test]
1713    fn unified_poll_returns_promptly_under_sparse_traffic() {
1714        use std::sync::mpsc;
1715        let sym = 64usize;
1716        let cfg = UnifiedConfig {
1717            policy: CodePolicy::ForceRlc,
1718            symbol_len: sym,
1719            k: 8,
1720            r: 2,
1721            rlc_flow_window: 256,
1722            debug_loss: 0,
1723            seed: 1,
1724            rlc_step: 4,
1725            rlc_static: false,
1726        };
1727        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
1728        let addr = recv.local_addr().unwrap();
1729
1730        // Three peers on heartbeat-shaped traffic, one dying early: the mesh
1731        // shape where the seconds-scale poll was measured.
1732        let (done_tx, done_rx) = mpsc::channel::<()>();
1733        let done_rx = std::sync::Arc::new(std::sync::Mutex::new(done_rx));
1734        let mut senders = Vec::new();
1735        for p in 0..3u64 {
1736            let done_rx = std::sync::Arc::clone(&done_rx);
1737            senders.push(std::thread::spawn(move || {
1738                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1739                let buf = vec![7u8; 8];
1740                std::thread::sleep(Duration::from_millis(150 * p));
1741                let n = if p == 1 { 2 } else { 8 };
1742                for _ in 0..n {
1743                    if send.send_item(&buf).is_err() {
1744                        break;
1745                    }
1746                    std::thread::sleep(Duration::from_millis(400));
1747                }
1748                if p == 1 {
1749                    return;
1750                }
1751                done_rx.lock().unwrap().recv_timeout(Duration::from_secs(20)).ok();
1752            }));
1753        }
1754
1755        let mut recv = recv;
1756        let mut worst = Duration::ZERO;
1757        let start = Instant::now();
1758        while start.elapsed() < Duration::from_secs(6) {
1759            let t = Instant::now();
1760            recv.poll().ok();
1761            worst = worst.max(t.elapsed());
1762        }
1763        done_tx.send(()).ok();
1764        done_tx.send(()).ok();
1765        for s in senders {
1766            s.join().ok();
1767        }
1768        assert!(
1769            worst < Duration::from_millis(500),
1770            "a single poll() blocked for {worst:?} under sparse traffic",
1771        );
1772    }
1773
1774    /// Three peers through the unified endpoint on ForceRlc, sending SPARSELY -
1775    /// one small item every 300ms - with one going silent partway. The
1776    /// consumer's topology: a heartbeat mesh where a node dies.
1777    ///
1778    /// Combines what the other multi-peer tests each cover separately: the
1779    /// demux socket, sparse traffic that lets the receiver's timers run between
1780    /// frames, and a peer that stops.
1781    #[test]
1782    fn unified_three_sparse_peers_survive_one_going_silent() {
1783        use std::sync::mpsc;
1784        let sym = 64usize;
1785        let cfg = UnifiedConfig {
1786            policy: CodePolicy::ForceRlc,
1787            symbol_len: sym,
1788            k: 8,
1789            r: 2,
1790            rlc_flow_window: 256,
1791            debug_loss: 0,
1792            seed: 1,
1793            rlc_step: 4,
1794            rlc_static: false,
1795        };
1796        let rounds: u64 = 10;
1797        let silent_after: u64 = 3;
1798        let peers: u64 = 3;
1799
1800        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
1801        let addr = recv.local_addr().unwrap();
1802        let (stop_tx, stop_rx) = mpsc::channel::<()>();
1803        let rh = std::thread::spawn(move || {
1804            let mut recv = recv;
1805            let mut got: Vec<(u64, u64)> = Vec::new();
1806            let start = Instant::now();
1807            while start.elapsed() < Duration::from_secs(15) && stop_rx.try_recv().is_err() {
1808                let batch: Vec<(u64, Vec<u8>)> = recv.poll_from().unwrap_or_default();
1809                for (tag, it) in batch {
1810                    let mut s = [0u8; 8];
1811                    s.copy_from_slice(&it[..8]);
1812                    let v = u64::from_le_bytes(s);
1813                    got.push((tag, v));
1814                }
1815                std::thread::sleep(Duration::from_millis(2));
1816            }
1817            got
1818        });
1819
1820        let mut handles = Vec::new();
1821        for p in 0..peers {
1822            handles.push(std::thread::spawn(move || {
1823                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1824                let mut buf = vec![0u8; 8];
1825                let n = if p == 2 { silent_after } else { rounds };
1826                for i in 0..n {
1827                    buf[..8].copy_from_slice(&((p << 56) | i).to_le_bytes());
1828                    if send.send_item(&buf).is_err() {
1829                        break;
1830                    }
1831                    std::thread::sleep(Duration::from_millis(300));
1832                }
1833                if p != 2 {
1834                    std::thread::sleep(Duration::from_secs(2));
1835                }
1836                send.finish().ok();
1837            }));
1838        }
1839        for h in handles {
1840            h.join().ok();
1841        }
1842        stop_tx.send(()).ok();
1843        let got: Vec<(u64, u64)> = rh.join().expect("collector thread");
1844
1845        let tags: std::collections::BTreeSet<u64> = got.iter().map(|(t, _)| *t).collect();
1846        for p in 0..2u64 {
1847            let mine: Vec<u64> = got
1848                .iter()
1849                .filter(|(_, v)| (*v >> 56) == p)
1850                .map(|(_, v)| v & 0x00FF_FFFF_FFFF_FFFF)
1851                .collect();
1852            assert_eq!(
1853                mine,
1854                (0..rounds).collect::<Vec<_>>(),
1855                "surviving peer {p} stopped being delivered; got {} of {rounds}, \
1856                 tags seen {tags:?}",
1857                mine.len(),
1858            );
1859        }
1860    }
1861
1862    #[test]
1863    fn unified_three_peers_on_block_rs_all_deliver() {
1864        use std::sync::mpsc;
1865        let sym = 64usize;
1866        let cfg = UnifiedConfig {
1867            policy: CodePolicy::ForceRs,
1868            symbol_len: sym,
1869            k: 8,
1870            r: 2,
1871            rlc_flow_window: 256,
1872            debug_loss: 0,
1873            seed: 1,
1874            rlc_step: 4,
1875            rlc_static: false,
1876        };
1877        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
1878        let addr = recv.local_addr().unwrap();
1879        let per_peer: u64 = 50;
1880        let peers: u64 = 3;
1881        let total = per_peer * peers;
1882
1883        let (tx, rx) = mpsc::channel();
1884        let rh = std::thread::spawn(move || {
1885            let mut recv = recv;
1886            let mut got: Vec<u64> = Vec::with_capacity(total as usize);
1887            let start = Instant::now();
1888            while (got.len() as u64) < total && start.elapsed() < Duration::from_secs(30) {
1889                let items = recv.poll().unwrap_or_default();
1890                let empty = items.is_empty();
1891                for it in items {
1892                    let mut s = [0u8; 8];
1893                    s.copy_from_slice(&it[..8]);
1894                    got.push(u64::from_le_bytes(s));
1895                }
1896                if empty {
1897                    std::thread::sleep(Duration::from_micros(200));
1898                }
1899            }
1900            tx.send(got).ok();
1901        });
1902
1903        let gate = Arc::new(std::sync::Barrier::new(peers as usize));
1904        let mut handles = Vec::new();
1905        for p in 0..peers {
1906            let gate = Arc::clone(&gate);
1907            handles.push(std::thread::spawn(move || {
1908                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1909                let mut buf = vec![0u8; 8];
1910                gate.wait();
1911                let start = Instant::now();
1912                for i in 0..per_peer {
1913                    if start.elapsed() > Duration::from_secs(20) {
1914                        break;
1915                    }
1916                    buf[..8].copy_from_slice(&((p << 56) | i).to_le_bytes());
1917                    if send.send_item(&buf).is_err() {
1918                        break;
1919                    }
1920                }
1921                send.finish().ok();
1922            }));
1923        }
1924        for h in handles {
1925            h.join().ok();
1926        }
1927
1928        let got = rx.recv_timeout(Duration::from_secs(35)).unwrap();
1929        rh.join().ok();
1930        for p in 0..peers {
1931            let mine: Vec<u64> = got
1932                .iter()
1933                .filter(|v| (*v >> 56) == p)
1934                .map(|v| v & 0x00FF_FFFF_FFFF_FFFF)
1935                .collect();
1936            assert_eq!(
1937                mine,
1938                (0..per_peer).collect::<Vec<_>>(),
1939                "peer {p} of {peers} did not deliver through the unified block-RS path",
1940            );
1941        }
1942    }
1943
1944    #[test]
1945    fn unified_two_peers_on_block_rs_both_deliver() {
1946        use std::sync::mpsc;
1947        let sym = 64usize;
1948        let cfg = UnifiedConfig {
1949            policy: CodePolicy::ForceRs,
1950            symbol_len: sym,
1951            k: 8,
1952            r: 2,
1953            rlc_flow_window: 256,
1954            debug_loss: 0,
1955            seed: 1,
1956            rlc_step: 4,
1957            rlc_static: false,
1958        };
1959        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
1960        let addr = recv.local_addr().unwrap();
1961        let per_peer: u64 = 60;
1962        let peers: u64 = 2;
1963        let total = per_peer * peers;
1964
1965        let (tx, rx) = mpsc::channel();
1966        let rh = std::thread::spawn(move || {
1967            let mut recv = recv;
1968            let mut got: Vec<u64> = Vec::with_capacity(total as usize);
1969            let start = Instant::now();
1970            while (got.len() as u64) < total && start.elapsed() < Duration::from_secs(25) {
1971                let items = recv.poll().unwrap_or_default();
1972                let empty = items.is_empty();
1973                for it in items {
1974                    let mut s = [0u8; 8];
1975                    s.copy_from_slice(&it[..8]);
1976                    got.push(u64::from_le_bytes(s));
1977                }
1978                if empty {
1979                    std::thread::sleep(Duration::from_micros(200));
1980                }
1981            }
1982            tx.send(got).ok();
1983        });
1984
1985        let gate = Arc::new(std::sync::Barrier::new(peers as usize));
1986        let mut handles = Vec::new();
1987        for p in 0..peers {
1988            let gate = Arc::clone(&gate);
1989            handles.push(std::thread::spawn(move || {
1990                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1991                let mut buf = vec![0u8; 8];
1992                gate.wait();
1993                let start = Instant::now();
1994                for i in 0..per_peer {
1995                    if start.elapsed() > Duration::from_secs(15) {
1996                        break;
1997                    }
1998                    buf[..8].copy_from_slice(&((p << 56) | i).to_le_bytes());
1999                    if send.send_item(&buf).is_err() {
2000                        break;
2001                    }
2002                }
2003                send.finish().ok();
2004            }));
2005        }
2006        for h in handles {
2007            h.join().ok();
2008        }
2009
2010        let got = rx.recv_timeout(Duration::from_secs(30)).unwrap();
2011        rh.join().ok();
2012        for p in 0..peers {
2013            let mine: Vec<u64> = got
2014                .iter()
2015                .filter(|v| (*v >> 56) == p)
2016                .map(|v| v & 0x00FF_FFFF_FFFF_FFFF)
2017                .collect();
2018            assert_eq!(
2019                mine,
2020                (0..per_peer).collect::<Vec<_>>(),
2021                "block-RS peer {p} must deliver every item alongside the other peer",
2022            );
2023        }
2024    }
2025
2026    #[test]
2027    fn unified_two_peers_deliver_and_are_attributed_separately() {
2028        use std::sync::mpsc;
2029        let sym = 64usize;
2030        let cfg = UnifiedConfig {
2031            policy: CodePolicy::ForceRlc,
2032            symbol_len: sym,
2033            k: 8,
2034            r: 2,
2035            rlc_flow_window: 256,
2036            debug_loss: 0,
2037            seed: 1,
2038            rlc_step: 4,
2039            rlc_static: false,
2040        };
2041        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
2042        let addr = recv.local_addr().unwrap();
2043        let per_peer: u64 = 150;
2044        let peers: u64 = 2;
2045        let total = per_peer * peers;
2046
2047        let (tx, rx) = mpsc::channel();
2048        let rh = std::thread::spawn(move || {
2049            let mut recv = recv;
2050            let mut got: Vec<(u64, u64)> = Vec::with_capacity(total as usize);
2051            let start = Instant::now();
2052            while (got.len() as u64) < total && start.elapsed() < Duration::from_secs(25) {
2053                let items = recv.poll_from().unwrap_or_default();
2054                let empty = items.is_empty();
2055                for (tag, it) in items {
2056                    let mut s = [0u8; 8];
2057                    s.copy_from_slice(&it[..8]);
2058                    got.push((tag, u64::from_le_bytes(s)));
2059                }
2060                if empty {
2061                    std::thread::sleep(Duration::from_micros(200));
2062                }
2063            }
2064            tx.send(got).ok();
2065        });
2066
2067        let mut handles = Vec::new();
2068        for p in 0..peers {
2069            handles.push(std::thread::spawn(move || {
2070                let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
2071                let mut buf = vec![0u8; 8];
2072                let start = Instant::now();
2073                for i in 0..per_peer {
2074                    if start.elapsed() > Duration::from_secs(15) {
2075                        break;
2076                    }
2077                    buf[..8].copy_from_slice(&((p << 56) | i).to_le_bytes());
2078                    if send.send_item(&buf).is_err() {
2079                        break;
2080                    }
2081                }
2082                send.finish().ok();
2083            }));
2084        }
2085        for h in handles {
2086            h.join().ok();
2087        }
2088
2089        let got = rx.recv_timeout(Duration::from_secs(30)).unwrap();
2090        rh.join().ok();
2091
2092        for p in 0..peers {
2093            let mine: Vec<u64> = got
2094                .iter()
2095                .filter(|(_, v)| (v >> 56) == p)
2096                .map(|(_, v)| v & 0x00FF_FFFF_FFFF_FFFF)
2097                .collect();
2098            assert_eq!(
2099                mine,
2100                (0..per_peer).collect::<Vec<_>>(),
2101                "peer {p} must deliver every item in order alongside the other peer",
2102            );
2103            // Every item a peer sent must carry ONE tag, and the two peers'
2104            // tags must differ - otherwise the attribution is a label, not a
2105            // routing fact.
2106            let tags: std::collections::BTreeSet<u64> =
2107                got.iter().filter(|(_, v)| (v >> 56) == p).map(|(t, _)| *t).collect();
2108            assert_eq!(tags.len(), 1, "peer {p} items must all carry one tag, got {tags:?}");
2109        }
2110        let all_tags: std::collections::BTreeSet<u64> = got.iter().map(|(t, _)| *t).collect();
2111        assert_eq!(all_tags.len(), 2, "the two peers must be attributed distinctly");
2112    }
2113
2114    #[test]
2115    fn unified_delivers_in_order_across_a_forced_switch() {
2116        use std::sync::mpsc;
2117        let sym = 64usize;
2118        let cfg = UnifiedConfig {
2119            policy: CodePolicy::default_auto(),
2120            symbol_len: sym,
2121            k: 8,
2122            r: 2,
2123            rlc_flow_window: 256,
2124            debug_loss: 0,
2125            seed: 1,
2126            rlc_step: 4,
2127            rlc_static: false,
2128        };
2129        let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
2130        let addr = recv.local_addr().unwrap();
2131        let n: u64 = 4000;
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(n as usize);
2137            let start = Instant::now();
2138            while (got.len() as u64) < n && 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, recv.switches())).ok();
2151        });
2152
2153        let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
2154        // Items must leave room for the RLC symbol's length prefix
2155        // (item.len() + LEN_PREFIX <= symbol_len), so ship the 8-byte seq.
2156        let mut buf = vec![0u8; 8];
2157        for seq in 0..n / 2 {
2158            buf[..8].copy_from_slice(&seq.to_le_bytes());
2159            send.send_item(&buf).unwrap();
2160        }
2161        send.force_switch(SensCode::Rs).unwrap();
2162        assert_eq!(send.active_code(), SensCode::Rs);
2163        for seq in n / 2..n {
2164            buf[..8].copy_from_slice(&seq.to_le_bytes());
2165            send.send_item(&buf).unwrap();
2166        }
2167        send.finish().unwrap();
2168
2169        let (got, rswitches) = rx.recv_timeout(Duration::from_secs(30)).unwrap();
2170        rh.join().ok();
2171        assert_eq!(got.len() as u64, n, "every item delivered exactly once");
2172        for (i, &v) in got.iter().enumerate() {
2173            assert_eq!(v, i as u64, "delivery in order across the switch at index {i}");
2174        }
2175        assert!(rswitches >= 1, "receiver followed the code switch");
2176    }
2177}