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

@@ -65,9 +65,8 @@ fn main() {
} else {
"aarch64-linux-android"
};
let lib_dir = format!(
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}"
);
let lib_dir =
format!("{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}");
println!("cargo:rustc-link-search=native={lib_dir}");
// Copy libc++_shared.so to the jniLibs directory
@@ -82,9 +81,7 @@ fn main() {
};
// Try to copy to the Gradle jniLibs directory
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let jni_dir = format!(
"{manifest}/../../android/app/src/main/jniLibs/{jni_abi}"
);
let jni_dir = format!("{manifest}/../../android/app/src/main/jniLibs/{jni_abi}");
if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
@@ -127,7 +124,12 @@ fn fetch_oboe() -> Option<PathBuf> {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let oboe_dir = out_dir.join("oboe");
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
if oboe_dir
.join("include")
.join("oboe")
.join("Oboe.h")
.exists()
{
return Some(oboe_dir);
}
@@ -143,7 +145,12 @@ fn fetch_oboe() -> Option<PathBuf> {
match status {
Ok(s) if s.success() => {
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
if oboe_dir
.join("include")
.join("oboe")
.join("Oboe.h")
.exists()
{
Some(oboe_dir)
} else {
None

View File

@@ -326,7 +326,10 @@ pub fn pin_to_big_core() {
&set,
);
if ret != 0 {
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error());
warn!(
"sched_setaffinity failed: {}",
std::io::Error::last_os_error()
);
} else {
info!(start, num_cpus, "pinned to big cores");
}

View File

@@ -77,7 +77,8 @@ impl AudioRing {
}
}
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
self.write_pos
.store(w.wrapping_add(count), Ordering::Release);
count
}
@@ -112,7 +113,8 @@ impl AudioRing {
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
}
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
self.read_pos
.store(r.wrapping_add(count), Ordering::Release);
count
}

View File

@@ -46,7 +46,11 @@ const PROFILES: [QualityProfile; 6] = [
];
fn profile_to_index(p: &QualityProfile) -> u8 {
PROFILES.iter().position(|pp| pp.codec == p.codec).map(|i| i as u8).unwrap_or(3)
PROFILES
.iter()
.position(|pp| pp.codec == p.codec)
.map(|i| i as u8)
.unwrap_or(3)
}
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
@@ -149,9 +153,10 @@ impl WzpEngine {
.enable_all()
.build()?;
let relay_addr: SocketAddr = config.relay_addr.parse().map_err(|e| {
anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr)
})?;
let relay_addr: SocketAddr = config
.relay_addr
.parse()
.map_err(|e| anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr))?;
let room = config.room.clone();
let identity_seed = config.identity_seed;
@@ -165,7 +170,16 @@ impl WzpEngine {
let state_clone = state.clone();
runtime.block_on(async move {
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, auto_profile, alias.as_deref(), state_clone).await
if let Err(e) = run_call(
relay_addr,
&room,
&identity_seed,
profile,
auto_profile,
alias.as_deref(),
state_clone,
)
.await
{
error!("call failed: {e}");
}
@@ -233,16 +247,21 @@ impl WzpEngine {
let server_fp = conn
.peer_identity()
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
.and_then(|certs| certs.first().map(|c| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
c.as_ref().hash(&mut h);
format!("{:016x}", h.finish())
}))
.and_then(|certs| {
certs.first().map(|c| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
c.as_ref().hash(&mut h);
format!("{:016x}", h.finish())
})
})
.unwrap_or_default();
conn.close(0u32.into(), b"ping");
Ok::<_, anyhow::Error>(format!(r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#, rtt_ms, server_fp))
Ok::<_, anyhow::Error>(format!(
r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#,
rtt_ms, server_fp
))
});
// Shutdown runtime cleanly with timeout
@@ -392,7 +411,11 @@ impl WzpEngine {
}
/// Answer an incoming direct call.
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
pub fn answer_call(
&self,
call_id: &str,
mode: wzp_proto::CallAcceptMode,
) -> Result<(), anyhow::Error> {
let _ = self.state.command_tx.send(EngineCommand::AnswerCall {
call_id: call_id.to_string(),
accept_mode: mode,
@@ -412,7 +435,9 @@ impl WzpEngine {
/// Stores the type atomically; the recv task polls it on each packet.
pub fn on_network_changed(&self, network_type: u8, bandwidth_kbps: u32) {
info!(network_type, bandwidth_kbps, "on_network_changed");
self.state.pending_network_type.store(network_type, Ordering::Release);
self.state
.pending_network_type
.store(network_type, Ordering::Release);
}
pub fn get_stats(&self) -> CallStats {
@@ -518,12 +543,16 @@ async fn run_call(
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
let (relay_ephemeral_pub, chosen_profile) = match answer {
SignalMessage::CallAnswer { ephemeral_pub, chosen_profile, .. } => (ephemeral_pub, chosen_profile),
SignalMessage::CallAnswer {
ephemeral_pub,
chosen_profile,
..
} => (ephemeral_pub, chosen_profile),
other => {
return Err(anyhow::anyhow!(
"expected CallAnswer, got {:?}",
std::mem::discriminant(&other)
))
));
}
};
@@ -725,9 +754,7 @@ async fn run_call(
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
warn!(
seq = s,
send_errors,
frames_dropped,
"send_media error (dropping packet): {e}"
send_errors, frames_dropped, "send_media error (dropping packet): {e}"
);
last_send_error_log = Instant::now();
}
@@ -820,7 +847,11 @@ async fn run_call(
avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us),
"send stats"
);
t_agc_us = 0; t_opus_us = 0; t_fec_us = 0; t_send_us = 0; t_frames = 0;
t_agc_us = 0;
t_opus_us = 0;
t_fec_us = 0;
t_send_us = 0;
t_frames = 0;
last_stats_log = Instant::now();
}
}
@@ -849,12 +880,9 @@ async fn run_call(
// when a packet arrives with seq > expected_seq, the frames in
// between are missing and we attempt to reconstruct them via
// DRED before decoding the newly-arrived packet.
let mut dred_decoder =
DredDecoderHandle::new().expect("opus_dred_decoder_create failed");
let mut dred_parse_scratch =
DredState::new().expect("opus_dred_alloc failed (scratch)");
let mut last_good_dred =
DredState::new().expect("opus_dred_alloc failed (good state)");
let mut dred_decoder = DredDecoderHandle::new().expect("opus_dred_decoder_create failed");
let mut dred_parse_scratch = DredState::new().expect("opus_dred_alloc failed (scratch)");
let mut last_good_dred = DredState::new().expect("opus_dred_alloc failed (good state)");
let mut last_good_dred_seq: Option<u16> = None;
let mut expected_seq: Option<u16> = None;
let mut dred_reconstructions: u64 = 0;
@@ -884,7 +912,9 @@ async fn run_call(
// Check for network transport change from ConnectivityManager
{
let net = state.pending_network_type.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
let net = state
.pending_network_type
.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if net != PROFILE_NO_CHANGE {
use wzp_proto::NetworkContext;
let ctx = match net {
@@ -927,12 +957,7 @@ async fn run_call(
// would accumulate block_id=0 duplicates that never
// decode. Codec2 packets still feed RaptorQ.
if !pkt_is_opus {
let _ = fec_dec.add_symbol(
pkt_block,
pkt_symbol,
is_repair,
&pkt.payload,
);
let _ = fec_dec.add_symbol(pkt_block, pkt_symbol, is_repair, &pkt.payload);
}
// Source packets: decode directly
@@ -952,7 +977,10 @@ async fn run_call(
frame_duration_ms: 20,
frames_per_block: 5,
},
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
other => QualityProfile {
codec: other,
..QualityProfile::GOOD
},
};
info!(from = ?decoder.codec_id(), to = ?pkt.header.codec_id, "recv: switching decoder");
let _ = decoder.set_profile(switch_profile);
@@ -984,10 +1012,7 @@ async fn run_call(
// Update DRED state from the current packet.
match dred_decoder.parse_into(&mut dred_parse_scratch, &pkt.payload) {
Ok(available) if available > 0 => {
std::mem::swap(
&mut dred_parse_scratch,
&mut last_good_dred,
);
std::mem::swap(&mut dred_parse_scratch, &mut last_good_dred);
last_good_dred_seq = Some(pkt.header.seq);
}
Ok(_) => {
@@ -1006,8 +1031,7 @@ async fn run_call(
let current_profile_frame_samples =
(48_000 * profile.frame_duration_ms as i32) / 1000;
let available = last_good_dred.samples_available();
let pcm_slice_len =
current_profile_frame_samples as usize;
let pcm_slice_len = current_profile_frame_samples as usize;
for gap_idx in 0..gap {
let missing_seq = expected.wrapping_add(gap_idx);
@@ -1026,28 +1050,24 @@ async fn run_call(
None => -1,
};
let reconstructed = if offset_samples > 0
&& offset_samples <= available
{
decoder
.reconstruct_from_dred(
&last_good_dred,
offset_samples,
&mut decode_buf[..pcm_slice_len],
)
.ok()
} else {
None
};
let reconstructed =
if offset_samples > 0 && offset_samples <= available {
decoder
.reconstruct_from_dred(
&last_good_dred,
offset_samples,
&mut decode_buf[..pcm_slice_len],
)
.ok()
} else {
None
};
match reconstructed {
Some(samples) => {
playout_agc.process_frame(
&mut decode_buf[..samples],
);
state
.playout_ring
.write(&decode_buf[..samples]);
playout_agc
.process_frame(&mut decode_buf[..samples]);
state.playout_ring.write(&decode_buf[..samples]);
dred_reconstructions += 1;
frames_decoded += 1;
}
@@ -1144,7 +1164,10 @@ async fn run_call(
}
}
Ok(None) => {
info!(frames_decoded, fec_recovered, "relay disconnected (stream ended)");
info!(
frames_decoded,
fec_recovered, "relay disconnected (stream ended)"
);
break;
}
Err(e) => {
@@ -1162,7 +1185,10 @@ async fn run_call(
}
}
}
info!(frames_decoded, fec_recovered, recv_errors, "recv task ended");
info!(
frames_decoded,
fec_recovered, recv_errors, "recv task ended"
);
};
// Stats task — polls path quality + quinn RTT every 500ms
@@ -1195,7 +1221,10 @@ async fn run_call(
let signal_task = async {
loop {
match transport_signal.recv_signal().await {
Ok(Some(SignalMessage::RoomUpdate { count, participants })) => {
Ok(Some(SignalMessage::RoomUpdate {
count,
participants,
})) => {
info!(count, "RoomUpdate received");
let members: Vec<crate::stats::RoomMember> = participants
.iter()
@@ -1209,7 +1238,10 @@ async fn run_call(
stats.room_participant_count = count;
stats.room_participants = members;
}
Ok(Some(SignalMessage::QualityDirective { recommended_profile, reason })) => {
Ok(Some(SignalMessage::QualityDirective {
recommended_profile,
reason,
})) => {
let idx = profile_to_index(&recommended_profile);
info!(
codec = ?recommended_profile.codec,
@@ -1247,7 +1279,9 @@ async fn run_call(
match tokio::time::timeout(
std::time::Duration::from_millis(500),
transport.connection().closed(),
).await {
)
.await
{
Ok(_) => info!("QUIC connection closed cleanly"),
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
}

View File

@@ -3,9 +3,9 @@
use std::panic;
use std::sync::Once;
use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use jni::JNIEnv;
use tracing::{error, info};
use wzp_proto::QualityProfile;
@@ -26,19 +26,20 @@ const PROFILE_AUTO: jint = 7;
fn profile_from_int(value: jint) -> QualityProfile {
match value {
0 => QualityProfile::GOOD, // Opus 24k
1 => QualityProfile::DEGRADED, // Opus 6k
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
3 => QualityProfile { // Codec2 3.2k
0 => QualityProfile::GOOD, // Opus 24k
1 => QualityProfile::DEGRADED, // Opus 6k
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
3 => QualityProfile {
// Codec2 3.2k
codec: wzp_proto::CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
4 => QualityProfile::STUDIO_32K, // Opus 32k
5 => QualityProfile::STUDIO_48K, // Opus 48k
6 => QualityProfile::STUDIO_64K, // Opus 64k
_ => QualityProfile::GOOD, // auto falls back to GOOD
4 => QualityProfile::STUDIO_32K, // Opus 32k
5 => QualityProfile::STUDIO_48K, // Opus 48k
6 => QualityProfile::STUDIO_64K, // Opus 64k
_ => QualityProfile::GOOD, // auto falls back to GOOD
}
}
@@ -101,11 +102,26 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
profile_j: jint,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default();
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
let relay_addr: String = env
.get_string(&relay_addr_j)
.map(|s| s.into())
.unwrap_or_default();
let room: String = env
.get_string(&room_j)
.map(|s| s.into())
.unwrap_or_default();
let seed_hex: String = env
.get_string(&seed_hex_j)
.map(|s| s.into())
.unwrap_or_default();
let token: String = env
.get_string(&token_j)
.map(|s| s.into())
.unwrap_or_default();
let alias: String = env
.get_string(&alias_j)
.map(|s| s.into())
.unwrap_or_default();
let h = unsafe { handle_ref(handle) };
@@ -128,7 +144,11 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
auto_profile: profile_j == PROFILE_AUTO,
relay_addr,
room,
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
auth_token: if token.is_empty() {
Vec::new()
} else {
token.into_bytes()
},
identity_seed,
alias: if alias.is_empty() { None } else { Some(alias) },
};
@@ -241,7 +261,8 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeOnNetworkChang
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
h.engine.on_network_changed(network_type as u8, bandwidth_kbps as u32);
h.engine
.on_network_changed(network_type as u8, bandwidth_kbps as u32);
}));
}
@@ -307,13 +328,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDire
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
let ptr = env
.get_direct_buffer_address(&buffer)
.unwrap_or(std::ptr::null_mut());
if ptr.is_null() || sample_count <= 0 {
return 0;
}
let samples = unsafe {
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize)
};
let samples =
unsafe { std::slice::from_raw_parts(ptr as *const i16, sample_count as usize) };
h.engine.write_audio(samples) as jint
}));
result.unwrap_or(0)
@@ -332,13 +354,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirec
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
let ptr = env
.get_direct_buffer_address(&buffer)
.unwrap_or(std::ptr::null_mut());
if ptr.is_null() || max_samples <= 0 {
return 0;
}
let samples = unsafe {
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize)
};
let samples =
unsafe { std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize) };
h.engine.read_audio(samples) as jint
}));
result.unwrap_or(0)
@@ -367,7 +390,10 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
) -> jstring {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
let relay: String = env
.get_string(&relay_j)
.map(|s| s.into())
.unwrap_or_default();
match h.engine.ping_relay(&relay) {
Ok(json) => Some(json),
Err(_) => None,
@@ -399,10 +425,22 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
let relay_addr: String = env
.get_string(&relay_addr_j)
.map(|s| s.into())
.unwrap_or_default();
let seed_hex: String = env
.get_string(&seed_hex_j)
.map(|s| s.into())
.unwrap_or_default();
let token: String = env
.get_string(&token_j)
.map(|s| s.into())
.unwrap_or_default();
let alias: String = env
.get_string(&alias_j)
.map(|s| s.into())
.unwrap_or_default();
h.engine.start_signaling(
&relay_addr,
@@ -414,8 +452,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling
match result {
Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 }
Err(_) => { error!("start_signaling panicked"); -1 }
Ok(Err(e)) => {
error!("start_signaling failed: {e}");
-1
}
Err(_) => {
error!("start_signaling panicked");
-1
}
}
}
@@ -430,14 +474,23 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default();
let target: String = env
.get_string(&target_fp_j)
.map(|s| s.into())
.unwrap_or_default();
h.engine.place_call(&target)
}));
match result {
Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("place_call failed: {e}"); -1 }
Err(_) => { error!("place_call panicked"); -1 }
Ok(Err(e)) => {
error!("place_call failed: {e}");
-1
}
Err(_) => {
error!("place_call panicked");
-1
}
}
}
@@ -453,7 +506,10 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
let call_id: String = env
.get_string(&call_id_j)
.map(|s| s.into())
.unwrap_or_default();
let accept_mode = match mode {
0 => wzp_proto::CallAcceptMode::Reject,
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
@@ -464,7 +520,13 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>
match result {
Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 }
Err(_) => { error!("answer_call panicked"); -1 }
Ok(Err(e)) => {
error!("answer_call failed: {e}");
-1
}
Err(_) => {
error!("answer_call panicked");
-1
}
}
}

View File

@@ -26,6 +26,6 @@ pub mod audio_android;
pub mod audio_ring;
pub mod commands;
pub mod engine;
pub mod jni_bridge;
pub mod pipeline;
pub mod stats;
pub mod jni_bridge;

View File

@@ -9,8 +9,8 @@ use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::quality::AdaptiveQualityController;
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
use wzp_proto::traits::QualityController;
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
use wzp_proto::{MediaPacket, QualityProfile};
use crate::audio_android::FRAME_SAMPLES;
@@ -58,14 +58,12 @@ pub struct Pipeline {
impl Pipeline {
/// Create a new pipeline configured for the given quality profile.
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
let encoder = AdaptiveEncoder::new(profile)
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
let decoder = AdaptiveDecoder::new(profile)
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
let fec_encoder =
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
let fec_decoder =
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
let encoder =
AdaptiveEncoder::new(profile).map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
let decoder =
AdaptiveDecoder::new(profile).map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
let fec_encoder = RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
let fec_decoder = RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
let jitter_buffer = JitterBuffer::new(10, 250, 3);
let quality_ctrl = AdaptiveQualityController::new();
@@ -211,10 +209,7 @@ impl Pipeline {
///
/// Returns a new profile if a tier transition occurred.
#[allow(unused)]
pub fn observe_quality(
&mut self,
report: &wzp_proto::QualityReport,
) -> Option<QualityProfile> {
pub fn observe_quality(&mut self, report: &wzp_proto::QualityReport) -> Option<QualityProfile> {
let new_profile = self.quality_ctrl.observe(report);
if let Some(ref profile) = new_profile {
if let Err(e) = self.encoder.set_profile(*profile) {

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

View File

@@ -19,7 +19,7 @@
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use wzp_client::dual_path::{race, PeerCandidates, WinningPath};
use wzp_client::dual_path::{PeerCandidates, WinningPath, race};
use wzp_client::reflect::Role;
use wzp_transport::{create_endpoint, server_config};
@@ -125,8 +125,15 @@ async fn dual_path_direct_wins_on_loopback() {
.await
.expect("race must succeed");
assert!(result.direct_transport.is_some(), "direct transport should be available");
assert_eq!(result.local_winner, WinningPath::Direct, "direct should win on loopback");
assert!(
result.direct_transport.is_some(),
"direct transport should be available"
);
assert_eq!(
result.local_winner,
WinningPath::Direct,
"direct should win on loopback"
);
// Cancel the acceptor accept task so the test finishes.
acceptor_accept_task.abort();
@@ -170,7 +177,10 @@ async fn dual_path_relay_wins_when_direct_is_dead() {
.await
.expect("race must succeed via relay fallback");
assert!(result.relay_transport.is_some(), "relay transport should be available");
assert!(
result.relay_transport.is_some(),
"relay transport should be available"
);
assert_eq!(
result.local_winner,
WinningPath::Relay,

View File

@@ -6,8 +6,8 @@
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use wzp_proto::packet::MediaPacket;
use wzp_proto::traits::{MediaTransport, PathQuality};
@@ -83,7 +83,11 @@ async fn full_handshake_both_sides_derive_same_session() {
// Run client and relay handshakes concurrently.
let (client_result, relay_result) = tokio::join!(
wzp_client::handshake::perform_handshake(client_transport_clone.as_ref(), &client_seed, None),
wzp_client::handshake::perform_handshake(
client_transport_clone.as_ref(),
&client_seed,
None
),
wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed),
);

View File

@@ -83,8 +83,12 @@ fn long_session_no_drift() {
println!(
"long_session_no_drift: decoded={frames_decoded}/{TOTAL_FRAMES}, \
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen,
stats.packets_late, stats.packets_lost,
stats.underruns,
stats.overruns,
stats.current_depth,
stats.max_depth_seen,
stats.packets_late,
stats.packets_lost,
);
// With 1 decode per tick over 3000 ticks, we expect ~3000 decoded frames
@@ -123,7 +127,7 @@ fn long_session_with_simulated_loss() {
for (j, pkt) in batch.into_iter().enumerate() {
// Drop every 20th *source* (non-repair) packet to simulate ~5% loss.
if !pkt.header.is_repair && i % 20 == 0 && j == 0 {
if !pkt.header.is_repair() && i % 20 == 0 && j == 0 {
continue; // drop this packet
}
decoder.ingest(pkt);
@@ -139,8 +143,12 @@ fn long_session_with_simulated_loss() {
println!(
"long_session_with_simulated_loss: decoded={frames_decoded}/{TOTAL_FRAMES}, \
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen,
stats.packets_late, stats.packets_lost,
stats.underruns,
stats.overruns,
stats.current_depth,
stats.max_depth_seen,
stats.packets_late,
stats.packets_lost,
);
// With 5% artificial loss + FEC recovery + PLC, we should still get >90% decoded.

View File

@@ -325,7 +325,10 @@ mod tests {
// Feed 960 samples (= delay amount). No samples released yet.
aec.feed_farend(&vec![1i16; 960]);
// far_buf should still be all zeros (nothing released).
assert!(aec.far_buf.iter().all(|&s| s == 0.0), "nothing should be released yet");
assert!(
aec.far_buf.iter().all(|&s| s == 0.0),
"nothing should be released yet"
);
// Feed 480 more. 480 should be released to far_buf.
aec.feed_farend(&vec![2i16; 480]);

View File

@@ -24,12 +24,12 @@ impl AutoGainControl {
/// Create a new AGC with sensible VoIP defaults.
pub fn new() -> Self {
Self {
target_rms: 3000.0, // ~-20 dBFS for i16
target_rms: 3000.0, // ~-20 dBFS for i16
current_gain: 1.0,
min_gain: 0.5,
max_gain: 32.0,
attack_alpha: 0.3, // fast attack
release_alpha: 0.02, // slow release
attack_alpha: 0.3, // fast attack
release_alpha: 0.02, // slow release
enabled: true,
}
}
@@ -211,9 +211,6 @@ mod tests {
fn agc_gain_db_at_unity() {
let agc = AutoGainControl::new();
let db = agc.current_gain_db();
assert!(
db.abs() < 0.01,
"expected ~0 dB at unity gain, got {db}"
);
assert!(db.abs() < 0.01, "expected ~0 dB at unity gain, got {db}");
}
}

View File

@@ -99,7 +99,11 @@ mod tests {
}
let original_len = pcm.len();
ns.process(&mut pcm);
assert_eq!(pcm.len(), original_len, "output length must match input length");
assert_eq!(
pcm.len(),
original_len,
"output length must match input length"
);
}
#[test]

View File

@@ -71,9 +71,8 @@ impl DecoderHandle {
"opus_decoder_create failed: err={error}"
)));
}
let inner = NonNull::new(ptr).ok_or_else(|| {
CodecError::DecodeFailed("opus_decoder_create returned null".into())
})?;
let inner = NonNull::new(ptr)
.ok_or_else(|| CodecError::DecodeFailed("opus_decoder_create returned null".into()))?;
Ok(Self { inner })
}
@@ -257,11 +256,7 @@ impl DredDecoderHandle {
/// The `dred_end` output is the silence gap at the tail of the DRED
/// window; we subtract it from the total offset to give callers the
/// truly usable sample count.
pub fn parse_into(
&mut self,
state: &mut DredState,
packet: &[u8],
) -> Result<i32, CodecError> {
pub fn parse_into(&mut self, state: &mut DredState, packet: &[u8]) -> Result<i32, CodecError> {
if packet.is_empty() {
state.samples_available = 0;
return Ok(0);
@@ -545,7 +540,10 @@ mod tests {
// to our sine wave because we fed a cold decoder only one warmup
// frame, but it should still produce non-silent speech-like output
// since the DRED state was parsed from real speech content.
let energy: u64 = recon_pcm.iter().map(|&s| (s as i32).unsigned_abs() as u64).sum();
let energy: u64 = recon_pcm
.iter()
.map(|&s| (s as i32).unsigned_abs() as u64)
.sum();
assert!(
energy > 0,
"reconstructed audio has zero total energy — DRED reconstruction produced silence"

View File

@@ -53,10 +53,7 @@ pub fn set_dred_verbose_logs(enabled: bool) {
/// The returned encoder accepts 48 kHz mono PCM regardless of the active
/// codec; resampling is handled internally when Codec2 is selected.
pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
Box::new(
AdaptiveEncoder::new(profile)
.expect("failed to create adaptive encoder"),
)
Box::new(AdaptiveEncoder::new(profile).expect("failed to create adaptive encoder"))
}
/// Create an adaptive decoder starting at the given quality profile.
@@ -64,10 +61,7 @@ pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
/// The returned decoder always produces 48 kHz mono PCM; upsampling from
/// Codec2's native 8 kHz is handled internally.
pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder> {
Box::new(
AdaptiveDecoder::new(profile)
.expect("failed to create adaptive decoder"),
)
Box::new(AdaptiveDecoder::new(profile).expect("failed to create adaptive decoder"))
}
#[cfg(test)]
@@ -210,7 +204,10 @@ mod codec2_tests {
let mut pcm_out_c2 = vec![0i16; 1920];
let samples_c2 = dec.decode(&encoded_c2[..n_c2], &mut pcm_out_c2).unwrap();
assert_eq!(samples_c2, 1920, "should get 1920 samples at 48kHz after upsample");
assert_eq!(
samples_c2, 1920,
"should get 1920 samples at 48kHz after upsample"
);
// Step 3: Switch back to Opus.
enc.set_profile(QualityProfile::GOOD).unwrap();

View File

@@ -332,7 +332,11 @@ impl AudioEncoder for OpusEncoder {
);
return;
}
let mode = if enabled { InbandFec::Mode1 } else { InbandFec::Off };
let mode = if enabled {
InbandFec::Mode1
} else {
InbandFec::Off
};
let _ = self.inner.set_inband_fec(mode);
}

View File

@@ -129,8 +129,7 @@ impl Downsampler48to8 {
// Update history: keep the last (FIR_TAPS - 1) samples from work.
if work.len() >= hist_len {
self.history
.copy_from_slice(&work[work.len() - hist_len..]);
self.history.copy_from_slice(&work[work.len() - hist_len..]);
} else {
// Input was shorter than history — shift.
let shift = hist_len - work.len();
@@ -209,8 +208,7 @@ impl Upsampler8to48 {
// Update history.
if work.len() >= hist_len {
self.history
.copy_from_slice(&work[work.len() - hist_len..]);
self.history.copy_from_slice(&work[work.len() - hist_len..]);
} else {
let shift = hist_len - work.len();
self.history.copy_within(shift.., 0);

View File

@@ -151,7 +151,10 @@ mod tests {
for _ in 0..4 {
det.is_silent(&silence);
}
assert!(det.is_silent(&silence), "should be suppressing after hangover");
assert!(
det.is_silent(&silence),
"should be suppressing after hangover"
);
// Speech arrives — should immediately stop suppressing.
assert!(!det.is_silent(&speech));
@@ -165,10 +168,16 @@ mod tests {
cn.generate(&mut pcm);
// At least some samples should be non-zero.
assert!(pcm.iter().any(|&s| s != 0), "CN output should not be all zeros");
assert!(
pcm.iter().any(|&s| s != 0),
"CN output should not be all zeros"
);
// All samples should be within [-50, 50].
assert!(pcm.iter().all(|&s| s.abs() <= 50), "CN samples out of range");
assert!(
pcm.iter().all(|&s| s.abs() <= 50),
"CN samples out of range"
);
}
#[test]
@@ -179,11 +188,17 @@ mod tests {
// Constant value: RMS of [v, v, v, ...] = |v|.
let pcm = vec![100i16; 100];
let rms = SilenceDetector::rms(&pcm);
assert!((rms - 100.0).abs() < 0.01, "RMS of constant 100 should be 100, got {rms}");
assert!(
(rms - 100.0).abs() < 0.01,
"RMS of constant 100 should be 100, got {rms}"
);
// Known pattern: [3, 4] → sqrt((9+16)/2) = sqrt(12.5) ≈ 3.5355
let rms2 = SilenceDetector::rms(&[3, 4]);
assert!((rms2 - 3.5355).abs() < 0.01, "RMS of [3,4] should be ~3.5355, got {rms2}");
assert!(
(rms2 - 3.5355).abs() < 0.01,
"RMS of [3,4] should be ~3.5355, got {rms2}"
);
// Empty buffer → 0.
assert_eq!(SilenceDetector::rms(&[]), 0.0);

View File

@@ -156,7 +156,11 @@ mod tests {
fn sequential_accepted() {
let mut w = AntiReplayWindow::new();
for i in 0..200 {
assert!(w.check_and_update(i).is_ok(), "seq {} should be accepted", i);
assert!(
w.check_and_update(i).is_ok(),
"seq {} should be accepted",
i
);
}
}

View File

@@ -9,8 +9,8 @@ use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
use hkdf::Hkdf;
use rand::rngs::OsRng;
use sha2::{Digest, Sha256};
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
use crate::session::ChaChaSession;
@@ -95,12 +95,11 @@ impl KeyExchange for WarzoneKeyExchange {
&self,
peer_ephemeral_pub: &[u8; 32],
) -> Result<Box<dyn CryptoSession>, CryptoError> {
let secret = self
.ephemeral_secret
.as_ref()
.ok_or_else(|| {
CryptoError::Internal("no ephemeral key generated; call generate_ephemeral first".into())
})?;
let secret = self.ephemeral_secret.as_ref().ok_or_else(|| {
CryptoError::Internal(
"no ephemeral key generated; call generate_ephemeral first".into(),
)
})?;
let peer_public = X25519PublicKey::from(*peer_ephemeral_pub);
// Use diffie_hellman with a clone of the StaticSecret

View File

@@ -79,7 +79,9 @@ impl Seed {
///
/// Mirrors: `warzone-protocol::mnemonic::mnemonic_to_seed`
pub fn from_mnemonic(words: &str) -> Result<Self, String> {
let mnemonic: bip39::Mnemonic = words.parse().map_err(|e| format!("invalid mnemonic: {e}"))?;
let mnemonic: bip39::Mnemonic = words
.parse()
.map_err(|e| format!("invalid mnemonic: {e}"))?;
let entropy = mnemonic.to_entropy();
if entropy.len() != 32 {
return Err(format!("expected 32 bytes entropy, got {}", entropy.len()));

View File

@@ -16,8 +16,8 @@ pub mod session;
pub use anti_replay::AntiReplayWindow;
pub use handshake::WarzoneKeyExchange;
pub use identity::{hash_room_name, Fingerprint, IdentityKeyPair, PublicIdentity, Seed};
pub use nonce::{build_nonce, Direction};
pub use identity::{Fingerprint, IdentityKeyPair, PublicIdentity, Seed, hash_room_name};
pub use nonce::{Direction, build_nonce};
pub use rekey::RekeyManager;
pub use session::ChaChaSession;

View File

@@ -5,9 +5,9 @@
use chacha20poly1305::aead::Aead;
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
use x25519_dalek::{PublicKey, StaticSecret};
use rand::rngs::OsRng;
use wzp_proto::{CryptoError, CryptoSession};
use x25519_dalek::{PublicKey, StaticSecret};
use crate::nonce::{self, Direction};
use crate::rekey::RekeyManager;
@@ -135,7 +135,9 @@ impl CryptoSession for ChaChaSession {
.ok_or_else(|| CryptoError::RekeyFailed("no pending rekey".into()))?;
let total_packets = self.send_seq as u64 + self.recv_seq as u64;
let new_key = self.rekey_mgr.perform_rekey(peer_ephemeral_pub, secret, total_packets);
let new_key = self
.rekey_mgr
.perform_rekey(peer_ephemeral_pub, secret, total_packets);
self.install_key(new_key);
// Reset sequence counters after rekey for nonce uniqueness

View File

@@ -52,7 +52,10 @@ fn wzp_identity_module_matches_featherchat() {
assert_eq!(wzp_pub.signing.as_bytes(), fc_pub.signing.as_bytes());
assert_eq!(wzp_pub.encryption.as_bytes(), fc_pub.encryption.as_bytes());
assert_eq!(wzp_pub.fingerprint.0, fc_pub.fingerprint.0);
assert_eq!(wzp_pub.fingerprint.to_string(), fc_pub.fingerprint.to_string());
assert_eq!(
wzp_pub.fingerprint.to_string(),
fc_pub.fingerprint.to_string()
);
}
#[test]
@@ -148,16 +151,25 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
// And deserializes back
let decoded: warzone_protocol::message::WireMessage = bincode::deserialize(&encoded).unwrap();
if let warzone_protocol::message::WireMessage::CallSignal {
id, payload: p, signal_type, ..
id,
payload: p,
signal_type,
..
} = decoded
{
assert_eq!(id, "call-123");
assert!(matches!(signal_type, warzone_protocol::message::CallSignalType::Offer));
assert!(matches!(
signal_type,
warzone_protocol::message::CallSignalType::Offer
));
// Decode the WZP payload back
let wzp_payload = wzp_client::featherchat::decode_call_payload(&p).unwrap();
assert_eq!(wzp_payload.relay_addr.unwrap(), "relay.example.com:4433");
assert!(matches!(wzp_payload.signal, wzp_proto::SignalMessage::CallOffer { .. }));
assert!(matches!(
wzp_payload.signal,
wzp_proto::SignalMessage::CallOffer { .. }
));
} else {
panic!("expected CallSignal");
}
@@ -204,7 +216,10 @@ fn wzp_hangup_round_trips_through_fc_callsignal() {
let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None);
let signal_type = wzp_client::featherchat::signal_to_call_type(&hangup);
assert!(matches!(signal_type, wzp_client::featherchat::CallSignalType::Hangup));
assert!(matches!(
signal_type,
wzp_client::featherchat::CallSignalType::Hangup
));
let fc_msg = warzone_protocol::message::WireMessage::CallSignal {
id: "call-789".to_string(),
@@ -219,7 +234,10 @@ fn wzp_hangup_round_trips_through_fc_callsignal() {
if let warzone_protocol::message::WireMessage::CallSignal { payload, .. } = decoded {
let wzp = wzp_client::featherchat::decode_call_payload(&payload).unwrap();
assert!(matches!(wzp.signal, wzp_proto::SignalMessage::Hangup { .. }));
assert!(matches!(
wzp.signal,
wzp_proto::SignalMessage::Hangup { .. }
));
}
}
@@ -252,8 +270,7 @@ fn auth_validate_response_matches_wzp_expectations() {
"eth_address": null
});
let wzp_resp: wzp_relay::auth::ValidateResponse =
serde_json::from_value(fc_response).unwrap();
let wzp_resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(fc_response).unwrap();
assert!(wzp_resp.valid);
assert_eq!(
wzp_resp.fingerprint.unwrap(),
@@ -265,8 +282,7 @@ fn auth_validate_response_matches_wzp_expectations() {
#[test]
fn auth_invalid_response_matches() {
let fc_response = serde_json::json!({ "valid": false });
let wzp_resp: wzp_relay::auth::ValidateResponse =
serde_json::from_value(fc_response).unwrap();
let wzp_resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(fc_response).unwrap();
assert!(!wzp_resp.valid);
assert!(wzp_resp.fingerprint.is_none());
}
@@ -280,15 +296,18 @@ fn all_signal_types_map_correctly() {
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
(
wzp_proto::SignalMessage::CallOffer {
identity_pub: [0; 32], ephemeral_pub: [0; 32],
signature: vec![], supported_profiles: vec![],
identity_pub: [0; 32],
ephemeral_pub: [0; 32],
signature: vec![],
supported_profiles: vec![],
alias: None,
},
"Offer",
),
(
wzp_proto::SignalMessage::CallAnswer {
identity_pub: [0; 32], ephemeral_pub: [0; 32],
identity_pub: [0; 32],
ephemeral_pub: [0; 32],
signature: vec![],
chosen_profile: wzp_proto::QualityProfile::GOOD,
},
@@ -312,7 +331,10 @@ fn all_signal_types_map_correctly() {
for (signal, expected_name) in cases {
let ct = signal_to_call_type(&signal);
let name = format!("{ct:?}");
assert_eq!(name, expected_name, "signal type mapping for {expected_name}");
assert_eq!(
name, expected_name,
"signal type mapping for {expected_name}"
);
}
}
@@ -426,8 +448,7 @@ fn auth_response_with_eth_address() {
"alias": "vitalik",
"eth_address": "0x1234567890abcdef1234567890abcdef12345678"
});
let resp: wzp_relay::auth::ValidateResponse =
serde_json::from_value(with_eth).unwrap();
let resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(with_eth).unwrap();
assert!(resp.valid);
assert_eq!(
resp.fingerprint.unwrap(),
@@ -442,8 +463,7 @@ fn auth_response_with_eth_address() {
"alias": "anon",
"eth_address": null
});
let resp2: wzp_relay::auth::ValidateResponse =
serde_json::from_value(with_null_eth).unwrap();
let resp2: wzp_relay::auth::ValidateResponse = serde_json::from_value(with_null_eth).unwrap();
assert!(resp2.valid);
assert_eq!(
resp2.fingerprint.unwrap(),
@@ -454,8 +474,7 @@ fn auth_response_with_eth_address() {
let without_eth = serde_json::json!({
"valid": false
});
let resp3: wzp_relay::auth::ValidateResponse =
serde_json::from_value(without_eth).unwrap();
let resp3: wzp_relay::auth::ValidateResponse = serde_json::from_value(without_eth).unwrap();
assert!(!resp3.valid);
}
@@ -496,7 +515,11 @@ fn all_fc_call_signal_types_representable() {
(CallSignalType::Busy, "Busy"),
];
assert_eq!(variants.len(), 7, "featherChat defines exactly 7 call signal types");
assert_eq!(
variants.len(),
7,
"featherChat defines exactly 7 call signal types"
);
for (variant, expected_name) in &variants {
let name = format!("{variant:?}");
@@ -550,10 +573,7 @@ fn hash_room_name_used_as_sni_is_valid() {
#[test]
fn wzp_proto_cargo_toml_is_standalone() {
// Try both paths (run from workspace root or from crate directory)
let candidates = [
"crates/wzp-proto/Cargo.toml",
"../wzp-proto/Cargo.toml",
];
let candidates = ["crates/wzp-proto/Cargo.toml", "../wzp-proto/Cargo.toml"];
let contents = candidates
.iter()

View File

@@ -4,8 +4,8 @@ use std::collections::HashMap;
use std::time::Instant;
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
use wzp_proto::error::FecError;
use wzp_proto::FecDecoder;
use wzp_proto::error::FecError;
/// Length prefix size (u16 little-endian), must match encoder.
const LEN_PREFIX: usize = 2;
@@ -140,10 +140,7 @@ impl FecDecoder for RaptorQFecDecoder {
frames.push(Vec::new());
continue;
}
let payload_len = u16::from_le_bytes([
data[offset],
data[offset + 1],
]) as usize;
let payload_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
let payload_start = offset + LEN_PREFIX;
let payload_end = (payload_start + payload_len).min(data.len());
frames.push(data[payload_start..payload_end].to_vec());
@@ -198,9 +195,7 @@ mod tests {
// Feed all source symbols (using the length-prefixed padded data).
for (i, pkt) in source_pkts.iter().enumerate() {
decoder
.add_symbol(0, i as u8, false, pkt.data())
.unwrap();
decoder.add_symbol(0, i as u8, false, pkt.data()).unwrap();
}
let result = decoder.try_decode(0).unwrap();
@@ -233,7 +228,11 @@ mod tests {
let config = ObjectTransmissionInformation::new(block_len, SYMBOL_SIZE, 1, 1, 1);
let mut dec = SourceBlockDecoder::new(0, &config, block_len);
let decoded = dec.decode(all);
assert!(decoded.is_some(), "Should recover with {:.0}% loss", drop_fraction * 100.0);
assert!(
decoded.is_some(),
"Should recover with {:.0}% loss",
drop_fraction * 100.0
);
let data = decoded.unwrap();
let ss = SYMBOL_SIZE as usize;
@@ -245,13 +244,19 @@ mod tests {
}
#[test]
fn decode_with_30pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3); }
fn decode_with_30pct_loss() {
run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3);
}
#[test]
fn decode_with_50pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5); }
fn decode_with_50pct_loss() {
run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5);
}
#[test]
fn decode_with_70pct_source_loss_heavy_repair() { run_loss_test(8, 2.0, 0.5); }
fn decode_with_70pct_source_loss_heavy_repair() {
run_loss_test(8, 2.0, 0.5);
}
#[test]
fn expire_removes_old_blocks() {

View File

@@ -1,8 +1,8 @@
//! RaptorQ FEC encoder — accumulates source symbols into blocks and generates repair symbols.
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockEncoder};
use wzp_proto::error::FecError;
use wzp_proto::FecEncoder;
use wzp_proto::error::FecError;
/// Maximum symbol size in bytes. Audio frames are typically < 200 bytes,
/// but we pad to a uniform size within a block.
@@ -54,8 +54,7 @@ impl RaptorQFecEncoder {
let payload_len = sym.len().min(max_payload);
let offset = i * ss;
// Write 2-byte little-endian length prefix.
data[offset..offset + LEN_PREFIX]
.copy_from_slice(&(payload_len as u16).to_le_bytes());
data[offset..offset + LEN_PREFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
// Write payload after prefix.
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
.copy_from_slice(&sym[..payload_len]);
@@ -81,7 +80,8 @@ impl FecEncoder for RaptorQFecEncoder {
}
let block_data = self.build_block_data();
let config = ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
let config =
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
let encoder = SourceBlockEncoder::new(self.block_id, &config, &block_data);
let num_source = self.source_symbols.len() as u32;
@@ -130,8 +130,7 @@ fn build_prefixed_block_data(symbols: &[Vec<u8>], symbol_size: u16) -> Vec<u8> {
let max_payload = ss - LEN_PREFIX;
let payload_len = sym.len().min(max_payload);
let offset = i * ss;
data[offset..offset + LEN_PREFIX]
.copy_from_slice(&(payload_len as u16).to_le_bytes());
data[offset..offset + LEN_PREFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
.copy_from_slice(&sym[..payload_len]);
}

View File

@@ -146,7 +146,10 @@ mod tests {
// Each block should lose exactly 2 (6 losses / 3 blocks)
for &loss in &losses_per_block {
assert_eq!(loss, 2, "Each block should lose at most 2 symbols from a burst of 6");
assert_eq!(
loss, 2,
"Each block should lose at most 2 symbols from a burst of 6"
);
}
}
}

View File

@@ -16,7 +16,9 @@ pub mod encoder;
pub mod interleave;
pub use adaptive::AdaptiveFec;
pub use block_manager::{DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState};
pub use block_manager::{
DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState,
};
pub use decoder::RaptorQFecDecoder;
pub use encoder::RaptorQFecEncoder;
pub use interleave::Interleaver;
@@ -24,9 +26,7 @@ pub use interleave::Interleaver;
pub use wzp_proto::{FecDecoder, FecEncoder, QualityProfile};
/// Create an encoder/decoder pair configured for the given quality profile.
pub fn create_fec_pair(
profile: &QualityProfile,
) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
pub fn create_fec_pair(profile: &QualityProfile) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
let cfg = AdaptiveFec::from_profile(profile);
let encoder = cfg.build_encoder();
let decoder = RaptorQFecDecoder::new(cfg.frames_per_block, cfg.symbol_size);

View File

@@ -24,7 +24,10 @@ fn main() {
let oboe_dir = fetch_oboe();
match oboe_dir {
Some(oboe_path) => {
println!("cargo:warning=wzp-native: building with Oboe from {:?}", oboe_path);
println!(
"cargo:warning=wzp-native: building with Oboe from {:?}",
oboe_path
);
let mut build = cc::Build::new();
build
.cpp(true)
@@ -96,7 +99,12 @@ fn fetch_oboe() -> Option<PathBuf> {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let oboe_dir = out_dir.join("oboe");
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
if oboe_dir
.join("include")
.join("oboe")
.join("Oboe.h")
.exists()
{
return Some(oboe_dir);
}
@@ -111,7 +119,14 @@ fn fetch_oboe() -> Option<PathBuf> {
.status();
match status {
Ok(s) if s.success() && oboe_dir.join("include").join("oboe").join("Oboe.h").exists() => {
Ok(s)
if s.success()
&& oboe_dir
.join("include")
.join("oboe")
.join("Oboe.h")
.exists() =>
{
Some(oboe_dir)
}
_ => None,

View File

@@ -116,7 +116,11 @@ impl RingBuffer {
let w = self.write_idx.load(Ordering::Acquire);
let r = self.read_idx.load(Ordering::Relaxed);
let avail = w - r;
if avail < 0 { (avail + self.capacity as i32) as usize } else { avail as usize }
if avail < 0 {
(avail + self.capacity as i32) as usize
} else {
avail as usize
}
}
fn available_write(&self) -> usize {
@@ -132,9 +136,13 @@ impl RingBuffer {
let cap = self.capacity;
let buf_ptr = self.buf.as_ptr() as *mut i16;
for sample in &data[..count] {
unsafe { *buf_ptr.add(w) = *sample; }
unsafe {
*buf_ptr.add(w) = *sample;
}
w += 1;
if w >= cap { w = 0; }
if w >= cap {
w = 0;
}
}
self.write_idx.store(w as i32, Ordering::Release);
count
@@ -149,9 +157,13 @@ impl RingBuffer {
let cap = self.capacity;
let buf_ptr = self.buf.as_ptr();
for slot in &mut out[..count] {
unsafe { *slot = *buf_ptr.add(r); }
unsafe {
*slot = *buf_ptr.add(r);
}
r += 1;
if r >= cap { r = 0; }
if r >= cap {
r = 0;
}
}
self.read_idx.store(r as i32, Ordering::Release);
count
@@ -316,17 +328,27 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
// has stopped firing → restart the streams. This is the
// self-healing behavior that makes rejoin work: teardown +
// rebuild clears whatever HAL state locked up the callback.
let current_read_idx = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed);
let last_read_idx = b.playout_last_read_idx.load(std::sync::atomic::Ordering::Relaxed);
let current_read_idx = b
.playout
.read_idx
.load(std::sync::atomic::Ordering::Relaxed);
let last_read_idx = b
.playout_last_read_idx
.load(std::sync::atomic::Ordering::Relaxed);
if current_read_idx == last_read_idx {
let stall = b.playout_stall_writes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let stall = b
.playout_stall_writes
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if stall >= 50 {
// Callback hasn't drained anything in ~1 second.
// Force a stream restart.
unsafe {
android_log("playout STALL detected (50 writes, read_idx unchanged) — restarting Oboe streams");
android_log(
"playout STALL detected (50 writes, read_idx unchanged) — restarting Oboe streams",
);
}
b.playout_stall_writes.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout_stall_writes
.store(0, std::sync::atomic::Ordering::Relaxed);
// Release the started lock, stop, re-start.
// This is the same logic as the Rust-side
// audio_stop() + audio_start() but done inline
@@ -341,10 +363,18 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
}
}
// Clear the rings so the restart doesn't read stale data
b.playout.write_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout.read_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture.write_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture.read_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout
.write_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout
.read_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture
.write_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture
.read_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
// Re-start (stall detector — always non-BT mode)
let config = WzpOboeConfig {
sample_rate: 48_000,
@@ -367,30 +397,49 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
if let Ok(mut started) = b.started.lock() {
*started = true;
}
unsafe { android_log("playout restart OK — Oboe streams rebuilt"); }
unsafe {
android_log("playout restart OK — Oboe streams rebuilt");
}
} else {
unsafe { android_log(&format!("playout restart FAILED: {ret}")); }
unsafe {
android_log(&format!("playout restart FAILED: {ret}"));
}
}
b.playout_last_read_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout_last_read_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
return 0; // caller will retry on next frame
}
} else {
// read_idx advanced — callback is alive, reset counter
b.playout_stall_writes.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout_last_read_idx.store(current_read_idx, std::sync::atomic::Ordering::Relaxed);
b.playout_stall_writes
.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout_last_read_idx
.store(current_read_idx, std::sync::atomic::Ordering::Relaxed);
}
let before_w = b.playout.write_idx.load(std::sync::atomic::Ordering::Relaxed);
let before_r = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed);
let before_w = b
.playout
.write_idx
.load(std::sync::atomic::Ordering::Relaxed);
let before_r = b
.playout
.read_idx
.load(std::sync::atomic::Ordering::Relaxed);
let written = b.playout.write(slice);
// First few writes: log ring state + sample range so we can compare what
// engine.rs hands us to what the C++ playout callback reads.
let first_writes = b.playout_write_log_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let first_writes = b
.playout_write_log_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if first_writes < 3 || first_writes % 50 == 0 {
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
for &s in slice.iter() {
if s < lo { lo = s; }
if s > hi { hi = s; }
if s < lo {
lo = s;
}
if s > hi {
hi = s;
}
sumsq += (s as i64) * (s as i64);
}
let rms = (sumsq as f64 / slice.len() as f64).sqrt() as i32;
@@ -398,7 +447,8 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
let avail_r_after = b.playout.available_read();
let msg = format!(
"playout WRITE #{first_writes}: in_len={} written={} range=[{lo}..{hi}] rms={rms} before_w={before_w} before_r={before_r} avail_read_after={avail_r_after} avail_write_after={avail_w_after}",
slice.len(), written
slice.len(),
written
);
unsafe {
android_log(msg.as_str());
@@ -422,7 +472,9 @@ unsafe fn android_log(msg: &str) {
let mut buf = Vec::with_capacity(msg.len() + 1);
buf.extend_from_slice(msg.as_bytes());
buf.push(0);
unsafe { __android_log_write(4, tag.as_ptr(), buf.as_ptr()); }
unsafe {
__android_log_write(4, tag.as_ptr(), buf.as_ptr());
}
}
#[cfg(not(target_os = "android"))]

View File

@@ -9,8 +9,8 @@
use std::collections::VecDeque;
use std::time::Instant;
use crate::packet::QualityReport;
use crate::QualityProfile;
use crate::packet::QualityReport;
/// Network congestion state derived from delay and loss signals.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -396,10 +396,7 @@ mod tests {
// Below 8 => CATASTROPHIC
let bwe_cat = BandwidthEstimator::new(7.9, 2.0, 100.0);
assert_eq!(
bwe_cat.recommended_profile(),
QualityProfile::CATASTROPHIC
);
assert_eq!(bwe_cat.recommended_profile(), QualityProfile::CATASTROPHIC);
// High bandwidth
let bwe_high = BandwidthEstimator::new(80.0, 2.0, 100.0);
@@ -413,7 +410,7 @@ mod tests {
// Build a QualityReport with moderate loss and RTT.
let report = QualityReport {
loss_pct: (10.0_f32 / 100.0 * 255.0) as u8, // ~10% loss
rtt_4ms: 25, // 100ms RTT
rtt_4ms: 25, // 100ms RTT
jitter_ms: 10,
bitrate_cap_kbps: 200,
};

View File

@@ -49,7 +49,7 @@ fn baseline_dred_frames(codec: CodecId) -> u8 {
match codec {
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, // 100 ms
CodecId::Opus16k | CodecId::Opus24k => 20, // 200 ms
CodecId::Opus6k => 50, // 500 ms
CodecId::Opus6k => 50, // 500 ms
_ => 0,
}
}
@@ -128,7 +128,11 @@ impl DredTuner {
self.initialized = true;
} else {
// Fast-up (alpha=0.3), slow-down (alpha=0.05) asymmetric EWMA
let alpha = if jitter_f > self.jitter_ewma { 0.3 } else { 0.05 };
let alpha = if jitter_f > self.jitter_ewma {
0.3
} else {
0.05
};
self.jitter_ewma = alpha * jitter_f + (1.0 - alpha) * self.jitter_ewma;
}

View File

@@ -81,9 +81,7 @@ impl AdaptivePlayoutDelay {
let jitter = (actual_delta - expected_delta).abs();
// Spike detection: check before EMA update
if self.jitter_ema > 0.0
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
{
if self.jitter_ema > 0.0 && jitter > self.jitter_ema * self.spike_threshold_multiplier {
self.spike_detected_at = Some(Instant::now());
}
@@ -107,10 +105,8 @@ impl AdaptivePlayoutDelay {
self.target_delay = self.max_delay;
} else {
// Convert jitter estimate to target delay in packets
let raw_target =
(self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
self.target_delay =
(raw_target as usize).clamp(self.min_delay, self.max_delay);
let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
self.target_delay = (raw_target as usize).clamp(self.min_delay, self.max_delay);
}
}
@@ -162,9 +158,9 @@ impl AdaptivePlayoutDelay {
/// Manages packet reordering, gap detection, and signals when PLC is needed.
pub struct JitterBuffer {
/// Packets waiting to be consumed, ordered by sequence number.
buffer: BTreeMap<u16, MediaPacket>,
buffer: BTreeMap<u32, MediaPacket>,
/// Next sequence number expected for playout.
next_playout_seq: u16,
next_playout_seq: u32,
/// Maximum buffer depth in number of packets.
max_depth: usize,
/// Target buffer depth (adaptive, based on jitter).
@@ -204,7 +200,7 @@ pub enum PlayoutResult {
/// A packet is available for playout.
Packet(MediaPacket),
/// The expected packet is missing — decoder should generate PLC.
Missing { seq: u16 },
Missing { seq: u32 },
/// Buffer is empty or not yet filled to target depth.
NotReady,
}
@@ -278,9 +274,18 @@ impl JitterBuffer {
// federation room — reset instead of dropping.
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected");
tracing::warn!(
seq,
next = self.next_playout_seq,
backward_distance,
"jitter: backward seq detected"
);
if backward_distance > 100 {
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected");
tracing::info!(
seq,
next = self.next_playout_seq,
"jitter: RESET — new sender detected"
);
self.buffer.clear();
self.next_playout_seq = seq;
self.stats.packets_late = 0;
@@ -428,9 +433,18 @@ impl JitterBuffer {
// federation room — reset instead of dropping.
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected");
tracing::warn!(
seq,
next = self.next_playout_seq,
backward_distance,
"jitter: backward seq detected"
);
if backward_distance > 100 {
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected");
tracing::info!(
seq,
next = self.next_playout_seq,
"jitter: RESET — new sender detected"
);
self.buffer.clear();
self.next_playout_seq = seq;
self.stats.packets_late = 0;
@@ -489,7 +503,7 @@ impl JitterBuffer {
/// Sequence number comparison with wrapping (RFC 1982 serial number arithmetic).
/// Returns true if `a` comes before `b` in sequence space.
fn seq_before(a: u16, b: u16) -> bool {
fn seq_before(a: u32, b: u32) -> bool {
let diff = b.wrapping_sub(a);
diff > 0 && diff < 0x8000
}
@@ -497,24 +511,23 @@ fn seq_before(a: u16, b: u16) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::CodecId;
use crate::MediaType;
use crate::packet::{MediaHeader, MediaPacket};
use bytes::Bytes;
use crate::CodecId;
fn make_packet(seq: u16) -> MediaPacket {
fn make_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: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(vec![0u8; 60]),
quality_report: None,
@@ -598,7 +611,7 @@ mod tests {
fn seq_before_wrapping() {
assert!(seq_before(0, 1));
assert!(seq_before(65534, 65535));
assert!(seq_before(65535, 0)); // wrap
assert!(seq_before(u32::MAX, 0)); // wrap
assert!(!seq_before(1, 0));
assert!(!seq_before(5, 5)); // equal
}
@@ -800,7 +813,7 @@ mod tests {
let mut jb = JitterBuffer::new_adaptive(3, 50);
// Push packets with consistent timing
for i in 0u16..20 {
for i in 0u32..20 {
let pkt = make_packet(i);
let arrival_ms = i as u64 * 20;
jb.push_with_arrival(pkt, arrival_ms);

View File

@@ -30,10 +30,9 @@ pub use dred_tuner::{DredTuner, DredTuning};
pub use error::*;
pub use media_type::MediaType;
pub use packet::{
CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV1,
MediaHeaderV2, MediaPacket, MiniFrameContext, MiniFrameContextV1, MiniFrameContextV2,
MiniHeader, MiniHeaderV1, MiniHeaderV2, PresenceUser, QualityReport, RoomParticipant,
SignalMessage, TrunkEntry, TrunkFrame,
CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV2,
MediaPacket, MiniFrameContext, MiniFrameContextV2, MiniHeader, MiniHeaderV2, PresenceUser,
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame,
};
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
pub use session::{Session, SessionEvent, SessionState};

View File

@@ -3,162 +3,8 @@ use serde::{Deserialize, Serialize};
use crate::{CodecId, MediaType};
/// 12-byte v1 media packet header for the lossy link.
///
/// Wire layout:
/// ```text
/// Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
/// Byte 1: [FecRatioLo:6][unused:2]
/// Byte 2-3: Sequence number (big-endian u16)
/// Byte 4-7: Timestamp in ms since session start (big-endian u32)
/// Byte 8: FEC block ID
/// Byte 9: FEC symbol index within block
/// Byte 10: Reserved / flags
/// Byte 11: CSRC count
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MediaHeaderV1 {
/// Protocol version (0 = v1).
pub version: u8,
/// true = FEC repair packet, false = source media.
pub is_repair: bool,
/// Codec identifier.
pub codec_id: CodecId,
/// Whether a QualityReport trailer is appended.
pub has_quality_report: bool,
/// FEC ratio as 7-bit value (0-127 maps to 0.0-1.0).
pub fec_ratio_encoded: u8,
/// Wrapping packet sequence number.
pub seq: u16,
/// Milliseconds since session start.
pub timestamp: u32,
/// FEC source block ID (wrapping).
pub fec_block: u8,
/// Symbol index within the FEC block.
pub fec_symbol: u8,
/// Reserved flags byte.
pub reserved: u8,
/// Number of contributing sources (for future mixing).
pub csrc_count: u8,
}
impl MediaHeaderV1 {
/// Header size in bytes on the wire.
pub const WIRE_SIZE: usize = 12;
/// Create a default header for raw PCM relay (used by WebSocket bridge).
pub fn default_pcm() -> Self {
Self {
version: 0,
is_repair: false,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 0,
seq: 0,
timestamp: 0,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}
}
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
pub fn encode_fec_ratio(ratio: f32) -> u8 {
// Map 0.0-2.0 to 0-127, clamping at 127
let scaled = (ratio * 63.5).round() as u8;
scaled.min(127)
}
/// Decode the 7-bit FEC ratio value back to a float.
pub fn decode_fec_ratio(encoded: u8) -> f32 {
(encoded & 0x7F) as f32 / 63.5
}
/// Serialize to a 12-byte buffer.
pub fn write_to(&self, buf: &mut impl BufMut) {
// Byte 0: V(1) | T(1) | CodecID(4) | Q(1) | FecRatioHi(1)
let byte0 = ((self.version & 0x01) << 7)
| ((self.is_repair as u8) << 6)
| ((self.codec_id.to_wire() & 0x0F) << 2)
| ((self.has_quality_report as u8) << 1)
| ((self.fec_ratio_encoded >> 6) & 0x01);
buf.put_u8(byte0);
// Byte 1: FecRatioLo(6) | unused(2)
let byte1 = (self.fec_ratio_encoded & 0x3F) << 2;
buf.put_u8(byte1);
// Bytes 2-3: sequence number
buf.put_u16(self.seq);
// Bytes 4-7: timestamp
buf.put_u32(self.timestamp);
// Byte 8: FEC block
buf.put_u8(self.fec_block);
// Byte 9: FEC symbol
buf.put_u8(self.fec_symbol);
// Byte 10: reserved
buf.put_u8(self.reserved);
// Byte 11: CSRC count
buf.put_u8(self.csrc_count);
}
/// Deserialize from a buffer. Returns None if insufficient data.
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
if buf.remaining() < Self::WIRE_SIZE {
return None;
}
let byte0 = buf.get_u8();
let byte1 = buf.get_u8();
let version = (byte0 >> 7) & 0x01;
let is_repair = ((byte0 >> 6) & 0x01) != 0;
let codec_wire = (byte0 >> 2) & 0x0F;
let has_quality_report = ((byte0 >> 1) & 0x01) != 0;
let fec_ratio_hi = byte0 & 0x01;
let fec_ratio_lo = (byte1 >> 2) & 0x3F;
let fec_ratio_encoded = (fec_ratio_hi << 6) | fec_ratio_lo;
let codec_id = CodecId::from_wire(codec_wire)?;
let seq = buf.get_u16();
let timestamp = buf.get_u32();
let fec_block = buf.get_u8();
let fec_symbol = buf.get_u8();
let reserved = buf.get_u8();
let csrc_count = buf.get_u8();
Some(Self {
version,
is_repair,
codec_id,
has_quality_report,
fec_ratio_encoded,
seq,
timestamp,
fec_block,
fec_symbol,
reserved,
csrc_count,
})
}
/// Serialize header to a new Bytes value.
pub fn to_bytes(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(Self::WIRE_SIZE);
self.write_to(&mut buf);
buf.freeze()
}
}
/// Temporary alias so existing code continues to compile.
/// Removed in T1.5 once all emit/parse sites migrate to v2.
pub type MediaHeader = MediaHeaderV1;
/// v2 media header alias. All production code uses this type.
pub type MediaHeader = MediaHeaderV2;
/// 16-byte v2 media header. See docs/PRD/PRD-wire-format-v2.md.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -258,6 +104,23 @@ impl MediaHeaderV2 {
pub fn is_frame_end(&self) -> bool {
self.flags & Self::FLAG_FRAME_END != 0
}
/// Encode the FEC ratio float (0.0-2.0) to an 8-bit value (0-200).
pub fn encode_fec_ratio(ratio: f32) -> u8 {
(ratio * 100.0).round() as u8
}
/// Decode the 8-bit FEC ratio value back to a float.
pub fn decode_fec_ratio(encoded: u8) -> f32 {
encoded as f32 / 100.0
}
/// Serialize header to a new Bytes value.
pub fn to_bytes(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(Self::WIRE_SIZE);
self.write_to(&mut buf);
buf.freeze()
}
}
/// A user visible in the signal presence list.
@@ -363,7 +226,7 @@ impl MediaPacket {
let header = MediaHeader::read_from(&mut cursor)?;
let remaining = data.len() - MediaHeader::WIRE_SIZE;
let (payload_len, quality_report) = if header.has_quality_report {
let (payload_len, quality_report) = if header.has_quality() {
if remaining < QualityReport::WIRE_SIZE {
return None;
}
@@ -393,11 +256,12 @@ impl MediaPacket {
pub fn encode_compact(&self, ctx: &mut MiniFrameContext, frames_since_full: &mut u32) -> Bytes {
if *frames_since_full > 0 && *frames_since_full < MINI_FRAME_FULL_INTERVAL {
// --- mini frame ---
let ts_delta = self
.header
.timestamp
.wrapping_sub(ctx.last_header.unwrap().timestamp) as u16;
let ts_delta =
self.header
.timestamp
.wrapping_sub(ctx.last_header().unwrap().timestamp) as u16;
let mini = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: ts_delta,
payload_len: self.payload.len() as u16,
};
@@ -599,42 +463,8 @@ pub const FRAME_TYPE_FULL: u8 = 0x00;
/// Frame type tag: MiniHeader follows (requires prior baseline).
pub const FRAME_TYPE_MINI: u8 = 0x01;
/// Compact 4-byte v1 header used after a full MediaHeader baseline has been
/// established. Only the timestamp delta and payload length are transmitted;
/// all other fields are inherited from the last full header.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MiniHeaderV1 {
/// Milliseconds elapsed since the last header's timestamp.
pub timestamp_delta_ms: u16,
/// Length of the payload that follows this header.
pub payload_len: u16,
}
impl MiniHeaderV1 {
/// Header size in bytes on the wire.
pub const WIRE_SIZE: usize = 4;
/// Serialize to a 4-byte buffer.
pub fn write_to(&self, buf: &mut impl BufMut) {
buf.put_u16(self.timestamp_delta_ms);
buf.put_u16(self.payload_len);
}
/// Deserialize from a buffer. Returns `None` if insufficient data.
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
if buf.remaining() < Self::WIRE_SIZE {
return None;
}
Some(Self {
timestamp_delta_ms: buf.get_u16(),
payload_len: buf.get_u16(),
})
}
}
/// Temporary alias so existing code continues to compile.
/// Removed in T1.5 once all emit/parse sites migrate to v2.
pub type MiniHeader = MiniHeaderV1;
/// v2 mini header alias. All production code uses this type.
pub type MiniHeader = MiniHeaderV2;
/// Compact 5-byte v2 mini header with explicit `seq_delta`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -672,34 +502,8 @@ impl MiniHeaderV2 {
}
}
/// Stateful v1 context that expands [`MiniHeaderV1`]s back into full
/// [`MediaHeader`]s by tracking the last baseline header.
#[derive(Clone, Debug, Default)]
pub struct MiniFrameContextV1 {
last_header: Option<MediaHeader>,
}
impl MiniFrameContextV1 {
/// Record a full header as the new baseline for subsequent mini-frames.
pub fn update(&mut self, header: &MediaHeader) {
self.last_header = Some(*header);
}
/// Expand a mini-header into a full [`MediaHeader`] using the stored
/// baseline. Returns `None` if no baseline has been set yet.
pub fn expand(&mut self, mini: &MiniHeader) -> Option<MediaHeader> {
let base = self.last_header.as_ref()?;
let mut expanded = *base;
expanded.seq = base.seq.wrapping_add(1);
expanded.timestamp = base.timestamp.wrapping_add(mini.timestamp_delta_ms as u32);
self.last_header = Some(expanded);
Some(expanded)
}
}
/// Temporary alias so existing code continues to compile.
/// Removed in T1.5 once all emit/parse sites migrate to v2.
pub type MiniFrameContext = MiniFrameContextV1;
/// v2 mini frame context alias. All production code uses this type.
pub type MiniFrameContext = MiniFrameContextV2;
/// Stateful v2 context that expands [`MiniHeaderV2`]s back into full
/// [`MediaHeaderV2`]s by tracking the last baseline header.
@@ -724,6 +528,11 @@ impl MiniFrameContextV2 {
self.last = Some(e);
Some(e)
}
/// Return a reference to the last baseline header, if any.
pub fn last_header(&self) -> Option<&MediaHeaderV2> {
self.last.as_ref()
}
}
/// Signaling messages sent over the reliable QUIC stream.
@@ -1332,17 +1141,15 @@ mod tests {
#[test]
fn header_roundtrip() {
let header = MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: MediaHeader::FLAG_QUALITY,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k,
has_quality_report: true,
fec_ratio_encoded: 42,
stream_id: 0,
fec_ratio: 42,
seq: 12345,
timestamp: 987654,
fec_block: 7,
fec_symbol: 3,
reserved: 0,
csrc_count: 0,
};
let bytes = header.to_bytes();
@@ -1356,17 +1163,15 @@ mod tests {
#[test]
fn header_repair_flag() {
let header = MediaHeader {
version: 0,
is_repair: true,
version: 2,
flags: MediaHeader::FLAG_REPAIR,
media_type: MediaType::Audio,
codec_id: CodecId::Codec2_1200,
has_quality_report: false,
fec_ratio_encoded: 127,
seq: 65535,
stream_id: 0,
fec_ratio: 127,
seq: 0xDEAD_BEEF,
timestamp: u32::MAX,
fec_block: 255,
fec_symbol: 255,
reserved: 0xFF,
csrc_count: 0,
fec_block: 0xABCD,
};
let bytes = header.to_bytes();
@@ -1418,17 +1223,15 @@ mod tests {
fn media_packet_roundtrip() {
let packet = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: MediaHeader::FLAG_QUALITY,
media_type: MediaType::Audio,
codec_id: CodecId::Opus6k,
has_quality_report: true,
fec_ratio_encoded: 32,
stream_id: 0,
fec_ratio: 32,
seq: 100,
timestamp: 2000,
fec_block: 1,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from_static(b"test audio data here"),
quality_report: Some(QualityReport {
@@ -1859,11 +1662,11 @@ mod tests {
let ratio = 0.5;
let encoded = MediaHeader::encode_fec_ratio(ratio);
let decoded = MediaHeader::decode_fec_ratio(encoded);
assert!((decoded - ratio).abs() < 0.02);
assert!((decoded - ratio).abs() < 0.01);
let ratio_max = 2.0;
let encoded_max = MediaHeader::encode_fec_ratio(ratio_max);
assert_eq!(encoded_max, 127);
assert_eq!(encoded_max, 200);
}
// ---------------------------------------------------------------
@@ -1924,6 +1727,7 @@ mod tests {
#[test]
fn mini_header_encode_decode() {
let mini = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: 20,
payload_len: 160,
};
@@ -1938,29 +1742,28 @@ mod tests {
#[test]
fn mini_header_wire_size() {
let mini = MiniHeader {
seq_delta: 0xFF,
timestamp_delta_ms: 0xFFFF,
payload_len: 0xFFFF,
};
let mut buf = BytesMut::new();
mini.write_to(&mut buf);
assert_eq!(buf.len(), 4);
assert_eq!(MiniHeader::WIRE_SIZE, 4);
assert_eq!(buf.len(), 5);
assert_eq!(MiniHeader::WIRE_SIZE, 5);
}
#[test]
fn mini_frame_context_expand() {
let baseline = 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: 10,
stream_id: 0,
fec_ratio: 10,
seq: 100,
timestamp: 1000,
fec_block: 5,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
};
let mut ctx = MiniFrameContext::default();
@@ -1968,6 +1771,7 @@ mod tests {
// First expansion
let mini1 = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: 20,
payload_len: 80,
};
@@ -1979,6 +1783,7 @@ mod tests {
// Second expansion — builds on expanded h1
let mini2 = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: 20,
payload_len: 80,
};
@@ -1991,6 +1796,7 @@ mod tests {
fn mini_frame_context_no_baseline() {
let mut ctx = MiniFrameContext::default();
let mini = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: 20,
payload_len: 80,
};
@@ -2065,13 +1871,13 @@ mod tests {
#[test]
fn full_vs_mini_size_comparison() {
// Full frame on wire: 1 byte type tag + 12 byte MediaHeader = 13
// Full frame on wire: 1 byte type tag + 16 byte MediaHeader = 17
let full_size = 1 + MediaHeader::WIRE_SIZE;
assert_eq!(full_size, 13);
assert_eq!(full_size, 17);
// Mini frame on wire: 1 byte type tag + 4 byte MiniHeader = 5
// Mini frame on wire: 1 byte type tag + 5 byte MiniHeader = 6
let mini_size = 1 + MiniHeader::WIRE_SIZE;
assert_eq!(mini_size, 5);
assert_eq!(mini_size, 6);
// Verify the constants match expectations
assert_eq!(FRAME_TYPE_FULL, 0x00);
@@ -2082,20 +1888,18 @@ mod tests {
// encode_compact / decode_compact tests
// ---------------------------------------------------------------
fn make_media_packet(seq: u16, ts: u32, payload: &[u8]) -> MediaPacket {
fn make_media_packet(seq: u32, ts: u32, payload: &[u8]) -> 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: 10,
stream_id: 0,
fec_ratio: 10,
seq,
timestamp: ts,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(payload.to_vec()),
quality_report: None,
@@ -2109,7 +1913,7 @@ mod tests {
let mut frames_since_full: u32 = 0;
let packets: Vec<MediaPacket> = (0..5)
.map(|i| make_media_packet(i, i as u32 * 20, b"audio"))
.map(|i| make_media_packet(i, i * 20, b"audio"))
.collect();
for (i, pkt) in packets.iter().enumerate() {
@@ -2121,7 +1925,7 @@ mod tests {
} else {
// Subsequent frames should be mini
assert_eq!(wire[0], FRAME_TYPE_MINI, "frame {i} should be MINI");
// Mini wire: 1 (tag) + 4 (mini header) + payload
// Mini wire: 1 (tag) + 5 (mini header) + payload
assert_eq!(wire.len(), 1 + MiniHeader::WIRE_SIZE + pkt.payload.len());
}
@@ -2141,7 +1945,7 @@ mod tests {
// Encode MINI_FRAME_FULL_INTERVAL + 1 frames. Frame 0 and frame 50
// should be FULL, everything in between should be MINI.
for i in 0..=MINI_FRAME_FULL_INTERVAL {
let pkt = make_media_packet(i as u16, i * 20, b"data");
let pkt = make_media_packet(i, i * 20, b"data");
let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full);
if i == 0 || i == MINI_FRAME_FULL_INTERVAL {
@@ -2196,8 +2000,8 @@ mod tests {
// (which is what the encoder does when the feature is off).
let mut ctx = MiniFrameContext::default();
for i in 0..10u16 {
let pkt = make_media_packet(i, i as u32 * 20, b"payload");
for i in 0..10u32 {
let pkt = make_media_packet(i, i * 20, b"payload");
// When mini-frames are disabled, the encoder always passes
// frames_since_full = 0 equivalent by never using encode_compact.
// We test the raw path: frames_since_full forced to 0 every time.

View File

@@ -7,9 +7,7 @@ fn main() {
.output();
let hash = match output {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "unknown".to_string(),
};

View File

@@ -32,10 +32,7 @@ pub struct AuthenticatedClient {
///
/// Calls `POST {auth_url}` with `{ "token": "..." }`.
/// Returns the client identity if valid, or an error string.
pub async fn validate_token(
auth_url: &str,
token: &str,
) -> Result<AuthenticatedClient, String> {
pub async fn validate_token(auth_url: &str, token: &str) -> Result<AuthenticatedClient, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()

View File

@@ -83,7 +83,12 @@ impl CallRegistry {
}
/// Create a new pending call. Returns the call_id.
pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall {
pub fn create_call(
&mut self,
call_id: String,
caller_fp: String,
callee_fp: String,
) -> &DirectCall {
let call = DirectCall {
call_id: call_id.clone(),
caller_fingerprint: caller_fp,
@@ -189,7 +194,12 @@ impl CallRegistry {
}
/// Transition to Active state.
pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool {
pub fn set_active(
&mut self,
call_id: &str,
mode: wzp_proto::CallAcceptMode,
room: String,
) -> bool {
if let Some(call) = self.calls.get_mut(call_id) {
if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing {
call.state = DirectCallState::Active;
@@ -213,7 +223,8 @@ impl CallRegistry {
/// Find active/pending calls involving a fingerprint.
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
self.calls.values()
self.calls
.values()
.filter(|c| {
c.state != DirectCallState::Ended
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
@@ -236,22 +247,25 @@ impl CallRegistry {
/// Returns call IDs of expired calls.
pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> {
let now = Instant::now();
let expired: Vec<String> = self.calls.iter()
let expired: Vec<String> = self
.calls
.iter()
.filter(|(_, c)| {
c.state == DirectCallState::Pending
&& now.duration_since(c.created_at) > timeout
c.state == DirectCallState::Pending && now.duration_since(c.created_at) > timeout
})
.map(|(id, _)| id.clone())
.collect();
expired.into_iter()
expired
.into_iter()
.filter_map(|id| self.calls.remove(&id))
.collect()
}
/// Number of active (non-ended) calls.
pub fn active_count(&self) -> usize {
self.calls.values()
self.calls
.values()
.filter(|c| c.state != DirectCallState::Ended)
.count()
}
@@ -270,9 +284,16 @@ mod tests {
assert!(reg.set_ringing("c1"));
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing);
assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into()));
assert!(reg.set_active(
"c1",
wzp_proto::CallAcceptMode::AcceptGeneric,
"_call:c1".into()
));
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active);
assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1"));
assert_eq!(
reg.get("c1").unwrap().room_name.as_deref(),
Some("_call:c1")
);
let ended = reg.end_call("c1").unwrap();
assert_eq!(ended.state, DirectCallState::Ended);
@@ -329,10 +350,7 @@ mod tests {
// Both addrs are independently readable — the relay uses
// them to cross-wire peer_direct_addr in CallSetup.
let c = reg.get("c1").unwrap();
assert_eq!(
c.caller_reflexive_addr.as_deref(),
Some("192.0.2.1:4433")
);
assert_eq!(c.caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433"));
assert_eq!(
c.callee_reflexive_addr.as_deref(),
Some("198.51.100.9:4433")

View File

@@ -145,7 +145,10 @@ pub struct RelayInfo {
}
/// Load config from path, or create a personalized example config if it doesn't exist.
pub fn load_or_create_config(path: &str, info: Option<&RelayInfo>) -> Result<RelayConfig, anyhow::Error> {
pub fn load_or_create_config(
path: &str,
info: Option<&RelayInfo>,
) -> Result<RelayConfig, anyhow::Error> {
let p = std::path::Path::new(path);
if p.exists() {
return load_config(path);
@@ -164,7 +167,9 @@ pub fn load_or_create_config(path: &str, info: Option<&RelayInfo>) -> Result<Rel
/// Generate an example TOML config, personalized with this relay's info if available.
fn generate_example_config(info: Option<&RelayInfo>) -> String {
let listen = info.map(|i| i.listen_addr.as_str()).unwrap_or("0.0.0.0:4433");
let listen = info
.map(|i| i.listen_addr.as_str())
.unwrap_or("0.0.0.0:4433");
let peer_example = if let Some(i) = info {
let ip = i.public_ip.as_deref().unwrap_or("this-relay-ip");
format!(

View File

@@ -25,16 +25,13 @@ pub struct Event {
pub src: Option<String>,
/// Packet sequence number.
#[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<u16>,
pub seq: Option<u32>,
/// Codec identifier.
#[serde(skip_serializing_if = "Option::is_none")]
pub codec: Option<String>,
/// FEC block ID.
/// FEC block ID (low byte) and symbol index (high byte).
#[serde(skip_serializing_if = "Option::is_none")]
pub fec_block: Option<u8>,
/// FEC symbol index.
#[serde(skip_serializing_if = "Option::is_none")]
pub fec_sym: Option<u8>,
pub fec_block: Option<u16>,
/// Is FEC repair packet.
#[serde(skip_serializing_if = "Option::is_none")]
pub repair: Option<bool>,
@@ -60,7 +57,9 @@ pub struct Event {
impl Event {
fn now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.6fZ")
.to_string()
}
/// Create a minimal event with just type and timestamp.
@@ -73,7 +72,6 @@ impl Event {
seq: None,
codec: None,
fec_block: None,
fec_sym: None,
repair: None,
len: None,
to_count: None,
@@ -85,33 +83,59 @@ impl Event {
}
/// Set room.
pub fn room(mut self, room: &str) -> Self { self.room = Some(room.to_string()); self }
pub fn room(mut self, room: &str) -> Self {
self.room = Some(room.to_string());
self
}
/// Set source.
pub fn src(mut self, src: &str) -> Self { self.src = Some(src.to_string()); self }
pub fn src(mut self, src: &str) -> Self {
self.src = Some(src.to_string());
self
}
/// Set packet header fields from a MediaPacket.
pub fn packet(mut self, pkt: &wzp_proto::MediaPacket) -> Self {
self.seq = Some(pkt.header.seq);
self.codec = Some(format!("{:?}", pkt.header.codec_id));
self.fec_block = Some(pkt.header.fec_block);
self.fec_sym = Some(pkt.header.fec_symbol);
self.repair = Some(pkt.header.is_repair);
self.repair = Some(pkt.header.is_repair());
self.len = Some(pkt.payload.len());
self
}
/// Set seq only (when full packet not available).
pub fn seq(mut self, seq: u16) -> Self { self.seq = Some(seq); self }
pub fn seq(mut self, seq: u32) -> Self {
self.seq = Some(seq);
self
}
/// Set payload length.
pub fn len(mut self, len: usize) -> Self { self.len = Some(len); self }
pub fn len(mut self, len: usize) -> Self {
self.len = Some(len);
self
}
/// Set recipient count.
pub fn to_count(mut self, n: usize) -> Self { self.to_count = Some(n); self }
pub fn to_count(mut self, n: usize) -> Self {
self.to_count = Some(n);
self
}
/// Set peer label.
pub fn peer(mut self, peer: &str) -> Self { self.peer = Some(peer.to_string()); self }
pub fn peer(mut self, peer: &str) -> Self {
self.peer = Some(peer.to_string());
self
}
/// Set drop reason.
pub fn reason(mut self, reason: &str) -> Self { self.reason = Some(reason.to_string()); self }
pub fn reason(mut self, reason: &str) -> Self {
self.reason = Some(reason.to_string());
self
}
/// Set presence action.
pub fn action(mut self, action: &str) -> Self { self.action = Some(action.to_string()); self }
pub fn action(mut self, action: &str) -> Self {
self.action = Some(action.to_string());
self
}
/// Set participant count.
pub fn participants(mut self, n: usize) -> Self { self.participants = Some(n); self }
pub fn participants(mut self, n: usize) -> Self {
self.participants = Some(n);
self
}
}
/// Handle for emitting events. Cheap to clone.
@@ -181,8 +205,12 @@ async fn writer_task(path: PathBuf, mut rx: mpsc::UnboundedReceiver<Event>) {
while let Some(event) = rx.recv().await {
match serde_json::to_string(&event) {
Ok(json) => {
if writer.write_all(json.as_bytes()).await.is_err() { break; }
if writer.write_all(b"\n").await.is_err() { break; }
if writer.write_all(json.as_bytes()).await.is_err() {
break;
}
if writer.write_all(b"\n").await.is_err() {
break;
}
count += 1;
// Flush every 100 events
if count % 100 == 0 {

View File

@@ -11,7 +11,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::Bytes;
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tracing::{error, info, warn};
@@ -56,13 +56,14 @@ impl Deduplicator {
}
/// Returns true if this packet is a duplicate (already seen within TTL).
fn is_dup(&mut self, room_hash: &[u8; 8], seq: u16, extra: u64) -> bool {
fn is_dup(&mut self, room_hash: &[u8; 8], seq: u32, extra: u64) -> bool {
let key = u64::from_be_bytes(*room_hash) ^ (seq as u64) ^ extra;
let now = Instant::now();
// Periodic cleanup (every ~256 packets)
if self.entries.len() > 256 {
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl);
self.entries
.retain(|_, ts| now.duration_since(*ts) < self.ttl);
}
if let Some(ts) = self.entries.get(&key) {
@@ -215,8 +216,11 @@ impl FederationManager {
pub async fn broadcast_signal(&self, msg: &wzp_proto::SignalMessage) -> usize {
let peers: Vec<(String, String, Arc<QuinnTransport>)> = {
let links = self.peer_links.lock().await;
links.iter().map(|(fp, l)| (fp.clone(), l.label.clone(), l.transport.clone())).collect()
}; // lock released
links
.iter()
.map(|(fp, l)| (fp.clone(), l.label.clone(), l.transport.clone()))
.collect()
}; // lock released
let mut count = 0;
for (fp, label, transport) in &peers {
match transport.send_signal(msg).await {
@@ -249,7 +253,7 @@ impl FederationManager {
let transport = {
let links = self.peer_links.lock().await;
links.get(&normalized).map(|l| l.transport.clone())
}; // lock released
}; // lock released
match transport {
Some(t) => t
.send_signal(msg)
@@ -300,9 +304,10 @@ impl FederationManager {
return Some(room.to_string());
}
// Hashed match (desktop clients hash room names for SNI privacy)
self.global_rooms.iter().find(|name| {
wzp_crypto::hash_room_name(name) == room
}).map(|s| s.to_string())
self.global_rooms
.iter()
.find(|name| wzp_crypto::hash_room_name(name) == room)
.map(|s| s.to_string())
}
/// Get the canonical federation room hash for a room.
@@ -371,7 +376,10 @@ impl FederationManager {
/// Get all remote participants for a room from all peer links.
/// Deduplicates by fingerprint (same participant may appear via multiple links).
pub async fn get_remote_participants(&self, room: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
pub async fn get_remote_participants(
&self,
room: &str,
) -> Vec<wzp_proto::packet::RoomParticipant> {
let canonical = self.resolve_global_room(room);
let links = self.peer_links.lock().await;
let mut result = Vec::new();
@@ -407,12 +415,22 @@ impl FederationManager {
/// the other room-tagged helpers and for future per-room-name logging
/// or rate limiting; the body currently forwards on `room_hash` alone
/// because that's what the wire format carries.
pub async fn forward_to_peers(&self, _room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
pub async fn forward_to_peers(
&self,
_room_name: &str,
room_hash: &[u8; 8],
media_data: &Bytes,
) {
let peers: Vec<(String, Arc<QuinnTransport>)> = {
let links = self.peer_links.lock().await;
if links.is_empty() { return; }
links.values().map(|l| (l.label.clone(), l.transport.clone())).collect()
}; // lock released
if links.is_empty() {
return;
}
links
.values()
.map(|l| (l.label.clone(), l.transport.clone()))
.collect()
}; // lock released
for (label, transport) in &peers {
let mut tagged = Vec::with_capacity(8 + media_data.len());
@@ -420,8 +438,10 @@ impl FederationManager {
tagged.extend_from_slice(media_data);
match transport.send_raw_datagram(&tagged) {
Ok(()) => {
self.metrics.federation_packets_forwarded
.with_label_values(&[label, "out"]).inc();
self.metrics
.federation_packets_forwarded
.with_label_values(&[label, "out"])
.inc();
}
Err(e) => warn!(peer = %label, "federation send error: {e}"),
}
@@ -431,20 +451,25 @@ impl FederationManager {
// ── Trust verification (kept from previous implementation) ──
pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> {
self.peers.iter().find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
self.peers
.iter()
.find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
}
pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> {
let addr_ip = addr.ip();
self.peers.iter().find(|p| {
p.url.parse::<SocketAddr>()
p.url
.parse::<SocketAddr>()
.map(|sa| sa.ip() == addr_ip)
.unwrap_or(false)
})
}
pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> {
self.trusted.iter().find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp))
self.trusted
.iter()
.find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp))
}
pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option<String> {
@@ -452,7 +477,12 @@ impl FederationManager {
return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone()));
}
if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) {
return Some(trusted.label.clone().unwrap_or_else(|| hello_fp[..16].to_string()));
return Some(
trusted
.label
.clone()
.unwrap_or_else(|| hello_fp[..16].to_string()),
);
}
None
}
@@ -471,7 +501,8 @@ pub async fn run_federation_media_egress(
if count == 1 || count % 250 == 0 {
info!(room = %out.room_name, count, "federation egress: forwarding media");
}
fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data).await;
fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data)
.await;
}
info!(total = count, "federation egress task ended");
}
@@ -536,7 +567,9 @@ async fn run_stale_presence_sweeper(fm: Arc<FederationManager>) {
let links = fm.peer_links.lock().await;
let mut stale = Vec::new();
for (fp, link) in links.iter() {
if link.last_seen.elapsed() > stale_threshold && !link.remote_participants.is_empty() {
if link.last_seen.elapsed() > stale_threshold
&& !link.remote_participants.is_empty()
{
for room in link.remote_participants.keys() {
stale.push((fp.clone(), room.clone()));
}
@@ -615,7 +648,10 @@ async fn run_peer_loop(fm: Arc<FederationManager>, peer: PeerConfig) {
}
/// Connect to a peer relay and send hello.
async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Arc<QuinnTransport>, anyhow::Error> {
async fn connect_to_peer(
fm: &FederationManager,
peer: &PeerConfig,
) -> Result<Arc<QuinnTransport>, anyhow::Error> {
let addr: SocketAddr = peer.url.parse()?;
let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
@@ -625,7 +661,9 @@ async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Ar
let hello = SignalMessage::FederationHello {
tls_fingerprint: fm.local_tls_fp.clone(),
};
transport.send_signal(&hello).await
transport
.send_signal(&hello)
.await
.map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?;
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected (hello sent)");
@@ -642,16 +680,22 @@ async fn run_federation_link(
peer_label: String,
) -> Result<(), anyhow::Error> {
// Register peer link + metrics
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(1);
fm.metrics
.federation_peer_status
.with_label_values(&[&peer_label])
.set(1);
{
let mut links = fm.peer_links.lock().await;
links.insert(peer_fp.clone(), PeerLink {
transport: transport.clone(),
label: peer_label.clone(),
active_rooms: HashSet::new(),
remote_participants: HashMap::new(),
last_seen: Instant::now(),
});
links.insert(
peer_fp.clone(),
PeerLink {
transport: transport.clone(),
label: peer_label.clone(),
active_rooms: HashSet::new(),
remote_participants: HashMap::new(),
last_seen: Instant::now(),
},
);
}
// Announce our currently active global rooms to this new peer
@@ -665,7 +709,10 @@ async fn run_federation_link(
if fm.is_global_room(room_name) {
let participants = fm.room_mgr.local_participant_list(room_name);
info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer");
msgs.push(SignalMessage::GlobalRoomActive { room: room_name.clone(), participants });
msgs.push(SignalMessage::GlobalRoomActive {
room: room_name.clone(),
participants,
});
}
}
@@ -761,7 +808,10 @@ async fn run_federation_link(
}
// Cleanup: remove peer link + metrics
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(0);
fm.metrics
.federation_peer_status
.with_label_values(&[&peer_label])
.set(0);
{
let mut links = fm.peer_links.lock().await;
links.remove(&peer_fp);
@@ -799,34 +849,43 @@ async fn handle_signal(
fm.metrics.federation_active_rooms.set(total as i64);
if let Some(link) = links.get_mut(peer_fp) {
// Tag remote participants with their relay label
let tagged: Vec<_> = participants.iter().map(|p| {
let mut tagged = p.clone();
if tagged.relay_label.is_none() {
tagged.relay_label = Some(link.label.clone());
}
tagged
}).collect();
let tagged: Vec<_> = participants
.iter()
.map(|p| {
let mut tagged = p.clone();
if tagged.relay_label.is_none() {
tagged.relay_label = Some(link.label.clone());
}
tagged
})
.collect();
link.remote_participants.insert(room.clone(), tagged);
}
// Propagate to other peers (with relay labels preserved)
let tagged_for_propagation = if let Some(link) = links.get(peer_fp) {
let label = link.label.clone();
participants.iter().map(|p| {
let mut t = p.clone();
if t.relay_label.is_none() {
t.relay_label = Some(label.clone());
}
t
}).collect::<Vec<_>>()
participants
.iter()
.map(|p| {
let mut t = p.clone();
if t.relay_label.is_none() {
t.relay_label = Some(label.clone());
}
t
})
.collect::<Vec<_>>()
} else {
participants.clone()
};
for (fp, link) in links.iter() {
if fp != peer_fp {
let _ = link.transport.send_signal(&SignalMessage::GlobalRoomActive {
room: room.clone(),
participants: tagged_for_propagation.clone(),
}).await;
let _ = link
.transport
.send_signal(&SignalMessage::GlobalRoomActive {
room: room.clone(),
participants: tagged_for_propagation.clone(),
})
.await;
}
}
drop(links);
@@ -835,19 +894,25 @@ async fn handle_signal(
// Find the local room name (may be hashed or raw)
let active = fm.room_mgr.active_rooms();
for local_room in &active {
if fm.is_global_room(local_room) && fm.resolve_global_room(local_room) == fm.resolve_global_room(&room) {
if fm.is_global_room(local_room)
&& fm.resolve_global_room(local_room) == fm.resolve_global_room(&room)
{
// Build merged participant list: local + all remote (deduped)
let mut all_participants = fm.room_mgr.local_participant_list(local_room);
{
let links = fm.peer_links.lock().await;
for link in links.values() {
if let Some(ref canonical) = fm.resolve_global_room(local_room) {
if let Some(remote) = link.remote_participants.get(canonical.as_str()) {
if let Some(remote) =
link.remote_participants.get(canonical.as_str())
{
all_participants.extend(remote.iter().cloned());
}
// Also check raw room name, but only if different from canonical
if canonical != local_room {
if let Some(remote) = link.remote_participants.get(local_room) {
if let Some(remote) =
link.remote_participants.get(local_room)
{
all_participants.extend(remote.iter().cloned());
}
}
@@ -890,7 +955,9 @@ async fn handle_signal(
let canonical = fm.resolve_global_room(&room);
let mut result = Vec::new();
for (fp, link) in links.iter() {
if fp == peer_fp { continue; }
if fp == peer_fp {
continue;
}
if let Some(ref c) = canonical {
if let Some(remote) = link.remote_participants.get(c.as_str()) {
result.extend(remote.iter().cloned());
@@ -904,11 +971,16 @@ async fn handle_signal(
// Propagate to other peers: send updated GlobalRoomActive with revised list,
// or GlobalRoomInactive if no participants remain anywhere
let local_active = fm.room_mgr.active_rooms().iter().any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room));
let local_active = fm
.room_mgr
.active_rooms()
.iter()
.any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room));
let has_remaining = !remaining_remote.is_empty() || local_active;
// Collect peer transports to send to (avoid holding lock across await)
let peer_sends: Vec<_> = links.iter()
let peer_sends: Vec<_> = links
.iter()
.filter(|(fp, _)| *fp != peer_fp)
.map(|(_, link)| link.transport.clone())
.collect();
@@ -920,7 +992,8 @@ async fn handle_signal(
if local_active {
for local_room in fm.room_mgr.active_rooms() {
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
updated_participants.extend(fm.room_mgr.local_participant_list(&local_room));
updated_participants
.extend(fm.room_mgr.local_participant_list(&local_room));
break;
}
}
@@ -943,7 +1016,9 @@ async fn handle_signal(
// Broadcast updated RoomUpdate to local clients (remote participant removed)
let active = fm.room_mgr.active_rooms();
for local_room in &active {
if fm.is_global_room(local_room) && fm.resolve_global_room(local_room) == fm.resolve_global_room(&room) {
if fm.is_global_room(local_room)
&& fm.resolve_global_room(local_room) == fm.resolve_global_room(&room)
{
let mut all_participants = fm.room_mgr.local_participant_list(local_room);
all_participants.extend(remaining_remote.iter().cloned());
// Deduplicate by fingerprint
@@ -972,7 +1047,10 @@ async fn handle_signal(
// Loop prevention: drop any forward whose origin matches
// our own federation TLS fingerprint. With
// broadcast-to-all-peers this prevents A→B→A echo loops.
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
SignalMessage::FederatedSignalForward {
inner,
origin_relay_fp,
} => {
if origin_relay_fp == fm.local_tls_fp {
tracing::debug!(
peer = %peer_label,
@@ -1016,12 +1094,10 @@ async fn handle_signal(
}
/// Handle an incoming federation datagram (room-hash-tagged media).
async fn handle_datagram(
fm: &Arc<FederationManager>,
source_peer_fp: &str,
data: Bytes,
) {
if data.len() < 12 { return; } // 8-byte hash + min packet
async fn handle_datagram(fm: &Arc<FederationManager>, source_peer_fp: &str, data: Bytes) {
if data.len() < 12 {
return;
} // 8-byte hash + min packet
let mut rh = [0u8; 8];
rh.copy_from_slice(&data[..8]);
@@ -1030,7 +1106,8 @@ async fn handle_datagram(
let pkt = match wzp_proto::MediaPacket::from_bytes(media_bytes.clone()) {
Some(pkt) => pkt,
None => {
fm.event_log.emit(Event::new("federation_ingress_malformed").len(data.len()));
fm.event_log
.emit(Event::new("federation_ingress_malformed").len(data.len()));
return;
}
};
@@ -1038,13 +1115,22 @@ async fn handle_datagram(
// Event log: federation ingress
let peer_label = {
let links = fm.peer_links.lock().await;
links.get(source_peer_fp).map(|l| l.label.clone()).unwrap_or_default()
links
.get(source_peer_fp)
.map(|l| l.label.clone())
.unwrap_or_default()
};
fm.event_log.emit(Event::new("federation_ingress").packet(&pkt).peer(&peer_label));
fm.event_log.emit(
Event::new("federation_ingress")
.packet(&pkt)
.peer(&peer_label),
);
// Count inbound federation packet + update last_seen
fm.metrics.federation_packets_forwarded
.with_label_values(&[source_peer_fp, "in"]).inc();
fm.metrics
.federation_packets_forwarded
.with_label_values(&[source_peer_fp, "in"])
.inc();
{
let mut links = fm.peer_links.lock().await;
if let Some(link) = links.get_mut(source_peer_fp) {
@@ -1065,7 +1151,11 @@ async fn handle_datagram(
{
let mut dedup = fm.dedup.lock().await;
if dedup.is_dup(&rh, pkt.header.seq, payload_hash) {
fm.event_log.emit(Event::new("dedup_drop").seq(pkt.header.seq).peer(&peer_label));
fm.event_log.emit(
Event::new("dedup_drop")
.seq(pkt.header.seq)
.peer(&peer_label),
);
return;
}
}
@@ -1074,18 +1164,33 @@ async fn handle_datagram(
let room_name = {
let active = fm.room_mgr.active_rooms();
// First: check local rooms (has participants)
active.iter().find(|r| room_hash(r) == rh).cloned()
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned())
active
.iter()
.find(|r| room_hash(r) == rh)
.cloned()
.or_else(|| {
active
.iter()
.find(|r| fm.global_room_hash(r) == rh)
.cloned()
})
// Second: check static global room config (hub relay may have no local participants)
.or_else(|| {
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned()
fm.global_rooms
.iter()
.find(|name| room_hash(name) == rh)
.cloned()
})
};
let room_name = match room_name {
Some(r) => r,
None => {
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label));
fm.event_log.emit(
Event::new("room_not_found")
.seq(pkt.header.seq)
.peer(&peer_label),
);
// Phase 4.1 diagnostic: log the hash + active rooms
// so we can diagnose cross-relay call-* media routing
// failures. This fires when a peer relay sends media
@@ -1107,10 +1212,15 @@ async fn handle_datagram(
// Rate limit per room
if FEDERATION_RATE_LIMIT_PPS > 0 {
let mut limiters = fm.rate_limiters.lock().await;
let limiter = limiters.entry(room_name.clone())
let limiter = limiters
.entry(room_name.clone())
.or_insert_with(|| RateLimiter::new(FEDERATION_RATE_LIMIT_PPS));
if !limiter.allow() {
fm.event_log.emit(Event::new("rate_limit_drop").room(&room_name).seq(pkt.header.seq));
fm.event_log.emit(
Event::new("rate_limit_drop")
.room(&room_name)
.seq(pkt.header.seq),
);
return;
}
}
@@ -1122,14 +1232,26 @@ async fn handle_datagram(
match sender {
room::ParticipantSender::Quic(t) => {
if let Err(e) = t.send_raw_datagram(&media_bytes) {
fm.event_log.emit(Event::new("local_deliver_error").room(&room_name).seq(pkt.header.seq).reason(&e.to_string()));
fm.event_log.emit(
Event::new("local_deliver_error")
.room(&room_name)
.seq(pkt.header.seq)
.reason(&e.to_string()),
);
warn!("federation local delivery error: {e}");
}
}
room::ParticipantSender::WebSocket(_) => { let _ = sender.send_raw(&pkt.payload).await; }
room::ParticipantSender::WebSocket(_) => {
let _ = sender.send_raw(&pkt.payload).await;
}
}
}
fm.event_log.emit(Event::new("local_deliver").room(&room_name).seq(pkt.header.seq).to_count(locals.len()));
fm.event_log.emit(
Event::new("local_deliver")
.room(&room_name)
.seq(pkt.header.seq)
.to_count(locals.len()),
);
// Multi-hop: forward to ALL other connected peers (not the source)
// Don't filter by active_rooms — the receiving peer decides whether to deliver

View File

@@ -20,29 +20,48 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
pub async fn accept_handshake(
transport: &dyn MediaTransport,
seed: &[u8; 32],
) -> Result<(Box<dyn CryptoSession>, QualityProfile, String, Option<String>), anyhow::Error> {
) -> Result<
(
Box<dyn CryptoSession>,
QualityProfile,
String,
Option<String>,
),
anyhow::Error,
> {
// 1. Receive CallOffer
let offer = transport
.recv_signal()
.await?
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles, caller_alias) =
match offer {
SignalMessage::CallOffer {
identity_pub,
ephemeral_pub,
signature,
supported_profiles,
alias,
} => (identity_pub, ephemeral_pub, signature, supported_profiles, alias),
other => {
return Err(anyhow::anyhow!(
"expected CallOffer, got {:?}",
std::mem::discriminant(&other)
))
}
};
let (
caller_identity_pub,
caller_ephemeral_pub,
caller_signature,
supported_profiles,
caller_alias,
) = match offer {
SignalMessage::CallOffer {
identity_pub,
ephemeral_pub,
signature,
supported_profiles,
alias,
} => (
identity_pub,
ephemeral_pub,
signature,
supported_profiles,
alias,
),
other => {
return Err(anyhow::anyhow!(
"expected CallOffer, got {:?}",
std::mem::discriminant(&other)
));
}
};
// 2. Verify caller's signature over (ephemeral_pub || "call-offer")
let mut verify_data = Vec::with_capacity(32 + 10);
@@ -81,11 +100,11 @@ pub async fn accept_handshake(
// Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:...
// Must match the format used in signal registration and presence.
let caller_fp = {
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let hash = Sha256::digest(&caller_identity_pub);
let fp = wzp_crypto::Fingerprint([
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8],
hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
]);
fp.to_string()
};

View File

@@ -12,7 +12,6 @@ pub mod call_registry;
pub mod config;
pub mod event_log;
pub mod federation;
pub mod signal_hub;
pub mod handshake;
pub mod metrics;
pub mod pipeline;
@@ -22,6 +21,7 @@ pub mod relay_link;
pub mod room;
pub mod route;
pub mod session_mgr;
pub mod signal_hub;
pub mod trunk;
pub mod ws;

View File

@@ -8,8 +8,8 @@
//! The web bridge connects with room name as SNI.
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use clap::Parser;
@@ -116,7 +116,9 @@ fn parse_args() -> CliResult {
}
// Track if we need to create the config after identity is known
let config_needs_create = args.config_file.as_ref()
let config_needs_create = args
.config_file
.as_ref()
.map(|p| !std::path::Path::new(p).exists())
.unwrap_or(false);
@@ -125,11 +127,10 @@ fn parse_args() -> CliResult {
// Will be re-created with personalized info after identity is loaded
RelayConfig::default()
} else {
wzp_relay::config::load_config(path)
.unwrap_or_else(|e| {
eprintln!("failed to load config from {path}: {e}");
std::process::exit(1);
})
wzp_relay::config::load_config(path).unwrap_or_else(|e| {
eprintln!("failed to load config from {path}: {e}");
std::process::exit(1);
})
}
} else {
RelayConfig::default()
@@ -164,7 +165,9 @@ fn parse_args() -> CliResult {
config.static_dir = Some(dir);
}
for name in args.global_room {
config.global_rooms.push(wzp_relay::config::GlobalRoomConfig { name });
config
.global_rooms
.push(wzp_relay::config::GlobalRoomConfig { name });
}
if let Some(tap) = args.debug_tap {
config.debug_tap = Some(tap);
@@ -199,7 +202,9 @@ async fn run_upstream(
let mut pipe = pipeline.lock().await;
let decoded = pipe.ingest(pkt);
let mut out = Vec::new();
for p in decoded { out.extend(pipe.prepare_outbound(p)); }
for p in decoded {
out.extend(pipe.prepare_outbound(p));
}
out
};
for p in &outbound {
@@ -208,10 +213,18 @@ async fn run_upstream(
return;
}
}
stats.upstream_packets.fetch_add(outbound.len() as u64, Ordering::Relaxed);
stats
.upstream_packets
.fetch_add(outbound.len() as u64, Ordering::Relaxed);
}
Ok(None) => {
info!("client disconnected (upstream)");
break;
}
Err(e) => {
error!("upstream recv: {e}");
break;
}
Ok(None) => { info!("client disconnected (upstream)"); break; }
Err(e) => { error!("upstream recv: {e}"); break; }
}
}
}
@@ -229,7 +242,9 @@ async fn run_downstream(
let mut pipe = pipeline.lock().await;
let decoded = pipe.ingest(pkt);
let mut out = Vec::new();
for p in decoded { out.extend(pipe.prepare_outbound(p)); }
for p in decoded {
out.extend(pipe.prepare_outbound(p));
}
out
};
for p in &outbound {
@@ -238,10 +253,18 @@ async fn run_downstream(
return;
}
}
stats.downstream_packets.fetch_add(outbound.len() as u64, Ordering::Relaxed);
stats
.downstream_packets
.fetch_add(outbound.len() as u64, Ordering::Relaxed);
}
Ok(None) => {
info!("remote disconnected (downstream)");
break;
}
Err(e) => {
error!("downstream recv: {e}");
break;
}
Ok(None) => { info!("remote disconnected (downstream)"); break; }
Err(e) => { error!("downstream recv: {e}"); break; }
}
}
}
@@ -266,7 +289,12 @@ const BUILD_GIT_HASH: &str = env!("WZP_BUILD_HASH");
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let CliResult { config, identity_path, config_file, config_needs_create } = parse_args();
let CliResult {
config,
identity_path,
config_file,
config_needs_create,
} = parse_args();
tracing_subscriber::fmt().init();
info!(version = BUILD_GIT_HASH, "wzp-relay build");
rustls::crypto::ring::default_provider()
@@ -303,7 +331,10 @@ async fn main() -> anyhow::Result<()> {
info!("loaded relay identity from {}", id_path.display());
s
} else {
warn!("corrupt identity file {}, generating new", id_path.display());
warn!(
"corrupt identity file {}, generating new",
id_path.display()
);
let s = wzp_crypto::Seed::generate();
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
let _ = std::fs::write(&id_path, &hex);
@@ -386,7 +417,7 @@ async fn main() -> anyhow::Result<()> {
} else {
// Probe via a dummy "connected" UDP socket. Never actually sends.
match std::net::UdpSocket::bind("0.0.0.0:0")
.and_then(|s| { s.connect("8.8.8.8:80").map(|_| s) })
.and_then(|s| s.connect("8.8.8.8:80").map(|_| s))
.and_then(|s| s.local_addr())
{
Ok(a) if !a.ip().is_loopback() => a.ip(),
@@ -398,47 +429,48 @@ async fn main() -> anyhow::Result<()> {
info!(%advertised_addr_str, "relay advertised address for CallSetup");
// Forward mode
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
if let Some(remote_addr) = config.remote_relay {
info!(%remote_addr, "forward mode → remote relay");
let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(&endpoint, remote_addr, "localhost", client_cfg).await?;
Some(Arc::new(wzp_transport::QuinnTransport::new(conn)))
} else {
info!("room mode — clients join named rooms (SFU)");
None
};
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> = if let Some(remote_addr) =
config.remote_relay
{
info!(%remote_addr, "forward mode → remote relay");
let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(&endpoint, remote_addr, "localhost", client_cfg).await?;
Some(Arc::new(wzp_transport::QuinnTransport::new(conn)))
} else {
info!("room mode — clients join named rooms (SFU)");
None
};
// Room manager (room mode only)
let room_mgr = Arc::new(RoomManager::new());
// Event log for protocol analysis
let event_log = wzp_relay::event_log::start_event_log(
config.event_log.as_ref().map(std::path::PathBuf::from)
config.event_log.as_ref().map(std::path::PathBuf::from),
);
// Federation manager
let global_room_set: std::collections::HashSet<String> = config.global_rooms.iter()
.map(|g| g.name.clone())
.collect();
let global_room_set: std::collections::HashSet<String> =
config.global_rooms.iter().map(|g| g.name.clone()).collect();
let federation_mgr = if !config.peers.is_empty() || !config.trusted.is_empty() || !global_room_set.is_empty() {
let fm = Arc::new(wzp_relay::federation::FederationManager::new(
config.peers.clone(),
config.trusted.clone(),
global_room_set.clone(),
room_mgr.clone(),
endpoint.clone(),
tls_fp.clone(),
metrics.clone(),
event_log.clone(),
));
let fm_run = fm.clone();
tokio::spawn(async move { fm_run.run().await });
Some(fm)
} else {
None
};
let federation_mgr =
if !config.peers.is_empty() || !config.trusted.is_empty() || !global_room_set.is_empty() {
let fm = Arc::new(wzp_relay::federation::FederationManager::new(
config.peers.clone(),
config.trusted.clone(),
global_room_set.clone(),
room_mgr.clone(),
endpoint.clone(),
tls_fp.clone(),
metrics.clone(),
event_log.clone(),
));
let fm_run = fm.clone();
tokio::spawn(async move { fm_run.run().await });
Some(fm)
} else {
None
};
// Session manager — enforces max concurrent sessions
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
@@ -624,14 +656,15 @@ async fn main() -> anyhow::Result<()> {
// active, then read back everything needed to
// cross-wire into the local CallSetup.
let room_name = format!("call-{call_id}");
let (callee_addr_for_setup, callee_local_for_setup, callee_mapped_for_setup) = {
let (
callee_addr_for_setup,
callee_local_for_setup,
callee_mapped_for_setup,
) = {
let mut reg = call_registry_d.lock().await;
reg.set_active(call_id, accept_mode, room_name.clone());
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
reg.set_callee_reflexive_addr(
call_id,
callee_reflexive_addr.clone(),
);
reg.set_callee_reflexive_addr(call_id, callee_reflexive_addr.clone());
reg.set_callee_local_addrs(call_id, callee_local_addrs.clone());
reg.set_callee_mapped_addr(call_id, callee_mapped_addr.clone());
let c = reg.get(call_id);
@@ -762,7 +795,9 @@ async fn main() -> anyhow::Result<()> {
let relay_seed_bytes = relay_seed.0;
let metrics = metrics.clone();
let trunking_enabled = config.trunking_enabled;
let debug_tap = config.debug_tap.as_ref().map(|filter| room::DebugTap { room_filter: filter.clone() });
let debug_tap = config.debug_tap.as_ref().map(|filter| room::DebugTap {
room_filter: filter.clone(),
});
let presence = presence.clone();
let route_resolver = route_resolver.clone();
let federation_mgr = federation_mgr.clone();
@@ -771,7 +806,9 @@ async fn main() -> anyhow::Result<()> {
let advertised_addr_str = advertised_addr_str.clone();
// Phase 8: relay region + peer addresses for RegisterPresenceAck
let relay_region = config.region.clone();
let relay_peers_for_ack: Vec<String> = config.peers.iter()
let relay_peers_for_ack: Vec<String> = config
.peers
.iter()
.filter_map(|p| {
let label = p.label.as_deref().unwrap_or("peer");
Some(format!("{label}|{}", p.url))
@@ -800,9 +837,7 @@ async fn main() -> anyhow::Result<()> {
let room_name = connection
.handshake_data()
.and_then(|hd| {
hd.downcast::<quinn::crypto::rustls::HandshakeData>().ok()
})
.and_then(|hd| hd.downcast::<quinn::crypto::rustls::HandshakeData>().ok())
.and_then(|hd| hd.server_name.clone())
.unwrap_or_else(|| "default".to_string());
@@ -832,17 +867,23 @@ async fn main() -> anyhow::Result<()> {
loop {
match transport.recv_signal().await {
Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms })) => {
if let Err(e) = transport.send_signal(
&wzp_proto::SignalMessage::Pong { timestamp_ms },
).await {
if let Err(e) = transport
.send_signal(&wzp_proto::SignalMessage::Pong { timestamp_ms })
.await
{
error!(%addr, "probe pong send error: {e}");
break;
}
}
Ok(Some(wzp_proto::SignalMessage::PresenceUpdate { fingerprints, relay_addr })) => {
Ok(Some(wzp_proto::SignalMessage::PresenceUpdate {
fingerprints,
relay_addr,
})) => {
// A peer relay is telling us which fingerprints it has
let peer_addr: std::net::SocketAddr = relay_addr.parse().unwrap_or(addr);
let fps: std::collections::HashSet<String> = fingerprints.into_iter().collect();
let peer_addr: std::net::SocketAddr =
relay_addr.parse().unwrap_or(addr);
let fps: std::collections::HashSet<String> =
fingerprints.into_iter().collect();
{
let mut reg = presence.lock().await;
reg.update_peer(peer_addr, fps);
@@ -871,9 +912,13 @@ async fn main() -> anyhow::Result<()> {
wzp_relay::route::Route::Local => {
(true, vec![route_resolver.local_addr().to_string()])
}
wzp_relay::route::Route::DirectPeer(peer_addr) => {
(true, vec![route_resolver.local_addr().to_string(), peer_addr.to_string()])
}
wzp_relay::route::Route::DirectPeer(peer_addr) => (
true,
vec![
route_resolver.local_addr().to_string(),
peer_addr.to_string(),
],
),
_ => {
// Not found locally; if ttl > 0 we could forward
// to other peers (future multi-hop). For now, reply not found.
@@ -918,8 +963,12 @@ async fn main() -> anyhow::Result<()> {
let hello_fp = match tokio::time::timeout(
std::time::Duration::from_secs(5),
transport.recv_signal(),
).await {
Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello { tls_fingerprint }))) => tls_fingerprint,
)
.await
{
Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello {
tls_fingerprint,
}))) => tls_fingerprint,
_ => {
warn!(%addr, "federation: no hello received, closing");
return;
@@ -964,7 +1013,10 @@ async fn main() -> anyhow::Result<()> {
}
}
}
_ => { warn!(%addr, "signal: expected AuthToken"); return; }
_ => {
warn!(%addr, "signal: expected AuthToken");
return;
}
}
} else {
None
@@ -974,15 +1026,22 @@ async fn main() -> anyhow::Result<()> {
let (client_fp, client_alias) = match tokio::time::timeout(
std::time::Duration::from_secs(10),
transport.recv_signal(),
).await {
Ok(Ok(Some(SignalMessage::RegisterPresence { identity_pub, signature: _, alias }))) => {
)
.await
{
Ok(Ok(Some(SignalMessage::RegisterPresence {
identity_pub,
signature: _,
alias,
}))) => {
// Compute fingerprint: SHA-256(Ed25519 pub key)[:16], same as Fingerprint type
let fp = {
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let hash = Sha256::digest(&identity_pub);
let fingerprint = wzp_crypto::Fingerprint([
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6],
hash[7], hash[8], hash[9], hash[10], hash[11], hash[12], hash[13],
hash[14], hash[15],
]);
fingerprint.to_string()
};
@@ -1006,13 +1065,15 @@ async fn main() -> anyhow::Result<()> {
}
// Send ack
let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck {
success: true,
error: None,
relay_build: Some(BUILD_GIT_HASH.to_string()),
relay_region: relay_region.clone(),
available_relays: relay_peers_for_ack.clone(),
}).await;
let _ = transport
.send_signal(&SignalMessage::RegisterPresenceAck {
success: true,
error: None,
relay_build: Some(BUILD_GIT_HASH.to_string()),
relay_region: relay_region.clone(),
available_relays: relay_peers_for_ack.clone(),
})
.await;
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
@@ -1086,10 +1147,12 @@ async fn main() -> anyhow::Result<()> {
if !forwarded {
info!(%addr, target = %target_fp, "call target not online (no federation route)");
let _ = transport.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
}).await;
let _ = transport
.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
})
.await;
continue;
}
@@ -1128,9 +1191,11 @@ async fn main() -> anyhow::Result<()> {
// Send ringing to caller immediately
// so the UI shows feedback while the
// federated delivery is in flight.
let _ = transport.send_signal(&SignalMessage::CallRinging {
call_id: call_id.clone(),
}).await;
let _ = transport
.send_signal(&SignalMessage::CallRinging {
call_id: call_id.clone(),
})
.await;
continue;
}
@@ -1141,10 +1206,23 @@ async fn main() -> anyhow::Result<()> {
// injected later into the callee's CallSetup.
{
let mut reg = call_registry.lock().await;
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone());
reg.set_caller_reflexive_addr(&call_id, caller_addr_for_registry);
reg.set_caller_local_addrs(&call_id, caller_local_for_registry);
reg.set_caller_mapped_addr(&call_id, caller_mapped_for_registry);
reg.create_call(
call_id.clone(),
client_fp.clone(),
target_fp.clone(),
);
reg.set_caller_reflexive_addr(
&call_id,
caller_addr_for_registry,
);
reg.set_caller_local_addrs(
&call_id,
caller_local_for_registry,
);
reg.set_caller_mapped_addr(
&call_id,
caller_mapped_for_registry,
);
}
// Forward offer to callee
@@ -1156,9 +1234,11 @@ async fn main() -> anyhow::Result<()> {
// Send ringing to caller
drop(hub);
let _ = transport.send_signal(&SignalMessage::CallRinging {
call_id: call_id.clone(),
}).await;
let _ = transport
.send_signal(&SignalMessage::CallRinging {
call_id: call_id.clone(),
})
.await;
}
SignalMessage::DirectCallAnswer {
@@ -1186,7 +1266,10 @@ async fn main() -> anyhow::Result<()> {
let reg = call_registry.lock().await;
match reg.get(&call_id) {
Some(c) => (
Some(reg.peer_fingerprint(&call_id, &client_fp).map(|s| s.to_string())),
Some(
reg.peer_fingerprint(&call_id, &client_fp)
.map(|s| s.to_string()),
),
c.peer_relay_fp.clone(),
),
None => (None, None),
@@ -1213,20 +1296,29 @@ async fn main() -> anyhow::Result<()> {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
};
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(hangup),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
let forward =
SignalMessage::FederatedSignalForward {
inner: Box::new(hangup),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm
.send_signal_to_peer(origin_fp, &forward)
.await
{
warn!(%call_id, %origin_fp, error = %e, "cross-relay reject forward failed");
}
}
} else {
let hub = signal_hub.lock().await;
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
}).await;
let _ = hub
.send_to(
&peer_fp,
&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
},
)
.await;
}
} else {
// Accept — create private room + stash the
@@ -1236,18 +1328,36 @@ async fn main() -> anyhow::Result<()> {
// BOTH parties' addrs so we can cross-wire
// peer_direct_addr on the CallSetups below.
let room = format!("call-{call_id}");
let (caller_addr, callee_addr, caller_local, callee_local, caller_mapped, callee_mapped) = {
let (
caller_addr,
callee_addr,
caller_local,
callee_local,
caller_mapped,
callee_mapped,
) = {
let mut reg = call_registry.lock().await;
reg.set_active(&call_id, mode, room.clone());
reg.set_callee_reflexive_addr(&call_id, callee_addr_for_registry);
reg.set_callee_local_addrs(&call_id, callee_local_for_registry.clone());
reg.set_callee_mapped_addr(&call_id, callee_mapped_for_registry);
reg.set_callee_reflexive_addr(
&call_id,
callee_addr_for_registry,
);
reg.set_callee_local_addrs(
&call_id,
callee_local_for_registry.clone(),
);
reg.set_callee_mapped_addr(
&call_id,
callee_mapped_for_registry,
);
let call = reg.get(&call_id);
(
call.and_then(|c| c.caller_reflexive_addr.clone()),
call.and_then(|c| c.callee_reflexive_addr.clone()),
call.map(|c| c.caller_local_addrs.clone()).unwrap_or_default(),
call.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
call.map(|c| c.caller_local_addrs.clone())
.unwrap_or_default(),
call.map(|c| c.callee_local_addrs.clone())
.unwrap_or_default(),
call.and_then(|c| c.caller_mapped_addr.clone()),
call.and_then(|c| c.callee_mapped_addr.clone()),
)
@@ -1278,11 +1388,15 @@ async fn main() -> anyhow::Result<()> {
// CallSetup (to our callee) with
// peer_direct_addr = caller_addr.
if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
let forward =
SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm
.send_signal_to_peer(origin_fp, &forward)
.await
{
warn!(
%call_id,
%origin_fp,
@@ -1301,7 +1415,8 @@ async fn main() -> anyhow::Result<()> {
peer_mapped_addr: caller_mapped.clone(),
};
let hub = signal_hub.lock().await;
let _ = hub.send_to(&client_fp, &setup_for_callee).await;
let _ =
hub.send_to(&client_fp, &setup_for_callee).await;
} else {
// Local call (existing Phase 3 path).
// Forward answer to caller
@@ -1331,7 +1446,8 @@ async fn main() -> anyhow::Result<()> {
};
let hub = signal_hub.lock().await;
let _ = hub.send_to(&peer_fp, &setup_for_caller).await;
let _ = hub.send_to(&client_fp, &setup_for_callee).await;
let _ =
hub.send_to(&client_fp, &setup_for_callee).await;
}
}
}
@@ -1346,21 +1462,31 @@ async fn main() -> anyhow::Result<()> {
if let Some(cid) = call_id {
// Targeted hangup: only the named call
reg.get(cid)
.map(|c| vec![(c.call_id.clone(), if c.caller_fingerprint == client_fp {
c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
})])
.map(|c| {
vec![(
c.call_id.clone(),
if c.caller_fingerprint == client_fp {
c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
},
)]
})
.unwrap_or_default()
} else {
// Legacy: end all calls for this user
reg.calls_for_fingerprint(&client_fp)
.iter()
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
}))
.map(|c| {
(
c.call_id.clone(),
if c.caller_fingerprint == client_fp {
c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
},
)
})
.collect::<Vec<_>>()
}
};
@@ -1396,11 +1522,15 @@ async fn main() -> anyhow::Result<()> {
if let Some(ref origin_fp) = peer_relay_fp {
// Cross-relay: wrap and forward
if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
let forward =
SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm
.send_signal_to_peer(origin_fp, &forward)
.await
{
warn!(
%call_id,
%origin_fp,
@@ -1436,11 +1566,15 @@ async fn main() -> anyhow::Result<()> {
if let Some(fp) = peer_fp {
if let Some(ref origin_fp) = peer_relay_fp {
if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
let forward =
SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm
.send_signal_to_peer(origin_fp, &forward)
.await
{
warn!(
%call_id,
%origin_fp,
@@ -1458,12 +1592,12 @@ async fn main() -> anyhow::Result<()> {
// Hard NAT: forward HardNatProbe + HardNatBirthdayStart
// to call peer (same pattern as CandidateUpdate).
SignalMessage::HardNatBirthdayStart { ref call_id, .. } |
SignalMessage::HardNatProbe { ref call_id, .. } |
SignalMessage::UpgradeProposal { ref call_id, .. } |
SignalMessage::UpgradeResponse { ref call_id, .. } |
SignalMessage::UpgradeConfirm { ref call_id, .. } |
SignalMessage::QualityCapability { ref call_id, .. } => {
SignalMessage::HardNatBirthdayStart { ref call_id, .. }
| SignalMessage::HardNatProbe { ref call_id, .. }
| SignalMessage::UpgradeProposal { ref call_id, .. }
| SignalMessage::UpgradeResponse { ref call_id, .. }
| SignalMessage::UpgradeConfirm { ref call_id, .. }
| SignalMessage::QualityCapability { ref call_id, .. } => {
let (peer_fp, peer_relay_fp) = {
let reg = call_registry.lock().await;
match reg.get(call_id) {
@@ -1479,11 +1613,14 @@ async fn main() -> anyhow::Result<()> {
if let Some(fp) = peer_fp {
if let Some(ref origin_fp) = peer_relay_fp {
if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
let _ = fm.send_signal_to_peer(origin_fp, &forward).await;
let forward =
SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
let _ = fm
.send_signal_to_peer(origin_fp, &forward)
.await;
}
} else {
let hub = signal_hub.lock().await;
@@ -1493,7 +1630,9 @@ async fn main() -> anyhow::Result<()> {
}
SignalMessage::Ping { timestamp_ms } => {
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
let _ = transport
.send_signal(&SignalMessage::Pong { timestamp_ms })
.await;
}
// QUIC-native NAT reflection ("STUN for QUIC").
@@ -1510,11 +1649,12 @@ async fn main() -> anyhow::Result<()> {
// reaches this match arm.
SignalMessage::Reflect => {
let observed_addr = addr.to_string();
if let Err(e) = transport.send_signal(
&SignalMessage::ReflectResponse {
if let Err(e) = transport
.send_signal(&SignalMessage::ReflectResponse {
observed_addr: observed_addr.clone(),
},
).await {
})
.await
{
warn!(%addr, error = %e, "reflect: failed to send response");
} else {
debug!(%addr, %observed_addr, "reflect: responded");
@@ -1552,19 +1692,29 @@ async fn main() -> anyhow::Result<()> {
let reg = call_registry.lock().await;
reg.calls_for_fingerprint(&client_fp)
.iter()
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
}))
.map(|c| {
(
c.call_id.clone(),
if c.caller_fingerprint == client_fp {
c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
},
)
})
.collect::<Vec<_>>()
};
for (call_id, peer_fp) in &active_calls {
let hub = signal_hub.lock().await;
let _ = hub.send_to(peer_fp, &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
}).await;
let _ = hub
.send_to(
peer_fp,
&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
},
)
.await;
drop(hub);
let mut reg = call_registry.lock().await;
reg.end_call(call_id);
@@ -1632,22 +1782,20 @@ async fn main() -> anyhow::Result<()> {
// Crypto handshake: verify client identity + negotiate quality profile
let handshake_start = std::time::Instant::now();
let (_crypto_session, _chosen_profile, caller_fp, caller_alias) = match wzp_relay::handshake::accept_handshake(
&*transport,
&relay_seed_bytes,
).await {
Ok(result) => {
let elapsed = handshake_start.elapsed().as_secs_f64();
metrics.handshake_duration.observe(elapsed);
info!(%addr, elapsed_ms = %(elapsed * 1000.0), "crypto handshake complete");
result
}
Err(e) => {
error!(%addr, "handshake failed: {e}");
close_transport(&*transport, "cleanup").await;
return;
}
};
let (_crypto_session, _chosen_profile, caller_fp, caller_alias) =
match wzp_relay::handshake::accept_handshake(&*transport, &relay_seed_bytes).await {
Ok(result) => {
let elapsed = handshake_start.elapsed().as_secs_f64();
metrics.handshake_duration.observe(elapsed);
info!(%addr, elapsed_ms = %(elapsed * 1000.0), "crypto handshake complete");
result
}
Err(e) => {
error!(%addr, "handshake failed: {e}");
close_transport(&*transport, "cleanup").await;
return;
}
};
// Use the caller's identity fingerprint from the handshake
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
@@ -1704,8 +1852,18 @@ async fn main() -> anyhow::Result<()> {
}
});
let up = tokio::spawn(run_upstream(transport.clone(), remote.clone(), up_pipe, stats.clone()));
let dn = tokio::spawn(run_downstream(transport.clone(), remote.clone(), dn_pipe, stats));
let up = tokio::spawn(run_upstream(
transport.clone(),
remote.clone(),
up_pipe,
stats.clone(),
));
let dn = tokio::spawn(run_downstream(
transport.clone(),
remote.clone(),
dn_pipe,
stats,
));
tokio::select! { _ = up => {} _ = dn => {} }
stats_handle.abort();
@@ -1752,7 +1910,11 @@ async fn main() -> anyhow::Result<()> {
// Merge federated participants into RoomUpdate if this is a global room
let merged_update = if let Some(ref fm) = federation_mgr {
if fm.is_global_room(&room_name) {
if let SignalMessage::RoomUpdate { count: _, participants: mut local_parts } = update {
if let SignalMessage::RoomUpdate {
count: _,
participants: mut local_parts,
} = update
{
let remote = fm.get_remote_participants(&room_name).await;
local_parts.extend(remote);
// Deduplicate by fingerprint
@@ -1762,17 +1924,27 @@ async fn main() -> anyhow::Result<()> {
count: local_parts.len() as u32,
participants: local_parts,
}
} else { update }
} else { update }
} else { update };
} else {
update
}
} else {
update
}
} else {
update
};
if let Some(ref tap) = debug_tap {
if tap.matches(&room_name) {
tap.log_signal(&room_name, &merged_update);
tap.log_event(&room_name, "join", &format!(
"participant={id} addr={addr} alias={}",
caller_alias.as_deref().unwrap_or("?")
));
tap.log_event(
&room_name,
"join",
&format!(
"participant={id} addr={addr} alias={}",
caller_alias.as_deref().unwrap_or("?")
),
);
}
}
room::broadcast_signal(&senders, &merged_update).await;
@@ -1789,10 +1961,8 @@ async fn main() -> anyhow::Result<()> {
}
};
let session_id_str: String = session_id
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let session_id_str: String =
session_id.iter().map(|b| format!("{b:02x}")).collect();
// Set up federation media channel if this is a global room
let (federation_tx, federation_room_hash) = if let Some(ref fm) = federation_mgr {
let is_global = fm.is_global_room(&room_name);
@@ -1823,7 +1993,8 @@ async fn main() -> anyhow::Result<()> {
debug_tap,
federation_tx,
federation_room_hash,
).await;
)
.await;
// Participant disconnected — clean up presence + per-session metrics
if let Some(ref fp) = authenticated_fp {

View File

@@ -4,8 +4,8 @@ use prometheus::{
Encoder, GaugeVec, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec,
Opts, Registry, TextEncoder,
};
use wzp_proto::packet::QualityReport;
use std::sync::Arc;
use wzp_proto::packet::QualityReport;
/// All relay-level Prometheus metrics.
#[derive(Clone)]
@@ -40,21 +40,23 @@ impl RelayMetrics {
pub fn new() -> Self {
let registry = Registry::new();
let active_sessions = IntGauge::with_opts(
Opts::new("wzp_relay_active_sessions", "Current active sessions"),
)
let active_sessions = IntGauge::with_opts(Opts::new(
"wzp_relay_active_sessions",
"Current active sessions",
))
.expect("metric");
let active_rooms = IntGauge::with_opts(
Opts::new("wzp_relay_active_rooms", "Current active rooms"),
)
let active_rooms =
IntGauge::with_opts(Opts::new("wzp_relay_active_rooms", "Current active rooms"))
.expect("metric");
let packets_forwarded = IntCounter::with_opts(Opts::new(
"wzp_relay_packets_forwarded_total",
"Total packets forwarded",
))
.expect("metric");
let packets_forwarded = IntCounter::with_opts(
Opts::new("wzp_relay_packets_forwarded_total", "Total packets forwarded"),
)
.expect("metric");
let bytes_forwarded = IntCounter::with_opts(
Opts::new("wzp_relay_bytes_forwarded_total", "Total bytes forwarded"),
)
let bytes_forwarded = IntCounter::with_opts(Opts::new(
"wzp_relay_bytes_forwarded_total",
"Total bytes forwarded",
))
.expect("metric");
let auth_attempts = IntCounterVec::new(
Opts::new("wzp_relay_auth_attempts_total", "Auth validation attempts"),
@@ -66,31 +68,51 @@ impl RelayMetrics {
"wzp_relay_handshake_duration_seconds",
"Crypto handshake time",
)
.buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5]),
.buckets(vec![
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5,
]),
)
.expect("metric");
let federation_peer_status = IntGaugeVec::new(
Opts::new("wzp_federation_peer_status", "Peer connection status (0=disconnected, 1=connected)"),
Opts::new(
"wzp_federation_peer_status",
"Peer connection status (0=disconnected, 1=connected)",
),
&["peer"],
).expect("metric");
)
.expect("metric");
let federation_peer_rtt_ms = GaugeVec::new(
Opts::new("wzp_federation_peer_rtt_ms", "QUIC RTT to federated peer in milliseconds"),
Opts::new(
"wzp_federation_peer_rtt_ms",
"QUIC RTT to federated peer in milliseconds",
),
&["peer"],
).expect("metric");
)
.expect("metric");
let federation_packets_forwarded = IntCounterVec::new(
Opts::new("wzp_federation_packets_forwarded_total", "Packets forwarded to/from federated peers"),
Opts::new(
"wzp_federation_packets_forwarded_total",
"Packets forwarded to/from federated peers",
),
&["peer", "direction"],
).expect("metric");
let federation_packets_deduped = IntCounter::with_opts(
Opts::new("wzp_federation_packets_deduped_total", "Duplicate federation packets dropped"),
).expect("metric");
let federation_packets_rate_limited = IntCounter::with_opts(
Opts::new("wzp_federation_packets_rate_limited_total", "Federation packets dropped by rate limiter"),
).expect("metric");
let federation_active_rooms = IntGauge::with_opts(
Opts::new("wzp_federation_active_rooms", "Number of federated rooms currently active"),
).expect("metric");
)
.expect("metric");
let federation_packets_deduped = IntCounter::with_opts(Opts::new(
"wzp_federation_packets_deduped_total",
"Duplicate federation packets dropped",
))
.expect("metric");
let federation_packets_rate_limited = IntCounter::with_opts(Opts::new(
"wzp_federation_packets_rate_limited_total",
"Federation packets dropped by rate limiter",
))
.expect("metric");
let federation_active_rooms = IntGauge::with_opts(Opts::new(
"wzp_federation_active_rooms",
"Number of federated rooms currently active",
))
.expect("metric");
let session_buffer_depth = IntGaugeVec::new(
Opts::new(
@@ -109,10 +131,7 @@ impl RelayMetrics {
)
.expect("metric");
let session_rtt_ms = GaugeVec::new(
Opts::new(
"wzp_relay_session_rtt_ms",
"Round-trip time per session",
),
Opts::new("wzp_relay_session_rtt_ms", "Round-trip time per session"),
&["session_id"],
)
.expect("metric");
@@ -150,25 +169,63 @@ impl RelayMetrics {
)
.expect("metric");
registry.register(Box::new(active_sessions.clone())).expect("register");
registry.register(Box::new(active_rooms.clone())).expect("register");
registry.register(Box::new(packets_forwarded.clone())).expect("register");
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
registry.register(Box::new(auth_attempts.clone())).expect("register");
registry.register(Box::new(handshake_duration.clone())).expect("register");
registry.register(Box::new(federation_peer_status.clone())).expect("register");
registry.register(Box::new(federation_peer_rtt_ms.clone())).expect("register");
registry.register(Box::new(federation_packets_forwarded.clone())).expect("register");
registry.register(Box::new(federation_packets_deduped.clone())).expect("register");
registry.register(Box::new(federation_packets_rate_limited.clone())).expect("register");
registry.register(Box::new(federation_active_rooms.clone())).expect("register");
registry.register(Box::new(session_buffer_depth.clone())).expect("register");
registry.register(Box::new(session_loss_pct.clone())).expect("register");
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
registry.register(Box::new(session_underruns.clone())).expect("register");
registry.register(Box::new(session_overruns.clone())).expect("register");
registry.register(Box::new(session_dred_reconstructions.clone())).expect("register");
registry.register(Box::new(session_classical_plc.clone())).expect("register");
registry
.register(Box::new(active_sessions.clone()))
.expect("register");
registry
.register(Box::new(active_rooms.clone()))
.expect("register");
registry
.register(Box::new(packets_forwarded.clone()))
.expect("register");
registry
.register(Box::new(bytes_forwarded.clone()))
.expect("register");
registry
.register(Box::new(auth_attempts.clone()))
.expect("register");
registry
.register(Box::new(handshake_duration.clone()))
.expect("register");
registry
.register(Box::new(federation_peer_status.clone()))
.expect("register");
registry
.register(Box::new(federation_peer_rtt_ms.clone()))
.expect("register");
registry
.register(Box::new(federation_packets_forwarded.clone()))
.expect("register");
registry
.register(Box::new(federation_packets_deduped.clone()))
.expect("register");
registry
.register(Box::new(federation_packets_rate_limited.clone()))
.expect("register");
registry
.register(Box::new(federation_active_rooms.clone()))
.expect("register");
registry
.register(Box::new(session_buffer_depth.clone()))
.expect("register");
registry
.register(Box::new(session_loss_pct.clone()))
.expect("register");
registry
.register(Box::new(session_rtt_ms.clone()))
.expect("register");
registry
.register(Box::new(session_underruns.clone()))
.expect("register");
registry
.register(Box::new(session_overruns.clone()))
.expect("register");
registry
.register(Box::new(session_dred_reconstructions.clone()))
.expect("register");
registry
.register(Box::new(session_classical_plc.clone()))
.expect("register");
Self {
active_sessions,
@@ -230,10 +287,7 @@ impl RelayMetrics {
.with_label_values(&[session_id])
.inc_by(underruns - cur_underruns as u64);
}
let cur_overruns = self
.session_overruns
.with_label_values(&[session_id])
.get();
let cur_overruns = self.session_overruns.with_label_values(&[session_id]).get();
if overruns > cur_overruns as u64 {
self.session_overruns
.with_label_values(&[session_id])
@@ -284,7 +338,9 @@ impl RelayMetrics {
let _ = self
.session_dred_reconstructions
.remove_label_values(&[session_id]);
let _ = self.session_classical_plc.remove_label_values(&[session_id]);
let _ = self
.session_classical_plc
.remove_label_values(&[session_id]);
}
/// Get a reference to the underlying Prometheus registry.
@@ -298,7 +354,9 @@ impl RelayMetrics {
let encoder = TextEncoder::new();
let metric_families = self.registry.gather();
let mut buffer = Vec::new();
encoder.encode(&metric_families, &mut buffer).expect("encode");
encoder
.encode(&metric_families, &mut buffer)
.expect("encode");
String::from_utf8(buffer).expect("utf8")
}
}
@@ -310,7 +368,7 @@ pub async fn serve_metrics(
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
route_resolver: Option<Arc<crate::route::RouteResolver>>,
) {
use axum::{extract::Path, routing::get, Router};
use axum::{Router, extract::Path, routing::get};
let metrics_clone = metrics.clone();
let presence_all = presence.clone();
@@ -454,8 +512,8 @@ mod tests {
fn session_quality_update() {
let m = RelayMetrics::new();
let report = QualityReport {
loss_pct: 128, // ~50%
rtt_4ms: 25, // 100ms
loss_pct: 128, // ~50%
rtt_4ms: 25, // 100ms
jitter_ms: 10,
bitrate_cap_kbps: 200,
};

View File

@@ -11,11 +11,11 @@
use tracing::{debug, info};
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::QualityProfile;
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::packet::{MediaHeader, MediaPacket};
use wzp_proto::quality::AdaptiveQualityController;
use wzp_proto::traits::{FecDecoder, FecEncoder, QualityController};
use wzp_proto::QualityProfile;
/// Configuration for a relay pipeline instance.
pub struct PipelineConfig {
@@ -51,7 +51,7 @@ pub struct RelayPipeline {
/// Current quality profile.
profile: QualityProfile,
/// Outbound sequence counter.
out_seq: u16,
out_seq: u32,
/// Packets processed count.
stats: PipelineStats,
}
@@ -110,15 +110,15 @@ impl RelayPipeline {
// Feed packet into FEC decoder
let header = &packet.header;
let _ = self.fec_decoder.add_symbol(
header.fec_block,
header.fec_symbol,
header.is_repair,
(header.fec_block & 0xFF) as u8,
(header.fec_block >> 8) as u8,
header.is_repair(),
&packet.payload,
);
// Try to decode the FEC block
let mut output = Vec::new();
if let Ok(Some(frames)) = self.fec_decoder.try_decode(header.fec_block) {
if let Ok(Some(frames)) = self.fec_decoder.try_decode((header.fec_block & 0xFF) as u8) {
debug!(
block = header.fec_block,
frames = frames.len(),
@@ -128,22 +128,21 @@ impl RelayPipeline {
for (i, frame) in frames.into_iter().enumerate() {
let reconstructed = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: header.codec_id,
has_quality_report: false,
fec_ratio_encoded: header.fec_ratio_encoded,
stream_id: 0,
fec_ratio: header.fec_ratio,
// Reconstruct seq from block + symbol index
seq: (header.fec_block as u16)
.wrapping_mul(self.profile.frames_per_block as u16)
.wrapping_add(i as u16),
timestamp: header
.timestamp
.wrapping_add((i as u32) * (header.codec_id.frame_duration_ms() as u32)),
fec_block: header.fec_block,
fec_symbol: i as u8,
reserved: 0,
csrc_count: 0,
seq: (header.fec_block as u32)
.wrapping_mul(self.profile.frames_per_block as u32)
.wrapping_add(i as u32),
timestamp: header.timestamp.wrapping_add(
(i as u32) * (header.codec_id.frame_duration_ms() as u32),
),
fec_block: u16::from((header.fec_block & 0xFF) as u8)
| (u16::from(i as u8) << 8),
},
payload: bytes::Bytes::from(frame),
quality_report: None,
@@ -191,19 +190,16 @@ impl RelayPipeline {
for (sym_idx, repair_data) in repairs {
let repair_packet = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: true,
version: 2,
flags: MediaHeader::FLAG_REPAIR,
media_type: wzp_proto::MediaType::Audio,
codec_id: packet.header.codec_id,
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.out_seq,
timestamp: packet.header.timestamp,
fec_block: self.fec_encoder.current_block_id(),
fec_symbol: sym_idx,
reserved: 0,
csrc_count: 0,
fec_block: u16::from(self.fec_encoder.current_block_id())
| (u16::from(sym_idx) << 8),
},
payload: bytes::Bytes::from(repair_data),
quality_report: None,
@@ -232,23 +228,21 @@ impl RelayPipeline {
#[cfg(test)]
mod tests {
use super::*;
use wzp_proto::CodecId;
use bytes::Bytes;
use wzp_proto::CodecId;
fn make_media_packet(seq: u16, block: u8, symbol: u8) -> MediaPacket {
fn make_media_packet(seq: u32, block: u8, symbol: u8) -> MediaPacket {
MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: wzp_proto::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,
fec_block: block,
fec_symbol: symbol,
reserved: 0,
csrc_count: 0,
timestamp: seq * 20,
fec_block: u16::from(block) | (u16::from(symbol) << 8),
},
payload: Bytes::from(vec![seq as u8; 60]),
quality_report: None,
@@ -283,7 +277,7 @@ mod tests {
// Feed 5 packets (one full block)
let mut total_out = 0;
for i in 0..5u16 {
for i in 0..5u32 {
let pkt = make_media_packet(i, 0, i as u8);
let out = pipeline.prepare_outbound(pkt);
total_out += out.len();

View File

@@ -74,13 +74,21 @@ impl PresenceRegistry {
}
/// Register a fingerprint as locally connected (called after auth + handshake).
pub fn register_local(&mut self, fingerprint: &str, alias: Option<String>, room: Option<String>) {
self.local.insert(fingerprint.to_string(), LocalPresence {
fingerprint: fingerprint.to_string(),
alias,
connected_at: Instant::now(),
room,
});
pub fn register_local(
&mut self,
fingerprint: &str,
alias: Option<String>,
room: Option<String>,
) {
self.local.insert(
fingerprint.to_string(),
LocalPresence {
fingerprint: fingerprint.to_string(),
alias,
connected_at: Instant::now(),
room,
},
);
}
/// Unregister a locally connected fingerprint (called on disconnect).
@@ -98,11 +106,14 @@ impl PresenceRegistry {
// Insert new remote entries
for fp in &fingerprints {
self.remote.insert(fp.clone(), RemotePresence {
fingerprint: fp.clone(),
relay_addr: addr,
last_seen: now,
});
self.remote.insert(
fp.clone(),
RemotePresence {
fingerprint: fp.clone(),
relay_addr: addr,
last_seen: now,
},
);
}
// Update the peer record
@@ -156,7 +167,8 @@ impl PresenceRegistry {
self.remote.retain(|_, rp| rp.last_seen > cutoff);
// Expire peer relay records and their fingerprint sets
let stale_peers: Vec<SocketAddr> = self.peers
let stale_peers: Vec<SocketAddr> = self
.peers
.iter()
.filter(|(_, p)| p.last_update <= cutoff)
.map(|(addr, _)| *addr)
@@ -280,13 +292,15 @@ mod tests {
let all = reg.all_known();
assert_eq!(all.len(), 2);
let local_entries: Vec<_> = all.iter()
let local_entries: Vec<_> = all
.iter()
.filter(|(_, loc)| *loc == PresenceLocation::Local)
.collect();
assert_eq!(local_entries.len(), 1);
assert_eq!(local_entries[0].0, "local1");
let remote_entries: Vec<_> = all.iter()
let remote_entries: Vec<_> = all
.iter()
.filter(|(_, loc)| matches!(loc, PresenceLocation::Remote(_)))
.collect();
assert_eq!(remote_entries.len(), 1);

View File

@@ -43,8 +43,7 @@ impl ProbeMetrics {
/// Register probe metrics with the given `target` label value.
pub fn register(target: &str, registry: &Registry) -> Self {
let rtt_ms = Gauge::with_opts(
Opts::new("wzp_probe_rtt_ms", "RTT to peer relay in ms")
.const_label("target", target),
Opts::new("wzp_probe_rtt_ms", "RTT to peer relay in ms").const_label("target", target),
)
.expect("probe metric");
@@ -66,9 +65,15 @@ impl ProbeMetrics {
)
.expect("probe metric");
registry.register(Box::new(rtt_ms.clone())).expect("register");
registry.register(Box::new(loss_pct.clone())).expect("register");
registry.register(Box::new(jitter_ms.clone())).expect("register");
registry
.register(Box::new(rtt_ms.clone()))
.expect("register");
registry
.register(Box::new(loss_pct.clone()))
.expect("register");
registry
.register(Box::new(jitter_ms.clone()))
.expect("register");
registry.register(Box::new(up.clone())).expect("register");
Self {
@@ -168,7 +173,11 @@ impl ProbeRunner {
) -> Self {
let target_str = config.target.to_string();
let metrics = ProbeMetrics::register(&target_str, registry);
Self { config, metrics, presence }
Self {
config,
metrics,
presence,
}
}
/// Run the probe forever. This function never returns under normal operation.
@@ -198,13 +207,8 @@ impl ProbeRunner {
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(
&endpoint,
self.config.target,
"_probe",
client_cfg,
)
.await?;
let conn =
wzp_transport::connect(&endpoint, self.config.target, "_probe", client_cfg).await?;
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
self.metrics.up.set(1);
@@ -237,11 +241,15 @@ impl ProbeRunner {
loss_gauge.set(w.loss_pct());
jitter_gauge.set(w.jitter_ms());
}
Ok(Some(SignalMessage::PresenceUpdate { fingerprints, relay_addr })) => {
Ok(Some(SignalMessage::PresenceUpdate {
fingerprints,
relay_addr,
})) => {
if let Some(ref reg) = recv_presence {
// Parse the relay_addr; fall back to the connection target
let addr = relay_addr.parse().unwrap_or(recv_target);
let fps: std::collections::HashSet<String> = fingerprints.into_iter().collect();
let fps: std::collections::HashSet<String> =
fingerprints.into_iter().collect();
let mut r = reg.lock().await;
r.update_peer(addr, fps);
}
@@ -374,10 +382,7 @@ pub fn mesh_summary(registry: &Registry) -> String {
let name = family.get_name();
for metric in family.get_metric() {
// Find the "target" label
let target_label = metric
.get_label()
.iter()
.find(|l| l.get_name() == "target");
let target_label = metric.get_label().iter().find(|l| l.get_name() == "target");
let target = match target_label {
Some(l) => l.get_value().to_string(),
None => continue,
@@ -420,10 +425,7 @@ pub fn mesh_summary(registry: &Registry) -> String {
/// Handle an incoming Ping signal by replying with a Pong carrying the same timestamp.
/// Returns true if the message was a Ping and was handled, false otherwise.
pub async fn handle_ping(
transport: &wzp_transport::QuinnTransport,
msg: &SignalMessage,
) -> bool {
pub async fn handle_ping(transport: &wzp_transport::QuinnTransport, msg: &SignalMessage) -> bool {
if let SignalMessage::Ping { timestamp_ms } = msg {
if let Err(e) = transport
.send_signal(&SignalMessage::Pong {
@@ -456,9 +458,18 @@ mod tests {
encoder.encode(&families, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("wzp_probe_rtt_ms"), "missing wzp_probe_rtt_ms");
assert!(output.contains("wzp_probe_loss_pct"), "missing wzp_probe_loss_pct");
assert!(output.contains("wzp_probe_jitter_ms"), "missing wzp_probe_jitter_ms");
assert!(
output.contains("wzp_probe_rtt_ms"),
"missing wzp_probe_rtt_ms"
);
assert!(
output.contains("wzp_probe_loss_pct"),
"missing wzp_probe_loss_pct"
);
assert!(
output.contains("wzp_probe_jitter_ms"),
"missing wzp_probe_jitter_ms"
);
assert!(output.contains("wzp_probe_up"), "missing wzp_probe_up");
assert!(
output.contains("target=\"127.0.0.1:4433\""),

View File

@@ -40,10 +40,7 @@ impl RelayLink {
/// should skip normal client auth/handshake for relay-SNI connections.
pub async fn connect(target: SocketAddr) -> Result<Self, anyhow::Error> {
// Create a client-only endpoint on an OS-assigned port.
let endpoint = wzp_transport::create_endpoint(
"0.0.0.0:0".parse().unwrap(),
None,
)?;
let endpoint = wzp_transport::create_endpoint("0.0.0.0:0".parse().unwrap(), None)?;
let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(&endpoint, target, "_relay", client_cfg).await?;
@@ -457,17 +454,15 @@ mod tests {
let pkt = MediaPacket {
header: wzp_proto::packet::MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: wzp_proto::CodecId::Opus16k,
has_quality_report: false,
fec_ratio_encoded: 0,
stream_id: 0,
fec_ratio: 0,
seq: 1,
timestamp: 100,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: bytes::Bytes::from_static(b"test"),
quality_report: None,

View File

@@ -4,18 +4,18 @@
//! the relay forwards it to all other participants in the room (SFU model).
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use bytes::Bytes;
use dashmap::DashMap;
use tracing::{error, info, warn};
use wzp_proto::MediaTransport;
use wzp_proto::packet::TrunkFrame;
use wzp_proto::quality::{AdaptiveQualityController, Tier};
use wzp_proto::traits::QualityController;
use wzp_proto::MediaTransport;
use crate::metrics::RelayMetrics;
use crate::trunk::TrunkBatcher;
@@ -32,7 +32,14 @@ impl DebugTap {
self.room_filter == "*" || self.room_filter == room_name
}
pub fn log_packet(&self, room: &str, dir: &str, addr: &std::net::SocketAddr, pkt: &wzp_proto::MediaPacket, fan_out: usize) {
pub fn log_packet(
&self,
room: &str,
dir: &str,
addr: &std::net::SocketAddr,
pkt: &wzp_proto::MediaPacket,
fan_out: usize,
) {
let h = &pkt.header;
info!(
target: "debug_tap",
@@ -43,8 +50,7 @@ impl DebugTap {
codec = ?h.codec_id,
ts = h.timestamp,
fec_block = h.fec_block,
fec_sym = h.fec_symbol,
repair = h.is_repair,
repair = h.is_repair(),
len = pkt.payload.len(),
fan_out,
"TAP"
@@ -53,8 +59,12 @@ impl DebugTap {
pub fn log_signal(&self, room: &str, signal: &wzp_proto::SignalMessage) {
match signal {
wzp_proto::SignalMessage::RoomUpdate { count, participants } => {
let names: Vec<&str> = participants.iter()
wzp_proto::SignalMessage::RoomUpdate {
count,
participants,
} => {
let names: Vec<&str> = participants
.iter()
.map(|p| p.alias.as_deref().unwrap_or("?"))
.collect();
info!(
@@ -66,7 +76,10 @@ impl DebugTap {
"TAP SIGNAL"
);
}
wzp_proto::SignalMessage::QualityDirective { recommended_profile, reason } => {
wzp_proto::SignalMessage::QualityDirective {
recommended_profile,
reason,
} => {
info!(
target: "debug_tap",
room = %room,
@@ -119,7 +132,7 @@ pub struct TapStats {
pub out_pkts: u64,
pub seq_gaps: u64,
pub codecs_seen: std::collections::HashSet<wzp_proto::CodecId>,
last_seq: Option<u16>,
last_seq: Option<u32>,
}
impl TapStats {
@@ -225,17 +238,29 @@ impl ParticipantSender {
/// Send raw bytes to this participant.
pub async fn send_raw(&self, data: &[u8]) -> Result<(), String> {
match self {
ParticipantSender::WebSocket(tx) => {
tx.try_send(Bytes::copy_from_slice(data))
.map_err(|e| format!("ws send: {e}"))
}
ParticipantSender::WebSocket(tx) => tx
.try_send(Bytes::copy_from_slice(data))
.map_err(|e| format!("ws send: {e}")),
ParticipantSender::Quic(transport) => {
let pkt = wzp_proto::MediaPacket {
header: wzp_proto::packet::MediaHeader::default_pcm(),
header: wzp_proto::packet::MediaHeader {
version: 2,
flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: wzp_proto::CodecId::Opus24k,
stream_id: 0,
fec_ratio: 0,
seq: 0,
timestamp: 0,
fec_block: 0,
},
payload: Bytes::copy_from_slice(data),
quality_report: None,
};
transport.send_media(&pkt).await.map_err(|e| format!("quic send: {e}"))
transport
.send_media(&pkt)
.await
.map_err(|e| format!("quic send: {e}"))
}
}
}
@@ -301,13 +326,23 @@ impl Room {
) -> ParticipantId {
let id = next_id();
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
self.participants.push(Participant { id, _addr: addr, sender, fingerprint, alias });
self.participants.push(Participant {
id,
_addr: addr,
sender,
fingerprint,
alias,
});
id
}
fn remove(&mut self, id: ParticipantId) {
self.participants.retain(|p| p.id != id);
info!(room_size = self.participants.len(), participant = id, "left room");
info!(
room_size = self.participants.len(),
participant = id,
"left room"
);
}
fn others(&self, exclude_id: ParticipantId) -> Vec<ParticipantSender> {
@@ -387,7 +422,8 @@ impl RoomManager {
/// Grant a fingerprint access to a room.
pub fn allow(&self, room_name: &str, fingerprint: &str) {
if let Some(ref acl) = self.acl {
acl.lock().unwrap()
acl.lock()
.unwrap()
.entry(room_name.to_string())
.or_default()
.insert(fingerprint.to_string());
@@ -398,7 +434,7 @@ impl RoomManager {
/// Returns true if ACL is disabled (open mode) or the fingerprint is in the allow list.
pub fn is_authorized(&self, room_name: &str, fingerprint: Option<&str>) -> bool {
match (&self.acl, fingerprint) {
(None, _) => true, // no ACL = open
(None, _) => true, // no ACL = open
(Some(_), None) => false, // ACL enabled but no fingerprint
(Some(acl), Some(fp)) => {
let acl = acl.lock().unwrap();
@@ -419,14 +455,29 @@ impl RoomManager {
sender: ParticipantSender,
fingerprint: Option<&str>,
alias: Option<&str>,
) -> Result<(ParticipantId, wzp_proto::SignalMessage, Vec<ParticipantSender>), String> {
) -> Result<
(
ParticipantId,
wzp_proto::SignalMessage,
Vec<ParticipantSender>,
),
String,
> {
if !self.is_authorized(room_name, fingerprint) {
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
return Err("not authorized for this room".to_string());
}
let was_empty = self.rooms.get(room_name).map_or(true, |r| r.is_empty());
let mut room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
let mut room = self
.rooms
.entry(room_name.to_string())
.or_insert_with(Room::new);
let id = room.add(
addr,
sender,
fingerprint.map(|s| s.to_string()),
alias.map(|s| s.to_string()),
);
room.qualities.insert(id, ParticipantQuality::new());
let update = wzp_proto::SignalMessage::RoomUpdate {
count: room.len() as u32,
@@ -435,7 +486,9 @@ impl RoomManager {
let senders = room.all_senders();
drop(room); // release DashMap guard before event_tx send (not async, but good practice)
if was_empty {
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() });
let _ = self.event_tx.send(RoomEvent::LocalJoin {
room: room_name.to_string(),
});
}
Ok((id, update, senders))
}
@@ -448,7 +501,13 @@ impl RoomManager {
sender: tokio::sync::mpsc::Sender<Bytes>,
fingerprint: Option<&str>,
) -> Result<ParticipantId, String> {
let (id, _update, _senders) = self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint, None)?;
let (id, _update, _senders) = self.join(
room_name,
addr,
ParticipantSender::WebSocket(sender),
fingerprint,
None,
)?;
Ok(id)
}
@@ -458,23 +517,30 @@ impl RoomManager {
}
/// Get participant list for a room (fingerprint + alias).
pub fn local_participant_list(&self, room_name: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
self.rooms.get(room_name)
pub fn local_participant_list(
&self,
room_name: &str,
) -> Vec<wzp_proto::packet::RoomParticipant> {
self.rooms
.get(room_name)
.map(|room| room.participant_list())
.unwrap_or_default()
}
/// Get all senders for participants in a room (for federation inbound media delivery).
pub fn local_senders(&self, room_name: &str) -> Vec<ParticipantSender> {
self.rooms.get(room_name)
.map(|room| room.participants.iter()
.map(|p| p.sender.clone())
.collect())
self.rooms
.get(room_name)
.map(|room| room.participants.iter().map(|p| p.sender.clone()).collect())
.unwrap_or_default()
}
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
pub fn leave(&self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
pub fn leave(
&self,
room_name: &str,
participant_id: ParticipantId,
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
let result = {
if let Some(mut room) = self.rooms.get_mut(room_name) {
room.qualities.remove(&participant_id);
@@ -482,7 +548,9 @@ impl RoomManager {
if room.is_empty() {
drop(room); // release write guard before remove
self.rooms.remove(room_name);
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
let _ = self.event_tx.send(RoomEvent::LocalLeave {
room: room_name.to_string(),
});
info!(room = room_name, "room closed (empty)");
return None;
}
@@ -500,11 +568,7 @@ impl RoomManager {
}
/// Get senders for all OTHER participants in a room.
pub fn others(
&self,
room_name: &str,
participant_id: ParticipantId,
) -> Vec<ParticipantSender> {
pub fn others(&self, room_name: &str, participant_id: ParticipantId) -> Vec<ParticipantSender> {
self.rooms
.get(room_name)
.map(|r| r.others(participant_id))
@@ -523,7 +587,10 @@ impl RoomManager {
/// List all rooms with their sizes.
pub fn list(&self) -> Vec<(String, usize)> {
self.rooms.iter().map(|r| (r.key().clone(), r.len())).collect()
self.rooms
.iter()
.map(|r| (r.key().clone(), r.len()))
.collect()
}
/// Feed a quality report from a participant. If the room-wide weakest
@@ -537,7 +604,8 @@ impl RoomManager {
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
let mut room = self.rooms.get_mut(room_name)?;
let tier_changed = room.qualities
let tier_changed = room
.qualities
.get_mut(&participant_id)
.and_then(|pq| pq.observe(report))
.is_some();
@@ -639,7 +707,9 @@ impl TrunkedForwarder {
}
fn send_frame(&self, frame: &TrunkFrame) -> anyhow::Result<()> {
self.transport.send_trunk(frame).map_err(|e| anyhow::anyhow!(e))
self.transport
.send_trunk(frame)
.map_err(|e| anyhow::anyhow!(e))
}
}
@@ -667,12 +737,25 @@ pub async fn run_participant(
) {
if trunking_enabled {
run_participant_trunked(
room_mgr, room_name, participant_id, transport, metrics, session_id,
room_mgr,
room_name,
participant_id,
transport,
metrics,
session_id,
)
.await;
} else {
run_participant_plain(
room_mgr, room_name, participant_id, transport, metrics, session_id, debug_tap, federation_tx, federation_room_hash,
room_mgr,
room_name,
participant_id,
transport,
metrics,
session_id,
debug_tap,
federation_tx,
federation_room_hash,
)
.await;
}
@@ -822,7 +905,8 @@ async fn run_participant_plain(
let data = pkt.to_bytes();
let _ = fed_tx.try_send(FederationMediaOut {
room_name: room_name.clone(),
room_hash: federation_room_hash.unwrap_or_else(|| crate::federation::room_hash(&room_name)),
room_hash: federation_room_hash
.unwrap_or_else(|| crate::federation::room_hash(&room_name)),
data,
});
}
@@ -874,18 +958,24 @@ async fn run_participant_plain(
if let Some((update, senders)) = room_mgr.leave(&room_name, participant_id) {
if let Some(ref tap) = debug_tap {
if tap.matches(&room_name) {
tap.log_event(&room_name, "leave", &format!(
"participant={participant_id} addr={addr} forwarded={packets_forwarded}"
));
tap.log_event(
&room_name,
"leave",
&format!(
"participant={participant_id} addr={addr} forwarded={packets_forwarded}"
),
);
tap.log_signal(&room_name, &update);
}
}
broadcast_signal(&senders, &update).await;
} else if let Some(ref tap) = debug_tap {
if tap.matches(&room_name) {
tap.log_event(&room_name, "leave", &format!(
"participant={participant_id} addr={addr} (room closed)"
));
tap.log_event(
&room_name,
"leave",
&format!("participant={participant_id} addr={addr} (room closed)"),
);
}
}
}
@@ -1146,17 +1236,15 @@ mod tests {
fn make_test_packet(payload: &[u8]) -> wzp_proto::MediaPacket {
wzp_proto::MediaPacket {
header: wzp_proto::packet::MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: wzp_proto::CodecId::Opus16k,
has_quality_report: false,
fec_ratio_encoded: 0,
stream_id: 0,
fec_ratio: 0,
seq: 1,
timestamp: 100,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(payload.to_vec()),
quality_report: None,
@@ -1266,6 +1354,10 @@ mod tests {
let participants = vec![good, bad];
let weakest = weakest_tier(participants.iter());
assert_ne!(weakest, Tier::Good, "weakest should not be Good when one participant is bad");
assert_ne!(
weakest,
Tier::Good,
"weakest should not be Good when one participant is bad"
);
}
}

View File

@@ -97,14 +97,13 @@ impl RouteResolver {
}
/// Build a JSON-serializable route response for the HTTP API.
pub fn route_json(
&self,
fingerprint: &str,
route: &Route,
) -> serde_json::Value {
pub fn route_json(&self, fingerprint: &str, route: &Route) -> serde_json::Value {
let (route_type, relay_chain) = match route {
Route::Local => ("local", vec![self.local_addr.to_string()]),
Route::DirectPeer(addr) => ("direct_peer", vec![self.local_addr.to_string(), addr.to_string()]),
Route::DirectPeer(addr) => (
"direct_peer",
vec![self.local_addr.to_string(), addr.to_string()],
),
Route::Chain(chain) => {
let mut addrs = vec![self.local_addr.to_string()];
addrs.extend(chain.iter().map(|a| a.to_string()));
@@ -184,7 +183,10 @@ mod tests {
reg.update_peer(peer, fps);
// Local lookup works via multi-hop
assert_eq!(resolver.resolve_multi_hop(&reg, "local_fp", 3), Route::Local);
assert_eq!(
resolver.resolve_multi_hop(&reg, "local_fp", 3),
Route::Local
);
// Remote lookup works via multi-hop
assert_eq!(
resolver.resolve_multi_hop(&reg, "remote_fp", 3),

View File

@@ -143,18 +143,18 @@ impl SessionManager {
fingerprint: Option<String>,
) -> Result<SessionId, String> {
if self.total_count() >= self.max_sessions {
return Err(format!(
"max sessions ({}) exceeded",
self.max_sessions
));
return Err(format!("max sessions ({}) exceeded", self.max_sessions));
}
let id = rand_session_id();
self.tracked.insert(id, SessionInfo {
room_name: room.to_string(),
fingerprint,
connected_at: Instant::now(),
state: SessionState::Active,
});
self.tracked.insert(
id,
SessionInfo {
room_name: room.to_string(),
fingerprint,
connected_at: Instant::now(),
state: SessionState::Active,
},
);
Ok(id)
}
@@ -165,7 +165,10 @@ impl SessionManager {
/// Number of currently tracked (room-mode) sessions.
pub fn active_count(&self) -> usize {
self.tracked.values().filter(|s| s.state == SessionState::Active).count()
self.tracked
.values()
.filter(|s| s.state == SessionState::Active)
.count()
}
/// Return all session IDs that belong to a given room.
@@ -278,7 +281,9 @@ mod tests {
#[test]
fn session_info_returns_correct_data() {
let mut mgr = SessionManager::new(10);
let id = mgr.create_session("room-x", Some("alice-fp".into())).unwrap();
let id = mgr
.create_session("room-x", Some("alice-fp".into()))
.unwrap();
let info = mgr.session_info(id).expect("session should exist");
assert_eq!(info.room_name, "room-x");
@@ -297,6 +302,9 @@ mod tests {
mgr.create_session("room", None).unwrap();
// Both layers should now reject
assert!(mgr.create_session("room", None).is_err());
assert!(mgr.create_pipeline_session([2u8; 16], PipelineConfig::default()).is_none());
assert!(
mgr.create_pipeline_session([2u8; 16], PipelineConfig::default())
.is_none()
);
}
}

View File

@@ -34,12 +34,15 @@ impl SignalHub {
/// Register a new signaling client.
pub fn register(&mut self, fp: String, transport: Arc<QuinnTransport>, alias: Option<String>) {
info!(fingerprint = %fp, alias = ?alias, "signal client registered");
self.clients.insert(fp.clone(), SignalClient {
fingerprint: fp,
alias,
transport,
connected_at: Instant::now(),
});
self.clients.insert(
fp.clone(),
SignalClient {
fingerprint: fp,
alias,
transport,
connected_at: Instant::now(),
},
);
}
/// Unregister a signaling client. Returns the client if found.
@@ -64,10 +67,11 @@ impl SignalHub {
/// Send a signal message to a client by fingerprint.
pub async fn send_to(&self, fp: &str, msg: &SignalMessage) -> Result<(), String> {
match self.clients.get(fp) {
Some(client) => {
client.transport.send_signal(msg).await
.map_err(|e| format!("send to {fp}: {e}"))
}
Some(client) => client
.transport
.send_signal(msg)
.await
.map_err(|e| format!("send to {fp}: {e}")),
None => Err(format!("{fp} not online")),
}
}

View File

@@ -8,17 +8,17 @@ use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
Router,
extract::{
ws::{Message, WebSocket},
Path, State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::IntoResponse,
routing::get,
Router,
};
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{mpsc, Mutex};
use tokio::sync::{Mutex, mpsc};
use tower_http::services::ServeDir;
use tracing::{error, info, warn};
@@ -143,9 +143,15 @@ async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
// 4. Join room with WS sender
let addr: SocketAddr = ([0, 0, 0, 0], 0).into();
let participant_id = {
match state.room_mgr.join_ws(&room, addr, tx, fingerprint.as_deref()) {
match state
.room_mgr
.join_ws(&room, addr, tx, fingerprint.as_deref())
{
Ok(id) => {
state.metrics.active_rooms.set(state.room_mgr.list().len() as i64);
state
.metrics
.active_rooms
.set(state.room_mgr.list().len() as i64);
id
}
Err(e) => {
@@ -187,10 +193,7 @@ async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
for other in &others {
let _ = other.send_raw(&data).await;
}
state
.metrics
.packets_forwarded
.inc_by(others.len() as u64);
state.metrics.packets_forwarded.inc_by(others.len() as u64);
state
.metrics
.bytes_forwarded
@@ -211,7 +214,10 @@ async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
}
state.room_mgr.leave(&room, participant_id);
state.metrics.active_rooms.set(state.room_mgr.list().len() as i64);
state
.metrics
.active_rooms
.set(state.room_mgr.list().len() as i64);
let session_id_str: String = session_id.iter().map(|b| format!("{b:02x}")).collect();
state.metrics.remove_session_metrics(&session_id_str);

View File

@@ -94,9 +94,10 @@ fn relay_a_handle_offer(reg_a: &mut CallRegistry, offer: &SignalMessage) -> Sign
/// reproduced here for the test.
fn relay_b_handle_forwarded_offer(reg_b: &mut CallRegistry, forward: &SignalMessage) {
let (inner, origin_relay_fp) = match forward {
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
(inner.as_ref().clone(), origin_relay_fp.clone())
}
SignalMessage::FederatedSignalForward {
inner,
origin_relay_fp,
} => (inner.as_ref().clone(), origin_relay_fp.clone()),
_ => panic!("not a forward"),
};
// Loop-prevention: drop self-sourced.
@@ -114,11 +115,7 @@ fn relay_b_handle_forwarded_offer(reg_b: &mut CallRegistry, forward: &SignalMess
};
// Simulated: target is local to B (Bob is registered here).
reg_b.create_call(
call_id.clone(),
caller_fingerprint,
target_fingerprint,
);
reg_b.create_call(call_id.clone(), caller_fingerprint, target_fingerprint);
reg_b.set_caller_reflexive_addr(&call_id, caller_reflexive_addr);
reg_b.set_peer_relay_fp(&call_id, Some(origin_relay_fp));
}
@@ -194,9 +191,10 @@ fn relay_a_handle_forwarded_answer(
forward: &SignalMessage,
) -> SignalMessage {
let (inner, origin_relay_fp) = match forward {
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
(inner.as_ref().clone(), origin_relay_fp.clone())
}
SignalMessage::FederatedSignalForward {
inner,
origin_relay_fp,
} => (inner.as_ref().clone(), origin_relay_fp.clone()),
_ => panic!("not a forward"),
};
assert_ne!(origin_relay_fp, RELAY_A_TLS_FP);
@@ -270,12 +268,15 @@ fn cross_relay_answer_crosswires_peer_direct_addrs() {
// Bob answers on Relay B.
let answer = bob_answer("c-xrelay-2");
let (answer_forward, setup_for_bob) =
relay_b_handle_local_answer(&mut reg_b, &answer);
let (answer_forward, setup_for_bob) = relay_b_handle_local_answer(&mut reg_b, &answer);
// Bob's CallSetup carries Alice's addr.
match setup_for_bob {
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr,
relay_addr,
..
} => {
assert_eq!(peer_direct_addr.as_deref(), Some(ALICE_ADDR));
assert_eq!(relay_addr, RELAY_B_ADDR);
}
@@ -286,7 +287,11 @@ fn cross_relay_answer_crosswires_peer_direct_addrs() {
// her CallSetup.
let setup_for_alice = relay_a_handle_forwarded_answer(&mut reg_a, &answer_forward);
match setup_for_alice {
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr,
relay_addr,
..
} => {
assert_eq!(peer_direct_addr.as_deref(), Some(BOB_ADDR));
assert_eq!(relay_addr, RELAY_A_ADDR);
}
@@ -313,9 +318,14 @@ fn cross_relay_loop_prevention_drops_self_sourced_forward() {
// The dispatcher in main.rs calls this explicit check before
// doing any work. Reproduce it inline.
let origin = match &forward {
SignalMessage::FederatedSignalForward { origin_relay_fp, .. } => origin_relay_fp.clone(),
SignalMessage::FederatedSignalForward {
origin_relay_fp, ..
} => origin_relay_fp.clone(),
_ => unreachable!(),
};
// Relay B sees origin == its own fp → drop.
assert_eq!(origin, RELAY_B_TLS_FP, "loop-prevention triggers on self-fp");
assert_eq!(
origin, RELAY_B_TLS_FP,
"loop-prevention triggers on self-fp"
);
}

View File

@@ -21,10 +21,10 @@ use bytes::Bytes;
use wzp_proto::{MediaTransport, SignalMessage};
use wzp_relay::config::{PeerConfig, TrustedConfig};
use wzp_relay::event_log::EventLogger;
use wzp_relay::federation::{room_hash, FederationManager};
use wzp_relay::federation::{FederationManager, room_hash};
use wzp_relay::metrics::RelayMetrics;
use wzp_relay::room::RoomManager;
use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport};
use wzp_transport::{QuinnTransport, client_config, create_endpoint, server_config};
// ───────────────────────────── helpers ──────────────────────────────
@@ -41,8 +41,7 @@ fn create_test_fm_full(
) -> Arc<FederationManager> {
let _ = rustls::crypto::ring::default_provider().install_default();
let (sc, _cert) = server_config();
let ep = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), Some(sc))
.expect("test endpoint");
let ep = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), Some(sc)).expect("test endpoint");
let room_mgr = Arc::new(RoomManager::new());
let metrics = Arc::new(RelayMetrics::new());
let event_log = EventLogger::Noop;
@@ -219,7 +218,10 @@ async fn forward_to_peers_empty_returns_immediately() {
fm.forward_to_peers("room", &hash, &data),
)
.await;
assert!(result.is_ok(), "forward_to_peers should return immediately with no peers");
assert!(
result.is_ok(),
"forward_to_peers should return immediately with no peers"
);
}
// ─────────── 4. forward_to_peers with live QUIC peer links ──────────
@@ -339,20 +341,20 @@ async fn broadcast_signal_sends_to_all_peers() {
.expect("FM should connect to mock peer within 5s");
// The FM sends FederationHello as the first signal. Read it.
let hello = tokio::time::timeout(
Duration::from_secs(2),
peer_transport.recv_signal(),
)
.await
.expect("hello timeout")
.expect("recv ok")
.expect("some message");
let hello = tokio::time::timeout(Duration::from_secs(2), peer_transport.recv_signal())
.await
.expect("hello timeout")
.expect("recv ok")
.expect("some message");
match hello {
SignalMessage::FederationHello { tls_fingerprint } => {
assert_eq!(tls_fingerprint, "test-relay-fp-abc123");
}
other => panic!("expected FederationHello, got: {:?}", std::mem::discriminant(&other)),
other => panic!(
"expected FederationHello, got: {:?}",
std::mem::discriminant(&other)
),
}
// Now the FM's run_federation_link registered the peer in peer_links
@@ -372,20 +374,22 @@ async fn broadcast_signal_sends_to_all_peers() {
assert_eq!(count, 1, "should have broadcast to exactly 1 peer");
// Read the signal on the peer side
let received = tokio::time::timeout(
Duration::from_secs(2),
peer_transport.recv_signal(),
)
.await
.expect("broadcast signal timeout")
.expect("recv ok")
.expect("some message");
let received = tokio::time::timeout(Duration::from_secs(2), peer_transport.recv_signal())
.await
.expect("broadcast signal timeout")
.expect("recv ok")
.expect("some message");
match received {
SignalMessage::FederatedSignalForward { origin_relay_fp, .. } => {
SignalMessage::FederatedSignalForward {
origin_relay_fp, ..
} => {
assert_eq!(origin_relay_fp, "other-relay-fp");
}
other => panic!("expected FederatedSignalForward, got: {:?}", std::mem::discriminant(&other)),
other => panic!(
"expected FederatedSignalForward, got: {:?}",
std::mem::discriminant(&other)
),
}
drop(peer_transport);
@@ -585,14 +589,11 @@ async fn federation_media_egress_forwards_to_peer() {
.expect("FM should connect within 5s");
// Read the FederationHello
let _hello = tokio::time::timeout(
Duration::from_secs(2),
peer_transport.recv_signal(),
)
.await
.expect("hello timeout")
.expect("recv ok")
.expect("some message");
let _hello = tokio::time::timeout(Duration::from_secs(2), peer_transport.recv_signal())
.await
.expect("hello timeout")
.expect("recv ok")
.expect("some message");
// Wait for link setup
tokio::time::sleep(Duration::from_millis(100)).await;

View File

@@ -11,14 +11,18 @@ use wzp_client::perform_handshake;
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
use wzp_proto::{MediaTransport, SignalMessage};
use wzp_relay::handshake::accept_handshake;
use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport};
use wzp_transport::{QuinnTransport, client_config, create_endpoint, server_config};
/// Establish a QUIC connection and wrap both sides in `QuinnTransport`.
///
/// Returns (client_transport, server_transport, _endpoints) where the endpoint
/// tuple must be kept alive for the duration of the test to avoid premature
/// connection teardown.
async fn connected_pair() -> (Arc<QuinnTransport>, Arc<QuinnTransport>, (quinn::Endpoint, quinn::Endpoint)) {
async fn connected_pair() -> (
Arc<QuinnTransport>,
Arc<QuinnTransport>,
(quinn::Endpoint, quinn::Endpoint),
) {
let _ = rustls::crypto::ring::default_provider().install_default();
let (sc, _cert_der) = server_config();
@@ -31,7 +35,9 @@ async fn connected_pair() -> (Arc<QuinnTransport>, Arc<QuinnTransport>, (quinn::
let server_ep_clone = server_ep.clone();
let accept_fut = tokio::spawn(async move {
let conn = wzp_transport::accept(&server_ep_clone).await.expect("accept");
let conn = wzp_transport::accept(&server_ep_clone)
.await
.expect("accept");
Arc::new(QuinnTransport::new(conn))
});
@@ -59,9 +65,8 @@ async fn handshake_succeeds() {
// Clone Arc so the server transport stays alive in the main task too.
let server_t = Arc::clone(&server_transport);
let callee_handle = tokio::spawn(async move {
accept_handshake(server_t.as_ref(), &callee_seed).await
});
let callee_handle =
tokio::spawn(async move { accept_handshake(server_t.as_ref(), &callee_seed).await });
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await
@@ -120,9 +125,8 @@ async fn handshake_verifies_identity() {
);
let server_t = Arc::clone(&server_transport);
let callee_handle = tokio::spawn(async move {
accept_handshake(server_t.as_ref(), &callee_seed).await
});
let callee_handle =
tokio::spawn(async move { accept_handshake(server_t.as_ref(), &callee_seed).await });
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await
@@ -179,13 +183,17 @@ async fn auth_then_handshake() {
let token = match auth_msg {
SignalMessage::AuthToken { token } => token,
other => panic!("expected AuthToken, got {:?}", std::mem::discriminant(&other)),
other => panic!(
"expected AuthToken, got {:?}",
std::mem::discriminant(&other)
),
};
// 2. Run the cryptographic handshake
let (session, profile, _caller_fp, _caller_alias) = accept_handshake(server_t.as_ref(), &callee_seed)
.await
.expect("accept_handshake after auth");
let (session, profile, _caller_fp, _caller_alias) =
accept_handshake(server_t.as_ref(), &callee_seed)
.await
.expect("accept_handshake after auth");
(token, session, profile)
});
@@ -203,9 +211,7 @@ async fn auth_then_handshake() {
.await
.expect("perform_handshake after auth");
let (received_token, callee_session, _profile) = callee_handle
.await
.expect("join callee task");
let (received_token, callee_session, _profile) = callee_handle.await.expect("join callee task");
// Verify the auth token was received correctly.
assert_eq!(received_token, "bearer-test-token-12345");
@@ -246,9 +252,8 @@ async fn handshake_rejects_bad_signature() {
// Spawn callee -- it should reject the tampered CallOffer.
let server_t = Arc::clone(&server_transport);
let callee_handle = tokio::spawn(async move {
accept_handshake(server_t.as_ref(), &callee_seed).await
});
let callee_handle =
tokio::spawn(async move { accept_handshake(server_t.as_ref(), &callee_seed).await });
// Manually build a CallOffer with a corrupted signature.
let mut kx = WarzoneKeyExchange::from_identity_seed(&caller_seed);

View File

@@ -151,12 +151,13 @@ fn both_peers_advertise_reflex_addrs_cross_wire_in_setup() {
);
let answer = mk_answer("c1", CallAcceptMode::AcceptTrusted, Some(callee_addr));
let (setup_caller, setup_callee) =
handle_answer_and_build_setups(&mut reg, &answer);
let (setup_caller, setup_callee) = handle_answer_and_build_setups(&mut reg, &answer);
// The CALLER's setup should carry the CALLEE's addr as peer_direct_addr.
match setup_caller {
SignalMessage::CallSetup { peer_direct_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert_eq!(
peer_direct_addr.as_deref(),
Some(callee_addr),
@@ -168,7 +169,9 @@ fn both_peers_advertise_reflex_addrs_cross_wire_in_setup() {
// The CALLEE's setup should carry the CALLER's addr.
match setup_callee {
SignalMessage::CallSetup { peer_direct_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert_eq!(
peer_direct_addr.as_deref(),
Some(caller_addr),
@@ -193,12 +196,13 @@ fn privacy_mode_answer_omits_callee_addr_from_setup() {
// AcceptGeneric explicitly passes None for callee_reflexive_addr —
// the whole point is to hide the callee's IP from the caller.
let answer = mk_answer("c2", CallAcceptMode::AcceptGeneric, None);
let (setup_caller, setup_callee) =
handle_answer_and_build_setups(&mut reg, &answer);
let (setup_caller, setup_callee) = handle_answer_and_build_setups(&mut reg, &answer);
// CALLER should see peer_direct_addr = None (privacy preserved).
match setup_caller {
SignalMessage::CallSetup { peer_direct_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert!(
peer_direct_addr.is_none(),
"privacy mode must not leak callee addr to caller"
@@ -210,7 +214,9 @@ fn privacy_mode_answer_omits_callee_addr_from_setup() {
// CALLEE still gets the caller's addr — only the callee opted for
// privacy, the caller already volunteered its addr in the offer.
match setup_callee {
SignalMessage::CallSetup { peer_direct_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert_eq!(
peer_direct_addr.as_deref(),
Some(caller_addr),
@@ -242,11 +248,12 @@ fn pre_phase3_caller_leaves_both_setups_relay_only() {
CallAcceptMode::AcceptTrusted,
Some("198.51.100.9:4433"),
);
let (setup_caller, setup_callee) =
handle_answer_and_build_setups(&mut reg, &answer);
let (setup_caller, setup_callee) = handle_answer_and_build_setups(&mut reg, &answer);
match setup_caller {
SignalMessage::CallSetup { peer_direct_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
// Phase 3 relay behavior: we always inject whatever
// addrs are in the registry, regardless of who
// advertised. The caller here gets the callee's addr
@@ -258,7 +265,9 @@ fn pre_phase3_caller_leaves_both_setups_relay_only() {
// The callee's setup has no caller addr (pre-Phase-3 offer).
match setup_callee {
SignalMessage::CallSetup { peer_direct_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert!(
peer_direct_addr.is_none(),
"callee should see no caller addr when offer was pre-Phase-3"
@@ -278,12 +287,15 @@ fn neither_peer_advertises_both_setups_are_relay_only() {
handle_offer(&mut reg, &mk_offer("c4", None));
let answer = mk_answer("c4", CallAcceptMode::AcceptTrusted, None);
let (setup_caller, setup_callee) =
handle_answer_and_build_setups(&mut reg, &answer);
let (setup_caller, setup_callee) = handle_answer_and_build_setups(&mut reg, &answer);
for (label, setup) in [("caller", setup_caller), ("callee", setup_callee)] {
match setup {
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr,
relay_addr,
..
} => {
assert!(
peer_direct_addr.is_none(),
"{label}'s CallSetup must have no peer_direct_addr"

View File

@@ -24,9 +24,9 @@ use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use wzp_client::reflect::{detect_nat_type, probe_reflect_addr, NatType};
use wzp_client::reflect::{NatType, detect_nat_type, probe_reflect_addr};
use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::{create_endpoint, server_config, QuinnTransport};
use wzp_transport::{QuinnTransport, create_endpoint, server_config};
/// Minimal mock relay that loops accepting connections, handles
/// RegisterPresence + Reflect, and responds correctly. Mirrors the
@@ -136,10 +136,7 @@ async fn detect_nat_type_two_loopback_relays_probes_work_but_classify_unknown()
let (addr_b, _h_b) = spawn_mock_relay().await;
let detection = detect_nat_type(
vec![
("RelayA".into(), addr_a),
("RelayB".into(), addr_b),
],
vec![("RelayA".into(), addr_a), ("RelayB".into(), addr_b)],
2000,
None,
)
@@ -194,10 +191,7 @@ async fn detect_nat_type_dead_relay_is_unknown() {
let dead_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
let detection = detect_nat_type(
vec![
("Alive".into(), alive_addr),
("Dead".into(), dead_addr),
],
vec![("Alive".into(), alive_addr), ("Dead".into(), dead_addr)],
600, // tight timeout so the dead probe fails fast
None,
)
@@ -207,8 +201,16 @@ async fn detect_nat_type_dead_relay_is_unknown() {
// Find the alive and dead probes by name (order of JoinSet
// completions is not guaranteed).
let alive = detection.probes.iter().find(|p| p.relay_name == "Alive").unwrap();
let dead = detection.probes.iter().find(|p| p.relay_name == "Dead").unwrap();
let alive = detection
.probes
.iter()
.find(|p| p.relay_name == "Alive")
.unwrap();
let dead = detection
.probes
.iter()
.find(|p| p.relay_name == "Dead")
.unwrap();
assert!(
alive.observed_addr.is_some(),

View File

@@ -31,7 +31,7 @@ use std::sync::Arc;
use std::time::Duration;
use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport};
use wzp_transport::{QuinnTransport, client_config, create_endpoint, server_config};
/// Spawn a minimal mock relay that loops over `recv_signal`,
/// matches on `Reflect`, and responds with `ReflectResponse` using
@@ -94,7 +94,11 @@ async fn spawn_mock_relay_without_reflect(
/// distinct-ports test).
async fn connected_pair_with_port(
_client_port_hint: u16,
) -> (Arc<QuinnTransport>, Arc<QuinnTransport>, (quinn::Endpoint, quinn::Endpoint)) {
) -> (
Arc<QuinnTransport>,
Arc<QuinnTransport>,
(quinn::Endpoint, quinn::Endpoint),
) {
let _ = rustls::crypto::ring::default_provider().install_default();
let (sc, _cert_der) = server_config();
@@ -109,7 +113,9 @@ async fn connected_pair_with_port(
let server_ep_clone = server_ep.clone();
let accept_fut = tokio::spawn(async move {
let conn = wzp_transport::accept(&server_ep_clone).await.expect("accept");
let conn = wzp_transport::accept(&server_ep_clone)
.await
.expect("accept");
Arc::new(QuinnTransport::new(conn))
});
@@ -134,10 +140,7 @@ async fn reflect_happy_path() {
// Grab the client's actual bound port so we can cross-check
// against the reflected response.
let client_port = client_ep
.local_addr()
.expect("client local addr")
.port();
let client_port = client_ep.local_addr().expect("client local addr").port();
assert_ne!(client_port, 0, "client must have a real bound port");
// Start the mock relay's reflect handler.
@@ -162,7 +165,10 @@ async fn reflect_happy_path() {
let observed_addr = match resp {
SignalMessage::ReflectResponse { observed_addr } => observed_addr,
other => panic!("expected ReflectResponse, got {:?}", std::mem::discriminant(&other)),
other => panic!(
"expected ReflectResponse, got {:?}",
std::mem::discriminant(&other)
),
};
let parsed: SocketAddr = observed_addr
@@ -210,19 +216,17 @@ async fn reflect_two_clients_distinct_ports() {
// Client A
let client_ep_a = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep A");
let conn_a =
wzp_transport::connect(&client_ep_a, server_listen, "localhost", client_config())
.await
.expect("connect A");
let conn_a = wzp_transport::connect(&client_ep_a, server_listen, "localhost", client_config())
.await
.expect("connect A");
let client_a = Arc::new(QuinnTransport::new(conn_a));
let port_a = client_ep_a.local_addr().unwrap().port();
// Client B
let client_ep_b = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep B");
let conn_b =
wzp_transport::connect(&client_ep_b, server_listen, "localhost", client_config())
.await
.expect("connect B");
let conn_b = wzp_transport::connect(&client_ep_b, server_listen, "localhost", client_config())
.await
.expect("connect B");
let client_b = Arc::new(QuinnTransport::new(conn_b));
let port_b = client_ep_b.local_addr().unwrap().port();
@@ -252,7 +256,8 @@ async fn reflect_two_clients_distinct_ports() {
}
};
let (addr_a, addr_b) = tokio::join!(reflect_for(client_a.clone()), reflect_for(client_b.clone()));
let (addr_a, addr_b) =
tokio::join!(reflect_for(client_a.clone()), reflect_for(client_b.clone()));
let parsed_a: SocketAddr = addr_a.parse().unwrap();
let parsed_b: SocketAddr = addr_b.parse().unwrap();
@@ -277,12 +282,10 @@ async fn reflect_two_clients_distinct_ports() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reflect_old_relay_times_out() {
let (client_transport, server_transport, _endpoints) =
connected_pair_with_port(0).await;
let (client_transport, server_transport, _endpoints) = connected_pair_with_port(0).await;
// Mock relay that ignores Reflect — simulates a pre-Phase-1 build.
let _relay_handle =
spawn_mock_relay_without_reflect(Arc::clone(&server_transport)).await;
let _relay_handle = spawn_mock_relay_without_reflect(Arc::clone(&server_transport)).await;
client_transport
.send_signal(&SignalMessage::Reflect)

View File

@@ -22,8 +22,8 @@ pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
/// Create a server configuration with a deterministic self-signed certificate
/// derived from a 32-byte seed. Same seed = same cert = same TLS fingerprint.
pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>) {
use ed25519_dalek::pkcs8::EncodePrivateKey;
use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::EncodePrivateKey;
use hkdf::Hkdf;
use sha2::Sha256;
@@ -35,22 +35,23 @@ pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>
// Create Ed25519 signing key and export as PKCS8 DER
let signing_key = SigningKey::from_bytes(&ed_bytes);
let pkcs8_doc = signing_key.to_pkcs8_der()
let pkcs8_doc = signing_key
.to_pkcs8_der()
.expect("failed to encode Ed25519 key as PKCS8");
let key_der_for_rcgen = rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec())
.expect("failed to wrap PKCS8 DER");
let key_der_for_rcgen =
rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec())
.expect("failed to wrap PKCS8 DER");
// Create rcgen KeyPair from DER
let key_pair = rcgen::KeyPair::from_der_and_sign_algo(
&key_der_for_rcgen,
&rcgen::PKCS_ED25519,
)
.expect("failed to create KeyPair from seed-derived Ed25519 key");
let key_pair = rcgen::KeyPair::from_der_and_sign_algo(&key_der_for_rcgen, &rcgen::PKCS_ED25519)
.expect("failed to create KeyPair from seed-derived Ed25519 key");
// Build self-signed cert with this deterministic keypair
let params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
.expect("failed to create CertificateParams");
let cert = params.self_signed(&key_pair).expect("failed to self-sign cert");
let cert = params
.self_signed(&key_pair)
.expect("failed to self-sign cert");
let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec());
let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der())
.expect("failed to serialize key DER");
@@ -62,7 +63,7 @@ pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>
///
/// Format: `xx:xx:xx:xx:...` (32 bytes = 64 hex chars with colons).
pub fn tls_fingerprint(cert_der: &[u8]) -> String {
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let hash = Sha256::digest(cert_der);
hash.iter()
.map(|b| format!("{b:02x}"))
@@ -148,7 +149,7 @@ fn transport_config() -> quinn::TransportConfig {
let mut mtu_config = quinn::MtuDiscoveryConfig::default();
mtu_config
.upper_bound(1452)
.interval(Duration::from_secs(300)) // re-probe every 5 min
.interval(Duration::from_secs(300)) // re-probe every 5 min
.black_hole_cooldown(Duration::from_secs(30)); // retry faster on lossy links
config.mtu_discovery_config(Some(mtu_config));
config.initial_mtu(1200); // safe starting point

View File

@@ -28,13 +28,13 @@ pub async fn connect(
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 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}"))
})?;
let connection = connecting
.await
.map_err(|e| TransportError::Internal(format!("connection failed: {e}")))?;
Ok(connection)
}
@@ -111,9 +111,9 @@ pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, Tra
.await
.ok_or(TransportError::ConnectionLost)?;
let connection = incoming.await.map_err(|e| {
TransportError::Internal(format!("accept failed: {e}"))
})?;
let connection = incoming
.await
.map_err(|e| TransportError::Internal(format!("accept failed: {e}")))?;
Ok(connection)
}

View File

@@ -26,22 +26,20 @@ pub fn max_datagram_payload(connection: &quinn::Connection) -> Option<usize> {
mod tests {
use super::*;
use bytes::Bytes;
use wzp_proto::{CodecId, MediaHeader};
use wzp_proto::{CodecId, MediaHeader, MediaType};
fn test_packet() -> MediaPacket {
MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
version: 2,
flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus16k,
has_quality_report: false,
fec_ratio_encoded: 16,
stream_id: 0,
fec_ratio: 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,
@@ -61,7 +59,7 @@ mod tests {
#[test]
fn serialize_deserialize_with_quality_report() {
let mut packet = test_packet();
packet.header.has_quality_report = true;
packet.header.flags |= MediaHeader::FLAG_QUALITY;
packet.quality_report = Some(wzp_proto::QualityReport {
loss_pct: 50,
rtt_4ms: 75,

View File

@@ -30,7 +30,7 @@ pub struct PathMonitor {
first_recv_time_ms: Option<u64>,
last_recv_time_ms: Option<u64>,
/// Sequence tracking for loss detection.
highest_sent_seq: Option<u16>,
highest_sent_seq: Option<u32>,
total_sent: u64,
total_received: u64,
/// Last observed RTT for jitter calculation.
@@ -64,7 +64,7 @@ impl PathMonitor {
}
/// Record that we sent a packet with the given sequence number and timestamp.
pub fn observe_sent(&mut self, seq: u16, timestamp_ms: u64) {
pub fn observe_sent(&mut self, seq: u32, timestamp_ms: u64) {
self.total_sent += 1;
self.highest_sent_seq = Some(seq);
@@ -78,7 +78,7 @@ impl PathMonitor {
}
/// Record that we received a packet with the given sequence number and timestamp.
pub fn observe_received(&mut self, seq: u16, timestamp_ms: u64) {
pub fn observe_received(&mut self, seq: u32, timestamp_ms: u64) {
self.total_received += 1;
if self.first_recv_time_ms.is_none() {
@@ -180,7 +180,12 @@ impl PathMonitor {
return 0.0;
}
let mean = self.rtt_window.iter().sum::<f64>() / n as f64;
let var = self.rtt_window.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
let var = self
.rtt_window
.iter()
.map(|r| (r - mean).powi(2))
.sum::<f64>()
/ n as f64;
var.sqrt()
}
@@ -274,7 +279,7 @@ mod tests {
}
// Receive only 7 of them (30% loss)
for i in [0u16, 1, 2, 3, 5, 7, 9] {
for i in [0u32, 1, 2, 3, 5, 7, 9] {
monitor.observe_received(i, i as u64 * 20 + 50);
}

View File

@@ -127,9 +127,9 @@ impl QuinnTransport {
}
}
self.connection.send_datagram(data).map_err(|e| {
TransportError::Internal(format!("send trunk datagram error: {e}"))
})?;
self.connection
.send_datagram(data)
.map_err(|e| TransportError::Internal(format!("send trunk datagram error: {e}")))?;
Ok(())
}
@@ -146,7 +146,7 @@ impl QuinnTransport {
Err(e) => {
return Err(TransportError::Internal(format!(
"recv trunk datagram error: {e}"
)))
)));
}
};
@@ -177,9 +177,9 @@ impl MediaTransport for QuinnTransport {
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}"))
})?;
self.connection
.send_datagram(data)
.map_err(|e| TransportError::Internal(format!("send datagram error: {e}")))?;
Ok(())
}
@@ -192,7 +192,7 @@ impl MediaTransport for QuinnTransport {
Err(e) => {
return Err(TransportError::Internal(format!(
"recv datagram error: {e}"
)))
)));
}
};
@@ -201,15 +201,15 @@ impl MediaTransport for QuinnTransport {
// Record receive observation
{
let mut monitor = self.path_monitor.lock().unwrap();
monitor.observe_received(
packet.header.seq,
packet.header.timestamp as u64,
);
monitor.observe_received(packet.header.seq, packet.header.timestamp as u64);
}
Ok(Some(packet))
}
None => {
tracing::warn!(len = data.len(), "skipping malformed media datagram, continuing");
tracing::warn!(
len = data.len(),
"skipping malformed media datagram, continuing"
);
// Don't return Ok(None) — that signals connection closed.
// Recurse to read the next datagram instead.
Box::pin(self.recv_media()).await
@@ -241,10 +241,8 @@ impl MediaTransport for QuinnTransport {
}
async fn close(&self) -> Result<(), TransportError> {
self.connection.close(
quinn::VarInt::from_u32(0),
b"normal close",
);
self.connection
.close(quinn::VarInt::from_u32(0), b"normal close");
Ok(())
}
}

View File

@@ -9,10 +9,14 @@ 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}"))
})?;
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}")))?;

View File

@@ -10,13 +10,13 @@
use std::net::SocketAddr;
use std::sync::Arc;
use axum::Router;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Path, WebSocketUpgrade};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use futures::stream::StreamExt;
use futures::SinkExt;
use futures::stream::StreamExt;
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
use tracing::{error, info, warn};
@@ -54,22 +54,45 @@ async fn main() -> anyhow::Result<()> {
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--port" => { i += 1; port = args[i].parse().expect("invalid port"); }
"--relay" => { i += 1; relay_addr = args[i].parse().expect("invalid relay address"); }
"--tls" => { use_tls = true; }
"--auth-url" => { i += 1; auth_url = Some(args[i].clone()); }
"--cert" => { i += 1; cert_path = Some(args[i].clone()); }
"--key" => { i += 1; key_path = Some(args[i].clone()); }
"--port" => {
i += 1;
port = args[i].parse().expect("invalid port");
}
"--relay" => {
i += 1;
relay_addr = args[i].parse().expect("invalid relay address");
}
"--tls" => {
use_tls = true;
}
"--auth-url" => {
i += 1;
auth_url = Some(args[i].clone());
}
"--cert" => {
i += 1;
cert_path = Some(args[i].clone());
}
"--key" => {
i += 1;
key_path = Some(args[i].clone());
}
"--help" | "-h" => {
eprintln!("Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls] [--auth-url <url>]");
eprintln!(
"Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls] [--auth-url <url>]"
);
eprintln!();
eprintln!("Options:");
eprintln!(" --port <port> HTTP/WebSocket port (default: 8080)");
eprintln!(" --relay <addr> WZP relay address (default: 127.0.0.1:4433)");
eprintln!(" --tls Enable HTTPS (required for mic on Android)");
eprintln!(" --auth-url <url> featherChat auth endpoint for token validation");
eprintln!(" --cert <path> TLS certificate PEM file (optional, overrides self-signed)");
eprintln!(" --key <path> TLS private key PEM file (optional, overrides self-signed)");
eprintln!(
" --cert <path> TLS certificate PEM file (optional, overrides self-signed)"
);
eprintln!(
" --key <path> TLS private key PEM file (optional, overrides self-signed)"
);
eprintln!();
eprintln!("Rooms: open https://host:port/<room-name> to join a room.");
eprintln!("Browser sends auth JSON as first WS message when --auth-url is set.");
@@ -81,7 +104,10 @@ async fn main() -> anyhow::Result<()> {
}
if let Some(ref url) = auth_url {
info!(url, "auth enabled — browsers must send token as first WS message");
info!(
url,
"auth enabled — browsers must send token as first WS message"
);
}
let web_metrics = WebMetrics::new();
@@ -101,10 +127,9 @@ async fn main() -> anyhow::Result<()> {
// Serve index.html for any path that isn't /ws/, /metrics, or a static file.
// This lets URLs like /manwe load the SPA which reads the room from the path.
let static_service = ServeDir::new(static_dir)
.fallback(tower_http::services::ServeFile::new(
format!("{}/index.html", static_dir),
));
let static_service = ServeDir::new(static_dir).fallback(tower_http::services::ServeFile::new(
format!("{}/index.html", static_dir),
));
let app = Router::new()
.route("/ws/{room}", get(ws_handler))
@@ -130,7 +155,8 @@ async fn main() -> anyhow::Result<()> {
// Generate self-signed for development
info!("generating self-signed TLS certificate (use --cert/--key for production)");
let cert_key = rcgen::generate_simple_self_signed(vec![
"localhost".to_string(), "wzp".to_string(),
"localhost".to_string(),
"wzp".to_string(),
])?;
let cert = rustls_pki_types::CertificateDer::from(cert_key.cert);
let key = rustls_pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der())
@@ -186,7 +212,11 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(v) if v.get("type").and_then(|t| t.as_str()) == Some("auth") => {
let token = v.get("token").and_then(|t| t.as_str()).unwrap_or("").to_string();
let token = v
.get("token")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
if token.is_empty() {
error!(room = %room, "empty auth token");
state.metrics.auth_failures.inc();
@@ -239,7 +269,10 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
let client_config = wzp_transport::client_config();
let endpoint = match wzp_transport::create_endpoint(bind_addr, None) {
Ok(e) => e,
Err(e) => { error!("create endpoint: {e}"); return; }
Err(e) => {
error!("create endpoint: {e}");
return;
}
};
// Hash room name for SNI privacy
@@ -248,11 +281,14 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
} else {
wzp_crypto::hash_room_name(&room)
};
let connection =
match wzp_transport::connect(&endpoint, relay_addr, &sni, client_config).await {
Ok(c) => c,
Err(e) => { error!("connect to relay: {e}"); return; }
};
let connection = match wzp_transport::connect(&endpoint, relay_addr, &sni, client_config).await
{
Ok(c) => c,
Err(e) => {
error!("connect to relay: {e}");
return;
}
};
info!(room = %room, "connected to relay");
@@ -290,9 +326,9 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
// (PTT handles silence at the browser level, no need to suppress here)
let config = CallConfig {
suppression_enabled: false,
jitter_target: 3, // 60ms instead of default (~1s)
jitter_max: 20, // 400ms cap
jitter_min: 1, // start playing after 20ms
jitter_target: 3, // 60ms instead of default (~1s)
jitter_max: 20, // 400ms cap
jitter_min: 1, // start playing after 20ms
..CallConfig::default()
};
let encoder = Arc::new(Mutex::new(CallEncoder::new(&config)));
@@ -308,8 +344,11 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
while let Some(Ok(msg)) = ws_receiver.next().await {
match msg {
Message::Binary(data) => {
if data.len() < FRAME_SAMPLES * 2 { continue; }
let pcm: Vec<i16> = data.chunks_exact(2)
if data.len() < FRAME_SAMPLES * 2 {
continue;
}
let pcm: Vec<i16> = data
.chunks_exact(2)
.take(FRAME_SAMPLES)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
@@ -318,7 +357,10 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
let mut enc = send_encoder.lock().await;
match enc.encode_frame(&pcm) {
Ok(p) => p,
Err(e) => { warn!("encode: {e}"); continue; }
Err(e) => {
warn!("encode: {e}");
continue;
}
}
};
@@ -352,19 +394,21 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
loop {
match recv_transport.recv_media().await {
Ok(Some(pkt)) => {
let is_repair = pkt.header.is_repair;
let is_repair = pkt.header.is_repair();
let mut dec = recv_decoder.lock().await;
dec.ingest(pkt);
if !is_repair {
if let Some(_n) = dec.decode_next(&mut pcm_buf) {
let bytes: Vec<u8> = pcm_buf.iter()
.flat_map(|s| s.to_le_bytes())
.collect();
let bytes: Vec<u8> =
pcm_buf.iter().flat_map(|s| s.to_le_bytes()).collect();
if let Err(e) = ws_sender.send(Message::Binary(bytes.into())).await {
error!("ws send: {e}");
return;
}
recv_metrics.frames_bridged.with_label_values(&["down"]).inc();
recv_metrics
.frames_bridged
.with_label_values(&["down"])
.inc();
frames_recv += 1;
if frames_recv % 500 == 0 {
info!(room = %recv_room, frames_recv, "relay → browser");
@@ -372,8 +416,14 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
}
}
}
Ok(None) => { info!(room = %recv_room, "relay closed"); break; }
Err(e) => { error!(room = %recv_room, "relay recv: {e}"); break; }
Ok(None) => {
info!(room = %recv_room, "relay closed");
break;
}
Err(e) => {
error!(room = %recv_room, "relay recv: {e}");
break;
}
}
}
info!(room = %recv_room, frames_recv, "recv ended");

View File

@@ -20,9 +20,10 @@ impl WebMetrics {
pub fn new() -> Self {
let registry = Registry::new();
let active_connections = IntGauge::with_opts(
Opts::new("wzp_web_active_connections", "Current WebSocket connections"),
)
let active_connections = IntGauge::with_opts(Opts::new(
"wzp_web_active_connections",
"Current WebSocket connections",
))
.expect("metric");
registry
.register(Box::new(active_connections.clone()))
@@ -37,20 +38,18 @@ impl WebMetrics {
.register(Box::new(frames_bridged.clone()))
.expect("register");
let auth_failures = IntCounter::with_opts(
Opts::new("wzp_web_auth_failures_total", "Browser auth failures"),
)
let auth_failures = IntCounter::with_opts(Opts::new(
"wzp_web_auth_failures_total",
"Browser auth failures",
))
.expect("metric");
registry
.register(Box::new(auth_failures.clone()))
.expect("register");
let handshake_latency = Histogram::with_opts(
HistogramOpts::new(
"wzp_web_handshake_latency_seconds",
"Relay handshake time",
)
.buckets(vec![0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]),
HistogramOpts::new("wzp_web_handshake_latency_seconds", "Relay handshake time")
.buckets(vec![0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]),
)
.expect("metric");
registry

View File

@@ -11,8 +11,8 @@
#![cfg(target_os = "android")]
use jni::objects::{JObject, JString, JValue};
use jni::JavaVM;
use jni::objects::{JObject, JString, JValue};
/// Grab the JavaVM + current Activity from the ndk_context that Tauri's
/// mobile runtime sets up at process startup.
@@ -22,8 +22,7 @@ fn jvm_and_activity() -> Result<(JavaVM, JObject<'static>), String> {
if vm_ptr.is_null() {
return Err("ndk_context: JavaVM pointer is null".into());
}
let vm = unsafe { JavaVM::from_raw(vm_ptr) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let vm = unsafe { JavaVM::from_raw(vm_ptr) }.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let activity_ptr = ctx.context() as jni::sys::jobject;
if activity_ptr.is_null() {
return Err("ndk_context: activity pointer is null".into());
@@ -140,13 +139,8 @@ pub fn start_bluetooth_sco() -> Result<(), String> {
let am = audio_manager(&mut env, &activity)?;
// Ensure speaker is off — mutually exclusive with BT.
env.call_method(
&am,
"setSpeakerphoneOn",
"(Z)V",
&[JValue::Bool(0)],
)
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
env.call_method(&am, "setSpeakerphoneOn", "(Z)V", &[JValue::Bool(0)])
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
// Try modern API first (API 31+): setCommunicationDevice(AudioDeviceInfo)
// Find a BT SCO or BLE device from getAvailableCommunicationDevices()
@@ -195,11 +189,7 @@ fn try_set_communication_device(
) -> Result<bool, String> {
// Check SDK_INT >= 31 (Android 12)
let sdk_int = env
.get_static_field(
"android/os/Build$VERSION",
"SDK_INT",
"I",
)
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")
.and_then(|v| v.i())
.unwrap_or(0);
@@ -261,11 +251,7 @@ fn try_set_communication_device(
.and_then(|v| v.z())
.unwrap_or(false);
tracing::info!(
device_type,
ok,
"setCommunicationDevice: set BT device"
);
tracing::info!(device_type, ok, "setCommunicationDevice: set BT device");
return Ok(ok);
}
}
@@ -293,7 +279,12 @@ pub fn is_bluetooth_sco_on() -> Result<bool, String> {
if sdk_int >= 31 {
// getCommunicationDevice() → AudioDeviceInfo (nullable)
let device = env
.call_method(am, "getCommunicationDevice", "()Landroid/media/AudioDeviceInfo;", &[])
.call_method(
am,
"getCommunicationDevice",
"()Landroid/media/AudioDeviceInfo;",
&[],
)
.and_then(|v| v.l())
.unwrap_or(JObject::null());
if device.is_null() {
@@ -351,7 +342,11 @@ pub fn is_bluetooth_available() -> Result<bool, String> {
.unwrap_or(0);
// TYPE_BLUETOOTH_SCO = 7, TYPE_BLUETOOTH_A2DP = 8
if device_type == 7 || device_type == 8 {
tracing::info!(device_type, idx = i, "is_bluetooth_available: found BT device");
tracing::info!(
device_type,
idx = i,
"is_bluetooth_available: found BT device"
);
return Ok(true);
}
}

View File

@@ -9,8 +9,8 @@
//! still fails cleanly but the rest of the engine code links in.
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::time::Instant;
use tauri::Emitter;
use tokio::sync::Mutex;
@@ -120,7 +120,10 @@ fn codec_to_profile(codec: CodecId) -> QualityProfile {
frame_duration_ms: 20,
frames_per_block: 5,
},
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
other => QualityProfile {
codec: other,
..QualityProfile::GOOD
},
}
}
@@ -289,8 +292,7 @@ impl DredRecvState {
// user can see "DRED is on the wire" in logcat. After
// that, sample every 100th parse to confirm the window
// is steady-state without drowning the log.
let should_log = self.parses_with_data == 1
|| self.parses_with_data % 100 == 0;
let should_log = self.parses_with_data == 1 || self.parses_with_data % 100 == 0;
if should_log && wzp_codec::dred_verbose_logs() {
info!(
seq,
@@ -467,8 +469,7 @@ impl CallEngine {
let relay_addr: SocketAddr = relay.parse()?;
info!(%relay_addr, "resolved relay addr");
let seed = crate::load_or_create_seed()
.map_err(|e| anyhow::anyhow!("identity: {e}"))?;
let seed = crate::load_or_create_seed().map_err(|e| anyhow::anyhow!("identity: {e}"))?;
let fp = seed.derive_identity().public_identity().fingerprint;
let fingerprint = fp.to_string();
info!(%fp, "identity loaded");
@@ -476,7 +477,10 @@ impl CallEngine {
// Transport source: either the pre-connected one from the
// dual-path race or build a fresh one here.
let transport = if let Some(t) = pre_connected_transport {
info!(t_ms = call_t0.elapsed().as_millis(), is_direct_p2p, "first-join diag: using pre-connected transport");
info!(
t_ms = call_t0.elapsed().as_millis(),
is_direct_p2p, "first-join diag: using pre-connected transport"
);
t
} else {
// QUIC transport + handshake (Phase 0 relay-only path).
@@ -492,8 +496,10 @@ impl CallEngine {
ep
} else {
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let ep = wzp_transport::create_endpoint(bind_addr, None)
.map_err(|e| { error!("create_endpoint failed: {e}"); e })?;
let ep = wzp_transport::create_endpoint(bind_addr, None).map_err(|e| {
error!("create_endpoint failed: {e}");
e
})?;
info!(local_addr = ?ep.local_addr().ok(), "created new endpoint, dialing relay");
ep
};
@@ -501,18 +507,27 @@ impl CallEngine {
let conn = match tokio::time::timeout(
std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
wzp_transport::connect(&endpoint, relay_addr, &room, client_config),
).await {
)
.await
{
Ok(Ok(c)) => c,
Ok(Err(e)) => {
error!("connect failed: {e}");
return Err(e.into());
}
Err(_) => {
error!("connect TIMED OUT after {CONNECT_TIMEOUT_SECS}s — QUIC handshake never completed. Relay may be unreachable from this endpoint.");
return Err(anyhow::anyhow!("QUIC connect timeout ({CONNECT_TIMEOUT_SECS}s)"));
error!(
"connect TIMED OUT after {CONNECT_TIMEOUT_SECS}s — QUIC handshake never completed. Relay may be unreachable from this endpoint."
);
return Err(anyhow::anyhow!(
"QUIC connect timeout ({CONNECT_TIMEOUT_SECS}s)"
));
}
};
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: QUIC connection established, performing handshake");
info!(
t_ms = call_t0.elapsed().as_millis(),
"first-join diag: QUIC connection established, performing handshake"
);
Arc::new(wzp_transport::QuinnTransport::new(conn))
};
@@ -526,16 +541,22 @@ impl CallEngine {
// through the signal channel (DirectCallOffer/Answer carry
// identity_pub + ephemeral_pub + signature).
if !is_direct_p2p {
let _session = wzp_client::handshake::perform_handshake(
&*transport,
&seed.0,
Some(&alias),
)
.await
.map_err(|e| { error!("perform_handshake failed: {e}"); e })?;
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: connected to relay, handshake complete");
let _session =
wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
.await
.map_err(|e| {
error!("perform_handshake failed: {e}");
e
})?;
info!(
t_ms = call_t0.elapsed().as_millis(),
"first-join diag: connected to relay, handshake complete"
);
} else {
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)");
info!(
t_ms = call_t0.elapsed().as_millis(),
"first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)"
);
}
event_cb("connected", &format!("joined room {room}"));
@@ -579,7 +600,9 @@ impl CallEngine {
let t_pre_audio = call_t0.elapsed().as_millis();
if let Err(code) = crate::wzp_native::audio_start() {
return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}"));
return Err(anyhow::anyhow!(
"wzp_native_audio_start failed: code {code}"
));
}
// Fix C (task #36): prime the playout ring with 20ms of
@@ -688,15 +711,17 @@ impl CallEngine {
}
// RMS for UI meter
let sum_sq: f64 = buf[..frame_samples].iter().map(|&s| (s as f64) * (s as f64)).sum();
let sum_sq: f64 = buf[..frame_samples]
.iter()
.map(|&s| (s as f64) * (s as f64))
.sum();
let rms = (sum_sq / frame_samples as f64).sqrt() as u32;
send_level.store(rms, Ordering::Relaxed);
last_rms = rms;
if !first_nonzero_rms_logged && rms > 0 {
info!(
t_ms = send_t0.elapsed().as_millis(),
rms,
"first-join diag: send first non-zero capture RMS"
rms, "first-join diag: send first non-zero capture RMS"
);
first_nonzero_rms_logged = true;
}
@@ -763,11 +788,9 @@ impl CallEngine {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update(
snap.loss_pct,
snap.rtt_ms,
pq.jitter_ms,
) {
if let Some(tuning) =
dred_tuner.update(snap.loss_pct, snap.rtt_ms, pq.jitter_ms)
{
encoder.apply_dred_tuning(tuning);
if wzp_codec::dred_verbose_logs() {
info!(
@@ -874,9 +897,7 @@ impl CallEngine {
// independent of Oboe routing. Convert locally with e.g.
// ffmpeg -f s16le -ar 48000 -ac 1 -i decoded.pcm decoded.wav
use std::io::Write;
let recorder_path = crate::APP_DATA_DIR
.get()
.map(|p| p.join("decoded.pcm"));
let recorder_path = crate::APP_DATA_DIR.get().map(|p| p.join("decoded.pcm"));
let mut recorder = match recorder_path.as_ref() {
Some(p) => match std::fs::File::create(p) {
Ok(f) => {
@@ -954,7 +975,9 @@ impl CallEngine {
{
let mut rx = recv_rx_codec.lock().await;
let codec_name = format!("{:?}", pkt.header.codec_id);
if *rx != codec_name { *rx = codec_name; }
if *rx != codec_name {
*rx = codec_name;
}
}
if pkt.header.codec_id != current_codec {
let new_profile = codec_to_profile(pkt.header.codec_id);
@@ -980,9 +1003,8 @@ impl CallEngine {
// no-op.
if pkt.header.codec_id.is_opus() {
dred_recv.ingest_opus(pkt.header.seq, &pkt.payload);
let frame_samples_now = (48_000
* current_profile.frame_duration_ms as usize)
/ 1000;
let frame_samples_now =
(48_000 * current_profile.frame_duration_ms as usize) / 1000;
let spk_muted_flag = recv_spk.load(Ordering::Relaxed);
dred_recv.fill_gap_to(
&mut decoder,
@@ -1046,10 +1068,15 @@ impl CallEngine {
// Log sample range for the first few decoded frames and periodically
if decoded_frames <= 3 || decoded_frames % 100 == 0 {
let slice = &pcm[..n];
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
let (mut lo, mut hi, mut sumsq) =
(i16::MAX, i16::MIN, 0i64);
for &s in slice.iter() {
if s < lo { lo = s; }
if s > hi { hi = s; }
if s < lo {
lo = s;
}
if s > hi {
hi = s;
}
sumsq += (s as i64) * (s as i64);
}
let rms = (sumsq as f64 / n as f64).sqrt() as i32;
@@ -1086,7 +1113,10 @@ impl CallEngine {
.saturating_add(byte_slice.len() as u64);
if recorder_bytes >= RECORDER_MAX_BYTES {
let _ = rec.flush();
info!(recorder_bytes, "decoded-pcm recorder: stopped after limit");
info!(
recorder_bytes,
"decoded-pcm recorder: stopped after limit"
);
}
}
}
@@ -1105,11 +1135,18 @@ impl CallEngine {
last_written = w;
written_samples = written_samples.saturating_add(w as u64);
if w < n && decoded_frames <= 10 {
tracing::warn!(n, w, "recv: partial playout write (ring nearly full)");
tracing::warn!(
n,
w,
"recv: partial playout write (ring nearly full)"
);
}
} else if decoded_frames <= 3 || decoded_frames % 100 == 0 {
// User clicked spk-mute — log it so we don't chase ghost bugs
tracing::info!(decoded_frames, "recv: spk_muted=true, skipping playout write");
tracing::info!(
decoded_frames,
"recv: spk_muted=true, skipping playout write"
);
}
}
Err(e) => {
@@ -1302,8 +1339,7 @@ impl CallEngine {
let relay_addr: SocketAddr = relay.parse()?;
let seed = crate::load_or_create_seed()
.map_err(|e| anyhow::anyhow!("identity: {e}"))?;
let seed = crate::load_or_create_seed().map_err(|e| anyhow::anyhow!("identity: {e}"))?;
let fp = seed.derive_identity().public_identity().fingerprint;
let fingerprint = fp.to_string();
info!(%fp, "identity loaded");
@@ -1325,15 +1361,20 @@ impl CallEngine {
ep
} else {
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let ep = wzp_transport::create_endpoint(bind_addr, None)
.map_err(|e| { error!("create_endpoint failed: {e}"); e })?;
let ep = wzp_transport::create_endpoint(bind_addr, None).map_err(|e| {
error!("create_endpoint failed: {e}");
e
})?;
info!(local_addr = ?ep.local_addr().ok(), "created new endpoint, dialing relay");
ep
};
let client_config = wzp_transport::client_config();
let conn = wzp_transport::connect(&endpoint, relay_addr, &room, client_config)
.await
.map_err(|e| { error!("connect failed: {e}"); e })?;
.map_err(|e| {
error!("connect failed: {e}");
e
})?;
info!("QUIC connection established, performing handshake");
Arc::new(wzp_transport::QuinnTransport::new(conn))
};
@@ -1343,13 +1384,13 @@ impl CallEngine {
// accept_handshake handler. See the android branch's
// comment for the full rationale.
if !is_direct_p2p {
let _session = wzp_client::handshake::perform_handshake(
&*transport,
&seed.0,
Some(&alias),
)
.await
.map_err(|e| { error!("perform_handshake failed: {e}"); e })?;
let _session =
wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
.await
.map_err(|e| {
error!("perform_handshake failed: {e}");
e
})?;
} else {
info!("direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)");
}
@@ -1494,11 +1535,9 @@ impl CallEngine {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update(
snap.loss_pct,
snap.rtt_ms,
pq.jitter_ms,
) {
if let Some(tuning) =
dred_tuner.update(snap.loss_pct, snap.rtt_ms, pq.jitter_ms)
{
encoder.apply_dred_tuning(tuning);
}
}
@@ -1558,7 +1597,9 @@ impl CallEngine {
{
let mut rx = recv_rx_codec.lock().await;
let codec_name = format!("{:?}", pkt.header.codec_id);
if *rx != codec_name { *rx = codec_name; }
if *rx != codec_name {
*rx = codec_name;
}
}
// Auto-switch decoder if incoming codec differs
if pkt.header.codec_id != current_codec {
@@ -1575,9 +1616,8 @@ impl CallEngine {
// start() recv task for full commentary.
if pkt.header.codec_id.is_opus() {
dred_recv.ingest_opus(pkt.header.seq, &pkt.payload);
let frame_samples_now = (48_000
* current_profile.frame_duration_ms as usize)
/ 1000;
let frame_samples_now =
(48_000 * current_profile.frame_duration_ms as usize) / 1000;
let spk_muted_flag = recv_spk.load(Ordering::Relaxed);
dred_recv.fill_gap_to(
&mut decoder,

View File

@@ -74,7 +74,9 @@ fn save_to_disk(entries: &[CallHistoryEntry]) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(json) = serde_json::to_vec_pretty(entries) else { return };
let Ok(json) = serde_json::to_vec_pretty(entries) else {
return;
};
// Atomic write via temp file + rename so a crash mid-write doesn't
// leave us with a half-file on disk.
let tmp = path.with_extension("json.tmp");
@@ -94,12 +96,7 @@ fn now_unix() -> u64 {
/// Append a new entry to the store and persist to disk. Trims the store to
/// `MAX_ENTRIES` after insertion.
pub fn log(
call_id: String,
peer_fp: String,
peer_alias: Option<String>,
direction: CallDirection,
) {
pub fn log(call_id: String, peer_fp: String, peer_alias: Option<String>, direction: CallDirection) {
tracing::info!(
%call_id, %peer_fp, ?direction,
alias = ?peer_alias,

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,10 @@ static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_START_BT: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
static AUDIO_CAPTURE_AVAILABLE: OnceLock<extern "C" fn() -> usize> = OnceLock::new();
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new();
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new();
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> =
OnceLock::new();
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> =
OnceLock::new();
static AUDIO_IS_RUNNING: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_CAPTURE_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
static AUDIO_PLAYOUT_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
@@ -56,25 +58,68 @@ pub fn init() -> Result<(), String> {
unsafe {
macro_rules! resolve {
($cell:expr, $ty:ty, $name:expr) => {{
let sym: libloading::Symbol<$ty> = lib_ref.get($name)
.map_err(|e| format!("dlsym {}: {e}", core::str::from_utf8($name).unwrap_or("?")))?;
let sym: libloading::Symbol<$ty> = lib_ref.get($name).map_err(|e| {
format!("dlsym {}: {e}", core::str::from_utf8($name).unwrap_or("?"))
})?;
// Dereference the Symbol to extract the raw fn pointer;
// it stays valid because lib_ref is 'static.
$cell.set(*sym).map_err(|_| format!("{} already set", core::str::from_utf8($name).unwrap_or("?")))?;
$cell.set(*sym).map_err(|_| {
format!("{} already set", core::str::from_utf8($name).unwrap_or("?"))
})?;
}};
}
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version");
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello");
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start");
resolve!(AUDIO_START_BT, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start_bt");
resolve!(
VERSION,
unsafe extern "C" fn() -> i32,
b"wzp_native_version"
);
resolve!(
HELLO,
unsafe extern "C" fn(*mut u8, usize) -> usize,
b"wzp_native_hello"
);
resolve!(
AUDIO_START,
unsafe extern "C" fn() -> i32,
b"wzp_native_audio_start"
);
resolve!(
AUDIO_START_BT,
unsafe extern "C" fn() -> i32,
b"wzp_native_audio_start_bt"
);
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
resolve!(AUDIO_CAPTURE_AVAILABLE, extern "C" fn() -> usize, b"wzp_native_audio_capture_available");
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture");
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout");
resolve!(AUDIO_IS_RUNNING, unsafe extern "C" fn() -> i32, b"wzp_native_audio_is_running");
resolve!(AUDIO_CAPTURE_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_capture_latency_ms");
resolve!(AUDIO_PLAYOUT_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_playout_latency_ms");
resolve!(
AUDIO_CAPTURE_AVAILABLE,
extern "C" fn() -> usize,
b"wzp_native_audio_capture_available"
);
resolve!(
AUDIO_READ_CAPTURE,
unsafe extern "C" fn(*mut i16, usize) -> usize,
b"wzp_native_audio_read_capture"
);
resolve!(
AUDIO_WRITE_PLAYOUT,
unsafe extern "C" fn(*const i16, usize) -> usize,
b"wzp_native_audio_write_playout"
);
resolve!(
AUDIO_IS_RUNNING,
unsafe extern "C" fn() -> i32,
b"wzp_native_audio_is_running"
);
resolve!(
AUDIO_CAPTURE_LATENCY,
unsafe extern "C" fn() -> f32,
b"wzp_native_audio_capture_latency_ms"
);
resolve!(
AUDIO_PLAYOUT_LATENCY,
unsafe extern "C" fn() -> f32,
b"wzp_native_audio_playout_latency_ms"
);
}
Ok(())
@@ -92,7 +137,9 @@ pub fn version() -> i32 {
}
pub fn hello() -> String {
let Some(f) = HELLO.get() else { return String::new(); };
let Some(f) = HELLO.get() else {
return String::new();
};
let mut buf = [0u8; 64];
let n = unsafe { f(buf.as_mut_ptr(), buf.len()) };
String::from_utf8_lossy(&buf[..n]).into_owned()
@@ -125,32 +172,47 @@ pub fn audio_stop() {
/// Number of capture samples available to read without blocking.
pub fn audio_capture_available() -> usize {
let Some(f) = AUDIO_CAPTURE_AVAILABLE.get() else { return 0; };
let Some(f) = AUDIO_CAPTURE_AVAILABLE.get() else {
return 0;
};
f()
}
/// Read captured i16 PCM into `out`. Returns bytes actually copied.
pub fn audio_read_capture(out: &mut [i16]) -> usize {
let Some(f) = AUDIO_READ_CAPTURE.get() else { return 0; };
let Some(f) = AUDIO_READ_CAPTURE.get() else {
return 0;
};
unsafe { f(out.as_mut_ptr(), out.len()) }
}
/// Write i16 PCM into the playout ring. Returns samples enqueued.
pub fn audio_write_playout(input: &[i16]) -> usize {
let Some(f) = AUDIO_WRITE_PLAYOUT.get() else { return 0; };
let Some(f) = AUDIO_WRITE_PLAYOUT.get() else {
return 0;
};
unsafe { f(input.as_ptr(), input.len()) }
}
pub fn audio_is_running() -> bool {
AUDIO_IS_RUNNING.get().map(|f| unsafe { f() } != 0).unwrap_or(false)
AUDIO_IS_RUNNING
.get()
.map(|f| unsafe { f() } != 0)
.unwrap_or(false)
}
#[allow(dead_code)]
pub fn audio_capture_latency_ms() -> f32 {
AUDIO_CAPTURE_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
AUDIO_CAPTURE_LATENCY
.get()
.map(|f| unsafe { f() })
.unwrap_or(0.0)
}
#[allow(dead_code)]
pub fn audio_playout_latency_ms() -> f32 {
AUDIO_PLAYOUT_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
AUDIO_PLAYOUT_LATENCY
.get()
.map(|f| unsafe { f() })
.unwrap_or(0.0)
}

View File

@@ -0,0 +1,228 @@
# Relay Abuse: Attack Surface & Mitigations
> WZP is end-to-end encrypted. The relay forwards ciphertext and cannot inspect payload content. This document enumerates the abuse vectors that survive E2E and the mitigations available without breaking it.
>
> Motivating threat: a PoC on another project (LiveKit) showed that an E2E SFU with no conformance enforcement can be repurposed as a free arbitrary-data tunnel. WZP must not be that.
## Threat model
### In scope
- **Bulk data tunneling.** Attacker uses a legitimate handshake, then pushes arbitrary bytes (file transfer, piracy, scraped traffic) through media datagrams.
- **Bandwidth parasitism.** Attacker uses the relay as a cheap forwarder for unrelated traffic at scale.
- **Quota / billing evasion.** Attacker disguises high-bandwidth use as low-bandwidth audio.
- **DoS via amplification.** Attacker sends one packet → SFU fans out to N peers, multiplying egress cost N×.
### Out of scope (cannot be solved without breaking E2E)
- **Steganography inside real audio.** Modulating Opus-encoded waveforms to encode a covert channel. Information-theoretic limit; ~tens to hundreds of bps achievable; economically uninteresting.
- **Modem-over-call.** Real audio whose semantic content is data. Same limit.
- **Slow exfiltration under all rate caps.** Attacker who stays within audio's natural bandwidth envelope, indefinitely.
### Threat actor profile
We are defending against **economically motivated abuse at scale**, not against a determined nation-state covert channel. The former needs bandwidth and is loud; the latter is impossible to stop and not worth the engineering cost.
## What the relay can observe
Despite E2E, the relay sees a lot. None of this is encrypted to the relay:
| Observable | Source | Bits available |
|---|---|---|
| `CodecID` (declared codec) | `MediaHeader`, AAD | 4 (today) / 6 (v2) |
| `MediaType` (audio / video / data / control) | `MediaHeader` v2 | 2 |
| `sequence`, `timestamp_ms` | `MediaHeader` | 32 + 32 |
| `fec_block_id`, `fec_symbol_idx`, `FecRatio`, `T` (repair) | `MediaHeader` | varies |
| `KeyFrame` bit | `MediaHeader` v2 | 1 |
| `Q` flag (QualityReport trailer present) | `MediaHeader` | 1 |
| Packet size | QUIC layer | — |
| Packet inter-arrival timing | QUIC layer | — |
| Aggregate bytes/sec per session | RelayMetrics | — |
| Source fingerprint, src IP | Session state | — |
This is enough surface for strong conformance enforcement without ever touching encrypted payload.
## Mitigation tiers
Listed in order of cost-to-implement vs. decisiveness. Tier A alone kills the gross-abuse threat. Higher tiers add defense in depth.
### Tier A — Codec-conformance bitrate caps
For each declared `CodecID`, the wire bitrate has a math-derivable hard ceiling:
```
ceiling_bps[CodecID] = nominal_bitrate * (1 + max_FEC_ratio) * (1 + overhead_pct)
= nominal * 3.0 * 1.15 // FEC max 2.0 → factor 3.0
```
| Codec | Nominal | Hard ceiling |
|---|---|---|
| Opus 64k | 64 kbps | ~221 kbps |
| Opus 24k | 24 kbps | ~83 kbps |
| Opus 6k | 6 kbps | ~21 kbps |
| Codec2 1200 | 1.2 kbps | ~4 kbps |
| ComfortNoise | 0 | ~2 kbps |
Sliding 1 s window per session. Sustained excess → hard violation, close session.
Decisive against bulk tunneling. False-positive rate negligible if ceilings set at math-derived max × 1.5.
### Tier B — Packet-rate conformance
Each codec has a fixed frame interval (20 ms or 40 ms), so legal `pps` is 25 or 50, plus FEC repair packets (max ~150 pps total at FEC ratio 2.0). Anything sustaining > 200 pps for an audio codec is not audio.
### Tier C — Timestamp-rate consistency
`timestamp_ms` advances at the declared frame interval. `Δtimestamp / Δseq` over a rolling window should match the codec's frame duration ±2×. Divergence catches abusers who send audio-rate small packets but burn fields for payload.
### Tier D — Per-codec packet-size sanity
EWMA of packet size per session, compared to per-codec typical:
| Codec | Typical | Reject above |
|---|---|---|
| Opus 24k 20 ms | 6080 B | 160 B |
| Opus 6k 40 ms | 3040 B | 90 B |
| Codec2 1200 40 ms | 6 B | 30 B |
| ComfortNoise | 04 B | 16 B |
### Tier E — Per-fingerprint / per-IP token bucket
Aggregate quota regardless of declared codec:
```
For each (fingerprint, src_ip):
monthly_bytes_quota authenticated = 50 GB (tune)
anonymous = 1 GB
per-session cap audio = 256 kbps
video = 5 Mbps
burst = 30 s at 2× cap
```
Won't stop a single rogue session under cap; bounds aggregate blast radius and makes relay economics predictable.
### Tier F — Behavioral entropy / statistical fingerprinting
The deeper layer. Computed continuously per session over 1030 s windows. Combined score flags streams that pass declared-codec checks but do not statistically look like real media.
**Why this works:** real audio and real video have very specific statistical signatures that tunneled data does not naturally produce, and that an attacker would have to deliberately and expensively mimic. The signatures differ wildly between audio and video — which is exactly why we separate them (see next section).
#### Audio fingerprint features
| Feature | Real Opus speech | Tunneled data |
|---|---|---|
| **IAT coefficient of variation** | 0.10.4 (clocked) | > 1.0 (bursty) |
| **Payload-size distribution** | Bimodal: speech 6080 B + silence/CN 010 B | Unimodal, large, MTU-skewed |
| **Silence fraction** | 1040 % (real conversation pauses) | < 2 % |
| **Bitrate over 30 s** | Tracks nominal codec ±20 % | Often saturates ceiling |
| **`Q` flag cadence** | Periodic, regular | Absent or random |
| **DRED / FEC ratio response** | Tracks `QualityReport` trend | Static or noise |
Single derived score: `audio_legitimacy ∈ [0, 1]`. Below threshold (e.g. 0.3) for 60 s → flag.
#### Video fingerprint features (post-V1)
| Feature | Real H.264 / AV1 video | Tunneled data |
|---|---|---|
| **Keyframe periodicity** | Regular (every 14 s, or on PLI) | Absent or uniform `KeyFrame=1` |
| **Frame-size ratio (I / P)** | 520× | ≈ 1× |
| **Burst structure** | One I-frame = N packets in < 5 ms, then quiet | Uniform spacing |
| **Bitrate response to BWE feedback** | Tracks `TransportFeedback::remb_bps` | Ignores it |
| **Resolution / FPS implied by bitrate** | Coherent (240 p ≠ 8 Mbps) | Incoherent |
| **NACK / PLI responsiveness** | Sender produces keyframe within 200 ms | No response |
Single derived score: `video_legitimacy ∈ [0, 1]`.
#### Implementation shape
```rust
pub struct LegitimacyScorer {
media_type: MediaType,
iat_ewma: ExponentialMovingAverage,
iat_variance: ExponentialMovingVariance,
size_histogram: SizeBuckets<8>,
silence_count: u32,
speech_count: u32,
quality_reports_seen: u32,
keyframe_intervals: RingBuffer<u32, 16>,
window_start: Instant,
}
impl LegitimacyScorer {
pub fn observe(&mut self, header: &MediaHeader, payload_len: usize, now: Instant);
pub fn score(&self) -> f32; // [0, 1]
pub fn verdict(&self) -> Verdict; // Legitimate | Suspect | Abusive
}
```
Cheap: a few floats and counters per session. Update on every packet, score every 1 s, escalate over 30+ s.
### Tier G — Reactive response
A scoring system needs a response policy:
| Verdict | Action |
|---|---|
| Legitimate | None |
| Suspect | Apply tighter Tier-E quota; emit `relay_conformance_suspect_total` |
| Abusive | Close session with `Hangup::PolicyViolation`; log to audit; cool-down fingerprint |
| Repeat-abusive | Lower-tier quota across the federation (gossip via federation channel) |
Never silent-drop. Always close with a typed reason so legitimate users hitting a bug get a clear error.
## Separating audio and video
**Yes — this is one of the strongest arguments for the v2 `MediaType` bit and should be a hard design rule.**
Audio and video have nothing in common statistically:
| Property | Audio | Video |
|---|---|---|
| Bitrate | 664 kbps | 100 kbps 5 Mbps |
| Packet rate | 2550 pps | 5002000 pps |
| Packet size | 6160 B | 2001450 B |
| Burst structure | Clocked, near-CBR | Bursty (I-frames) |
| Silence | Common (1040 %) | Meaningless |
| Loss tolerance | High (PLC, DRED) | Variable (keyframes critical) |
| Recovery primitive | FEC + DRED | NACK + PLI + keyframe cache |
A single scoring model trying to cover both would have to be so permissive at the union of envelopes that it would let tunnels through. **Separation is mandatory for Tier F to work.**
### What separation requires
1. **`MediaType:2` in `MediaHeader` v2** (already in `ROAD-TO-VIDEO.md` Phase V1). Without this, the relay must keep a `CodecID → MediaType` table and update it every time a codec is added — fragile.
2. **Per-`MediaType` conformance rules.** A and B and D have separate tables per type. Tier F has separate scorers.
3. **Per-`MediaType` quotas.** Tier E uses two buckets: `audio_bps_cap`, `video_bps_cap`. A session in audio-only mode never gets to spend the video budget. A video session has both, audio-priority.
4. **Per-`MediaType` keyframe/silence semantics.** `KeyFrame` bit is meaningless for audio; silence fraction is meaningless for video. The scorer needs to know which features apply.
### Bonus: separation also helps the SFU
Beyond abuse detection, the same separation makes graceful degradation cleaner: under congestion the relay can drop video packets first while preserving audio, because it knows which is which without parsing the codec table.
## Open questions for later decision
1. **Hard-close on first hard violation, or three-strikes?** Three-strikes is friendlier but lets twice the abuse through. Recommend hard-close + clear typed reason; legitimate users will reconnect, abusers won't try again at the same fingerprint.
2. **Where do verdicts persist?** In-memory per relay is simplest. Federated gossip is more powerful but a new attack surface (poisoning).
3. **Threshold tuning.** All thresholds in this doc are first-pass math. Real numbers come from a few weeks of Prometheus data on legitimate traffic before any enforcement turns on.
4. **Anonymous vs. authenticated split.** featherChat-authed users get generous quotas; anonymous users get tight ones. This makes the economics of mass abuse hostile (need many real identities) without locking out small legitimate use.
5. **What to log.** Conformance hits should be Prometheus counters + ringbuffer of recent violations; never log raw payload content (even encrypted) for privacy.
## Suggested implementation order (whenever this is picked up)
| Step | What | Why first |
|---|---|---|
| 1 | Land v2 wire format with `MediaType:2` | Prereq for separation; already on the road-to-video plan |
| 2 | Tier A + B + C as `wzp-relay/src/conformance.rs` | Kills bulk tunneling; cheap; no false positives if math is right |
| 3 | Prometheus metrics for violations + raw observables (IAT, size, silence frac) | Gather baseline of legitimate traffic before tightening |
| 4 | Tier D + E (size sanity + token bucket) | Defense in depth |
| 5 | Tier F scorer, audio-only first; tuned against the baseline from step 3 | Adds covert-tunnel pressure |
| 6 | Tier F video scorer once video is in production | Same shape, different features |
| 7 | Tier G response policy + audit log | Operationalize |
Steps 12 are decisive against the LiveKit-style PoC. The rest is steady tightening as real traffic accumulates.
## What this does NOT promise
- It does not stop a patient adversary running a slow covert channel inside real audio. Nothing E2E-preserving can.
- It does not detect content (no CSAM scan, no copyright fingerprint). Those would require breaking E2E and are out of scope by design.
- It does not eliminate abuse — it makes abuse loud, expensive, and detectable, which is the realistic goal for any E2E system.

View File

@@ -0,0 +1,109 @@
# PRD: Protocol Hardening Batch
> **Status:** proposed
> **Resolves:** Audit W2 (fec_block_id width), W3 (timestamp rebase doc), W5 (QualityReport AEAD binding), W11 (per-stream anti-replay), W12 (signal version byte), W13 (RoomManager lock).
> **Depends on:** PRD #1 (wire format v2 already widens block_id field).
## Problem
A handful of medium-priority audit findings that don't individually justify a PRD but together represent the long tail of protocol correctness and concurrency. Batching them avoids version churn.
## Items
### H1 — W5: `QualityReport` trailer must be inside AEAD
**Current risk.** If the 4-byte trailer sits *outside* the encrypted payload, anything stripping the last 4 bytes corrupts AEAD verification on legitimate packets and creates a quality-feedback downgrade vector. Even if it's correctly inside today, the v2 wire format change is the right moment to assert this explicitly.
**Action.**
- Audit `crates/wzp-proto/src/packet.rs` for `QualityReport` placement.
- Move inside AEAD payload if currently outside.
- Document: "QualityReport, when Q-flag set, is appended to plaintext payload before encryption."
- Test: tamper with trailer → AEAD decrypt fails.
**Severity.** Security correctness. Do this in Wave 1.
### H2 — W2: `fec_block_id` width
Resolved by v2 wire format (`u16` instead of `u8`). PRD #1 carries the wire change; this PRD just confirms semantics:
- Wraps at 2^16. At 5-frame blocks and 50 pps → ~22 min between collisions, vs. ~25 s in v1.
- Late-joining peers must still discard FEC blocks older than 2 s; widening is defense in depth.
**Action.** Update `wzp-fec` to operate on u16 block_id end-to-end. Test reconstruction across a synthetic 22-min session.
### H3 — W11: Per-stream, per-`MediaType` anti-replay window
**Current.** 64-packet sliding window globally.
**Problem.** Video keyframe burst (100+ packets) can stall the window behind one reordered prior packet.
**Action.**
- Anti-replay state is per (stream_id, media_type).
- Window size: 64 for audio, 1024 for video, 256 for data.
- Window size selected at session setup based on declared profile; tunable via `QualityProfile`.
**Severity.** Required before video. Wave 1.
### H4 — W12: `SignalMessage` versioning
**Current.** Bincode-serialized enum. `#[serde(default, skip_serializing_if)]` handles field additions; variant removals or semantic changes are unsafe.
**Action.**
- Every variant gains `version: u8` as its first field.
- Add `SignalMessage::Unknown { version, raw: Bytes }` to absorb future unknown variants gracefully.
- Decode path: unknown variant → log + drop, do not close session.
**Severity.** Future-proofing. Wave 3.
### H5 — W3: `timestamp_ms` rebase documentation
**Current.** Behavior at rekey (every 65,536 packets, ~22 min) is not documented.
**Decision (this PRD).** `timestamp_ms` is **monotonic across rekeys** — it does not reset. Rekey changes only the cryptographic key material; sequence and timestamp are session-scoped, not key-scoped.
**Action.**
- Document in `WZP-SPEC.md` and inline in `packet.rs` doc comments.
- Add a test that performs a rekey mid-session and asserts `timestamp_ms` continuity.
**Severity.** Doc + test. Wave 3.
### H6 — W13: `RoomManager` lock concurrency
**Current.** Single `Mutex<RoomManager>` acquired per packet by every participant for fan-out peer list. Serializes packet processing within a room.
**Problem.** At 1500 pps/sender for video, this is the dominant bottleneck.
**Action.**
- Migrate to `DashMap<RoomId, Arc<RwLock<Room>>>`.
- Per-room `RwLock` allows concurrent reads (fan-out peer list) and exclusive writes (join/leave/quality changes).
- Fan-out path holds read lock; participant churn holds write lock.
- Federation manager updated to match.
**Severity.** Required for video scale. Wave 3.
**Migration safety.**
- Integration test suite (40 + 4 relay tests) must pass.
- Federation tests must pass.
- Trunking tests must pass.
- Property-test: 100-participant room, 500 join/leave events, 10k packets — no panics, no missed forwards.
## Implementation order
| Wave | Item | Task |
|---|---|---|
| 1 | H1 (W5 AEAD binding) | T1.4 |
| 1 | H3 (W11 anti-replay per-stream) | T1.5 |
| 1 | H2 (W2 block_id widening) | folded into PRD #1 |
| 3 | H4 (W12 signal versioning) | T3.3 |
| 3 | H5 (W3 timestamp doc) | T3.2 |
| 3 | H6 (W13 RoomManager lock) | T3.4 |
## Acceptance criteria
- All current tests pass post-hardening.
- New tests: AEAD trailer tampering, rekey timestamp continuity, 100-participant property test, signal forward-compat decode.
- No Prometheus regression in fan-out latency p99 after H6.
## Effort
~4.5 engineer-days total (1.5 in Wave 1, 3 in Wave 3).

View File

@@ -0,0 +1,171 @@
# PRD: Relay Conformance Enforcement (Abuse Mitigation Tiers AG)
> **Status:** proposed
> **Resolves:** All in-scope vectors from `docs/ATTACK-SURFACE-RELAY-ABUSE.md`.
> **Depends on:** PRD #1 (wire format v2 — for `MediaType` separation in Tiers D/F).
## Problem
WZP relays forward E2E-encrypted ciphertext and cannot inspect payload content. A trivial PoC on another E2E SFU (LiveKit) showed that without conformance enforcement, the relay becomes a free arbitrary-data tunnel. WZP must enforce media-shape conformance against observable header and timing metadata, without breaking E2E.
## Goals
- Make bulk data tunneling through WZP infeasible.
- Bound aggregate per-user abuse blast radius.
- Make covert tunneling expensive (Tier F) without false-positiving real calls.
- Audio and video evaluated by **separate scorers** (statistical signatures don't overlap).
## Non-goals
- Content inspection (would break E2E).
- Detecting steganographic covert channels inside legitimate audio (information-theoretic limit; not worth chasing).
- CSAM / copyright detection (would require E2E break; explicit non-goal).
## Design — tiered enforcement
### Tier A — Codec-conformance bitrate caps
For each `CodecID`, compute math-derived ceiling and enforce sliding 1 s window per session:
```
ceiling_bps[CodecID] = nominal * (1 + max_FEC_ratio) * (1 + overhead_pct)
= nominal * 3.0 * 1.15
```
Hard violation (sustained > ceiling for 1 s) → close session with `Hangup::PolicyViolation { code: BITRATE }`.
### Tier B — Packet-rate cap
Per `CodecID`, max `pps` known (25 or 50 base × up to 3× for FEC = ~150 pps for audio). Sustained > 200 pps audio → hard violation.
### Tier C — Timestamp-rate consistency
`Δtimestamp_ms / Δsequence` over rolling 200-packet window must match codec frame duration ± 2×. Violation → hard.
### Tier D — Per-codec packet-size sanity
EWMA(`payload_len`) per session; reject sustained mean > 2× codec typical. Per-codec table in spec.
### Tier E — Per-fingerprint / per-IP token bucket
```
For each (fingerprint, src_ip):
monthly_bytes_quota authed = 50 GB (tunable)
anon = 1 GB
per-session bps cap audio = 256 kbps
video = 5 Mbps
burst = 30 s @ 2× cap
```
Anonymous quotas tight; authenticated (via featherChat) quotas generous. Soft enforcement: throttle, then close on persistent overage.
### Tier F — Behavioral entropy scoring (per `MediaType`)
Separate scorers for audio and video. Computed over 1030 s windows.
**Audio scorer features:**
| Feature | Legitimate | Abusive |
|---|---|---|
| IAT coefficient of variation | 0.10.4 | > 1.0 |
| Payload-size bimodality | Bimodal (speech + silence) | Unimodal |
| Silence fraction | 1040 % | < 2 % |
| 30 s bitrate vs. nominal | ± 20 % | Saturates ceiling |
| `Q` flag cadence | Periodic | Absent/random |
**Video scorer features (post-PRD #5):**
| Feature | Legitimate | Abusive |
|---|---|---|
| Keyframe periodicity | Regular (14 s or on PLI) | Absent / uniform KF=1 |
| I/P frame-size ratio | 520× | ~1× |
| Burst structure | I-frame in < 5 ms, then quiet | Uniform spacing |
| Bitrate response to BWE | Tracks `remb_bps` | Ignores |
| NACK/PLI responsiveness | Keyframe within 200 ms | No response |
Output: `legitimacy ∈ [0, 1]` per session per `MediaType`. < 0.3 for 60 s → Suspect; < 0.1 for 60 s → Abusive.
### Tier G — Reactive response
```
Verdict::Legitimate → no action
Verdict::Suspect → apply tighter Tier E quota; emit metric
Verdict::Abusive → close session with typed Hangup; cool-down fingerprint 1 h
Verdict::RepeatAbusive → relay-local block 24 h; (optional gossip)
```
Always typed close. No silent drops.
## Implementation outline
New module `wzp-relay/src/conformance.rs`:
```rust
pub struct ConformanceMeter {
media_type: MediaType,
declared_codec: AtomicU8,
bytes_window: SlidingWindow<1000>,
packet_window: SlidingWindow<1000>,
iat_ewma: ExponentialMovingAverage,
iat_variance: ExponentialMovingVariance,
size_histogram: SizeBuckets<8>,
silence_count: AtomicU32,
speech_count: AtomicU32,
quality_reports_seen: AtomicU32,
last_timestamp_ms: AtomicU32,
last_seq: AtomicU32,
keyframe_intervals: RingBuffer<u32, 16>,
violations: AtomicU32,
}
impl ConformanceMeter {
pub fn observe(&self, h: &MediaHeader, payload_len: usize, now: Instant) -> Result<(), Violation>;
pub fn legitimacy(&self) -> f32;
pub fn verdict(&self) -> Verdict;
}
```
Hooked into per-participant forwarding loop in `RoomManager`. Tier AD run synchronously (cheap). Tier F runs on a periodic task (every 1 s per session).
Prometheus exports:
```
wzp_relay_conformance_violations_total{tier,codec_id,media_type,verdict}
wzp_relay_conformance_legitimacy{media_type} histogram
wzp_relay_conformance_iat_cov{media_type} histogram
wzp_relay_conformance_silence_fraction histogram
```
## Rollout
1. Deploy with all tiers in **observe-only** mode (Prometheus only, no enforcement).
2. Collect 12 weeks of baseline traffic.
3. Set thresholds at observed 99.9th percentile of legitimate traffic + headroom.
4. Flip Tier A enforcement first (highest confidence, lowest false-positive risk).
5. Flip B, C, D over 2 weeks.
6. Tune Tier F thresholds against the baseline; flip Suspect first, then Abusive.
## Acceptance criteria
- Synthetic abuse test (5 Mbps random bytes declared as Opus 24 k) closed within 1 s.
- Synthetic abuse test (audio-rate small packets with stuffed payload) closed within 5 s by Tier D.
- Synthetic abuse test (audio-rate, audio-sized, but no silence and CoV=2.0 IAT) flagged Suspect within 60 s.
- Real-call false-positive rate < 0.1 % over a week of production baseline.
- All verdict transitions emit Prometheus counters.
## Risks
- **False positives on edge cases** (long lectures with little silence, ambient-music calls). Mitigation: Tier F floor at Suspect for 30 s minimum; manual review channel for repeat-flagged authed users.
- **Threshold drift** as codecs evolve. Mitigation: ceilings are math-derived from codec table; updated when codec table updates.
- **Federated abuse moving between relays.** Mitigation: Tier G optional gossip (post-Wave 5).
## Effort
- Tier A + B + C: 1.5 d (T2.4 + T2.5)
- Tier D: 0.5 d (T3.6)
- Tier E: 1.5 d (T3.5)
- Tier F audio: 3 d (T5.7)
- Tier F video: 3 d (T6.2)
- Tier G: 1 d (T5.8)
Total: ~10 engineer-days, spread across Waves 26.

Some files were not shown because too many files have changed in this diff Show More