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