T1.5: Migrate emit/parse sites to v2 wire format

This commit is contained in:
Siavash Sameni
2026-05-11 12:36:45 +04:00
parent 9680b6ff34
commit c93d302656
120 changed files with 5953 additions and 2888 deletions

View File

@@ -86,7 +86,7 @@ struct ParticipantStats {
/// Detected lost packets (sequence gaps)
lost: u64,
/// Last seen sequence number
last_seq: u16,
last_seq: u32,
/// Whether we've seen the first packet (for gap detection)
seq_initialized: bool,
/// EWMA jitter in ms
@@ -181,7 +181,7 @@ impl ParticipantStats {
/// distinguish streams by proximity of consecutive sequence numbers.
fn find_or_create_participant(
participants: &mut Vec<ParticipantStats>,
seq: u16,
seq: u32,
codec: CodecId,
) -> usize {
for (i, p) in participants.iter().enumerate() {
@@ -304,7 +304,7 @@ struct TimelineEntry {
#[allow(dead_code)]
codec: CodecId,
#[allow(dead_code)]
seq: u16,
seq: u32,
#[allow(dead_code)]
payload_len: usize,
loss_pct: f64,
@@ -333,21 +333,25 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
let mut timeline: Vec<TimelineEntry> = Vec::new();
// Decrypt session from --key (optional)
let mut decrypt_session: Option<wzp_crypto::ChaChaSession> = args.key.as_ref().and_then(|hex| {
if hex.len() != 64 { return None; }
let mut key = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
let s = std::str::from_utf8(chunk).unwrap_or("00");
key[i] = u8::from_str_radix(s, 16).unwrap_or(0);
}
Some(wzp_crypto::ChaChaSession::new(key))
});
let mut decrypt_session: Option<wzp_crypto::ChaChaSession> =
args.key.as_ref().and_then(|hex| {
if hex.len() != 64 {
return None;
}
let mut key = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
let s = std::str::from_utf8(chunk).unwrap_or("00");
key[i] = u8::from_str_radix(s, 16).unwrap_or(0);
}
Some(wzp_crypto::ChaChaSession::new(key))
});
let mut decrypt_ok: u64 = 0;
let mut decrypt_fail: u64 = 0;
while let Some((ts_us, pkt)) = reader.next_packet()? {
let now = Instant::now();
let idx = find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id);
let idx =
find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id);
participants[idx].ingest(&pkt, now);
total_packets += 1;
@@ -362,8 +366,10 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
if decrypt_ok <= 5 || decrypt_ok % 100 == 0 {
eprintln!(
" decrypt ok: seq={} codec={:?} payload={}B → plaintext={}B",
pkt.header.seq, pkt.header.codec_id,
pkt.payload.len(), plaintext.len()
pkt.header.seq,
pkt.header.codec_id,
pkt.payload.len(),
plaintext.len()
);
}
}
@@ -402,7 +408,13 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
// Generate HTML if requested
if let Some(html_path) = &args.html {
generate_html_report(html_path, &participants, &timeline, total_packets, &reader.header)?;
generate_html_report(
html_path,
&participants,
&timeline,
total_packets,
&reader.header,
)?;
eprintln!("HTML report: {}", html_path);
}
@@ -587,12 +599,12 @@ async fn run_no_tui(
w.write_packet(&pkt, now)?;
}
}
Ok(Ok(None)) => break, // connection closed
Ok(Ok(None)) => break, // connection closed
Ok(Err(e)) => {
tracing::warn!("recv error: {e}");
break;
}
Err(_) => {} // timeout, loop again
Err(_) => {} // timeout, loop again
}
if print_timer.elapsed() >= Duration::from_secs(2) {
print_stats(participants, *total_packets);
@@ -603,7 +615,11 @@ async fn run_no_tui(
}
fn print_stats(participants: &[ParticipantStats], total: u64) {
eprintln!("--- {} participants | {} total packets ---", participants.len(), total);
eprintln!(
"--- {} participants | {} total packets ---",
participants.len(),
total
);
for p in participants {
eprintln!(
" {}: {} pkts, {:.1}% loss, {:.0}ms jitter, {:?}, {:.0}s",
@@ -693,10 +709,7 @@ async fn run_tui(
// Always restore terminal, even on error
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen
)?;
crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
result
}
@@ -723,7 +736,7 @@ fn draw_ui(
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // header
Constraint::Min(5), // participant table
Constraint::Min(5), // participant table
Constraint::Length(3), // footer
])
.split(f.area());
@@ -735,7 +748,11 @@ fn draw_ui(
total_packets,
elapsed_str
))
.block(Block::default().borders(Borders::ALL).title(" Protocol Analyzer "));
.block(
Block::default()
.borders(Borders::ALL)
.title(" Protocol Analyzer "),
);
f.render_widget(header, chunks[0]);
// Participant table
@@ -780,9 +797,11 @@ fn draw_ui(
Constraint::Length(10), // Duration
];
let table = Table::new(rows, widths)
.header(header_row)
.block(Block::default().borders(Borders::ALL).title(" Participants "));
let table = Table::new(rows, widths).header(header_row).block(
Block::default()
.borders(Borders::ALL)
.title(" Participants "),
);
f.render_widget(table, chunks[1]);
// Footer
@@ -832,7 +851,10 @@ async fn main() -> anyhow::Result<()> {
let _crypto_session: Option<std::sync::Mutex<wzp_crypto::ChaChaSession>> =
if let Some(ref key_hex) = args.key {
if key_hex.len() != 64 {
eprintln!("Error: --key must be 64 hex characters (32 bytes). Got {} chars.", key_hex.len());
eprintln!(
"Error: --key must be 64 hex characters (32 bytes). Got {} chars.",
key_hex.len()
);
std::process::exit(1);
}
let mut key_bytes = [0u8; 32];
@@ -841,9 +863,9 @@ async fn main() -> anyhow::Result<()> {
key_bytes[i] = u8::from_str_radix(hex_str, 16).unwrap_or(0);
}
eprintln!("Encrypted payload decoding enabled (key loaded).");
Some(std::sync::Mutex::new(
wzp_crypto::ChaChaSession::new(key_bytes),
))
Some(std::sync::Mutex::new(wzp_crypto::ChaChaSession::new(
key_bytes,
)))
} else {
None
};
@@ -854,14 +876,12 @@ async fn main() -> anyhow::Result<()> {
}
// Live mode requires relay and room
let relay = args
.relay
.as_deref()
.ok_or_else(|| anyhow::anyhow!("relay address required for live mode (use --replay for offline)"))?;
let room = args
.room
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--room required for live mode (use --replay for offline)"))?;
let relay = args.relay.as_deref().ok_or_else(|| {
anyhow::anyhow!("relay address required for live mode (use --replay for offline)")
})?;
let room = args.room.as_deref().ok_or_else(|| {
anyhow::anyhow!("--room required for live mode (use --replay for offline)")
})?;
// TLS crypto provider
let _ = rustls::crypto::ring::default_provider().install_default();

View File

@@ -6,10 +6,10 @@
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{anyhow, Context};
use anyhow::{Context, anyhow};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SampleRate, StreamConfig};
use tracing::{info, warn};
@@ -78,7 +78,10 @@ impl AudioCapture {
return;
}
if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[audio] capture callback: {} f32 samples", data.len());
eprintln!(
"[audio] capture callback: {} f32 samples",
data.len()
);
}
let mut tmp = [0i16; FRAME_SAMPLES];
for chunk in data.chunks(FRAME_SAMPLES) {
@@ -103,7 +106,10 @@ impl AudioCapture {
return;
}
if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[audio] capture callback: {} i16 samples", data.len());
eprintln!(
"[audio] capture callback: {} i16 samples",
data.len()
);
}
ring.write(data);
},

View File

@@ -54,13 +54,13 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{anyhow, Context};
use anyhow::{Context, anyhow};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SampleRate, StreamConfig};
use tracing::{info, warn};
use webrtc_audio_processing::{
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
NoiseSuppression, NoiseSuppressionLevel, Processor, NUM_SAMPLES_PER_FRAME,
NUM_SAMPLES_PER_FRAME, NoiseSuppression, NoiseSuppressionLevel, Processor,
};
use crate::audio_ring::AudioRing;
@@ -97,8 +97,8 @@ fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
num_render_channels: APM_NUM_CHANNELS as i32,
..Default::default()
};
let mut processor = Processor::new(&init_config)
.map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
let mut processor =
Processor::new(&init_config).map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
let config = Config {
echo_cancellation: Some(EchoCancellation {

View File

@@ -5,8 +5,8 @@
//! to the speaker, so it can cancel the echo from the mic signal internally.
//! This is the same engine FaceTime and other Apple apps use.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Context;
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
@@ -146,7 +146,8 @@ impl VpioAudio {
)
.context("failed to set render callback")?;
au.initialize().context("failed to initialize VoiceProcessingIO")?;
au.initialize()
.context("failed to initialize VoiceProcessingIO")?;
au.start().context("failed to start VoiceProcessingIO")?;
info!("VoiceProcessingIO started (OS-level AEC enabled)");

View File

@@ -15,24 +15,24 @@
//! `wzp-client`'s lib.rs can transparently re-export either one as
//! `AudioCapture`.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{anyhow, Context};
use anyhow::{Context, anyhow};
use tracing::{info, warn};
use windows::core::{Interface, GUID};
use windows::Win32::Foundation::{CloseHandle, BOOL, WAIT_OBJECT_0};
use windows::Win32::Foundation::{BOOL, CloseHandle, WAIT_OBJECT_0};
use windows::Win32::Media::Audio::{
eCapture, eCommunications, AudioCategory_Communications, AudioClientProperties,
IAudioCaptureClient, IAudioClient, IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator,
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, WAVEFORMATEX,
WAVE_FORMAT_PCM,
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
AudioCategory_Communications, AudioClientProperties, IAudioCaptureClient, IAudioClient,
IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator, WAVE_FORMAT_PCM, WAVEFORMATEX,
eCapture, eCommunications,
};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED,
CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject, INFINITE};
use windows::Win32::System::Threading::{CreateEventW, INFINITE, WaitForSingleObject};
use windows::core::{GUID, Interface};
use crate::audio_ring::AudioRing;
@@ -138,9 +138,8 @@ unsafe fn capture_thread_main(
}
let _com_guard = ComGuard;
let enumerator: IMMDeviceEnumerator =
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
// eCommunications role (not eConsole) — this picks the device the user
// has designated for communications in Sound Settings. It's the one
@@ -206,12 +205,13 @@ unsafe fn capture_thread_main(
&wave_format,
Some(&GUID::zeroed()),
)
.context("IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16")?;
.context(
"IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16",
)?;
// Event-driven capture: Windows signals this handle each time a new
// audio packet is available. We wait on it from the loop below.
let event = CreateEventW(None, false, false, None)
.context("CreateEventW failed")?;
let event = CreateEventW(None, false, false, None).context("CreateEventW failed")?;
audio_client
.SetEventHandle(event)
.context("SetEventHandle failed")?;
@@ -285,10 +285,8 @@ unsafe fn capture_thread_main(
// Because we asked for 48 kHz mono i16, each frame is
// exactly one i16. Windows's AUTOCONVERTPCM handles the
// conversion from whatever the engine mix format is.
let samples = std::slice::from_raw_parts(
buffer_ptr as *const i16,
num_frames as usize,
);
let samples =
std::slice::from_raw_parts(buffer_ptr as *const i16, num_frames as usize);
ring.write(samples);
}

View File

@@ -6,8 +6,8 @@ use std::time::{Duration, Instant};
use wzp_crypto::ChaChaSession;
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
use wzp_proto::QualityProfile;
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
use crate::call::{CallConfig, CallDecoder, CallEncoder};
@@ -201,9 +201,13 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
// Deterministic shuffle for reproducibility using a simple seed
// We use a basic Fisher-Yates with a fixed-per-block seed
let mut indices: Vec<usize> = (0..all_symbols.len()).collect();
let mut seed = (block_idx as u64).wrapping_mul(6364136223846793005).wrapping_add(1);
let mut seed = (block_idx as u64)
.wrapping_mul(6364136223846793005)
.wrapping_add(1);
for i in (1..indices.len()).rev() {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let j = (seed >> 33) as usize % (i + 1);
indices.swap(i, j);
}

View File

@@ -24,8 +24,14 @@ fn run_codec() {
print_header("Codec Roundtrip (Opus 24kbps)");
let r = bench::bench_codec_roundtrip();
print_row("Frames", &format!("{}", r.frames));
print_row("Encode total", &format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0));
print_row("Decode total", &format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0));
print_row(
"Encode total",
&format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0),
);
print_row(
"Decode total",
&format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0),
);
print_row("Avg encode", &format!("{:.1} us", r.avg_encode_us));
print_row("Avg decode", &format!("{:.1} us", r.avg_decode_us));
print_row("Throughput", &format!("{:.0} frames/sec", r.frames_per_sec));
@@ -41,7 +47,10 @@ fn run_fec(loss_pct: f32) {
print_row("Recovery rate", &format!("{:.1}%", r.recovery_rate_pct));
print_row("Source bytes", &format!("{}", r.total_source_bytes));
print_row("Repair (overhead) bytes", &format!("{}", r.overhead_bytes));
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0));
print_row(
"Total time",
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
);
print_footer();
}
@@ -49,7 +58,10 @@ fn run_crypto() {
print_header("Crypto (ChaCha20-Poly1305)");
let r = bench::bench_encrypt_decrypt();
print_row("Packets", &format!("{}", r.packets));
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0));
print_row(
"Total time",
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
);
print_row("Throughput", &format!("{:.0} pkt/sec", r.packets_per_sec));
print_row("Bandwidth", &format!("{:.2} MB/sec", r.megabytes_per_sec));
print_row("Avg latency", &format!("{:.2} us", r.avg_latency_us));
@@ -60,9 +72,18 @@ fn run_pipeline() {
print_header("Full Pipeline (E2E)");
let r = bench::bench_full_pipeline();
print_row("Frames", &format!("{}", r.frames));
print_row("Encode pipeline", &format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0));
print_row("Decode pipeline", &format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0));
print_row("Avg E2E latency", &format!("{:.1} us/frame", r.avg_e2e_latency_us));
print_row(
"Encode pipeline",
&format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0),
);
print_row(
"Decode pipeline",
&format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0),
);
print_row(
"Avg E2E latency",
&format!("{:.1} us/frame", r.avg_e2e_latency_us),
);
print_row("PCM in", &format!("{} bytes", r.pcm_bytes_in));
print_row("Wire out", &format!("{} bytes", r.wire_bytes_out));
print_row("Overhead ratio", &format!("{:.3}x", r.overhead_ratio));

View File

@@ -165,10 +165,7 @@ pub fn generate_dialer_targets(
// First: all known ports (guaranteed targets)
for &port in known_ports {
targets.push(SocketAddr::new(
std::net::IpAddr::V4(acceptor_ip),
port,
));
targets.push(SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port));
}
// Fill remaining with random ports (birthday attack)
@@ -178,10 +175,7 @@ pub fn generate_dialer_targets(
let mut rng = rand::thread_rng();
for _ in 0..remaining {
let port = rng.gen_range(1024..=65535u16);
let addr = SocketAddr::new(
std::net::IpAddr::V4(acceptor_ip),
port,
);
let addr = SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port);
if !targets.contains(&addr) {
targets.push(addr);
}
@@ -339,7 +333,10 @@ mod tests {
fn acceptor_ports_serializes() {
let result = AcceptorPorts {
external_ip: Some(Ipv4Addr::new(203, 0, 113, 5)),
ports: vec![PortMapping { local_port: 12345, external_port: 54321 }],
ports: vec![PortMapping {
local_port: 12345,
external_port: 54321,
}],
attempted: 32,
succeeded: 1,
};

View File

@@ -13,11 +13,11 @@ use wzp_codec::{
};
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::packet::QualityReport;
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
use wzp_proto::quality::AdaptiveQualityController;
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
use wzp_proto::packet::QualityReport;
use wzp_proto::{CodecId, QualityProfile};
use wzp_proto::{CodecId, MediaType, QualityProfile};
/// Configuration for a call session.
pub struct CallConfig {
@@ -205,7 +205,7 @@ pub struct CallEncoder {
/// Current profile.
profile: QualityProfile,
/// Outbound sequence counter.
seq: u16,
seq: u32,
/// Current FEC block.
block_id: u8,
/// Frame index within current block.
@@ -318,17 +318,15 @@ impl CallEncoder {
if self.cn_counter % 10 == 0 {
let cn_pkt = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::ComfortNoise,
has_quality_report: false,
fec_ratio_encoded: 0,
stream_id: 0,
fec_ratio: 0,
seq: self.seq,
timestamp: self.timestamp_ms,
fec_block: self.block_id,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
fec_block: u16::from(self.block_id),
},
payload: Bytes::from(vec![self.cn_level as u8]),
quality_report: None,
@@ -354,30 +352,31 @@ impl CallEncoder {
// can cleanly identify "no RaptorQ block to assemble" and new
// receivers can short-circuit their FEC ingest path.
let is_opus = self.profile.codec.is_opus();
let (fec_block, fec_symbol, fec_ratio_encoded) = if is_opus {
(0u8, 0u8, 0u8)
let (fec_block, fec_ratio) = if is_opus {
(0u16, 0u8)
} else {
(
self.block_id,
self.frame_in_block,
u16::from(self.block_id) | (u16::from(self.frame_in_block) << 8),
MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
)
};
// Build source media packet
let mut flags = 0u8;
if self.pending_quality_report.is_some() {
flags |= MediaHeader::FLAG_QUALITY;
}
let source_pkt = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags,
media_type: MediaType::Audio,
codec_id: self.profile.codec,
has_quality_report: self.pending_quality_report.is_some(),
fec_ratio_encoded,
stream_id: 0,
fec_ratio,
seq: self.seq,
timestamp: self.timestamp_ms,
fec_block,
fec_symbol,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(encoded.clone()),
quality_report: self.pending_quality_report.take(),
@@ -402,19 +401,15 @@ impl CallEncoder {
for (sym_idx, repair_data) in repairs {
output.push(MediaPacket {
header: MediaHeader {
version: 0,
is_repair: true,
version: 2,
flags: MediaHeader::FLAG_REPAIR,
media_type: MediaType::Audio,
codec_id: self.profile.codec,
has_quality_report: false,
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
self.profile.fec_ratio,
),
stream_id: 0,
fec_ratio: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
seq: self.seq,
timestamp: self.timestamp_ms,
fec_block: self.block_id,
fec_symbol: sym_idx,
reserved: 0,
csrc_count: 0,
fec_block: u16::from(self.block_id) | (u16::from(sym_idx) << 8),
},
payload: Bytes::from(repair_data),
quality_report: None,
@@ -508,7 +503,7 @@ pub struct CallDecoder {
last_good_dred: DredState,
/// Sequence number of the packet that produced `last_good_dred`. `None`
/// if no packet has yielded DRED state yet (cold start or legacy sender).
last_good_dred_seq: Option<u16>,
last_good_dred_seq: Option<u32>,
/// Phase 4 telemetry counter: gaps recovered via DRED reconstruction.
pub dred_reconstructions: u64,
/// Phase 4 telemetry counter: gaps filled via classical Opus PLC
@@ -570,9 +565,9 @@ impl CallDecoder {
// ignored — a graceful mixed-version degradation).
if !packet.header.codec_id.is_opus() {
let _ = self.fec_dec.add_symbol(
packet.header.fec_block,
packet.header.fec_symbol,
packet.header.is_repair,
(packet.header.fec_block & 0xFF) as u8,
(packet.header.fec_block >> 8) as u8,
packet.header.is_repair(),
&packet.payload,
);
}
@@ -582,7 +577,7 @@ impl CallDecoder {
// swap with the cached `last_good_dred` so later gap reconstruction
// has fresh neural redundancy to draw from. Parsing happens before
// the jitter push because the jitter buffer consumes the packet.
if packet.header.codec_id.is_opus() && !packet.header.is_repair {
if packet.header.codec_id.is_opus() && !packet.header.is_repair() {
match self
.dred_decoder
.parse_into(&mut self.dred_parse_scratch, &packet.payload)
@@ -611,7 +606,7 @@ impl CallDecoder {
// Source packets (Opus or Codec2) go to the jitter buffer for decode.
// Repair packets never reach the jitter buffer; for Codec2 they're
// used by the FEC decoder above, for Opus they're dropped here.
if !packet.header.is_repair {
if !packet.header.is_repair() {
self.jitter.push(packet);
}
}
@@ -711,12 +706,12 @@ impl CallDecoder {
if let Some(last_seq) = self.last_good_dred_seq {
// How many frames ahead of the missing seq is the
// last-good packet? Use wrapping arithmetic for the
// u16 seq space.
// u32 seq space.
let seq_delta = last_seq.wrapping_sub(seq);
// Reject stale or backward state. u16 wraparound
// Reject stale or backward state. u32 wraparound
// would make a "seq went backward" delta very large;
// cap at a sane forward-looking window.
const MAX_SEQ_DELTA: u16 = 128;
const MAX_SEQ_DELTA: u32 = 128;
if seq_delta > 0 && seq_delta <= MAX_SEQ_DELTA {
let frame_samples =
(48_000 * self.profile.frame_duration_ms as i32) / 1000;
@@ -785,7 +780,7 @@ impl CallDecoder {
/// Phase 3b introspection: sequence number of the most recently parsed
/// valid DRED state, or `None` if no Opus packet has yielded DRED data
/// yet. Used by tests to debug reconstruction eligibility.
pub fn last_good_dred_seq(&self) -> Option<u16> {
pub fn last_good_dred_seq(&self) -> Option<u32> {
self.last_good_dred_seq
}
@@ -852,7 +847,7 @@ mod tests {
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty());
assert_eq!(packets[0].header.seq, 0);
assert!(!packets[0].header.is_repair);
assert!(!packets[0].header.is_repair());
}
/// Phase 2: Opus packets have zero FEC header fields — no block, no
@@ -875,10 +870,9 @@ mod tests {
assert_eq!(packets.len(), 1, "Opus must emit exactly 1 source packet");
let hdr = &packets[0].header;
assert!(hdr.codec_id.is_opus());
assert!(!hdr.is_repair);
assert!(!hdr.is_repair());
assert_eq!(hdr.fec_block, 0, "Opus fec_block must be 0");
assert_eq!(hdr.fec_symbol, 0, "Opus fec_symbol must be 0");
assert_eq!(hdr.fec_ratio_encoded, 0, "Opus fec_ratio_encoded must be 0");
assert_eq!(hdr.fec_ratio, 0, "Opus fec_ratio must be 0");
}
/// Phase 2: Opus never emits repair packets, regardless of how many
@@ -902,7 +896,7 @@ mod tests {
for _ in 0..20 {
let packets = enc.encode_frame(&pcm).unwrap();
total_packets += packets.len();
repair_count += packets.iter().filter(|p| p.header.is_repair).count();
repair_count += packets.iter().filter(|p| p.header.is_repair()).count();
}
assert_eq!(repair_count, 0, "Opus must emit zero repair packets");
assert_eq!(
@@ -934,7 +928,7 @@ mod tests {
for _ in 0..16 {
let packets = enc.encode_frame(&pcm).unwrap();
for p in &packets {
if p.header.is_repair {
if p.header.is_repair() {
repair_count += 1;
}
}
@@ -953,17 +947,15 @@ mod tests {
let pkt = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 0,
stream_id: 0,
fec_ratio: 0,
seq: 0,
timestamp: 0,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(vec![0u8; 60]),
quality_report: None,
@@ -1025,17 +1017,15 @@ mod tests {
encoded.truncate(n);
let pkt = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 0,
seq: i,
stream_id: 0,
fec_ratio: 0,
seq: i as u32,
timestamp: (i as u32) * 20,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(encoded),
quality_report: None,
@@ -1105,9 +1095,7 @@ mod tests {
let dred_delta = dec.dred_reconstructions - baseline_dred;
let plc_delta = dec.classical_plc_invocations - baseline_plc;
eprintln!(
"[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}"
);
eprintln!("[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}");
assert!(
dred_delta >= 1,
"expected ≥1 DRED reconstruction on single-packet loss, \
@@ -1168,7 +1156,7 @@ mod tests {
let packets = enc.encode_frame(&pcm).unwrap();
for pkt in packets {
// Drop every 5th source packet to simulate loss.
if !pkt.header.is_repair && i % 5 == 3 {
if !pkt.header.is_repair() && i % 5 == 3 {
continue;
}
dec.ingest(pkt);
@@ -1322,20 +1310,18 @@ mod tests {
// ---- JitterStats telemetry tests ----
fn make_test_packet(seq: u16) -> MediaPacket {
fn make_test_packet(seq: u32) -> MediaPacket {
MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 0,
stream_id: 0,
fec_ratio: 0,
seq,
timestamp: seq as u32 * 20,
timestamp: seq * 20,
fec_block: 0,
fec_symbol: seq as u8,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(vec![0u8; 60]),
quality_report: None,
@@ -1347,7 +1333,7 @@ mod tests {
let config = CallConfig::default();
let mut dec = CallDecoder::new(&config);
for i in 0..5u16 {
for i in 0..5u32 {
dec.ingest(make_test_packet(i));
}
@@ -1377,7 +1363,7 @@ mod tests {
let mut dec = CallDecoder::new(&config);
// Generate some stats: ingest packets and trigger underruns on empty buffer
for i in 0..3u16 {
for i in 0..3u32 {
dec.ingest(make_test_packet(i));
}
// Also call decode on empty decoder to get underruns
@@ -1456,10 +1442,7 @@ mod tests {
cn_packets >= 1,
"should have at least one CN packet, got {cn_packets}"
);
assert!(
enc.frames_suppressed > 0,
"frames_suppressed should be > 0"
);
assert!(enc.frames_suppressed > 0, "frames_suppressed should be > 0");
}
// ---- DredTuner integration tests ----
@@ -1506,7 +1489,10 @@ mod tests {
// Verify the encoder still works after tuning.
let pcm = voice_frame_20ms(0);
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty(), "encoder must still produce packets after DRED tuning");
assert!(
!packets.is_empty(),
"encoder must still produce packets after DRED tuning"
);
}
/// DredTuner jitter spike triggers pre-emptive DRED boost to ceiling.
@@ -1524,11 +1510,15 @@ mod tests {
// Jitter spikes to 40ms (8x baseline of ~5ms).
let tuning = tuner.update(0.0, 50, 40);
assert!(tuner.spike_boost_active(), "jitter spike should activate boost");
assert!(
tuner.spike_boost_active(),
"jitter spike should activate boost"
);
assert!(tuning.is_some());
// Ceiling for Opus24k is 50 frames = 500 ms.
assert_eq!(
tuning.unwrap().dred_frames, 50,
tuning.unwrap().dred_frames,
50,
"spike should push to ceiling"
);
}
@@ -1604,12 +1594,18 @@ mod tests {
let pcm = voice_frame_20ms(0);
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty());
assert!(packets[0].header.has_quality_report, "first packet should have quality report");
assert!(
packets[0].header.has_quality(),
"first packet should have quality report"
);
assert!(packets[0].quality_report.is_some());
// Next frame should NOT have quality_report (it was consumed)
let packets2 = enc.encode_frame(&voice_frame_20ms(960)).unwrap();
assert!(!packets2[0].header.has_quality_report, "second packet should not have quality report");
assert!(
!packets2[0].header.has_quality(),
"second packet should not have quality report"
);
assert!(packets2[0].quality_report.is_none());
}
}

View File

@@ -108,7 +108,11 @@ fn parse_args() -> CliArgs {
"--signal" => signal = true,
"--call" => {
i += 1;
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string());
call_target = Some(
args.get(i)
.expect("--call requires a fingerprint")
.to_string(),
);
}
"--send-tone" => {
i += 1;
@@ -185,8 +189,12 @@ fn parse_args() -> CliArgs {
);
}
"--sweep" => sweep = true,
"--netcheck" => { netcheck = true; }
"--version-check" => { version_check = true; }
"--netcheck" => {
netcheck = true;
}
"--version-check" => {
version_check = true;
}
"--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]");
eprintln!();
@@ -197,13 +205,19 @@ fn parse_args() -> CliArgs {
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" --echo-test <secs> Run automated echo quality test");
eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)");
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
eprintln!(
" --sweep Run jitter buffer parameter sweep (local, no network)"
);
eprintln!(
" --seed <hex> Identity seed (64 hex chars, featherChat compatible)"
);
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
eprintln!(" --room <name> Room name (hashed for privacy before sending)");
eprintln!(" --token <token> featherChat bearer token for relay auth");
eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)");
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
eprintln!(
" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)"
);
eprintln!();
eprintln!("Default relay: 127.0.0.1:4433");
std::process::exit(0);
@@ -265,9 +279,7 @@ async fn main() -> anyhow::Result<()> {
if cli.netcheck {
let config = wzp_client::netcheck::NetcheckConfig {
stun_config: wzp_client::stun::StunConfig::default(),
relays: vec![
("relay".into(), cli.relay_addr),
],
relays: vec![("relay".into(), cli.relay_addr)],
timeout: std::time::Duration::from_secs(5),
test_portmap: true,
test_ipv6: true,
@@ -283,7 +295,8 @@ async fn main() -> anyhow::Result<()> {
let client_config = wzp_transport::client_config();
let bind_addr: SocketAddr = "0.0.0.0:0".parse()?;
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let conn = wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?;
let conn =
wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?;
match conn.accept_uni().await {
Ok(mut recv) => {
let data = recv.read_to_end(256).await.unwrap_or_default();
@@ -291,7 +304,10 @@ async fn main() -> anyhow::Result<()> {
println!("{} {}", cli.relay_addr, version.trim());
}
Err(e) => {
eprintln!("relay {} does not support version query: {e}", cli.relay_addr);
eprintln!(
"relay {} does not support version query: {e}",
cli.relay_addr
);
}
}
endpoint.close(0u32.into(), b"done");
@@ -331,8 +347,7 @@ async fn main() -> anyhow::Result<()> {
"0.0.0.0:0".parse()?
};
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let connection =
wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?;
let connection = wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?;
info!("Connected to relay");
@@ -343,10 +358,12 @@ async fn main() -> anyhow::Result<()> {
{
let shutdown_transport = transport.clone();
tokio::spawn(async move {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to register SIGTERM handler");
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.expect("failed to register SIGINT handler");
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to register SIGTERM handler");
let mut sigint =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.expect("failed to register SIGINT handler");
tokio::select! {
_ = sigterm.recv() => { info!("SIGTERM received, closing connection..."); }
_ = sigint.recv() => { info!("SIGINT received, closing connection..."); }
@@ -354,7 +371,9 @@ async fn main() -> anyhow::Result<()> {
// Close the QUIC connection immediately (APPLICATION_CLOSE frame).
// Don't call process::exit — let the main task detect the closed
// connection and perform clean shutdown (e.g., save recordings).
shutdown_transport.connection().close(0u32.into(), b"shutdown");
shutdown_transport
.connection()
.close(0u32.into(), b"shutdown");
});
}
@@ -372,7 +391,8 @@ async fn main() -> anyhow::Result<()> {
&*transport,
&seed.0,
None, // alias — desktop client doesn't set one yet
).await?;
)
.await?;
info!("crypto handshake complete");
if cli.live {
@@ -382,7 +402,9 @@ async fn main() -> anyhow::Result<()> {
}
#[cfg(not(feature = "audio"))]
{
anyhow::bail!("--live requires the 'audio' feature (build with: cargo build --features audio)");
anyhow::bail!(
"--live requires the 'audio' feature (build with: cargo build --features audio)"
);
}
} else if let Some(secs) = cli.echo_test_secs {
let result = wzp_client::echo_test::run_echo_test(&*transport, secs, 5.0).await?;
@@ -399,7 +421,13 @@ async fn main() -> anyhow::Result<()> {
transport.close().await?;
Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await
run_file_mode(
transport,
cli.send_tone_secs,
cli.send_file,
cli.record_file,
)
.await
} else {
run_silence(transport).await
}
@@ -420,7 +448,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
for i in 0..250u32 {
let packets = encoder.encode_frame(&pcm)?;
for pkt in &packets {
if pkt.header.is_repair {
if pkt.header.is_repair() {
total_repair += 1;
} else {
total_source += 1;
@@ -470,21 +498,28 @@ async fn run_file_mode(
// Read raw PCM file (48kHz mono s16le)
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => { error!("read {path}: {e}"); return; }
Err(e) => {
error!("read {path}: {e}");
return;
}
};
let samples: Vec<i16> = bytes.chunks_exact(2)
let samples: Vec<i16> = bytes
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
let duration = samples.len() as f64 / 48_000.0;
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
samples.chunks(FRAME_SAMPLES)
samples
.chunks(FRAME_SAMPLES)
.filter(|c| c.len() == FRAME_SAMPLES)
.map(|c| c.to_vec())
.collect()
} else if let Some(secs) = send_tone_secs {
let total = (secs as u64) * 50;
info!(seconds = secs, frames = total, "sending 440Hz tone");
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect()
(0..total)
.map(|i| generate_sine_frame(440.0, 48_000, i))
.collect()
} else {
// No sending, just wait
tokio::signal::ctrl_c().await.ok();
@@ -508,7 +543,7 @@ async fn run_file_mode(
}
};
for pkt in &packets {
if pkt.header.is_repair {
if pkt.header.is_repair() {
total_repair += 1;
} else {
total_source += 1;
@@ -556,7 +591,7 @@ async fn run_file_mode(
result = recv_transport.recv_media() => {
match result {
Ok(Some(pkt)) => {
let is_repair = pkt.header.is_repair;
let is_repair = pkt.header.is_repair();
decoder.ingest(pkt);
if !is_repair {
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
@@ -756,22 +791,30 @@ async fn run_signal_mode(
// Auth if token provided
if let Some(ref tok) = token {
transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await?;
transport
.send_signal(&SignalMessage::AuthToken { token: tok.clone() })
.await?;
}
// Register presence (signature not verified in Phase 1)
transport.send_signal(&SignalMessage::RegisterPresence {
identity_pub,
signature: vec![], // Phase 1: not verified
alias: None,
}).await?;
transport
.send_signal(&SignalMessage::RegisterPresence {
identity_pub,
signature: vec![], // Phase 1: not verified
alias: None,
})
.await?;
// Wait for ack
match transport.recv_signal().await? {
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
info!(fingerprint = %fp, "registered on relay — waiting for calls");
}
Some(SignalMessage::RegisterPresenceAck { success: false, error, .. }) => {
Some(SignalMessage::RegisterPresenceAck {
success: false,
error,
..
}) => {
anyhow::bail!("registration failed: {}", error.unwrap_or_default());
}
other => {
@@ -782,25 +825,32 @@ async fn run_signal_mode(
// If --call specified, place the call
if let Some(ref target) = call_target {
info!(target = %target, "placing direct call...");
let call_id = format!("{:016x}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
let call_id = format!(
"{:016x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
transport.send_signal(&SignalMessage::DirectCallOffer {
caller_fingerprint: fp.clone(),
caller_alias: None,
target_fingerprint: target.clone(),
call_id: call_id.clone(),
identity_pub,
ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange
signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
// CLI client doesn't attempt hole-punching; always
// relay-path.
caller_reflexive_addr: None,
caller_local_addrs: Vec::new(),
caller_mapped_addr: None,
caller_build_version: None,
}).await?;
transport
.send_signal(&SignalMessage::DirectCallOffer {
caller_fingerprint: fp.clone(),
caller_alias: None,
target_fingerprint: target.clone(),
call_id: call_id.clone(),
identity_pub,
ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange
signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
// CLI client doesn't attempt hole-punching; always
// relay-path.
caller_reflexive_addr: None,
caller_local_addrs: Vec::new(),
caller_mapped_addr: None,
caller_build_version: None,
})
.await?;
}
// Signal recv loop — handle incoming signals
@@ -814,7 +864,12 @@ async fn run_signal_mode(
SignalMessage::CallRinging { call_id } => {
info!(call_id = %call_id, "ringing...");
}
SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. } => {
SignalMessage::DirectCallOffer {
caller_fingerprint,
caller_alias,
call_id,
..
} => {
info!(
from = %caller_fingerprint,
alias = ?caller_alias,
@@ -822,25 +877,38 @@ async fn run_signal_mode(
"incoming call — auto-accepting (generic)"
);
// Auto-accept for CLI testing
let _ = signal_transport.send_signal(&SignalMessage::DirectCallAnswer {
call_id,
accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric,
identity_pub: Some(identity_pub),
ephemeral_pub: None,
signature: None,
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
// CLI auto-accept uses generic (privacy) mode,
// so callee addr stays hidden from the caller.
callee_reflexive_addr: None,
callee_local_addrs: Vec::new(),
callee_mapped_addr: None,
callee_build_version: None,
}).await;
let _ = signal_transport
.send_signal(&SignalMessage::DirectCallAnswer {
call_id,
accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric,
identity_pub: Some(identity_pub),
ephemeral_pub: None,
signature: None,
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
// CLI auto-accept uses generic (privacy) mode,
// so callee addr stays hidden from the caller.
callee_reflexive_addr: None,
callee_local_addrs: Vec::new(),
callee_mapped_addr: None,
callee_build_version: None,
})
.await;
}
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => {
SignalMessage::DirectCallAnswer {
call_id,
accept_mode,
..
} => {
info!(call_id = %call_id, mode = ?accept_mode, "call answered");
}
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay, peer_direct_addr: _, peer_local_addrs: _, peer_mapped_addr: _ } => {
SignalMessage::CallSetup {
call_id,
room,
relay_addr: setup_relay,
peer_direct_addr: _,
peer_local_addrs: _,
peer_mapped_addr: _,
} => {
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
// Connect to the media room
@@ -848,18 +916,28 @@ async fn run_signal_mode(
let media_cfg = wzp_transport::client_config();
match wzp_transport::connect(&endpoint, media_relay, &room, media_cfg).await {
Ok(media_conn) => {
let media_transport = Arc::new(wzp_transport::QuinnTransport::new(media_conn));
let media_transport =
Arc::new(wzp_transport::QuinnTransport::new(media_conn));
// Crypto handshake
match wzp_client::handshake::perform_handshake(&*media_transport, &my_seed, None).await {
match wzp_client::handshake::perform_handshake(
&*media_transport,
&my_seed,
None,
)
.await
{
Ok(_session) => {
info!("media connected — sending tone (press Ctrl+C to hang up)");
info!(
"media connected — sending tone (press Ctrl+C to hang up)"
);
// Simple tone sender for testing
let mt = media_transport.clone();
let send_task = tokio::spawn(async move {
let config = wzp_client::call::CallConfig::default();
let mut encoder = wzp_client::call::CallEncoder::new(&config);
let mut encoder =
wzp_client::call::CallEncoder::new(&config);
let duration = tokio::time::Duration::from_millis(20);
loop {
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
@@ -867,7 +945,9 @@ async fn run_signal_mode(
.collect();
if let Ok(pkts) = encoder.encode_frame(&pcm) {
for pkt in &pkts {
if mt.send_media(pkt).await.is_err() { return; }
if mt.send_media(pkt).await.is_err() {
return;
}
}
}
tokio::time::sleep(duration).await;

View File

@@ -144,7 +144,7 @@ pub async fn run_drift_test(
}
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => {
let is_repair = pkt.header.is_repair;
let is_repair = pkt.header.is_repair();
decoder.ingest(pkt);
if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
@@ -180,7 +180,7 @@ pub async fn run_drift_test(
while Instant::now() < drain_deadline {
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => {
let is_repair = pkt.header.is_repair;
let is_repair = pkt.header.is_repair();
decoder.ingest(pkt);
if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
@@ -234,7 +234,10 @@ pub fn print_drift_report(result: &DriftResult) {
println!();
println!("Expected duration: {} ms", result.expected_duration_ms);
println!("Actual duration: {} ms", result.actual_duration_ms);
println!("Drift: {} ms ({:+.4}%)", result.drift_ms, result.drift_pct);
println!(
"Drift: {} ms ({:+.4}%)",
result.drift_ms, result.drift_pct
);
println!();
// Interpretation
@@ -246,9 +249,15 @@ pub fn print_drift_report(result: &DriftResult) {
} else if abs_drift < 20 {
println!("Result: GOOD -- drift is within acceptable bounds (<20 ms).");
} else if abs_drift < 100 {
println!("Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.", abs_drift);
println!(
"Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.",
abs_drift
);
} else {
println!("Result: POOR -- significant drift ({} ms). Investigate clock sources.", abs_drift);
println!(
"Result: POOR -- significant drift ({} ms). Investigate clock sources.",
abs_drift
);
}
println!();
}

View File

@@ -43,7 +43,7 @@ pub enum WinningPath {
pub struct CandidateDiag {
pub index: usize,
pub addr: String,
pub result: String, // "ok", "skipped:ipv6", "error:..."
pub result: String, // "ok", "skipped:ipv6", "error:..."
pub elapsed_ms: Option<u32>,
}
@@ -299,10 +299,16 @@ pub async fn race(
socket2::Domain::IPV4,
socket2::Type::DGRAM,
Some(socket2::Protocol::UDP),
).map_err(|e| format!("socket: {e}"))?;
sock.set_reuse_address(true).map_err(|e| format!("reuseaddr: {e}"))?;
)
.map_err(|e| format!("socket: {e}"))?;
sock.set_reuse_address(true)
.map_err(|e| format!("reuseaddr: {e}"))?;
// macOS/BSD/Linux also need SO_REUSEPORT
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))]
#[cfg(any(
target_os = "macos",
target_os = "linux",
target_os = "android"
))]
{
// socket2 exposes set_reuse_port on unix
unsafe {
@@ -316,12 +322,14 @@ pub async fn race(
);
}
}
sock.set_nonblocking(true).map_err(|e| format!("nonblock: {e}"))?;
sock.set_nonblocking(true)
.map_err(|e| format!("nonblock: {e}"))?;
let bind_addr: SocketAddr = SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
local_addr.port(),
);
sock.bind(&bind_addr.into()).map_err(|e| format!("bind :{}: {e}", local_addr.port()))?;
sock.bind(&bind_addr.into())
.map_err(|e| format!("bind :{}: {e}", local_addr.port()))?;
let std_sock: StdUdpSocket = sock.into();
for addr in &tickle_addrs {
let _ = std_sock.send_to(&[0u8; 1], addr);
@@ -469,13 +477,8 @@ pub async fn race(
candidate_idx = idx,
"dual_path: dialing candidate"
);
let result = wzp_transport::connect(
&ep,
candidate,
&sni,
client_cfg,
)
.await;
let result =
wzp_transport::connect(&ep, candidate, &sni, client_cfg).await;
let elapsed = start.elapsed().as_millis() as u32;
let diag_result = match &result {
Ok(_) => "ok".to_string(),
@@ -604,9 +607,7 @@ pub async fn race(
"dual_path: racing direct vs relay"
);
let mut direct_task = tokio::spawn(
tokio::time::timeout(Duration::from_secs(4), direct_fut),
);
let mut direct_task = tokio::spawn(tokio::time::timeout(Duration::from_secs(4), direct_fut));
let mut relay_task = tokio::spawn(async move {
// Keep the 500ms head start so direct has a chance
tokio::time::sleep(Duration::from_millis(500)).await;
@@ -695,8 +696,12 @@ pub async fn race(
// If it doesn't, we still proceed with just the winner.
if direct_result.is_none() {
match tokio::time::timeout(Duration::from_secs(1), direct_task).await {
Ok(Ok(Ok(Ok(t)))) => { direct_result = Some(Ok(t)); }
Ok(Ok(Ok(Err(e)))) => { direct_result = Some(Err(anyhow::anyhow!("{e}"))); }
Ok(Ok(Ok(Ok(t)))) => {
direct_result = Some(Ok(t));
}
Ok(Ok(Ok(Err(e)))) => {
direct_result = Some(Err(anyhow::anyhow!("{e}")));
}
_ => {
direct_result = Some(Err(anyhow::anyhow!("direct: no result in grace period")));
// Fill timeout diags for candidates that never reported.
@@ -719,9 +724,15 @@ pub async fn race(
}
if relay_result.is_none() {
match tokio::time::timeout(Duration::from_secs(1), relay_task).await {
Ok(Ok(Ok(Ok(t)))) => { relay_result = Some(Ok(t)); }
Ok(Ok(Ok(Err(e)))) => { relay_result = Some(Err(anyhow::anyhow!("{e}"))); }
_ => { relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period"))); }
Ok(Ok(Ok(Ok(t)))) => {
relay_result = Some(Ok(t));
}
Ok(Ok(Ok(Err(e)))) => {
relay_result = Some(Err(anyhow::anyhow!("{e}")));
}
_ => {
relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period")));
}
}
}
@@ -736,22 +747,21 @@ pub async fn race(
);
if !direct_ok && !relay_ok {
return Err(anyhow::anyhow!("both paths failed: no media transport available"));
return Err(anyhow::anyhow!(
"both paths failed: no media transport available"
));
}
let _ = (direct_ep, relay_ep, ipv6_endpoint);
let candidate_diags = diags_collector.lock()
let candidate_diags = diags_collector
.lock()
.map(|d| d.clone())
.unwrap_or_default();
Ok(RaceResult {
direct_transport: direct_result
.and_then(|r| r.ok())
.map(|t| Arc::new(t)),
relay_transport: relay_result
.and_then(|r| r.ok())
.map(|t| Arc::new(t)),
direct_transport: direct_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
relay_transport: relay_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
local_winner,
candidate_diags,
})
@@ -777,7 +787,10 @@ mod tests {
assert_eq!(order.len(), 4);
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
assert_eq!(order[1], "10.0.0.5:4433".parse::<SocketAddr>().unwrap());
assert_eq!(order[2], "198.51.100.42:12345".parse::<SocketAddr>().unwrap());
assert_eq!(
order[2],
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
);
assert_eq!(order[3], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
}
@@ -805,7 +818,10 @@ mod tests {
let order = candidates.dial_order();
assert_eq!(order.len(), 1);
assert_eq!(order[0], "198.51.100.42:12345".parse::<SocketAddr>().unwrap());
assert_eq!(
order[0],
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
);
}
#[test]

View File

@@ -166,7 +166,7 @@ pub async fn run_echo_test(
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => {
total_packets_received += 1;
let is_repair = pkt.header.is_repair;
let is_repair = pkt.header.is_repair();
decoder.ingest(pkt);
if !is_repair {
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
@@ -184,7 +184,8 @@ pub async fn run_echo_test(
let time_offset = start.elapsed().as_secs_f64();
// Compare sent vs received for this window
let sent_start = (window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
let sent_start =
(window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
let sent_end = sent_start + (window_frames_sent as usize * FRAME_SAMPLES);
let sent_window = if sent_end <= sent_pcm.len() {
&sent_pcm[sent_start..sent_end]
@@ -192,7 +193,9 @@ pub async fn run_echo_test(
&sent_pcm[sent_start..]
};
let recv_start = recv_pcm.len().saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
let recv_start = recv_pcm
.len()
.saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
let recv_window = &recv_pcm[recv_start..];
let peak = recv_window.iter().map(|s| s.abs()).max().unwrap_or(0);
@@ -256,7 +259,7 @@ pub async fn run_echo_test(
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => {
total_packets_received += 1;
let is_repair = pkt.header.is_repair;
let is_repair = pkt.header.is_repair();
decoder.ingest(pkt);
if !is_repair {
decoder.decode_next(&mut pcm_buf);
@@ -310,8 +313,14 @@ pub fn print_report(result: &EchoTestResult) {
let status = if w.is_silent { " !" } else { " " };
println!(
"{:>3}{}{:>5.1}s │ {:>4}{:>4}{:>5.1}% │ {:>5.1}{:.3}",
w.index, status, w.time_offset_secs, w.frames_sent, w.frames_received,
w.loss_pct, w.snr_db, w.correlation
w.index,
status,
w.time_offset_secs,
w.frames_sent,
w.frames_received,
w.loss_pct,
w.snr_db,
w.correlation
);
}
println!("└───────┴─────────┴──────┴──────┴─────────┴───────┴───────┘");
@@ -321,18 +330,28 @@ pub fn print_report(result: &EchoTestResult) {
let first_half: Vec<_> = result.windows[..result.windows.len() / 2].to_vec();
let second_half: Vec<_> = result.windows[result.windows.len() / 2..].to_vec();
let avg_loss_first = first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
let avg_loss_second = second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
let avg_corr_first = first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
let avg_corr_second = second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
let avg_loss_first =
first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
let avg_loss_second =
second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
let avg_corr_first =
first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
let avg_corr_second =
second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
println!();
if avg_loss_second > avg_loss_first + 5.0 {
println!("WARNING: Quality degradation detected!");
println!(" Loss increased from {:.1}% to {:.1}% over time", avg_loss_first, avg_loss_second);
println!(
" Loss increased from {:.1}% to {:.1}% over time",
avg_loss_first, avg_loss_second
);
}
if avg_corr_second < avg_corr_first - 0.1 {
println!("WARNING: Signal correlation dropped from {:.3} to {:.3}", avg_corr_first, avg_corr_second);
println!(
"WARNING: Signal correlation dropped from {:.3} to {:.3}",
avg_corr_first, avg_corr_second
);
}
if avg_loss_second <= avg_loss_first + 5.0 && avg_corr_second >= avg_corr_first - 0.1 {
println!("Quality is STABLE over the test duration.");

View File

@@ -118,14 +118,14 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
SignalMessage::RegisterPresence { .. }
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
SignalMessage::RegisterPresence { .. } | SignalMessage::RegisterPresenceAck { .. } => {
CallSignalType::Offer
} // relay-only
// NAT reflection is a client↔relay control exchange that
// never crosses the featherChat bridge — if it ever reaches
// this mapper something is wrong, but we still have to give
// an answer. "Offer" is the generic catch-all.
SignalMessage::Reflect
| SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
SignalMessage::Reflect | SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
// Phase 4 cross-relay forwarding envelope — strictly a
// relay-to-relay message, never rides the featherChat
// bridge. Catch-all mapping for completeness.
@@ -181,17 +181,35 @@ mod tests {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
};
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));
assert!(matches!(
signal_to_call_type(&hangup),
CallSignalType::Hangup
));
assert!(matches!(signal_to_call_type(&SignalMessage::Hold), CallSignalType::Hold));
assert!(matches!(signal_to_call_type(&SignalMessage::Unhold), CallSignalType::Unhold));
assert!(matches!(signal_to_call_type(&SignalMessage::Mute), CallSignalType::Mute));
assert!(matches!(signal_to_call_type(&SignalMessage::Unmute), CallSignalType::Unmute));
assert!(matches!(
signal_to_call_type(&SignalMessage::Hold),
CallSignalType::Hold
));
assert!(matches!(
signal_to_call_type(&SignalMessage::Unhold),
CallSignalType::Unhold
));
assert!(matches!(
signal_to_call_type(&SignalMessage::Mute),
CallSignalType::Mute
));
assert!(matches!(
signal_to_call_type(&SignalMessage::Unmute),
CallSignalType::Unmute
));
let transfer = SignalMessage::Transfer {
target_fingerprint: "abc".to_string(),
relay_addr: None,
};
assert!(matches!(signal_to_call_type(&transfer), CallSignalType::Transfer));
assert!(matches!(
signal_to_call_type(&transfer),
CallSignalType::Transfer
));
}
}

View File

@@ -55,21 +55,21 @@ pub async fn perform_handshake(
.await?
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallAnswer"))?;
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) = match answer
{
SignalMessage::CallAnswer {
identity_pub,
ephemeral_pub,
signature,
chosen_profile,
} => (identity_pub, ephemeral_pub, signature, chosen_profile),
other => {
return Err(anyhow::anyhow!(
"expected CallAnswer, got {:?}",
std::mem::discriminant(&other)
))
}
};
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) =
match answer {
SignalMessage::CallAnswer {
identity_pub,
ephemeral_pub,
signature,
chosen_profile,
} => (identity_pub, ephemeral_pub, signature, chosen_profile),
other => {
return Err(anyhow::anyhow!(
"expected CallAnswer, got {:?}",
std::mem::discriminant(&other)
));
}
};
// 6. Verify callee's signature over (ephemeral_pub || "call-answer")
let mut verify_data = Vec::with_capacity(32 + 11);

View File

@@ -106,14 +106,9 @@ impl IceAgent {
);
let reflexive = stun_result.ok().and_then(|r| r.ok());
let mapped = portmap_result
.ok()
.flatten()
.map(|m| m.external_addr);
let local = reflect::local_host_candidates(
self.config.local_v4_port,
self.config.local_v6_port,
);
let mapped = portmap_result.ok().flatten().map(|m| m.external_addr);
let local =
reflect::local_host_candidates(self.config.local_v4_port, self.config.local_v6_port);
tracing::info!(
generation,
@@ -151,10 +146,7 @@ impl IceAgent {
/// Process a peer's candidate update. Returns `Some(PeerCandidates)`
/// if the update is newer than the last-seen generation, `None`
/// if it's stale.
pub fn apply_peer_update(
&self,
update: &SignalMessage,
) -> Option<PeerCandidates> {
pub fn apply_peer_update(&self, update: &SignalMessage) -> Option<PeerCandidates> {
let (reflexive_addr, local_addrs, mapped_addr, generation) = match update {
SignalMessage::CandidateUpdate {
reflexive_addr,
@@ -177,16 +169,9 @@ impl IceAgent {
return None;
}
let reflexive = reflexive_addr
.as_deref()
.and_then(|s| s.parse().ok());
let local: Vec<SocketAddr> = local_addrs
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let mapped = mapped_addr
.as_deref()
.and_then(|s| s.parse().ok());
let reflexive = reflexive_addr.as_deref().and_then(|s| s.parse().ok());
let local: Vec<SocketAddr> = local_addrs.iter().filter_map(|s| s.parse().ok()).collect();
let mapped = mapped_addr.as_deref().and_then(|s| s.parse().ok());
tracing::info!(
generation,
@@ -304,10 +289,7 @@ mod tests {
let update = SignalMessage::CandidateUpdate {
call_id: "test-call".into(),
reflexive_addr: Some("203.0.113.5:4433".into()),
local_addrs: vec![
"192.168.1.10:4433".into(),
"10.0.0.5:4433".into(),
],
local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()],
mapped_addr: Some("198.51.100.42:12345".into()),
generation: 1,
};
@@ -382,16 +364,19 @@ mod tests {
async fn gather_returns_candidates_even_with_no_stun() {
// With default config (port 0 = no portmap, STUN will timeout
// quickly on loopback), gather should still return host candidates.
let agent = IceAgent::new("test".into(), IceAgentConfig {
stun_config: stun::StunConfig {
servers: vec![], // no servers = quick failure
timeout: Duration::from_millis(100),
let agent = IceAgent::new(
"test".into(),
IceAgentConfig {
stun_config: stun::StunConfig {
servers: vec![], // no servers = quick failure
timeout: Duration::from_millis(100),
},
enable_portmap: false,
gather_timeout: Duration::from_millis(200),
local_v4_port: 12345,
local_v6_port: None,
},
enable_portmap: false,
gather_timeout: Duration::from_millis(200),
local_v4_port: 12345,
local_v6_port: None,
});
);
let candidates = agent.gather().await;
assert_eq!(candidates.generation, 0);
@@ -405,16 +390,19 @@ mod tests {
#[tokio::test]
async fn re_gather_produces_signal_message() {
let agent = IceAgent::new("call-42".into(), IceAgentConfig {
stun_config: stun::StunConfig {
servers: vec![],
timeout: Duration::from_millis(50),
let agent = IceAgent::new(
"call-42".into(),
IceAgentConfig {
stun_config: stun::StunConfig {
servers: vec![],
timeout: Duration::from_millis(50),
},
enable_portmap: false,
gather_timeout: Duration::from_millis(100),
local_v4_port: 4433,
local_v6_port: None,
},
enable_portmap: false,
gather_timeout: Duration::from_millis(100),
local_v4_port: 4433,
local_v6_port: None,
});
);
let (candidates, signal) = agent.re_gather().await;
assert_eq!(candidates.generation, 0);

View File

@@ -27,15 +27,15 @@ pub mod audio_wasapi;
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
pub mod audio_linux_aec;
pub mod bench;
pub mod birthday;
pub mod call;
pub mod drift_test;
pub mod dual_path;
pub mod echo_test;
pub mod featherchat;
pub mod handshake;
pub mod dual_path;
pub mod metrics;
pub mod birthday;
pub mod ice_agent;
pub mod metrics;
pub mod netcheck;
pub mod portmap;
pub mod reflect;

View File

@@ -178,7 +178,10 @@ mod tests {
// Immediate second write should be skipped (60s interval).
let second = writer.maybe_write(&snap).unwrap();
assert!(!second, "second write should be skipped — interval not elapsed");
assert!(
!second,
"second write should be skipped — interval not elapsed"
);
// Clean up.
let _ = std::fs::remove_file(&path);

View File

@@ -112,22 +112,30 @@ pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
let ipv6_fut = test_ipv6(config.test_ipv6, config.timeout);
let port_alloc_fut = stun::detect_port_allocation(&config.stun_config);
let (stun_probes, relay_latencies, portmap_result, gateway_result, ipv6_reachable, port_alloc_result) =
tokio::join!(stun_fut, relay_fut, portmap_fut, gateway_result_fut(gateway_fut), ipv6_fut, port_alloc_fut);
let (
stun_probes,
relay_latencies,
portmap_result,
gateway_result,
ipv6_reachable,
port_alloc_result,
) = tokio::join!(
stun_fut,
relay_fut,
portmap_fut,
gateway_result_fut(gateway_fut),
ipv6_fut,
port_alloc_fut
);
// Classify NAT from STUN probes.
let (nat_type, consensus_addr) = reflect::classify_nat(&stun_probes);
// Determine STUN latency (first successful probe).
let stun_latency_ms = stun_probes
.iter()
.filter_map(|p| p.latency_ms)
.min();
let stun_latency_ms = stun_probes.iter().filter_map(|p| p.latency_ms).min();
// IPv4 reachable if any STUN probe succeeded.
let ipv4_reachable = stun_probes
.iter()
.any(|p| p.observed_addr.is_some());
let ipv4_reachable = stun_probes.iter().any(|p| p.observed_addr.is_some());
// Preferred relay = lowest RTT.
let preferred_relay = relay_latencies
@@ -176,10 +184,7 @@ pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
}
/// Probe relay latencies via reflect.
async fn probe_relays(
relays: &[(String, SocketAddr)],
timeout: Duration,
) -> Vec<RelayLatency> {
async fn probe_relays(relays: &[(String, SocketAddr)], timeout: Duration) -> Vec<RelayLatency> {
if relays.is_empty() {
return Vec::new();
}
@@ -223,10 +228,7 @@ async fn probe_relays(
}
/// Attempt port mapping and return the mapping if successful.
async fn probe_portmap(
enabled: bool,
local_port: u16,
) -> Option<portmap::PortMapping> {
async fn probe_portmap(enabled: bool, local_port: u16) -> Option<portmap::PortMapping> {
if !enabled || local_port == 0 {
return None;
}
@@ -251,7 +253,9 @@ async fn test_ipv6(enabled: bool, timeout: Duration) -> bool {
let sock = tokio::net::UdpSocket::bind("[::]:0").await.ok()?;
// Try Google's IPv6 STUN — if DNS resolves to an AAAA record
// and we can send a packet, IPv6 is working.
let addr = stun::resolve_stun_server("stun.l.google.com:19302").await.ok()?;
let addr = stun::resolve_stun_server("stun.l.google.com:19302")
.await
.ok()?;
if addr.is_ipv6() {
sock.send_to(&[0u8; 1], addr).await.ok()?;
Some(true)
@@ -276,10 +280,7 @@ pub fn format_report(report: &NetcheckReport) -> String {
let mut out = String::new();
out.push_str(&format!("=== WarzonePhone Netcheck ===\n\n"));
out.push_str(&format!(
"NAT Type: {:?}\n",
report.nat_type
));
out.push_str(&format!("NAT Type: {:?}\n", report.nat_type));
out.push_str(&format!(
"Reflexive Addr: {}\n",
report.reflexive_addr.as_deref().unwrap_or("(unknown)")
@@ -298,15 +299,17 @@ pub fn format_report(report: &NetcheckReport) -> String {
));
if let Some(ref alloc) = report.port_allocation {
out.push_str(&format!(
"Port Alloc: {alloc}\n"
));
out.push_str(&format!("Port Alloc: {alloc}\n"));
}
out.push_str(&format!("\n--- Port Mapping ---\n"));
out.push_str(&format!(
"NAT-PMP: {} PCP: {} UPnP: {}\n",
if report.nat_pmp_available { "yes" } else { "no" },
if report.nat_pmp_available {
"yes"
} else {
"no"
},
if report.pcp_available { "yes" } else { "no" },
if report.upnp_available { "yes" } else { "no" },
));
@@ -321,8 +324,13 @@ pub fn format_report(report: &NetcheckReport) -> String {
" {}{} ({}ms){}\n",
p.relay_name,
p.observed_addr.as_deref().unwrap_or("failed"),
p.latency_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()),
p.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(),
p.latency_ms
.map(|ms| ms.to_string())
.unwrap_or_else(|| "-".into()),
p.error
.as_ref()
.map(|e| format!(" [{e}]"))
.unwrap_or_default(),
));
}
}
@@ -334,8 +342,13 @@ pub fn format_report(report: &NetcheckReport) -> String {
" {} ({}) → {}ms{}\n",
r.name,
r.addr,
r.rtt_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()),
r.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(),
r.rtt_ms
.map(|ms| ms.to_string())
.unwrap_or_else(|| "-".into()),
r.error
.as_ref()
.map(|e| format!(" [{e}]"))
.unwrap_or_default(),
));
}
if let Some(ref pref) = report.preferred_relay {

View File

@@ -279,8 +279,15 @@ async fn try_natpmp(
// Step 2: request port mapping
// Request same port as internal (preferred); 7200s lifetime (standard)
let (mapped_port, lifetime) =
natpmp_map_udp(&socket, gw_addr, internal_port, internal_port, 7200, timeout).await?;
let (mapped_port, lifetime) = natpmp_map_udp(
&socket,
gw_addr,
internal_port,
internal_port,
7200,
timeout,
)
.await?;
let lifetime_dur = Duration::from_secs(lifetime as u64);
Ok(PortMapping {
@@ -533,17 +540,12 @@ async fn fetch_url_simple(url: &str, timeout: Duration) -> Result<String, PortMa
.map_err(|e| PortMapError::Protocol(format!("parse {host_port}:80: {e}")))?
};
let mut stream = tokio::time::timeout(
timeout,
tokio::net::TcpStream::connect(addr),
)
.await
.map_err(|_| PortMapError::Timeout)?
.map_err(|e| PortMapError::Io(e.to_string()))?;
let mut stream = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr))
.await
.map_err(|_| PortMapError::Timeout)?
.map_err(|e| PortMapError::Io(e.to_string()))?;
let request = format!(
"GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"
);
let request = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n");
stream
.write_all(request.as_bytes())
.await
@@ -593,13 +595,10 @@ async fn soap_post(
.map_err(|e| PortMapError::Protocol(format!("parse {host_port}:80: {e}")))?
};
let mut stream = tokio::time::timeout(
timeout,
tokio::net::TcpStream::connect(addr),
)
.await
.map_err(|_| PortMapError::Timeout)?
.map_err(|e| PortMapError::Io(e.to_string()))?;
let mut stream = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr))
.await
.map_err(|_| PortMapError::Timeout)?
.map_err(|e| PortMapError::Io(e.to_string()))?;
let soap_body = format!(
"<?xml version=\"1.0\"?>\
@@ -662,9 +661,7 @@ fn extract_control_url(xml: &str, base_url: &str) -> Result<String, PortMapError
return Ok(control_path.to_string());
}
// Build absolute URL from base
let base = base_url
.strip_prefix("http://")
.unwrap_or(base_url);
let base = base_url.strip_prefix("http://").unwrap_or(base_url);
let host_port = base.split('/').next().unwrap_or(base);
return Ok(format!("http://{host_port}{control_path}"));
}
@@ -681,7 +678,8 @@ async fn upnp_get_external_ip(
control_url: &str,
timeout: Duration,
) -> Result<Ipv4Addr, PortMapError> {
let body = "<u:GetExternalIPAddress xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\"/>";
let body =
"<u:GetExternalIPAddress xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\"/>";
let action = "urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress";
let response = soap_post(control_url, action, body, timeout).await?;
@@ -933,7 +931,10 @@ mod tests {
assert_eq!(request[0], 0);
assert_eq!(request[1], 1);
assert_eq!(u16::from_be_bytes([request[4], request[5]]), 12345);
assert_eq!(u32::from_be_bytes([request[8], request[9], request[10], request[11]]), 7200);
assert_eq!(
u32::from_be_bytes([request[8], request[9], request[10], request[11]]),
7200
);
}
#[test]

View File

@@ -31,7 +31,7 @@ use std::time::{Duration, Instant};
use serde::Serialize;
use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::{client_config, create_endpoint, QuinnTransport};
use wzp_transport::{QuinnTransport, client_config, create_endpoint};
/// Result of one probe against one relay. Always returned so the
/// UI can render per-relay status even when some fail.
@@ -110,10 +110,9 @@ pub async fn probe_reflect_addr(
let start = Instant::now();
let probe = async {
// Open the signal connection.
let conn =
wzp_transport::connect(&endpoint, relay, "_signal", client_config())
.await
.map_err(|e| format!("connect: {e}"))?;
let conn = wzp_transport::connect(&endpoint, relay, "_signal", client_config())
.await
.map_err(|e| format!("connect: {e}"))?;
let transport = QuinnTransport::new(conn);
// The relay signal handler waits for a RegisterPresence
@@ -540,10 +539,7 @@ mod tests {
#[test]
fn classify_two_identical_is_cone() {
let probes = vec![
mk(Some("192.0.2.1:4433")),
mk(Some("192.0.2.1:4433")),
];
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:4433"))];
let (nt, addr) = classify_nat(&probes);
assert_eq!(nt, NatType::Cone);
assert_eq!(addr.as_deref(), Some("192.0.2.1:4433"));
@@ -551,10 +547,7 @@ mod tests {
#[test]
fn classify_same_ip_different_ports_is_symmetric() {
let probes = vec![
mk(Some("192.0.2.1:4433")),
mk(Some("192.0.2.1:51234")),
];
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:51234"))];
let (nt, addr) = classify_nat(&probes);
assert_eq!(nt, NatType::SymmetricPort);
assert!(addr.is_none());
@@ -562,10 +555,7 @@ mod tests {
#[test]
fn classify_different_ips_is_multiple() {
let probes = vec![
mk(Some("192.0.2.1:4433")),
mk(Some("198.51.100.9:4433")),
];
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("198.51.100.9:4433"))];
let (nt, addr) = classify_nat(&probes);
assert_eq!(nt, NatType::Multiple);
assert!(addr.is_none());
@@ -591,9 +581,9 @@ mod tests {
#[test]
fn classify_drops_loopback_probes() {
let probes = vec![
mk(Some("127.0.0.1:4433")), // loopback — must be dropped
mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:4433")), // public, same addr
mk(Some("127.0.0.1:4433")), // loopback — must be dropped
mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:4433")), // public, same addr
];
let (nt, addr) = classify_nat(&probes);
// Two public probes with identical addrs → Cone.
@@ -608,9 +598,9 @@ mod tests {
// client with a 100.64/10 addr is on the same CGNAT
// network and can't contribute to public NAT classification.
let probes = vec![
mk(Some("100.64.0.42:4433")), // CGNAT — dropped
mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:12345")), // public, different port
mk(Some("100.64.0.42:4433")), // CGNAT — dropped
mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:12345")), // public, different port
];
let (nt, _) = classify_nat(&probes);
// Two public probes same IP different port → SymmetricPort.

View File

@@ -109,11 +109,9 @@ impl RelayMap {
/// Check if any entry has a stale probe (older than `max_age`).
pub fn needs_reprobe(&self, max_age: Duration) -> bool {
self.entries.iter().any(|e| {
match e.last_probed {
None => true,
Some(t) => t.elapsed() > max_age,
}
self.entries.iter().any(|e| match e.last_probed {
None => true,
Some(t) => t.elapsed() > max_age,
})
}

View File

@@ -223,9 +223,7 @@ pub fn parse_binding_response(
pos = value_end + ((4 - (attr_len % 4)) % 4);
}
xor_mapped
.or(mapped)
.ok_or(StunError::NoMappedAddress)
xor_mapped.or(mapped).ok_or(StunError::NoMappedAddress)
}
/// Parse a MAPPED-ADDRESS attribute value (RFC 5389 §15.1).
@@ -279,10 +277,7 @@ fn parse_mapped_address(value: &[u8]) -> Result<SocketAddr, StunError> {
/// - Port: XOR with top 16 bits of magic cookie
/// - IPv4 address: XOR with magic cookie
/// - IPv6 address: XOR with magic cookie || transaction ID
fn parse_xor_mapped_address(
value: &[u8],
txn_id: &[u8; 12],
) -> Result<SocketAddr, StunError> {
fn parse_xor_mapped_address(value: &[u8], txn_id: &[u8; 12]) -> Result<SocketAddr, StunError> {
if value.len() < 4 {
return Err(StunError::Malformed("XOR-MAPPED-ADDRESS too short".into()));
}
@@ -471,9 +466,7 @@ pub async fn discover_reflexive(config: &StunConfig) -> Result<SocketAddr, StunE
/// Unlike `discover_reflexive` (which returns on first success), this
/// waits for ALL servers and returns individual results — needed for
/// NAT type classification which requires 2+ observations.
pub async fn probe_stun_servers(
config: &StunConfig,
) -> Vec<crate::reflect::NatProbeResult> {
pub async fn probe_stun_servers(config: &StunConfig) -> Vec<crate::reflect::NatProbeResult> {
use std::time::Instant;
let mut set = tokio::task::JoinSet::new();
@@ -596,9 +589,7 @@ pub struct PortAllocationResult {
/// - No pattern → `Random`
///
/// Requires at least 3 servers for reliable classification.
pub async fn detect_port_allocation(
config: &StunConfig,
) -> PortAllocationResult {
pub async fn detect_port_allocation(config: &StunConfig) -> PortAllocationResult {
if config.servers.len() < 2 {
return PortAllocationResult {
allocation: PortAllocation::Unknown,
@@ -696,11 +687,15 @@ pub fn classify_port_allocation(ports: &[u16]) -> PortAllocation {
// Allow small jitter: if all deltas are within ±1 of each other,
// consider it sequential with the median delta.
let all_close = deltas.iter().all(|&d| (d - first_delta).unsigned_abs() <= 1);
let all_close = deltas
.iter()
.all(|&d| (d - first_delta).unsigned_abs() <= 1);
if all_close {
// Use the most common delta (mode).
let median_delta = first_delta;
return PortAllocation::Sequential { delta: median_delta };
return PortAllocation::Sequential {
delta: median_delta,
};
}
// Check for consistent delta with occasional skip (some NATs
@@ -727,12 +722,7 @@ pub fn classify_port_allocation(ports: &[u16]) -> PortAllocation {
/// predicted ports centered around the most likely next value.
/// The `offset` parameter accounts for additional flows that may
/// open between the probe and the actual connection attempt.
pub fn predict_ports(
last_port: u16,
delta: i16,
offset: u16,
spread: u16,
) -> Vec<u16> {
pub fn predict_ports(last_port: u16, delta: i16, offset: u16, spread: u16) -> Vec<u16> {
let base = last_port as i32 + (delta as i32 * (offset as i32 + 1));
let mut ports = Vec::with_capacity((spread * 2 + 1) as usize);
for i in -(spread as i32)..=(spread as i32) {
@@ -1217,7 +1207,11 @@ mod tests {
assert!(StunError::TxnMismatch.to_string().contains("mismatch"));
assert!(StunError::NoMappedAddress.to_string().contains("MAPPED"));
assert!(StunError::Io("test".into()).to_string().contains("test"));
assert!(StunError::DnsError("bad".into()).to_string().contains("bad"));
assert!(
StunError::DnsError("bad".into())
.to_string()
.contains("bad")
);
assert!(StunError::ErrorResponse(420).to_string().contains("420"));
assert!(StunError::Malformed("x".into()).to_string().contains("x"));
}
@@ -1244,7 +1238,10 @@ mod tests {
#[test]
fn classify_port_preserving() {
let ports = vec![4433, 4433, 4433, 4433, 4433];
assert_eq!(classify_port_allocation(&ports), PortAllocation::PortPreserving);
assert_eq!(
classify_port_allocation(&ports),
PortAllocation::PortPreserving
);
}
#[test]
@@ -1290,7 +1287,10 @@ mod tests {
#[test]
fn classify_two_same_is_preserving() {
let ports = vec![4433, 4433];
assert_eq!(classify_port_allocation(&ports), PortAllocation::PortPreserving);
assert_eq!(
classify_port_allocation(&ports),
PortAllocation::PortPreserving
);
}
#[test]
@@ -1359,8 +1359,14 @@ mod tests {
#[test]
fn port_allocation_display() {
assert_eq!(PortAllocation::PortPreserving.to_string(), "port-preserving");
assert_eq!(PortAllocation::Sequential { delta: 1 }.to_string(), "sequential(delta=1)");
assert_eq!(
PortAllocation::PortPreserving.to_string(),
"port-preserving"
);
assert_eq!(
PortAllocation::Sequential { delta: 1 }.to_string(),
"sequential(delta=1)"
);
assert_eq!(PortAllocation::Random.to_string(), "random");
assert_eq!(PortAllocation::Unknown.to_string(), "unknown");
}
@@ -1421,7 +1427,10 @@ mod tests {
let config = StunConfig::default();
let probes = probe_stun_servers(&config).await;
assert!(!probes.is_empty());
let successes: Vec<_> = probes.iter().filter(|p| p.observed_addr.is_some()).collect();
let successes: Vec<_> = probes
.iter()
.filter(|p| p.observed_addr.is_some())
.collect();
assert!(
!successes.is_empty(),
"at least one STUN server should respond"

View File

@@ -72,8 +72,7 @@ fn sine_frame(freq_hz: f32, frame_offset: u64) -> Vec<i16> {
/// decoder, pushes frames through the pipeline, and collects statistics.
/// Combinations where `target_depth > max_depth` are skipped.
pub fn run_local_sweep(config: &SweepConfig) -> Vec<SweepResult> {
let frames_per_config =
(config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
let frames_per_config = (config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
let mut results = Vec::new();