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:
Siavash Sameni
2026-03-27 12:45:07 +04:00
commit 51e893590c
47 changed files with 7097 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
[package]
name = "wzp-transport"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "WarzonePhone transport layer — QUIC (quinn) with DATAGRAM frames"
[dependencies]
wzp-proto = { workspace = true }
quinn = { workspace = true }
tokio = { workspace = true }
bytes = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
serde_json = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
rcgen = "0.13"
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View 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);
}
}

View 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)
}

View 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());
}
}

View 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};

View 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"
);
}
}

View 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(())
}
}

View 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}")))
}