use std::collections::VecDeque;
use std::time::{Duration, Instant};
use super::crypt::CryptStats;
pub(crate) const PING_INTERVAL: Duration = Duration::from_secs(1);
const UDP_TIMEOUT: Duration = Duration::from_secs(5);
const MISSED_PINGS_TO_FALL_BACK: u32 = 5;
const REPLIES_TO_RECOVER: u32 = 3;
const RESYNC_AFTER: Duration = Duration::from_secs(5);
const LOSS_WINDOW: Duration = Duration::from_secs(5);
const RTT_ALPHA: f64 = 0.25;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Udp,
Tcp,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkStats {
pub transport: Transport,
pub loss: f32,
pub rtt: Option<Duration>,
pub crypt: CryptStats,
pub oversized: u64,
pub audio_sent: u64,
pub audio_received: u64,
}
pub(crate) struct Link {
transport: Transport,
ever_udp: bool,
last_udp: Option<Instant>,
outstanding: Option<u64>,
replies_in_row: u32,
missed_in_row: u32,
rtt: Option<Duration>,
samples: VecDeque<(Instant, u32, u32, u32, u32)>,
pings_sent: u32,
pings_answered: u32,
last_good_decrypt: Instant,
last_resync_request: Option<Instant>,
}
impl Link {
pub(crate) fn new(now: Instant) -> Self {
Self {
transport: Transport::Tcp,
ever_udp: false,
last_udp: None,
outstanding: None,
replies_in_row: 0,
missed_in_row: 0,
rtt: None,
samples: VecDeque::new(),
pings_sent: 0,
pings_answered: 0,
last_good_decrypt: now,
last_resync_request: None,
}
}
pub(crate) fn transport(&self) -> Transport {
self.transport
}
pub(crate) fn ping_sent(&mut self, timestamp: u64) {
if self.outstanding.is_some() {
self.replies_in_row = 0;
self.missed_in_row = self.missed_in_row.saturating_add(1);
}
self.outstanding = Some(timestamp);
self.pings_sent = self.pings_sent.wrapping_add(1);
}
pub(crate) fn udp_received(&mut self, now: Instant) {
self.last_udp = Some(now);
self.last_good_decrypt = now;
}
pub(crate) fn ping_reply(&mut self, timestamp: u64, rtt: Duration) -> Option<Transport> {
self.missed_in_row = 0;
if self.outstanding != Some(timestamp) {
return None;
}
self.outstanding = None;
self.pings_answered = self.pings_answered.wrapping_add(1);
self.replies_in_row += 1;
self.rtt = Some(match self.rtt {
Some(avg) => avg.mul_f64(1.0 - RTT_ALPHA) + rtt.mul_f64(RTT_ALPHA),
None => rtt,
});
let needed = if self.ever_udp { REPLIES_TO_RECOVER } else { 1 };
(self.transport == Transport::Tcp && self.replies_in_row >= needed)
.then(|| self.switch(Transport::Udp))
}
pub(crate) fn tick(&mut self, now: Instant, crypt: CryptStats) -> Option<Transport> {
self.samples.push_back((
now,
crypt.good,
crypt.lost,
self.pings_sent,
self.pings_answered,
));
while self
.samples
.front()
.is_some_and(|s| now.duration_since(s.0) > LOSS_WINDOW)
{
self.samples.pop_front();
}
let silent = self
.last_udp
.is_none_or(|t| now.duration_since(t) >= UDP_TIMEOUT);
let missed = self.missed_in_row + u32::from(self.outstanding.is_some());
(self.transport == Transport::Udp && silent && missed >= MISSED_PINGS_TO_FALL_BACK)
.then(|| self.switch(Transport::Tcp))
}
pub(crate) fn decrypt_failed(&mut self, now: Instant) -> bool {
let stale = now.duration_since(self.last_good_decrypt) > RESYNC_AFTER;
let may_ask = self
.last_resync_request
.is_none_or(|t| now.duration_since(t) > RESYNC_AFTER);
if stale && may_ask {
self.last_resync_request = Some(now);
return true;
}
false
}
pub(crate) fn stats(&self, crypt: CryptStats) -> LinkStats {
let udp = self.transport == Transport::Udp;
let loss = match (udp, self.samples.front()) {
(true, Some(&(_, good0, lost0, sent0, answered0))) => {
let good = crypt.good.wrapping_sub(good0);
let lost = crypt.lost.wrapping_sub(lost0);
let sent = self.pings_sent.wrapping_sub(sent0);
let answered = self.pings_answered.wrapping_sub(answered0);
let crypt_loss = ratio(lost, good.saturating_add(lost));
let unanswered = sent.saturating_sub(answered).saturating_sub(1);
crypt_loss.max(ratio(unanswered, sent.saturating_sub(1)))
}
_ => 0.0,
};
LinkStats {
transport: self.transport,
loss,
rtt: if udp { self.rtt } else { None },
crypt,
oversized: 0,
audio_sent: 0,
audio_received: 0,
}
}
fn switch(&mut self, to: Transport) -> Transport {
self.transport = to;
self.replies_in_row = 0;
self.missed_in_row = 0;
if to == Transport::Udp {
self.ever_udp = true;
}
to
}
}
fn ratio(part: u32, total: u32) -> f32 {
if total == 0 {
0.0
} else {
(f64::from(part) / f64::from(total)) as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
const MS: Duration = Duration::from_millis(1);
fn stats(good: u32, lost: u32) -> CryptStats {
CryptStats {
good,
lost,
..CryptStats::default()
}
}
#[test]
fn starts_on_tcp_and_moves_to_udp_on_first_reply() {
let t0 = Instant::now();
let mut l = Link::new(t0);
assert_eq!(l.transport(), Transport::Tcp);
l.ping_sent(1);
l.udp_received(t0 + MS);
assert_eq!(l.ping_reply(1, MS), Some(Transport::Udp));
assert_eq!(l.stats(stats(1, 0)).rtt, Some(MS));
}
#[test]
fn falls_back_after_silence_and_missed_pings_and_needs_three_replies_to_recover() {
let t0 = Instant::now();
let mut l = Link::new(t0);
l.ping_sent(0);
l.udp_received(t0);
l.ping_reply(0, MS);
assert_eq!(l.tick(t0 + Duration::from_secs(6), stats(1, 0)), None);
let t = t0 + Duration::from_secs(6);
for ts in 1..=4 {
l.ping_sent(ts);
assert_eq!(l.tick(t + Duration::from_secs(ts), stats(1, 0)), None);
}
l.ping_sent(5);
assert_eq!(
l.tick(t + Duration::from_secs(5), stats(1, 0)),
Some(Transport::Tcp)
);
assert_eq!(l.stats(stats(1, 0)).rtt, None);
let t = t0 + Duration::from_secs(12);
for ts in 6..8 {
l.ping_sent(ts);
l.udp_received(t);
assert_eq!(l.ping_reply(ts, MS), None);
}
l.ping_sent(8);
l.ping_sent(9);
assert_eq!(l.ping_reply(8, MS), None, "stale reply ignored");
assert_eq!(l.ping_reply(9, MS), None);
l.ping_sent(10);
assert_eq!(l.ping_reply(10, MS), None);
l.ping_sent(11);
assert_eq!(l.ping_reply(11, MS), Some(Transport::Udp));
}
#[test]
fn recent_voice_prevents_fallback_despite_missed_pings() {
let t0 = Instant::now();
let mut l = Link::new(t0);
l.ping_sent(0);
l.udp_received(t0);
l.ping_reply(0, MS);
for ts in 1..=10 {
let now = t0 + Duration::from_secs(ts);
l.udp_received(now - 100 * MS);
assert_eq!(l.tick(now, stats(1, 0)), None, "second {ts}");
l.ping_sent(ts);
}
}
#[test]
fn resync_requested_only_when_stale_and_rate_limited() {
let t0 = Instant::now();
let mut l = Link::new(t0);
assert!(!l.decrypt_failed(t0 + Duration::from_secs(1)));
assert!(l.decrypt_failed(t0 + Duration::from_secs(6)));
assert!(!l.decrypt_failed(t0 + Duration::from_secs(8)));
assert!(l.decrypt_failed(t0 + Duration::from_secs(12)));
l.udp_received(t0 + Duration::from_secs(13));
assert!(!l.decrypt_failed(t0 + Duration::from_secs(14)));
}
#[test]
fn loss_fraction_over_window() {
let t0 = Instant::now();
let mut l = Link::new(t0);
l.ping_sent(1);
l.udp_received(t0);
l.ping_reply(1, MS);
l.tick(t0, stats(100, 0));
l.tick(t0 + Duration::from_secs(1), stats(150, 10));
let s = l.stats(stats(190, 10));
assert!((s.loss - 0.1).abs() < 1e-6, "{}", s.loss);
l.tick(t0 + Duration::from_secs(7), stats(290, 10));
assert_eq!(l.stats(stats(290, 10)).loss, 0.0);
}
#[test]
fn unanswered_pings_count_as_loss() {
let t0 = Instant::now();
let mut l = Link::new(t0);
l.ping_sent(1);
l.udp_received(t0);
l.ping_reply(1, MS);
l.tick(t0, stats(1, 0));
for ts in 2..=5 {
l.ping_sent(ts);
}
l.ping_reply(5, MS);
let s = l.stats(stats(2, 0));
assert!(s.loss > 0.6, "{}", s.loss);
}
struct Rng(u64);
impl Rng {
fn chance(&mut self, p: f64) -> bool {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
let x = self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11;
(x as f64) / ((1u64 << 53) as f64) < p
}
}
fn simulate(
seed: u64,
secs: u64,
voice: bool,
loss: impl Fn(f64) -> f64,
) -> Vec<(Duration, Transport)> {
let t0 = Instant::now();
let mut rng = Rng(seed | 1);
let mut l = Link::new(t0);
let mut changes = Vec::new();
let (mut good, mut lost) = (0u32, 0u32);
let mut reply_due: Option<(Duration, u64)> = None;
for step in 0..secs * 100 {
let at = Duration::from_millis(step * 10);
let now = t0 + at;
let p = loss(at.as_secs_f64());
if let Some((due, ts)) = reply_due
&& due <= at
{
reply_due = None;
if !rng.chance(p) {
good += 1;
l.udp_received(now);
if let Some(to) = l.ping_reply(ts, 20 * MS) {
changes.push((at, to));
}
}
}
if voice && step % 2 == 0 {
if rng.chance(p) {
lost += 1;
} else {
good += 1;
l.udp_received(now);
}
}
if step % 100 == 0 {
if let Some(to) = l.tick(now, stats(good, lost)) {
changes.push((at, to));
}
let ts = step;
l.ping_sent(ts);
if !rng.chance(p) {
reply_due = Some((at + 20 * MS, ts));
}
}
}
changes
}
#[test]
fn random_loss_with_voice_never_falls_back() {
for loss in [0.1, 0.3] {
for seed in 1..=20 {
let changes = simulate(seed, 600, true, |_| loss);
assert!(
matches!(changes[..], [(_, Transport::Udp)]),
"loss {loss}, seed {seed}: {changes:?}"
);
}
}
}
#[test]
fn ten_percent_loss_without_voice_stays_on_udp() {
let mut fallbacks = 0;
for seed in 1..=20 {
fallbacks += simulate(seed, 600, false, |_| 0.1)
.iter()
.filter(|c| c.1 == Transport::Tcp)
.count();
}
assert_eq!(fallbacks, 0);
}
#[test]
fn blocked_udp_falls_back_within_six_seconds_and_recovers_after_unblock() {
const BLOCK: f64 = 30.5;
const UNBLOCK: f64 = 60.0;
for voice in [false, true] {
for seed in 1..=10 {
let changes = simulate(seed, 90, voice, |t| {
if (BLOCK..UNBLOCK).contains(&t) {
1.0
} else {
0.0
}
});
let [first, down, up] = changes[..] else {
panic!("unexpected changes {changes:?}");
};
assert_eq!(first.1, Transport::Udp);
assert_eq!(down.1, Transport::Tcp);
let after_block = down.0.as_secs_f64() - BLOCK;
assert!(
(5.0..=6.5).contains(&after_block),
"fell back {after_block} s after blocking"
);
assert_eq!(up.1, Transport::Udp);
let after_unblock = up.0.as_secs_f64() - UNBLOCK;
assert!(
(0.0..=3.5).contains(&after_unblock),
"recovered {after_unblock} s after unblocking"
);
}
}
}
}