Skip to main content

subetha_cxc/
sens_unified.rs

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