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