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