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}