feat: WarzonePhone lossy VoIP protocol — Phase 1 complete
Rust workspace with 7 crates implementing a custom VoIP protocol designed for extremely lossy connections (5-70% loss, 100-500kbps, 300-800ms RTT). 89 tests passing across all crates. Crates: - wzp-proto: Wire format, traits, adaptive quality controller, jitter buffer, session FSM - wzp-codec: Opus encoder/decoder (audiopus), Codec2 stubs, adaptive switching, resampling - wzp-fec: RaptorQ fountain codes, interleaving, block management (proven 30-70% loss recovery) - wzp-crypto: X25519+ChaCha20-Poly1305, Warzone identity compatible, anti-replay, rekeying - wzp-transport: QUIC via quinn with DATAGRAM frames, path monitoring, signaling streams - wzp-relay: Integration stub (Phase 2) - wzp-client: Integration stub (Phase 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
153
crates/wzp-transport/src/config.rs
Normal file
153
crates/wzp-transport/src/config.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! QUIC configuration tuned for lossy VoIP links.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use quinn::crypto::rustls::QuicClientConfig;
|
||||
use quinn::crypto::rustls::QuicServerConfig;
|
||||
|
||||
/// Create a server configuration with a self-signed certificate (for testing).
|
||||
///
|
||||
/// Tunes QUIC transport parameters for lossy VoIP:
|
||||
/// - 30s idle timeout
|
||||
/// - 5s keep-alive interval
|
||||
/// - DATAGRAM extension enabled
|
||||
/// - Conservative flow control for bandwidth-constrained links
|
||||
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
||||
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||
.expect("failed to generate self-signed cert");
|
||||
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
||||
let key_der =
|
||||
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
||||
|
||||
let mut server_crypto = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||
.expect("bad server cert/key");
|
||||
server_crypto.alpn_protocols = vec![b"wzp".to_vec()];
|
||||
|
||||
let quic_server_config =
|
||||
QuicServerConfig::try_from(server_crypto).expect("failed to create QuicServerConfig");
|
||||
|
||||
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server_config));
|
||||
let transport = transport_config();
|
||||
server_config.transport_config(Arc::new(transport));
|
||||
|
||||
(server_config, cert_der.to_vec())
|
||||
}
|
||||
|
||||
/// Create a client configuration that trusts any certificate (for testing).
|
||||
///
|
||||
/// Uses the same VoIP-tuned transport parameters as the server.
|
||||
pub fn client_config() -> quinn::ClientConfig {
|
||||
let mut client_crypto = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
|
||||
.with_no_client_auth();
|
||||
client_crypto.alpn_protocols = vec![b"wzp".to_vec()];
|
||||
|
||||
let quic_client_config =
|
||||
QuicClientConfig::try_from(client_crypto).expect("failed to create QuicClientConfig");
|
||||
|
||||
let mut client_config = quinn::ClientConfig::new(Arc::new(quic_client_config));
|
||||
let transport = transport_config();
|
||||
client_config.transport_config(Arc::new(transport));
|
||||
|
||||
client_config
|
||||
}
|
||||
|
||||
/// Shared transport configuration tuned for lossy VoIP.
|
||||
fn transport_config() -> quinn::TransportConfig {
|
||||
let mut config = quinn::TransportConfig::default();
|
||||
|
||||
// 30 second idle timeout
|
||||
config.max_idle_timeout(Some(
|
||||
quinn::IdleTimeout::try_from(Duration::from_secs(30)).unwrap(),
|
||||
));
|
||||
|
||||
// 5 second keep-alive interval
|
||||
config.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||
|
||||
// Enable DATAGRAM extension for unreliable media packets.
|
||||
// Allow datagrams up to 1200 bytes (conservative for lossy links).
|
||||
config.datagram_receive_buffer_size(Some(65536));
|
||||
|
||||
// Conservative flow control for bandwidth-constrained links
|
||||
config.receive_window(quinn::VarInt::from_u32(256 * 1024)); // 256KB
|
||||
config.send_window(128 * 1024); // 128KB
|
||||
config.stream_receive_window(quinn::VarInt::from_u32(64 * 1024)); // 64KB per stream
|
||||
|
||||
// Aggressive initial RTT estimate for high-latency links
|
||||
config.initial_rtt(Duration::from_millis(300));
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// Certificate verifier that accepts any server certificate (testing only).
|
||||
#[derive(Debug)]
|
||||
struct SkipServerVerification;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
// Support the schemes that rustls typically uses
|
||||
vec![
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_config_creates_without_error() {
|
||||
let (cfg, cert_der) = server_config();
|
||||
assert!(!cert_der.is_empty());
|
||||
// Verify the config was created (no panic)
|
||||
drop(cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_config_creates_without_error() {
|
||||
let cfg = client_config();
|
||||
drop(cfg);
|
||||
}
|
||||
}
|
||||
54
crates/wzp-transport/src/connection.rs
Normal file
54
crates/wzp-transport/src/connection.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! QUIC connection lifecycle management.
|
||||
//!
|
||||
//! Provides helpers for creating endpoints, connecting to peers, and accepting connections.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use wzp_proto::TransportError;
|
||||
|
||||
/// Create a QUIC endpoint bound to the given address.
|
||||
///
|
||||
/// If `server_config` is provided, the endpoint can accept incoming connections.
|
||||
pub fn create_endpoint(
|
||||
bind_addr: SocketAddr,
|
||||
server_config: Option<quinn::ServerConfig>,
|
||||
) -> Result<quinn::Endpoint, TransportError> {
|
||||
let endpoint = if let Some(sc) = server_config {
|
||||
quinn::Endpoint::server(sc, bind_addr)?
|
||||
} else {
|
||||
quinn::Endpoint::client(bind_addr)?
|
||||
};
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
/// Connect to a remote peer using the given client configuration.
|
||||
pub async fn connect(
|
||||
endpoint: &quinn::Endpoint,
|
||||
addr: SocketAddr,
|
||||
server_name: &str,
|
||||
config: quinn::ClientConfig,
|
||||
) -> Result<quinn::Connection, TransportError> {
|
||||
let connecting = endpoint.connect_with(config, addr, server_name).map_err(|e| {
|
||||
TransportError::Internal(format!("connect error: {e}"))
|
||||
})?;
|
||||
|
||||
let connection = connecting.await.map_err(|e| {
|
||||
TransportError::Internal(format!("connection failed: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Accept the next incoming connection on an endpoint.
|
||||
pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, TransportError> {
|
||||
let incoming = endpoint
|
||||
.accept()
|
||||
.await
|
||||
.ok_or(TransportError::ConnectionLost)?;
|
||||
|
||||
let connection = incoming.await.map_err(|e| {
|
||||
TransportError::Internal(format!("accept failed: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(connection)
|
||||
}
|
||||
84
crates/wzp-transport/src/datagram.rs
Normal file
84
crates/wzp-transport/src/datagram.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! DATAGRAM frame serialization for media packets.
|
||||
//!
|
||||
//! Wraps `MediaPacket` serialization with MTU awareness for QUIC DATAGRAM frames.
|
||||
|
||||
use bytes::Bytes;
|
||||
use wzp_proto::MediaPacket;
|
||||
|
||||
/// Serialize a `MediaPacket` into bytes suitable for a QUIC DATAGRAM frame.
|
||||
pub fn serialize_media(packet: &MediaPacket) -> Bytes {
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
/// Deserialize a `MediaPacket` from QUIC DATAGRAM frame bytes.
|
||||
pub fn deserialize_media(data: Bytes) -> Option<MediaPacket> {
|
||||
MediaPacket::from_bytes(data)
|
||||
}
|
||||
|
||||
/// Return the maximum payload size for a QUIC DATAGRAM on this connection.
|
||||
///
|
||||
/// Returns `None` if the peer does not support DATAGRAM frames.
|
||||
pub fn max_datagram_payload(connection: &quinn::Connection) -> Option<usize> {
|
||||
connection.max_datagram_size()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bytes::Bytes;
|
||||
use wzp_proto::{CodecId, MediaHeader};
|
||||
|
||||
fn test_packet() -> MediaPacket {
|
||||
MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus16k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 16,
|
||||
seq: 42,
|
||||
timestamp: 1000,
|
||||
fec_block: 1,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from_static(b"fake opus frame data"),
|
||||
quality_report: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_deserialize_roundtrip() {
|
||||
let packet = test_packet();
|
||||
let data = serialize_media(&packet);
|
||||
let decoded = deserialize_media(data).expect("deserialize should succeed");
|
||||
assert_eq!(packet.header, decoded.header);
|
||||
assert_eq!(packet.payload, decoded.payload);
|
||||
assert_eq!(packet.quality_report, decoded.quality_report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_deserialize_with_quality_report() {
|
||||
let mut packet = test_packet();
|
||||
packet.header.has_quality_report = true;
|
||||
packet.quality_report = Some(wzp_proto::QualityReport {
|
||||
loss_pct: 50,
|
||||
rtt_4ms: 75,
|
||||
jitter_ms: 10,
|
||||
bitrate_cap_kbps: 100,
|
||||
});
|
||||
|
||||
let data = serialize_media(&packet);
|
||||
let decoded = deserialize_media(data).expect("deserialize should succeed");
|
||||
assert_eq!(packet.header, decoded.header);
|
||||
assert_eq!(packet.payload, decoded.payload);
|
||||
assert_eq!(packet.quality_report, decoded.quality_report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_invalid_data_returns_none() {
|
||||
let data = Bytes::from_static(b"too short");
|
||||
assert!(deserialize_media(data).is_none());
|
||||
}
|
||||
}
|
||||
29
crates/wzp-transport/src/lib.rs
Normal file
29
crates/wzp-transport/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! WarzonePhone Transport Layer
|
||||
//!
|
||||
//! QUIC-based transport using quinn with:
|
||||
//! - DATAGRAM frames for unreliable media packets
|
||||
//! - Reliable streams for signaling messages
|
||||
//! - Path quality monitoring (EWMA loss, RTT, bandwidth estimation)
|
||||
//! - Connection lifecycle management
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - `config` — QUIC configuration tuned for lossy VoIP links
|
||||
//! - `datagram` — DATAGRAM frame serialization and MTU management
|
||||
//! - `reliable` — Length-prefixed JSON framing over reliable QUIC streams
|
||||
//! - `path_monitor` — EWMA-based PathQuality estimation
|
||||
//! - `quic` — `QuinnTransport` implementing the `MediaTransport` trait
|
||||
//! - `connection` — Connection lifecycle (create endpoint, connect, accept)
|
||||
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod datagram;
|
||||
pub mod path_monitor;
|
||||
pub mod quic;
|
||||
pub mod reliable;
|
||||
|
||||
pub use config::{client_config, server_config};
|
||||
pub use connection::{accept, connect, create_endpoint};
|
||||
pub use path_monitor::PathMonitor;
|
||||
pub use quic::QuinnTransport;
|
||||
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
||||
263
crates/wzp-transport/src/path_monitor.rs
Normal file
263
crates/wzp-transport/src/path_monitor.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
//! Network path quality estimation using EWMA smoothing.
|
||||
//!
|
||||
//! Tracks packet loss (via sequence number gaps), RTT, jitter, and bandwidth.
|
||||
|
||||
use wzp_proto::PathQuality;
|
||||
|
||||
/// EWMA smoothing factor.
|
||||
const ALPHA: f64 = 0.1;
|
||||
|
||||
/// Monitors network path quality metrics.
|
||||
pub struct PathMonitor {
|
||||
/// EWMA-smoothed loss percentage (0.0 - 100.0).
|
||||
loss_ewma: f64,
|
||||
/// EWMA-smoothed RTT in milliseconds.
|
||||
rtt_ewma: f64,
|
||||
/// EWMA-smoothed jitter (RTT variance) in milliseconds.
|
||||
jitter_ewma: f64,
|
||||
/// Total bytes observed for bandwidth estimation.
|
||||
bytes_sent: u64,
|
||||
bytes_received: u64,
|
||||
/// Timestamps for bandwidth calculation.
|
||||
first_send_time_ms: Option<u64>,
|
||||
last_send_time_ms: Option<u64>,
|
||||
first_recv_time_ms: Option<u64>,
|
||||
last_recv_time_ms: Option<u64>,
|
||||
/// Sequence tracking for loss detection.
|
||||
highest_sent_seq: Option<u16>,
|
||||
total_sent: u64,
|
||||
total_received: u64,
|
||||
/// Last observed RTT for jitter calculation.
|
||||
last_rtt_ms: Option<f64>,
|
||||
/// Whether we have any observations yet.
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl PathMonitor {
|
||||
/// Create a new path monitor with default (zero) initial values.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
loss_ewma: 0.0,
|
||||
rtt_ewma: 0.0,
|
||||
jitter_ewma: 0.0,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
first_send_time_ms: None,
|
||||
last_send_time_ms: None,
|
||||
first_recv_time_ms: None,
|
||||
last_recv_time_ms: None,
|
||||
highest_sent_seq: None,
|
||||
total_sent: 0,
|
||||
total_received: 0,
|
||||
last_rtt_ms: None,
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that we sent a packet with the given sequence number and timestamp.
|
||||
pub fn observe_sent(&mut self, seq: u16, timestamp_ms: u64) {
|
||||
self.total_sent += 1;
|
||||
self.highest_sent_seq = Some(seq);
|
||||
|
||||
if self.first_send_time_ms.is_none() {
|
||||
self.first_send_time_ms = Some(timestamp_ms);
|
||||
}
|
||||
self.last_send_time_ms = Some(timestamp_ms);
|
||||
|
||||
// Estimate ~100 bytes per packet for bandwidth calculation
|
||||
self.bytes_sent += 100;
|
||||
}
|
||||
|
||||
/// Record that we received a packet with the given sequence number and timestamp.
|
||||
pub fn observe_received(&mut self, seq: u16, timestamp_ms: u64) {
|
||||
self.total_received += 1;
|
||||
|
||||
if self.first_recv_time_ms.is_none() {
|
||||
self.first_recv_time_ms = Some(timestamp_ms);
|
||||
}
|
||||
self.last_recv_time_ms = Some(timestamp_ms);
|
||||
|
||||
self.bytes_received += 100;
|
||||
|
||||
// Estimate loss from sequence gaps.
|
||||
// After we've sent some packets, compute instantaneous loss.
|
||||
if self.total_sent > 0 {
|
||||
let expected = self.total_sent;
|
||||
let received = self.total_received;
|
||||
let inst_loss = if expected > received {
|
||||
((expected - received) as f64 / expected as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if !self.initialized {
|
||||
self.loss_ewma = inst_loss;
|
||||
self.initialized = true;
|
||||
} else {
|
||||
self.loss_ewma = ALPHA * inst_loss + (1.0 - ALPHA) * self.loss_ewma;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = seq; // seq used implicitly via total counts
|
||||
}
|
||||
|
||||
/// Record an RTT observation in milliseconds.
|
||||
pub fn observe_rtt(&mut self, rtt_ms: u32) {
|
||||
let rtt = rtt_ms as f64;
|
||||
|
||||
// Update jitter (difference from last RTT, smoothed)
|
||||
if let Some(last_rtt) = self.last_rtt_ms {
|
||||
let diff = (rtt - last_rtt).abs();
|
||||
if self.jitter_ewma == 0.0 {
|
||||
self.jitter_ewma = diff;
|
||||
} else {
|
||||
self.jitter_ewma = ALPHA * diff + (1.0 - ALPHA) * self.jitter_ewma;
|
||||
}
|
||||
}
|
||||
self.last_rtt_ms = Some(rtt);
|
||||
|
||||
// Update RTT EWMA
|
||||
if self.rtt_ewma == 0.0 {
|
||||
self.rtt_ewma = rtt;
|
||||
} else {
|
||||
self.rtt_ewma = ALPHA * rtt + (1.0 - ALPHA) * self.rtt_ewma;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current estimated path quality.
|
||||
pub fn quality(&self) -> PathQuality {
|
||||
let bandwidth_kbps = self.estimate_bandwidth_kbps();
|
||||
|
||||
PathQuality {
|
||||
loss_pct: self.loss_ewma as f32,
|
||||
rtt_ms: self.rtt_ewma as u32,
|
||||
jitter_ms: self.jitter_ewma as u32,
|
||||
bandwidth_kbps,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate bandwidth in kbps from bytes received over time.
|
||||
fn estimate_bandwidth_kbps(&self) -> u32 {
|
||||
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {
|
||||
let duration_ms = last.saturating_sub(first);
|
||||
if duration_ms > 0 {
|
||||
// bytes_received * 8 bits / duration_ms * 1000 ms/s / 1000 bits/kbit
|
||||
let bits = self.bytes_received * 8;
|
||||
let kbps = bits as f64 / duration_ms as f64;
|
||||
return kbps as u32;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PathMonitor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn initial_quality_is_zero() {
|
||||
let monitor = PathMonitor::new();
|
||||
let q = monitor.quality();
|
||||
assert_eq!(q.loss_pct, 0.0);
|
||||
assert_eq!(q.rtt_ms, 0);
|
||||
assert_eq!(q.jitter_ms, 0);
|
||||
assert_eq!(q.bandwidth_kbps, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtt_ewma_smoothing() {
|
||||
let mut monitor = PathMonitor::new();
|
||||
|
||||
// First observation sets the initial value
|
||||
monitor.observe_rtt(100);
|
||||
let q = monitor.quality();
|
||||
assert_eq!(q.rtt_ms, 100);
|
||||
|
||||
// Second observation should be smoothed: 0.1 * 200 + 0.9 * 100 = 110
|
||||
monitor.observe_rtt(200);
|
||||
let q = monitor.quality();
|
||||
assert_eq!(q.rtt_ms, 110);
|
||||
|
||||
// Third: 0.1 * 200 + 0.9 * 110 = 119
|
||||
monitor.observe_rtt(200);
|
||||
let q = monitor.quality();
|
||||
assert_eq!(q.rtt_ms, 119);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_from_rtt_variance() {
|
||||
let mut monitor = PathMonitor::new();
|
||||
|
||||
monitor.observe_rtt(100);
|
||||
// No jitter yet (only one observation)
|
||||
assert_eq!(monitor.quality().jitter_ms, 0);
|
||||
|
||||
monitor.observe_rtt(150);
|
||||
// Jitter = |150 - 100| = 50 (first jitter observation, sets directly)
|
||||
assert_eq!(monitor.quality().jitter_ms, 50);
|
||||
|
||||
monitor.observe_rtt(140);
|
||||
// diff = |140 - 150| = 10
|
||||
// jitter = 0.1 * 10 + 0.9 * 50 = 46
|
||||
assert_eq!(monitor.quality().jitter_ms, 46);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_packet_loss_from_gaps() {
|
||||
let mut monitor = PathMonitor::new();
|
||||
|
||||
// Send 10 packets
|
||||
for i in 0..10 {
|
||||
monitor.observe_sent(i, i as u64 * 20);
|
||||
}
|
||||
|
||||
// Receive only 7 of them (30% loss)
|
||||
for i in [0u16, 1, 2, 3, 5, 7, 9] {
|
||||
monitor.observe_received(i, i as u64 * 20 + 50);
|
||||
}
|
||||
|
||||
let q = monitor.quality();
|
||||
// After 7 observations, the EWMA should converge towards 30%
|
||||
// The exact value depends on the EWMA progression
|
||||
assert!(q.loss_pct > 0.0, "should detect some loss");
|
||||
assert!(q.loss_pct < 100.0, "loss should be reasonable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bandwidth_estimation() {
|
||||
let mut monitor = PathMonitor::new();
|
||||
|
||||
// Receive 100 packets over 1000ms, each ~100 bytes
|
||||
for i in 0..100 {
|
||||
monitor.observe_received(i, i as u64 * 10);
|
||||
monitor.observe_sent(i, i as u64 * 10);
|
||||
}
|
||||
|
||||
let q = monitor.quality();
|
||||
// 100 packets * 100 bytes * 8 bits / 990ms ~= 80.8 kbps
|
||||
assert!(q.bandwidth_kbps > 0, "should estimate non-zero bandwidth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_loss_when_all_received() {
|
||||
let mut monitor = PathMonitor::new();
|
||||
|
||||
for i in 0..20 {
|
||||
monitor.observe_sent(i, i as u64 * 20);
|
||||
monitor.observe_received(i, i as u64 * 20 + 30);
|
||||
}
|
||||
|
||||
let q = monitor.quality();
|
||||
assert!(
|
||||
q.loss_pct < 1.0,
|
||||
"loss should be near zero when all packets received"
|
||||
);
|
||||
}
|
||||
}
|
||||
130
crates/wzp-transport/src/quic.rs
Normal file
130
crates/wzp-transport/src/quic.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! `QuinnTransport` — implements `MediaTransport` trait from wzp-proto.
|
||||
//!
|
||||
//! Wraps a `quinn::Connection` and provides unreliable media (DATAGRAM frames)
|
||||
//! and reliable signaling (QUIC streams).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use wzp_proto::{MediaPacket, MediaTransport, PathQuality, SignalMessage, TransportError};
|
||||
|
||||
use crate::datagram;
|
||||
use crate::path_monitor::PathMonitor;
|
||||
use crate::reliable;
|
||||
|
||||
/// QUIC-based transport implementing the `MediaTransport` trait.
|
||||
pub struct QuinnTransport {
|
||||
connection: quinn::Connection,
|
||||
path_monitor: Mutex<PathMonitor>,
|
||||
}
|
||||
|
||||
impl QuinnTransport {
|
||||
/// Create a new transport wrapping an established QUIC connection.
|
||||
pub fn new(connection: quinn::Connection) -> Self {
|
||||
Self {
|
||||
connection,
|
||||
path_monitor: Mutex::new(PathMonitor::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying QUIC connection.
|
||||
pub fn connection(&self) -> &quinn::Connection {
|
||||
&self.connection
|
||||
}
|
||||
|
||||
/// Get the maximum datagram payload size, if datagrams are supported.
|
||||
pub fn max_datagram_size(&self) -> Option<usize> {
|
||||
datagram::max_datagram_payload(&self.connection)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaTransport for QuinnTransport {
|
||||
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
|
||||
let data = datagram::serialize_media(packet);
|
||||
|
||||
// Check MTU
|
||||
if let Some(max_size) = self.connection.max_datagram_size() {
|
||||
if data.len() > max_size {
|
||||
return Err(TransportError::DatagramTooLarge {
|
||||
size: data.len(),
|
||||
max: max_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Record send observation
|
||||
{
|
||||
let mut monitor = self.path_monitor.lock().unwrap();
|
||||
monitor.observe_sent(packet.header.seq, packet.header.timestamp as u64);
|
||||
}
|
||||
|
||||
self.connection.send_datagram(data).map_err(|e| {
|
||||
TransportError::Internal(format!("send datagram error: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
|
||||
let data = match self.connection.read_datagram().await {
|
||||
Ok(data) => data,
|
||||
Err(quinn::ConnectionError::ApplicationClosed(_)) => return Ok(None),
|
||||
Err(quinn::ConnectionError::LocallyClosed) => return Ok(None),
|
||||
Err(e) => {
|
||||
return Err(TransportError::Internal(format!(
|
||||
"recv datagram error: {e}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
match datagram::deserialize_media(data) {
|
||||
Some(packet) => {
|
||||
// Record receive observation
|
||||
{
|
||||
let mut monitor = self.path_monitor.lock().unwrap();
|
||||
monitor.observe_received(
|
||||
packet.header.seq,
|
||||
packet.header.timestamp as u64,
|
||||
);
|
||||
}
|
||||
Ok(Some(packet))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("received malformed media datagram");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError> {
|
||||
reliable::send_signal(&self.connection, msg).await
|
||||
}
|
||||
|
||||
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
|
||||
match self.connection.accept_bi().await {
|
||||
Ok((_send, mut recv)) => {
|
||||
let msg = reliable::recv_signal(&mut recv).await?;
|
||||
Ok(Some(msg))
|
||||
}
|
||||
Err(quinn::ConnectionError::ApplicationClosed(_)) => Ok(None),
|
||||
Err(quinn::ConnectionError::LocallyClosed) => Ok(None),
|
||||
Err(e) => Err(TransportError::Internal(format!(
|
||||
"accept stream error: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_quality(&self) -> PathQuality {
|
||||
let monitor = self.path_monitor.lock().unwrap();
|
||||
monitor.quality()
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TransportError> {
|
||||
self.connection.close(
|
||||
quinn::VarInt::from_u32(0),
|
||||
b"normal close",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
58
crates/wzp-transport/src/reliable.rs
Normal file
58
crates/wzp-transport/src/reliable.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Reliable stream transport for signaling messages.
|
||||
//!
|
||||
//! Uses length-prefixed framing (4-byte big-endian length + serde_json) over QUIC streams.
|
||||
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use quinn::Connection;
|
||||
use wzp_proto::{SignalMessage, TransportError};
|
||||
|
||||
/// Send a signaling message over a new bidirectional QUIC stream.
|
||||
///
|
||||
/// Opens a new bidi stream, writes a length-prefixed JSON frame, then finishes the send side.
|
||||
pub async fn send_signal(connection: &Connection, msg: &SignalMessage) -> Result<(), TransportError> {
|
||||
let (mut send, _recv) = connection.open_bi().await.map_err(|e| {
|
||||
TransportError::Internal(format!("failed to open bidi stream: {e}"))
|
||||
})?;
|
||||
|
||||
let json = serde_json::to_vec(msg)
|
||||
.map_err(|e| TransportError::Internal(format!("signal serialize error: {e}")))?;
|
||||
|
||||
let mut frame = BytesMut::with_capacity(4 + json.len());
|
||||
frame.put_u32(json.len() as u32);
|
||||
frame.put_slice(&json);
|
||||
|
||||
send.write_all(&frame)
|
||||
.await
|
||||
.map_err(|e| TransportError::Internal(format!("stream write error: {e}")))?;
|
||||
|
||||
send.finish()
|
||||
.map_err(|e| TransportError::Internal(format!("stream finish error: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive a signaling message from a QUIC receive stream.
|
||||
///
|
||||
/// Reads a 4-byte big-endian length prefix, then the JSON payload.
|
||||
pub async fn recv_signal(recv: &mut quinn::RecvStream) -> Result<SignalMessage, TransportError> {
|
||||
// Read 4-byte length prefix
|
||||
let mut len_buf = [0u8; 4];
|
||||
recv.read_exact(&mut len_buf)
|
||||
.await
|
||||
.map_err(|e| TransportError::Internal(format!("stream read length error: {e}")))?;
|
||||
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > 1_048_576 {
|
||||
return Err(TransportError::Internal(format!(
|
||||
"signal message too large: {len} bytes"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
recv.read_exact(&mut payload)
|
||||
.await
|
||||
.map_err(|e| TransportError::Internal(format!("stream read payload error: {e}")))?;
|
||||
|
||||
serde_json::from_slice(&payload)
|
||||
.map_err(|e| TransportError::Internal(format!("signal deserialize error: {e}")))
|
||||
}
|
||||
Reference in New Issue
Block a user