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 rlc_q.lock().unwrap().push_back((data, from, kts));
323 } else if (b0 == 15 || b0 == 16)
324 && let Some(hq) = hs_q
325 {
326 // PKT_RLC_CRYPTO (15) / PKT_RLC_CRYPTO_ACK (16): the one-port Sens TLS
327 // handshake. The standalone path completes its handshake before the demux
328 // reader starts, so it passes `None` and these never arrive there; the
329 // one-port path routes them to the handshake driver's queue.
330 hq.lock().unwrap().push_back((data, from, kts));
331 } else if b0 == PKT_UNIFIED_FB
332 && let (Some(fb), Some(v)) = (fb_received, decode_unified_fb(&data))
333 {
334 fb.store(v, Ordering::Relaxed);
335 } else if b0 == PKT_CODE_SWITCH
336 && let (Some(sig), Some(p)) = (switch_signal, decode_code_switch(&data))
337 {
338 *sig.lock().unwrap() = Some(p);
339 }
340}
341
342/// splitmix64 step: a cheap, seedable PRNG for the demux loss injector.
343fn next_rand(state: &mut u64) -> u64 {
344 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
345 let mut z = *state;
346 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
347 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
348 z ^ (z >> 31)
349}
350
351/// Spawn the demux reader: read the one real socket and route each datagram to
352/// the matching code's queue by its first byte. The classification is a single
353/// byte compare per datagram (the hot path stays branch-light; the per-code
354/// decoders carry their own GF(256) SIMD). A `switch_signal` (receiver side)
355/// captures CODE_SWITCH frames; on the sender side it is `None` and any stray
356/// CODE_SWITCH is dropped.
357#[allow(clippy::too_many_arguments)]
358fn spawn_demux(
359 sock: UdpSocket,
360 rlc_q: DemuxQueue,
361 rs_q: DemuxQueue,
362 switch_signal: Option<SwitchSignal>,
363 recv_counter: Option<Arc<AtomicU64>>,
364 fb_received: Option<Arc<AtomicU64>>,
365 loss_pct: u32,
366 seed: u64,
367 stop: Arc<AtomicBool>,
368) -> JoinHandle<()> {
369 std::thread::spawn(move || {
370 let mut buf = vec![0u8; 2048];
371 let mut last_from: Option<SocketAddr> = None;
372 let mut last_fb = Instant::now();
373 let mut rng = seed;
374 while !stop.load(Ordering::Relaxed) {
375 match crate::dgram::udp_recv_with_kts(&sock, &mut buf) {
376 Ok((n, from, kts)) if n > 0 => {
377 let b0 = buf[0];
378 last_from = Some(from);
379 // Uniform link-loss injection on the forward data/repair
380 // stream (RS data 1, RLC data 10 / repair 11): drop BEFORE
381 // counting or routing, so the raw-loss estimate AND the codes
382 // both see a realistic lossy link. Control frames pass.
383 let is_fwd = b0 == 1 || b0 == 10 || b0 == 11;
384 let dropped =
385 loss_pct > 0 && is_fwd && (next_rand(&mut rng) % 100) < loss_pct as u64;
386 if !dropped {
387 // QUIC (0x40 bit set) and unknown first bytes are dropped
388 // by route_sens_inbound; the one-port quinn demux consumes
389 // QUIC separately.
390 route_sens_inbound(
391 buf[..n].to_vec(),
392 from,
393 kts,
394 &rlc_q,
395 &rs_q,
396 switch_signal.as_ref(),
397 fb_received.as_deref(),
398 recv_counter.as_deref(),
399 // Standalone path: the handshake completed before this
400 // reader started, so no crypto frames arrive here.
401 None,
402 );
403 }
404 }
405 Ok(_) => {}
406 Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
407 std::thread::sleep(Duration::from_micros(100));
408 }
409 Err(e) if e.kind() == io::ErrorKind::TimedOut => {}
410 Err(_) => std::thread::sleep(Duration::from_micros(200)),
411 }
412 // Receiver: report the cumulative received-datagram count back so
413 // the sender derives the true raw channel loss (sent vs received),
414 // which neither code's post-recovery feedback reveals.
415 if let (Some(c), Some(dst)) = (&recv_counter, last_from)
416 && last_fb.elapsed() >= UNIFIED_FB_PERIOD
417 {
418 last_fb = Instant::now();
419 let frame = encode_unified_fb(c.load(Ordering::Relaxed));
420 sock.send_to(&frame, dst).ok();
421 }
422 }
423 })
424}
425
426/// How often the sender samples the fed-back loss and asks the controller for a
427/// switch. Time-based (not per-item) so the controller's hold counts track the
428/// receiver's ~10ms feedback cadence rather than the item rate.
429const SWITCH_SAMPLE_PERIOD: Duration = Duration::from_millis(50);
430/// Warmup before the switch is evaluated: the in-flight window ramps from 0 to
431/// the flow window at connection start, and that growth reads as loss; wait for
432/// it to stabilize so the ramp does not trip a spurious switch.
433const SWITCH_WARMUP: Duration = Duration::from_millis(1000);
434/// Feedback windows accumulated AFTER the warmup before the loss estimate is
435/// trusted to move the code. The decaying accumulator is cold at warmup-end (its
436/// first window's raw ratio dominates), so a start-of-stream retransmit burst
437/// reads as a spike that crosses the up threshold and flaps the code. Holding the
438/// switch until a few windows have decayed in lets the estimate mature first.
439const MIN_ACCUM_WINDOWS: u32 = 6;
440/// Drain deadline for a code handover (the in-flight tail of the old code must
441/// be delivered before the new code starts, for in-order delivery).
442const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
443/// How long RLC's DELIVERY FRONTIER may stay stuck (no item delivered while the
444/// send window is full) before the transport gives up on RLC and migrates to RS.
445/// This is the genuine-deadlock backstop: a frontier that does not advance for
446/// this long means RLC cannot decode the loss it is seeing (extreme loss past its
447/// redundancy ceiling), which the loss-driven `maybe_switch` cannot catch because
448/// a stalled sender produces no fresh loss sample. It is measured against frontier
449/// progress (the send loop resets the timer whenever a delivery lands), so a
450/// recoverable hard gap at sub-ceiling loss does NOT trip it - only a true stall.
451/// Measured against frontier progress, so it fires fast (the stalling unified RLC
452/// needs prompt rescue - a slower value starves it into a multi-second stall).
453const RLC_BLOCK_ESCAPE: Duration = Duration::from_millis(750);
454/// Drain deadline for the flow-block escape specifically: the stuck window's
455/// frontier is retransmitted (over a high-loss link, so each copy may also be
456/// lost) until fully delivered, so it must be generous enough to land every item
457/// before RS takes over (no gap = in-order delivery preserved).
458const ESCAPE_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
459/// Hard cap on the sender-side replay ring (items). The ring normally holds only
460/// the un-acked tail `[acked_through, items_total)` (evicted as RLC confirms
461/// delivery), but at extreme loss that tail can grow; this bounds the memory. If
462/// the un-acked tail ever exceeds the cap, the RLC->RS handover falls back to
463/// draining RLC so no item is dropped. 65536 * symbol covers the worst observed
464/// 30%-loss tail with headroom.
465const SENT_RING_CAP: usize = 65536;
466/// Recycled replay-ring buffers held for reuse. A trimmed (delivered) buffer is
467/// returned here instead of freed, and the next seal reuses it instead of
468/// allocating - so the per-item path does no heap alloc/free in steady state.
469/// Sized to the in-flight working set (a few flow-windows) rather than the full
470/// ring cap: the pool only needs to bridge trim-tail to send-head, and capping it
471/// keeps idle memory bounded when the ring shrinks. At small item sizes (where the
472/// item rate, and thus the alloc churn, is highest) this removes ~190k alloc/free
473/// pairs per second from the hot path.
474const RING_POOL_CAP: usize = 1024;
475/// CODE_SWITCH is a one-off control frame sent on the (drained, quiet) path at
476/// the switch point; send it a few times so a single drop does not strand the
477/// receiver on the old decoder.
478const CODE_SWITCH_REPEATS: usize = 6;
479
480// ---------------------------------------------------------------------------
481// Unified sender
482// ---------------------------------------------------------------------------
483
484/// Background reporter for the one-port path: periodically send the cumulative
485/// received-datagram count to the Sens peer (the raw-loss numerator). The QUIC
486/// demux socket feeds the receiver's queues, so there is no demux thread to do
487/// it; this small thread covers just the feedback send.
488fn spawn_fb_reporter(
489 sock: Arc<UdpSocket>,
490 recv_counter: Arc<AtomicU64>,
491 peer: Arc<Mutex<Option<SocketAddr>>>,
492 stop: Arc<AtomicBool>,
493) -> JoinHandle<()> {
494 std::thread::spawn(move || {
495 while !stop.load(Ordering::Relaxed) {
496 std::thread::sleep(UNIFIED_FB_PERIOD);
497 if let Some(dst) = *peer.lock().unwrap() {
498 let frame = encode_unified_fb(recv_counter.load(Ordering::Relaxed));
499 sock.send_to(&frame, dst).ok();
500 }
501 }
502 })
503}
504
505/// Construction parameters shared by the unified sender and receiver.
506#[derive(Debug, Clone, Copy)]
507pub struct UnifiedConfig {
508 /// Erasure-code selection policy (loss-driven Auto, or a forced code).
509 pub policy: CodePolicy,
510 /// Item / symbol size in bytes (matches the application's record size).
511 pub symbol_len: usize,
512 /// Reed-Solomon block geometry: `k` data shards.
513 pub k: usize,
514 /// Reed-Solomon base parity shards `r` (the receiver provisions per loss).
515 pub r: usize,
516 /// RLC sender flow window (outstanding source symbols); 0 = transport
517 /// default. Size it to the path BDP so RLC fills the pipe (the fair-A/B
518 /// config; the default caps RLC ~2x below its capability on a high-BDP path).
519 pub rlc_flow_window: u32,
520 /// Receiver-side diagnostic loss injection (percent, 0 = off) applied to
521 /// BOTH decoders, with `seed` for reproducibility. Drives the loss-based
522 /// switch without a real lossy link.
523 pub debug_loss: u32,
524 /// Seed for the reproducible `debug_loss` drop sequence.
525 pub seed: u64,
526 /// RLC repair cadence: one repair every `rlc_step` source symbols (redundancy
527 /// `1/(rlc_step+1)`). The starting value; the adaptive controller retunes it
528 /// per measured loss unless `rlc_static` pins it.
529 pub rlc_step: u16,
530 /// Pin the RLC coding parameters (disable the adaptive controller), holding a
531 /// fixed code rate instead of letting the sensing plane retune window / step /
532 /// density. The adaptive controller's disable-on-clean state drops coding
533 /// entirely on a quiet assessment and then pays an ARQ round trip on the next
534 /// loss; pinning trades that latency risk for a constant redundancy.
535 pub rlc_static: bool,
536}
537
538impl UnifiedConfig {
539 /// Defaults: loss-driven Auto policy, MTU-sized items, RS (8, 2), RLC flow
540 /// window sized for a filled BDP, no injected loss.
541 pub fn new(symbol_len: usize) -> Self {
542 Self {
543 policy: CodePolicy::default_auto(),
544 symbol_len,
545 k: 8,
546 r: 2,
547 rlc_flow_window: 4096,
548 debug_loss: 0,
549 seed: 1,
550 rlc_step: 4,
551 rlc_static: false,
552 }
553 }
554}
555
556/// Unified Sens-O-Matic sender: carries items over whichever erasure code the
557/// loss-driven controller selects, switching RLC <-> RS mid-stream via a
558/// drain-barrier handover. One real socket is shared by both codes through
559/// per-code demux queues fed by a background reader.
560pub struct UnifiedSensSender {
561 real: Arc<UdpSocket>,
562 peer: SocketAddr,
563 rlc: SensOMaticRlcSender,
564 rs: ReliableUdpSender,
565 active: SensCode,
566 ctrl: CodeSwitchController,
567 /// Cumulative items handed to the application across both codes (the switch
568 /// boundary the receiver keys on).
569 items_total: u64,
570 last_sample: Instant,
571 /// Connection start, for the switch-evaluation warmup.
572 started: Instant,
573 /// Datagrams sent through both codes' demux sockets (raw-loss numerator).
574 sent_counter: Arc<AtomicU64>,
575 /// Receiver's last-reported cumulative received-datagram count.
576 fb_received: Arc<AtomicU64>,
577 /// Sent / received baselines captured at the previous evaluated window.
578 prev_sent: u64,
579 prev_received: u64,
580 /// Size-weighted decaying raw-loss estimate (-1 = uninitialized). Decay the
581 /// lost / sent COUNTS (`loss_acc` / `sent_acc`) and take their ratio, rather
582 /// than EWMA-ing per-window ratios: a small feedback window with one drop
583 /// reads a spuriously high ratio, and an equal-weight EWMA of ratios over-
584 /// weights it, inflating the estimate at low loss (3% read as ~11%). Weighting
585 /// by datagram count makes the estimate track the true channel loss.
586 ewma_loss: f64,
587 /// Decaying sums of lost and sent forward datagrams (the size-weighted
588 /// estimate's numerator / denominator); their ratio is `ewma_loss`.
589 loss_acc: f64,
590 sent_acc: f64,
591 /// Feedback windows accumulated since the warmup ended. The switch is gated on
592 /// this reaching `MIN_ACCUM_WINDOWS` so a cold accumulator cannot flap the code.
593 post_warm_windows: u32,
594 /// Recently-sent item payloads, kept so a code switch can RESEND the un-acked
595 /// tail over the new code instead of slowly draining the old one. Holds the
596 /// global index range `[ring_base, items_total)`; the front is evicted once
597 /// RLC confirms delivery (its `acked_through`) and is hard-capped so a stalled
598 /// receiver cannot grow it without bound. This is the sender-side replay ring.
599 sent_ring: VecDeque<Vec<u8>>,
600 /// Global index of `sent_ring[0]` (the oldest retained item).
601 ring_base: u64,
602 /// Recycled wire-payload buffers (capacity retained, length reset). Trimmed
603 /// ring buffers land here; the next seal pops one instead of allocating.
604 ring_pool: Vec<Vec<u8>>,
605 /// Unified AEAD record layer (TLS feature). When set, every item payload is
606 /// sealed before it enters the replay ring and goes to either code, so the
607 /// RLC<->RS switch is crypto-transparent and the wire is confidential. The
608 /// seal packet number is the item's global index (sealed once, in order), so
609 /// a resend reuses it and the receiver opens by index.
610 #[cfg(feature = "tls")]
611 crypto: Option<crate::rlc_crypto::CryptoState>,
612 stop: Arc<AtomicBool>,
613 demux: Option<JoinHandle<()>>,
614}
615
616impl UnifiedSensSender {
617 /// Bind a local socket, connect to `peer`, and bring up both codes sharing
618 /// it. Starts on the policy's initial code (RLC for Auto / ForceRlc).
619 pub fn connect<A: ToSocketAddrs>(local: A, peer: SocketAddr, cfg: UnifiedConfig) -> io::Result<Self> {
620 let udp = UdpSocket::bind(local)?;
621 udp.set_nonblocking(true)?;
622 Self::assemble(udp, peer, cfg, 0)
623 }
624
625 /// Like [`connect`](Self::connect) but runs a TLS 1.3 handshake to `peer`
626 /// first and AEAD-seals every item: the auto-switching transport made
627 /// confidential for an untrusted WAN. The handshake completes before the
628 /// demux reader takes the socket, so its frames never reach the data path.
629 #[cfg(feature = "tls")]
630 pub fn connect_tls<A: ToSocketAddrs>(
631 local: A,
632 peer: SocketAddr,
633 cfg: UnifiedConfig,
634 tls: std::sync::Arc<rustls::ClientConfig>,
635 ) -> io::Result<Self> {
636 let udp = UdpSocket::bind(local)?;
637 udp.set_nonblocking(true)?;
638 let mut cs = crate::rlc_crypto::CryptoState::new_client(tls)
639 .map_err(io::Error::other)?;
640 let hs = DgramSock::from_udp(udp.try_clone()?);
641 crate::sens_rlc::drive_handshake(&hs, Some(peer), &mut cs, true)?;
642 let mut s = Self::assemble(udp, peer, cfg, crate::rlc_crypto::TAG_LEN)?;
643 s.crypto = Some(cs);
644 Ok(s)
645 }
646
647 /// Build the sender over an already-bound (and, for TLS, already-handshaked)
648 /// socket: bring up both codes sharing it and spawn the demux reader.
649 fn assemble(
650 udp: UdpSocket,
651 peer: SocketAddr,
652 cfg: UnifiedConfig,
653 seal_overhead: usize,
654 ) -> io::Result<Self> {
655 // Both codes carry the wire payload, which is the item plus the AEAD tag
656 // when TLS is on; size their symbols for the sealed width so pack_symbol
657 // and the RS shard split never overflow.
658 let wire_sym = cfg.symbol_len + seal_overhead;
659 // Left UNCONNECTED: the per-code demux sockets send via send_to(peer),
660 // and send_to on a connected socket is rejected on Windows. The demux
661 // reader still only ever hears from `peer` on this private socket.
662 // A clone for the demux thread: UdpSocket is Send, DgramSock is not
663 // (its io_uring variant is not Send), so the thread holds the raw socket.
664 let thread_sock = udp.try_clone()?;
665 thread_sock.set_nonblocking(true)?;
666 let real = Arc::new(udp);
667 let rlc_q = new_demux_queue();
668 let rs_q = new_demux_queue();
669 let sent_counter = Arc::new(AtomicU64::new(0));
670 let fb_received = Arc::new(AtomicU64::new(0));
671
672 let mut rlc = SensOMaticRlcSender::bind("0.0.0.0:0", peer, 32, cfg.rlc_step as usize, 15, wire_sym)?;
673 if cfg.rlc_flow_window > 0 {
674 rlc = rlc.with_flow_window(cfg.rlc_flow_window);
675 }
676 if cfg.rlc_static {
677 rlc = rlc.with_static_params();
678 } else {
679 // The RLC leg is the latency-priority code (the switch hands bulk /
680 // high-loss traffic to block-RS). Keep a light FEC floor on at all
681 // times so an isolated loss recovers in-window instead of falling to
682 // an ARQ round trip that head-of-line-stalls the in-order stream.
683 rlc = rlc.with_latency_priority();
684 }
685 let rlc_sock = DgramSock::demux_counted(
686 Arc::clone(&real),
687 Arc::clone(&rlc_q),
688 Arc::clone(&sent_counter),
689 );
690 rlc_sock.connect(peer).ok();
691 rlc.set_sock(rlc_sock);
692
693 let mut rs = ReliableUdpSender::bind("0.0.0.0:0", peer, cfg.k, cfg.r, wire_sym)?;
694 let rs_sock = DgramSock::demux_counted(
695 Arc::clone(&real),
696 Arc::clone(&rs_q),
697 Arc::clone(&sent_counter),
698 );
699 rs_sock.connect(peer).ok();
700 rs.set_sock(rs_sock);
701
702 let stop = Arc::new(AtomicBool::new(false));
703 let demux = spawn_demux(
704 thread_sock,
705 rlc_q,
706 rs_q,
707 None,
708 None,
709 Some(Arc::clone(&fb_received)),
710 0,
711 1,
712 Arc::clone(&stop),
713 );
714
715 Ok(Self {
716 real,
717 peer,
718 rlc,
719 rs,
720 active: cfg.policy.initial_code(),
721 ctrl: CodeSwitchController::with_policy(cfg.policy),
722 items_total: 0,
723 last_sample: Instant::now(),
724 started: Instant::now(),
725 sent_counter,
726 fb_received,
727 prev_sent: 0,
728 prev_received: 0,
729 ewma_loss: -1.0,
730 loss_acc: 0.0,
731 sent_acc: 0.0,
732 post_warm_windows: 0,
733 sent_ring: VecDeque::new(),
734 ring_base: 0,
735 ring_pool: Vec::new(),
736 #[cfg(feature = "tls")]
737 crypto: None,
738 stop,
739 demux: Some(demux),
740 })
741 }
742
743 /// Fill `buf` (cleared, capacity reused) with the wire payload for `item`:
744 /// AEAD-sealed in place (TLS) or the raw bytes. Sealed once, in send order, so
745 /// the packet number equals the item's global index. Reusing a pooled `buf`
746 /// keeps the per-item send path allocation-free in steady state.
747 fn seal_into(&self, item: &[u8], buf: &mut Vec<u8>) -> io::Result<()> {
748 buf.clear();
749 buf.extend_from_slice(item);
750 #[cfg(feature = "tls")]
751 if let Some(cs) = &self.crypto {
752 cs.seal(buf).map_err(io::Error::other)?;
753 }
754 Ok(())
755 }
756
757 /// The code currently transmitting.
758 pub fn active_code(&self) -> SensCode {
759 self.active
760 }
761
762 /// Confirmed code switches so far.
763 pub fn switches(&self) -> u64 {
764 self.ctrl.switches()
765 }
766
767 /// The RLC leg's live coding parameters `(window, step, dt, coding_on)`
768 /// (telemetry: shows what the adaptive controller settled at vs the baseline).
769 pub fn rlc_coding_params(&self) -> (u16, u16, u8, bool) {
770 self.rlc.coding_params()
771 }
772
773 /// Times the RLC leg's coding parameters changed under feedback (telemetry).
774 pub fn rlc_adapt_count(&self) -> u64 {
775 self.rlc.adapt_count()
776 }
777
778 /// The switch controller's current EWMA raw-loss estimate (sent-vs-received
779 /// datagrams), 0.0..1.0, or a negative value before the first sample. This is
780 /// the signal the up/down thresholds compare against, so it shows whether the
781 /// estimate tracks the true channel loss (telemetry).
782 pub fn raw_loss_estimate(&self) -> f64 {
783 self.ewma_loss
784 }
785
786 /// Cumulative (datagrams sent through both codes' demux sockets, receiver's
787 /// last-reported forward-received count). The raw inputs to the loss estimate;
788 /// `(sent - recv) / sent` should equal the channel loss if the counts are
789 /// clean (telemetry to find a sent-side over-count / recv-side under-count).
790 pub fn raw_sent_recv(&self) -> (u64, u64) {
791 (
792 self.sent_counter.load(Ordering::Relaxed),
793 self.fb_received.load(Ordering::Relaxed),
794 )
795 }
796
797 /// Send one item over the active code, then periodically sample the fed-back
798 /// loss and switch codes if the controller calls for it. The item is recorded
799 /// in the replay ring so a switch can resend the un-acked tail over the new
800 /// code rather than draining the old one.
801 pub fn send_item(&mut self, item: &[u8]) -> io::Result<()> {
802 // Seal to the wire payload once (the packet number is this item's global
803 // index); both codes carry it and the replay ring stores it, so a resend
804 // reuses the same packet number and the switch is crypto-transparent. Seal
805 // into a recycled buffer so the hot path does no per-item heap alloc.
806 let mut payload = self.ring_pool.pop().unwrap_or_default();
807 self.seal_into(item, &mut payload)?;
808 match self.active {
809 SensCode::Rlc => {
810 // Own RLC's flow-window wait here (via the non-blocking
811 // try_send_item) instead of letting rlc.send_item block out of
812 // sight: when the window will not clear, RLC cannot decode the
813 // loss it is seeing (extreme loss past its redundancy ceiling), so
814 // a persistent block IS the trigger to migrate to RS. The loss-
815 // driven maybe_switch cannot catch this - a stalled sender emits no
816 // fresh loss sample, and the stall arrives inside the startup
817 // warmup. The handover resends the un-acked tail over RS (from the
818 // replay ring), so no slow RLC drain is needed.
819 // Progress-aware deadlock detection: escape only when RLC's
820 // delivery frontier is STUCK for RLC_BLOCK_ESCAPE, not merely when
821 // a single send flow-blocks while RLC is still delivering (slow but
822 // recovering). A blocked-but-advancing frontier is RLC working
823 // through loss at its own pace - that is the loss-threshold's job to
824 // switch on, not the deadlock backstop's; escaping there flaps the
825 // code (escape to RS, then the accurate loss estimate, being below
826 // the down threshold, switches straight back).
827 let mut escape_start = Instant::now();
828 let mut last_acked = self.rlc.acked_through();
829 loop {
830 if self.rlc.try_send_item(&payload)? {
831 break;
832 }
833 self.rlc.pump_once()?;
834 let acked_now = self.rlc.acked_through();
835 if acked_now > last_acked {
836 last_acked = acked_now;
837 escape_start = Instant::now();
838 }
839 if escape_start.elapsed() > RLC_BLOCK_ESCAPE {
840 if self.ctrl.force(SensCode::Rs) {
841 // Resend the un-acked tail [acked_through, items_total)
842 // over RS, then this item.
843 self.switch_rlc_to_rs()?;
844 self.send_via_rs(&payload)?;
845 } else {
846 // A forced-RLC policy: honor it with the blocking send.
847 self.rlc.send_item(&payload)?;
848 }
849 break;
850 }
851 std::thread::sleep(Duration::from_micros(50));
852 }
853 }
854 SensCode::Rs => {
855 self.send_via_rs(&payload)?;
856 }
857 }
858 // Record in the replay ring (global index = items_total), advance, and
859 // trim the delivered front + hard-cap.
860 self.sent_ring.push_back(payload);
861 self.items_total += 1;
862 self.trim_sent_ring();
863 if self.last_sample.elapsed() >= SWITCH_SAMPLE_PERIOD {
864 self.last_sample = Instant::now();
865 self.maybe_switch()?;
866 }
867 Ok(())
868 }
869
870 /// Evict replay-ring items RLC has confirmed delivered (below its cumulative
871 /// frontier) and hard-cap the ring length. Preserves the invariant
872 /// `items_total == ring_base + sent_ring.len()`.
873 fn trim_sent_ring(&mut self) {
874 if self.active == SensCode::Rlc {
875 let frontier = self.rlc.acked_through() as u64;
876 while self.ring_base < frontier && !self.sent_ring.is_empty() {
877 if let Some(buf) = self.sent_ring.pop_front() {
878 self.recycle(buf);
879 }
880 self.ring_base += 1;
881 }
882 }
883 while self.sent_ring.len() > SENT_RING_CAP {
884 if let Some(buf) = self.sent_ring.pop_front() {
885 self.recycle(buf);
886 }
887 self.ring_base += 1;
888 }
889 }
890
891 /// Return a trimmed wire-payload buffer to the pool for reuse by the next
892 /// seal, capped so a shrinking ring does not pin idle memory.
893 fn recycle(&mut self, buf: Vec<u8>) {
894 if self.ring_pool.len() < RING_POOL_CAP {
895 self.ring_pool.push(buf);
896 }
897 }
898
899 /// RLC -> RS handover by RESEND (not drain): announce the boundary RLC has
900 /// delivered to, switch, and resend the un-acked tail `[boundary,
901 /// items_total)` over RS from the replay ring, in order. RS is reliable, so
902 /// it recovers the tail fast at any loss - no waiting on RLC's slow frontier
903 /// recovery. Falls back to draining RLC only if the cap evicted un-acked
904 /// items (so nothing is ever dropped).
905 fn switch_rlc_to_rs(&mut self) -> io::Result<()> {
906 let boundary = self.rlc.acked_through() as u64;
907 let frame = encode_code_switch(boundary, SensCode::Rs);
908 for _ in 0..CODE_SWITCH_REPEATS {
909 self.real.send_to(&frame, self.peer).ok();
910 std::thread::sleep(Duration::from_millis(2));
911 }
912 self.active = SensCode::Rs;
913 if boundary >= self.ring_base {
914 let start = (boundary - self.ring_base) as usize;
915 let end = self.sent_ring.len();
916 for i in start..end {
917 let item = self.sent_ring[i].clone();
918 self.send_via_rs(&item)?;
919 }
920 } else {
921 // Un-acked tail underflowed the cap: drain RLC so nothing is lost.
922 let target = self.rlc.next_source_id();
923 self.rlc.drain_until_acked(target, ESCAPE_DRAIN_TIMEOUT)?;
924 }
925 Ok(())
926 }
927
928 /// Send one item over RS, waiting out RS flow-control back-pressure (RS's ARQ
929 /// guarantees the window clears, so this wait is bounded by delivery, not by a
930 /// decode cliff). Shared by the RS steady state and the RLC escape handover.
931 fn send_via_rs(&mut self, item: &[u8]) -> io::Result<()> {
932 while self.rs.flow_blocked() {
933 self.rs.pump_feedback().ok();
934 if self.rs.flow_blocked() {
935 std::thread::sleep(Duration::from_micros(50));
936 }
937 }
938 self.rs.send_item(item)
939 }
940
941 /// Sample the active code's fed-back loss and switch codes if the controller
942 /// confirms a crossing of the configured thresholds.
943 fn maybe_switch(&mut self) -> io::Result<()> {
944 // The raw channel loss from sent-vs-received datagram counts: code-
945 // agnostic, so it does not collapse when the active code recovers the
946 // loss (which is what made the active code's own feedback flap).
947 let sent = self.sent_counter.load(Ordering::Relaxed);
948 let recv = self.fb_received.load(Ordering::Relaxed);
949 if recv == 0 {
950 return Ok(()); // no raw-loss report from the receiver yet
951 }
952 // Warmup: the in-flight window ramps 0 -> flow window at start, and that
953 // growth reads as loss; track the baseline but do not evaluate until it
954 // stabilizes, so the ramp does not trip a spurious switch.
955 if self.started.elapsed() < SWITCH_WARMUP {
956 self.prev_sent = sent;
957 self.prev_received = recv;
958 return Ok(());
959 }
960 if self.prev_received == 0 {
961 // First report: set the baseline, evaluate from the next window.
962 self.prev_sent = sent;
963 self.prev_received = recv;
964 return Ok(());
965 }
966 // Align the window to FEEDBACK arrivals: skip ticks with no new report,
967 // so a tick landing between reports does not read a spurious 100% loss
968 // (sent advanced, received not yet updated this window).
969 if recv <= self.prev_received {
970 return Ok(());
971 }
972 let sent_d = sent.saturating_sub(self.prev_sent);
973 if sent_d < MIN_LOSS_SAMPLE {
974 return Ok(()); // window too small to trust; keep accumulating
975 }
976 let recv_d = recv.saturating_sub(self.prev_received);
977 self.prev_sent = sent;
978 self.prev_received = recv;
979 let lost_d = sent_d.saturating_sub(recv_d) as f64;
980 // Size-weighted decaying loss: decay the lost / sent COUNTS and take their
981 // ratio, NOT an equal-weight EWMA of per-window ratios. A small feedback
982 // window with one drop reads a spuriously high ratio, and equal-weight
983 // averaging over-read low loss ~3.5x (3% measured as ~11%); weighting by
984 // datagram count makes large windows dominate so the estimate tracks the
985 // true channel loss. The 0.95 decay (effective window ~20 feedback samples)
986 // keeps it recent yet smooths the retransmit-burst windows that a tighter
987 // decay let spike across the up threshold and flap the code.
988 self.loss_acc = 0.95 * self.loss_acc + lost_d;
989 self.sent_acc = 0.95 * self.sent_acc + sent_d as f64;
990 self.ewma_loss = if self.sent_acc > 0.0 {
991 self.loss_acc / self.sent_acc
992 } else {
993 0.0
994 };
995 // Gate the switch until the accumulator has matured past its cold start: at
996 // warmup-end loss_acc/sent_acc are near-empty, so the first post-warmup
997 // window's raw ratio (a start-of-stream burst) would otherwise dominate the
998 // estimate and trip a spurious up-switch. Keep accumulating, just do not act
999 // on it yet.
1000 if self.post_warm_windows < MIN_ACCUM_WINDOWS {
1001 self.post_warm_windows += 1;
1002 return Ok(());
1003 }
1004 let loss_q8 = (self.ewma_loss * 256.0).clamp(0.0, 255.0) as u8;
1005 if let Some(to) = self.ctrl.observe(loss_q8) {
1006 self.do_switch(to)?;
1007 }
1008 Ok(())
1009 }
1010
1011 /// Code handover. RLC -> RS RESENDS the un-acked tail over RS (RS is reliable
1012 /// and fast at any loss, so it never waits on RLC's slow frontier recovery).
1013 /// RS -> RLC drains RS first (RS's ARQ clears its window quickly), then starts
1014 /// RLC from the fully-delivered boundary. In-order delivery holds either way.
1015 fn do_switch(&mut self, to: SensCode) -> io::Result<()> {
1016 match (self.active, to) {
1017 (SensCode::Rlc, SensCode::Rs) => self.switch_rlc_to_rs(),
1018 _ => self.do_switch_with_drain(to, DRAIN_TIMEOUT),
1019 }
1020 }
1021
1022 /// `do_switch` with an explicit drain deadline. The flow-block escape passes a
1023 /// generous one ([`ESCAPE_DRAIN_TIMEOUT`]) because draining a stuck window
1024 /// over a high-loss link (retransmitting its frontier, each copy itself
1025 /// lossy) takes far longer than a healthy handover.
1026 fn do_switch_with_drain(&mut self, to: SensCode, drain_timeout: Duration) -> io::Result<()> {
1027 match self.active {
1028 SensCode::Rlc => {
1029 let target = self.rlc.next_source_id();
1030 self.rlc.drain_until_acked(target, drain_timeout)?;
1031 }
1032 SensCode::Rs => {
1033 self.rs.flush()?;
1034 self.rs.drain_until_acked(drain_timeout)?;
1035 }
1036 }
1037 let frame = encode_code_switch(self.items_total, to);
1038 for _ in 0..CODE_SWITCH_REPEATS {
1039 self.real.send_to(&frame, self.peer).ok();
1040 std::thread::sleep(Duration::from_millis(2));
1041 }
1042 self.active = to;
1043 // Returning to RLC: another code carried [old RLC frontier, items_total),
1044 // so RLC's source-id stream diverged from the global index. Re-base it to
1045 // the global boundary so the resumed stream's source ids equal the global
1046 // item indices the receiver expects (it re-bases in lockstep on the same
1047 // boundary), instead of stalling on holes RLC will never resend or
1048 // replaying its stale pre-switch buffer.
1049 if to == SensCode::Rlc {
1050 self.rlc.skip_to(self.items_total as u32);
1051 }
1052 Ok(())
1053 }
1054
1055 /// Flush and drain the active code so the final items are delivered. Returns
1056 /// whether everything was acked before the deadline.
1057 pub fn finish(&mut self) -> io::Result<bool> {
1058 match self.active {
1059 SensCode::Rlc => {
1060 let target = self.rlc.next_source_id();
1061 self.rlc.drain_until_acked(target, Duration::from_secs(120))
1062 }
1063 SensCode::Rs => {
1064 self.rs.flush()?;
1065 self.rs.drain_until_acked(Duration::from_secs(120))
1066 }
1067 }
1068 }
1069
1070 /// Force the active code to `to` now (operator override), via the same
1071 /// handover an automatic switch uses (RLC->RS resend / RS->RLC drain), and
1072 /// keep the controller in sync so it does not immediately switch back. No-op
1073 /// if already on `to`.
1074 pub fn force_switch(&mut self, to: SensCode) -> io::Result<()> {
1075 if to != self.active {
1076 self.ctrl.force(to);
1077 self.do_switch(to)?;
1078 }
1079 Ok(())
1080 }
1081}
1082
1083impl Drop for UnifiedSensSender {
1084 fn drop(&mut self) {
1085 self.stop.store(true, Ordering::Relaxed);
1086 if let Some(h) = self.demux.take() {
1087 h.join().ok();
1088 }
1089 }
1090}
1091
1092// ---------------------------------------------------------------------------
1093// Unified receiver
1094// ---------------------------------------------------------------------------
1095
1096/// Unified Sens-O-Matic receiver: demuxes both codes off one socket and
1097/// delivers items in order across mid-stream code switches. The sender's
1098/// drain-barrier guarantees the old code is fully delivered before the new code
1099/// starts, so the receiver simply runs the active decoder and switches at the
1100/// announced boundary.
1101pub struct UnifiedSensReceiver {
1102 real: Arc<UdpSocket>,
1103 rlc: SensOMaticRlcReceiver,
1104 rs: ReliableUdpReceiver,
1105 active: SensCode,
1106 switch_signal: SwitchSignal,
1107 pending_switch: Option<(u64, SensCode)>,
1108 delivered_total: u64,
1109 /// Global index of the next item the RS decoder will deliver. RS delivers in
1110 /// its own local order; this maps that to the global stream so the un-acked
1111 /// tail an RLC->RS handover resends over RS can be deduped against what RLC
1112 /// already delivered. Set to the handover boundary on RLC->RS; advances per RS
1113 /// item thereafter.
1114 rs_next_global: u64,
1115 switches: u64,
1116 /// Unified AEAD record layer (TLS feature). When set, each item a decoder
1117 /// delivers is opened with its global index as the packet number before it
1118 /// reaches the application; duplicates (the resend overlap) are skipped before
1119 /// opening, so the packet number always matches the seal. A `OnceLock` shared
1120 /// with the handshake driver: the one-port server completes its handshake on a
1121 /// thread (the QUIC endpoint owns the socket, so the Sens handshake rides the
1122 /// demux queue) and publishes the keys here once; `bind_tls` sets it inline.
1123 #[cfg(feature = "tls")]
1124 crypto: Arc<std::sync::OnceLock<crate::rlc_crypto::CryptoState>>,
1125 /// TLS is expected on this receiver (set by `bind_tls` / `from_shared_tls`):
1126 /// `poll` withholds delivery until `crypto` is published, so a data frame that
1127 /// races ahead of the handshake completion is never opened with absent keys.
1128 #[cfg(feature = "tls")]
1129 expect_tls: bool,
1130 stop: Arc<AtomicBool>,
1131 demux: Option<JoinHandle<()>>,
1132}
1133
1134impl UnifiedSensReceiver {
1135 /// Bind `local` and bring up both decoders sharing it.
1136 pub fn bind<A: ToSocketAddrs>(local: A, cfg: UnifiedConfig) -> io::Result<Self> {
1137 let udp = UdpSocket::bind(local)?;
1138 udp.set_nonblocking(true)?;
1139 Self::assemble(udp, cfg, 0)
1140 }
1141
1142 /// Like [`bind`](Self::bind) but runs a TLS 1.3 server handshake first and
1143 /// AEAD-opens every delivered item: the WAN-confidential counterpart to
1144 /// [`UnifiedSensSender::connect_tls`]. The handshake completes before the
1145 /// demux reader takes the socket.
1146 #[cfg(feature = "tls")]
1147 pub fn bind_tls<A: ToSocketAddrs>(
1148 local: A,
1149 cfg: UnifiedConfig,
1150 tls: std::sync::Arc<rustls::ServerConfig>,
1151 ) -> io::Result<Self> {
1152 let udp = UdpSocket::bind(local)?;
1153 udp.set_nonblocking(true)?;
1154 let mut cs = crate::rlc_crypto::CryptoState::new_server(tls)
1155 .map_err(io::Error::other)?;
1156 let hs = DgramSock::from_udp(udp.try_clone()?);
1157 crate::sens_rlc::drive_handshake(&hs, None, &mut cs, false)?;
1158 let mut s = Self::assemble(udp, cfg, crate::rlc_crypto::TAG_LEN)?;
1159 s.crypto.set(cs).ok();
1160 s.expect_tls = true;
1161 Ok(s)
1162 }
1163
1164 /// Build the receiver over an already-bound (and, for TLS, already-handshaked)
1165 /// socket: bring up both decoders sharing it and spawn the demux reader.
1166 fn assemble(udp: UdpSocket, cfg: UnifiedConfig, seal_overhead: usize) -> io::Result<Self> {
1167 // The decoder must accept the sealed wire width (item + AEAD tag under
1168 // TLS); the RS decoder learns its shard width from the wire header, so
1169 // only the RLC decoder's symbol size needs widening here.
1170 let wire_sym = cfg.symbol_len + seal_overhead;
1171 let thread_sock = udp.try_clone()?;
1172 thread_sock.set_nonblocking(true)?;
1173 let real = Arc::new(udp);
1174 let rlc_q = new_demux_queue();
1175 let rs_q = new_demux_queue();
1176
1177 // No per-code debug loss: the unified path injects loss uniformly at the
1178 // demux (below), modelling a real lossy link AND letting the raw-loss
1179 // estimate see it (a sub-receiver drop would be invisible to the demux
1180 // count).
1181 let mut rlc = SensOMaticRlcReceiver::bind("0.0.0.0:0", wire_sym)?;
1182 rlc.set_sock(DgramSock::demux(Arc::clone(&real), Arc::clone(&rlc_q)));
1183
1184 let mut rs = ReliableUdpReceiver::bind("0.0.0.0:0")?;
1185 rs.set_sock(DgramSock::demux(Arc::clone(&real), Arc::clone(&rs_q)));
1186
1187 let switch_signal: SwitchSignal = Arc::new(Mutex::new(None));
1188 let recv_counter = Arc::new(AtomicU64::new(0));
1189 let stop = Arc::new(AtomicBool::new(false));
1190 let demux = spawn_demux(
1191 thread_sock,
1192 rlc_q,
1193 rs_q,
1194 Some(Arc::clone(&switch_signal)),
1195 Some(recv_counter),
1196 None,
1197 cfg.debug_loss,
1198 cfg.seed,
1199 Arc::clone(&stop),
1200 );
1201
1202 Ok(Self {
1203 real,
1204 rlc,
1205 rs,
1206 active: cfg.policy.initial_code(),
1207 switch_signal,
1208 pending_switch: None,
1209 delivered_total: 0,
1210 rs_next_global: 0,
1211 switches: 0,
1212 #[cfg(feature = "tls")]
1213 crypto: Arc::new(std::sync::OnceLock::new()),
1214 #[cfg(feature = "tls")]
1215 expect_tls: false,
1216 stop,
1217 demux: Some(demux),
1218 })
1219 }
1220
1221 /// Build a receiver fed by an EXTERNAL demux (the one-port QUIC endpoint's
1222 /// socket routes Sens datagrams into `rlc_q` / `rs_q` / `switch_signal` and
1223 /// tallies `recv_counter`). `send_sock` is a clone of the shared socket for
1224 /// control + raw-loss feedback. No demux thread is spawned (the QUIC socket
1225 /// feeds the queues); a small reporter thread sends the feedback to the peer
1226 /// the QUIC socket records in `sens_peer`.
1227 #[allow(clippy::too_many_arguments)]
1228 pub fn from_shared(
1229 send_sock: Arc<UdpSocket>,
1230 rlc_q: DemuxQueue,
1231 rs_q: DemuxQueue,
1232 switch_signal: SwitchSignal,
1233 recv_counter: Arc<AtomicU64>,
1234 sens_peer: Arc<Mutex<Option<SocketAddr>>>,
1235 cfg: UnifiedConfig,
1236 seal_overhead: usize,
1237 ) -> io::Result<Self> {
1238 // The RLC decoder must accept the sealed wire width (item + AEAD tag under
1239 // TLS) so it frames the symbols the sender shipped; the RS decoder learns
1240 // its shard width from the wire header, so only the RLC width needs it.
1241 let mut rlc = SensOMaticRlcReceiver::bind("0.0.0.0:0", cfg.symbol_len + seal_overhead)?;
1242 rlc.set_sock(DgramSock::demux(Arc::clone(&send_sock), rlc_q));
1243 let mut rs = ReliableUdpReceiver::bind("0.0.0.0:0")?;
1244 rs.set_sock(DgramSock::demux(Arc::clone(&send_sock), rs_q));
1245 let stop = Arc::new(AtomicBool::new(false));
1246 let demux = spawn_fb_reporter(Arc::clone(&send_sock), recv_counter, sens_peer, Arc::clone(&stop));
1247 Ok(Self {
1248 real: send_sock,
1249 rlc,
1250 rs,
1251 active: cfg.policy.initial_code(),
1252 switch_signal,
1253 pending_switch: None,
1254 delivered_total: 0,
1255 rs_next_global: 0,
1256 switches: 0,
1257 #[cfg(feature = "tls")]
1258 crypto: Arc::new(std::sync::OnceLock::new()),
1259 #[cfg(feature = "tls")]
1260 expect_tls: false,
1261 stop,
1262 demux: Some(demux),
1263 })
1264 }
1265
1266 /// Like [`from_shared`](Self::from_shared) but runs a TLS 1.3 server handshake
1267 /// over the demux'd `hs_q`. The one-port QUIC endpoint owns the socket, so the
1268 /// Sens handshake cannot own a recv loop; it rides the same demux queue as data
1269 /// (the demux routes `PKT_RLC_CRYPTO` frames into `hs_q`). The handshake runs
1270 /// on a thread and publishes the 1-RTT keys to the shared `crypto` cell once
1271 /// complete; `poll` withholds delivery until then. Returns immediately so the
1272 /// caller can start the QUIC + Sens clients that drive the handshake.
1273 #[cfg(feature = "tls")]
1274 #[allow(clippy::too_many_arguments)]
1275 pub fn from_shared_tls(
1276 send_sock: Arc<UdpSocket>,
1277 rlc_q: DemuxQueue,
1278 rs_q: DemuxQueue,
1279 hs_q: DemuxQueue,
1280 switch_signal: SwitchSignal,
1281 recv_counter: Arc<AtomicU64>,
1282 sens_peer: Arc<Mutex<Option<SocketAddr>>>,
1283 cfg: UnifiedConfig,
1284 tls: std::sync::Arc<rustls::ServerConfig>,
1285 ) -> io::Result<Self> {
1286 let mut s = Self::from_shared(
1287 Arc::clone(&send_sock),
1288 rlc_q,
1289 rs_q,
1290 switch_signal,
1291 recv_counter,
1292 sens_peer,
1293 cfg,
1294 crate::rlc_crypto::TAG_LEN,
1295 )?;
1296 s.expect_tls = true;
1297 let crypto = Arc::clone(&s.crypto);
1298 let stop = Arc::clone(&s.stop);
1299 let hs_sock = DgramSock::demux(send_sock, hs_q);
1300 std::thread::spawn(move || {
1301 let mut cs = match crate::rlc_crypto::CryptoState::new_server(tls) {
1302 Ok(c) => c,
1303 Err(_) => return,
1304 };
1305 // Drive the server handshake over the demux'd queue (peer learned from
1306 // the first flight); publish the keys once the 1-RTT secrets derive.
1307 if !stop.load(Ordering::Relaxed)
1308 && crate::sens_rlc::drive_handshake(&hs_sock, None, &mut cs, false).is_ok()
1309 {
1310 crypto.set(cs).ok();
1311 }
1312 });
1313 Ok(s)
1314 }
1315
1316 /// The decoder currently delivering.
1317 pub fn active_code(&self) -> SensCode {
1318 self.active
1319 }
1320
1321 /// Code switches the receiver has followed.
1322 pub fn switches(&self) -> u64 {
1323 self.switches
1324 }
1325
1326 /// The bound local address.
1327 pub fn local_addr(&self) -> io::Result<SocketAddr> {
1328 self.real.local_addr()
1329 }
1330
1331 /// Recover an item from a delivered wire payload: AEAD-open (TLS) with `pn`
1332 /// the item's global index, or pass the bytes through. A failed open (a
1333 /// tampered datagram) surfaces as an error rather than delivering bad data.
1334 #[cfg_attr(not(feature = "tls"), allow(unused_variables, unused_mut))]
1335 fn open_payload(&self, mut payload: Vec<u8>, pn: u64) -> io::Result<Vec<u8>> {
1336 #[cfg(feature = "tls")]
1337 if let Some(cs) = self.crypto.get() {
1338 let n = cs
1339 .open(pn, &mut payload)
1340 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1341 payload.truncate(n);
1342 return Ok(payload);
1343 }
1344 Ok(payload)
1345 }
1346
1347 /// Drive the active decoder and return the items it delivered this call.
1348 /// Honors a pending CODE_SWITCH once the active decoder has delivered every
1349 /// item up to the announced boundary.
1350 pub fn poll(&mut self) -> io::Result<Vec<Vec<u8>>> {
1351 // One-port TLS: the handshake completes asynchronously on a thread (the
1352 // QUIC endpoint owns the socket), so until the keys are published, withhold
1353 // delivery. The decoders keep buffering inbound frames; the peer only sends
1354 // data after ITS handshake finished, so the backlog is at most a few frames
1355 // and they open correctly once the keys land. (bind_tls sets the keys
1356 // inline before returning, so this gate is already clear there.)
1357 #[cfg(feature = "tls")]
1358 if self.expect_tls && self.crypto.get().is_none() {
1359 return Ok(Vec::new());
1360 }
1361 if self.pending_switch.is_none() {
1362 self.pending_switch = self.switch_signal.lock().unwrap().take();
1363 }
1364 let out = match self.active {
1365 SensCode::Rlc => {
1366 // Open each payload with its global index as the packet number.
1367 let raw = self.rlc.poll()?;
1368 let mut d = Vec::with_capacity(raw.len());
1369 for payload in raw {
1370 let item = self.open_payload(payload, self.delivered_total)?;
1371 self.delivered_total += 1;
1372 d.push(item);
1373 }
1374 d
1375 }
1376 SensCode::Rs => {
1377 // RS delivers in its own local order; map each to its global index
1378 // (rs_next_global, advancing per item). After an RLC->RS resend
1379 // handover the leading items overlap what RLC already delivered, so
1380 // drop any whose global index is below the delivery frontier
1381 // (before opening, so the packet number always matches the seal).
1382 let raw = self.rs.poll()?;
1383 let mut d = Vec::with_capacity(raw.len());
1384 for payload in raw {
1385 if self.rs_next_global >= self.delivered_total {
1386 let item = self.open_payload(payload, self.rs_next_global)?;
1387 self.delivered_total += 1;
1388 d.push(item);
1389 }
1390 self.rs_next_global += 1;
1391 }
1392 d
1393 }
1394 };
1395 if let Some((boundary, to)) = self.pending_switch
1396 && self.delivered_total >= boundary
1397 {
1398 // The sender repeats CODE_SWITCH for reliability; only act (and
1399 // count) when the target differs from the active code, so the
1400 // repeats do not inflate the switch tally or re-switch.
1401 if to != self.active {
1402 match to {
1403 SensCode::Rs => {
1404 // The RS stream resumes at the boundary (RLC's delivery
1405 // frontier); index its local order from there.
1406 self.rs_next_global = boundary;
1407 }
1408 SensCode::Rlc => {
1409 // Returning to RLC: re-base the decoder to the boundary so
1410 // it delivers the resumed stream from there (whose source
1411 // ids the sender re-aligned to the global index) and does
1412 // not replay its stale pre-switch buffer or stall on holes
1413 // the other code already delivered.
1414 self.rlc.skip_to(boundary as u32);
1415 }
1416 }
1417 self.active = to;
1418 self.switches += 1;
1419 }
1420 self.pending_switch = None;
1421 }
1422 Ok(out)
1423 }
1424}
1425
1426impl Drop for UnifiedSensReceiver {
1427 fn drop(&mut self) {
1428 self.stop.store(true, Ordering::Relaxed);
1429 if let Some(h) = self.demux.take() {
1430 h.join().ok();
1431 }
1432 }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use super::*;
1438
1439 #[test]
1440 fn forced_policies_never_switch() {
1441 for policy in [CodePolicy::ForceRlc, CodePolicy::ForceRs] {
1442 let mut c = CodeSwitchController::with_policy(policy);
1443 let start = c.code();
1444 for q in [0u8, 80, 200, 255, 10, 0] {
1445 assert_eq!(c.observe(q), None, "forced policy must not switch");
1446 }
1447 assert_eq!(c.code(), start);
1448 assert_eq!(c.switches(), 0);
1449 }
1450 }
1451
1452 #[test]
1453 fn force_rs_starts_on_rs() {
1454 let c = CodeSwitchController::with_policy(CodePolicy::ForceRs);
1455 assert_eq!(c.code(), SensCode::Rs);
1456 }
1457
1458 #[test]
1459 fn auto_starts_on_rlc_then_up_switches_when_loss_sustains() {
1460 let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1461 assert_eq!(c.code(), SensCode::Rlc);
1462 // 12% loss (q8 ~30) is below the ~15% up threshold (q8 38): no switch.
1463 assert_eq!(c.observe(30), None);
1464 assert_eq!(c.observe(30), None);
1465 assert_eq!(c.code(), SensCode::Rlc);
1466 // 18% loss (q8 46) above the up threshold: one sample arms, the second
1467 // (up_hold = 2) confirms the switch to RS.
1468 assert_eq!(c.observe(46), None, "first over-threshold sample only arms");
1469 assert_eq!(c.observe(46), Some(SensCode::Rs), "second confirms up-switch");
1470 assert_eq!(c.code(), SensCode::Rs);
1471 assert_eq!(c.switches(), 1);
1472 }
1473
1474 #[test]
1475 fn stall_escape_latches_rs_and_does_not_flap() {
1476 // A flow-block escape to RS (RLC stalled at this loss) must NOT down-switch
1477 // back even when the loss estimate sits below the down threshold: returning
1478 // to a code that just stalled flaps, and the RS->RLC handover then corrupts
1479 // in-order delivery. The latch holds RS after a stall-escape.
1480 let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 4);
1481 assert!(c.force(SensCode::Rs), "stall-escape forces to RS");
1482 assert_eq!(c.code(), SensCode::Rs);
1483 for i in 0..20 {
1484 assert_eq!(c.observe(5), None, "latched RS must not down-switch at tick {i}");
1485 }
1486 assert_eq!(c.code(), SensCode::Rs);
1487 assert_eq!(c.switches(), 1, "no flap: only the one escape switch");
1488 }
1489
1490 #[test]
1491 fn a_single_loss_spike_does_not_flap_the_code() {
1492 let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1493 // One isolated spike over the threshold then back down: up_hold = 2 is
1494 // not met, so no switch (the streak resets on the low sample).
1495 assert_eq!(c.observe(200), None);
1496 assert_eq!(c.observe(10), None);
1497 assert_eq!(c.observe(200), None);
1498 assert_eq!(c.code(), SensCode::Rlc, "an isolated spike must not switch");
1499 assert_eq!(c.switches(), 0);
1500 }
1501
1502 #[test]
1503 fn down_switch_needs_a_longer_sustained_low_streak() {
1504 let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1505 // Drive up to RS first.
1506 c.observe(80);
1507 assert_eq!(c.observe(80), Some(SensCode::Rs));
1508 // Loss drops below the 10% down threshold (q8 26). It must SUSTAIN for
1509 // down_hold = 8 samples; a brief low spell does not relax the code.
1510 for _ in 0..7 {
1511 assert_eq!(c.observe(10), None, "down-switch must not fire early");
1512 }
1513 assert_eq!(c.observe(10), Some(SensCode::Rlc), "8th low sample relaxes to RLC");
1514 assert_eq!(c.code(), SensCode::Rlc);
1515 assert_eq!(c.switches(), 2);
1516 }
1517
1518 #[test]
1519 fn hysteresis_band_holds_rs_between_thresholds() {
1520 let mut c = CodeSwitchController::new(CodePolicy::default_auto(), 2, 8);
1521 c.observe(80);
1522 c.observe(80); // now on RS
1523 assert_eq!(c.code(), SensCode::Rs);
1524 // Loss in the band (down_q8=26 < q8=32 < up_q8=38): neither relaxes nor
1525 // re-arms; RS holds across the whole band (no flapping).
1526 for _ in 0..20 {
1527 assert_eq!(c.observe(32), None);
1528 }
1529 assert_eq!(c.code(), SensCode::Rs, "RS holds inside the hysteresis band");
1530 }
1531
1532 // A real two-socket loopback round trip that forces an RLC -> RS handover
1533 // mid-stream and asserts every item is delivered exactly once, in order,
1534 // across the switch. Exercises the demux sockets, the drain-barrier, the
1535 // CODE_SWITCH frame, and the receiver's boundary merge end to end.
1536 #[test]
1537 fn unified_delivers_in_order_across_a_forced_switch() {
1538 use std::sync::mpsc;
1539 let sym = 64usize;
1540 let cfg = UnifiedConfig {
1541 policy: CodePolicy::default_auto(),
1542 symbol_len: sym,
1543 k: 8,
1544 r: 2,
1545 rlc_flow_window: 256,
1546 debug_loss: 0,
1547 seed: 1,
1548 rlc_step: 4,
1549 rlc_static: false,
1550 };
1551 let recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg).unwrap();
1552 let addr = recv.local_addr().unwrap();
1553 let n: u64 = 4000;
1554
1555 let (tx, rx) = mpsc::channel();
1556 let rh = std::thread::spawn(move || {
1557 let mut recv = recv;
1558 let mut got: Vec<u64> = Vec::with_capacity(n as usize);
1559 let start = Instant::now();
1560 while (got.len() as u64) < n && start.elapsed() < Duration::from_secs(25) {
1561 let items = recv.poll().unwrap_or_default();
1562 let empty = items.is_empty();
1563 for it in items {
1564 let mut s = [0u8; 8];
1565 s.copy_from_slice(&it[..8]);
1566 got.push(u64::from_le_bytes(s));
1567 }
1568 if empty {
1569 std::thread::sleep(Duration::from_micros(200));
1570 }
1571 }
1572 tx.send((got, recv.switches())).ok();
1573 });
1574
1575 let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg).unwrap();
1576 // Items must leave room for the RLC symbol's length prefix
1577 // (item.len() + LEN_PREFIX <= symbol_len), so ship the 8-byte seq.
1578 let mut buf = vec![0u8; 8];
1579 for seq in 0..n / 2 {
1580 buf[..8].copy_from_slice(&seq.to_le_bytes());
1581 send.send_item(&buf).unwrap();
1582 }
1583 send.force_switch(SensCode::Rs).unwrap();
1584 assert_eq!(send.active_code(), SensCode::Rs);
1585 for seq in n / 2..n {
1586 buf[..8].copy_from_slice(&seq.to_le_bytes());
1587 send.send_item(&buf).unwrap();
1588 }
1589 send.finish().unwrap();
1590
1591 let (got, rswitches) = rx.recv_timeout(Duration::from_secs(30)).unwrap();
1592 rh.join().ok();
1593 assert_eq!(got.len() as u64, n, "every item delivered exactly once");
1594 for (i, &v) in got.iter().enumerate() {
1595 assert_eq!(v, i as u64, "delivery in order across the switch at index {i}");
1596 }
1597 assert!(rswitches >= 1, "receiver followed the code switch");
1598 }
1599}