feat(hole-punching): advertise peer reflexive addrs in DirectCall flow — Phase 3
Completes the signal-plane plumbing for P2P direct calling: both
peers now learn their own server-reflexive address (Phase 1
Reflect), include it in DirectCallOffer / DirectCallAnswer, and
the relay cross-wires them into each side's CallSetup so the
client knows the OTHER party's direct addr. Dual-path QUIC race
is scaffolded but deferred to Phase 3.5 — this commit ships the
full advertising layer so real-hardware testing can confirm the
addrs flow end-to-end before adding the concurrent-connect logic.
Wire protocol (wzp-proto/src/packet.rs):
- DirectCallOffer gains optional `caller_reflexive_addr`
- DirectCallAnswer gains optional `callee_reflexive_addr`
- CallSetup gains optional `peer_direct_addr`
- All #[serde(default, skip_serializing_if = "Option::is_none")] so
pre-Phase-3 peers and relays stay backward compatible by
construction — the new fields are elided from the JSON on the
wire when None, and older clients parse the JSON ignoring any
fields they don't know.
- 2 new roundtrip tests (Some + None cases, old-JSON parse-back).
Call registry (wzp-relay/src/call_registry.rs):
- DirectCall gains caller_reflexive_addr + callee_reflexive_addr.
- set_caller_reflexive_addr / set_callee_reflexive_addr setters.
- 2 new unit tests: stores and returns addrs, clearing works.
Relay cross-wiring (wzp-relay/src/main.rs):
- On DirectCallOffer: stash the caller's addr in the registry.
- On DirectCallAnswer: stash the callee's addr (only set by
AcceptTrusted answers — privacy-mode leaves it None).
- Send two different CallSetup messages: one to the caller with
peer_direct_addr=callee_addr, and one to the callee with
peer_direct_addr=caller_addr. The cross-wiring means each side
gets the OTHER party's direct addr, not its own.
- Logs `p2p_viable=true` when both sides advertised.
Client advertising (desktop/src-tauri/src/lib.rs):
- New `try_reflect_own_addr` helper that reuses the Phase 1
oneshot pattern WITHOUT holding state.signal.lock() across the
await (critical: the recv loop reacquires the same mutex to
fire the oneshot, so holding it would deadlock).
- `place_call` queries reflect first and includes the returned
addr in DirectCallOffer. Falls back to None on any failure —
call still proceeds via the relay path.
- `answer_call` queries reflect ONLY on AcceptTrusted so
AcceptGeneric keeps the callee's IP private by design. Reject
and AcceptGeneric both pass None.
- recv loop's CallSetup handler destructures and forwards
peer_direct_addr to the JS layer in the signal-event payload.
Client scaffolding for dual-path (desktop/src-tauri/src/lib.rs +
desktop/src/main.ts):
- `connect` Tauri command gets a new optional `peer_direct_addr`
argument. Currently LOGS the addr but still uses the relay
path for the media connection — Phase 3.5 will swap in a
tokio::select! race between direct dial + relay dial. Scaffolding
lands here so the JS wire is stable, real-hardware testing can
confirm advertising works end-to-end, and Phase 3.5 is a pure
Rust change with no JS touches.
- JS setup handler forwards `data.peer_direct_addr` to invoke.
Back-compat with the CLI client (crates/wzp-client/src/cli.rs):
- CLI test harness updated for the new fields — always passes
None for both reflex addrs (no hole-punching). Also destructures
peer_direct_addr: _ in its CallSetup handler.
Tests (8 new, all passing):
- wzp-proto: hole_punching_optional_fields_roundtrip,
hole_punching_backward_compat_old_json_parses
- wzp-relay call_registry: call_registry_stores_reflexive_addrs,
call_registry_clearing_reflex_addr_works
- wzp-relay integration: crates/wzp-relay/tests/hole_punching.rs
* both_peers_advertise_reflex_addrs_cross_wire_in_setup
* privacy_mode_answer_omits_callee_addr_from_setup
* pre_phase3_caller_leaves_both_setups_relay_only
* neither_peer_advertises_both_setups_are_relay_only
Full workspace test goes from 396 → 404 passing.
PRD: .taskmaster/docs/prd_hole_punching.txt
Tasks: 53-60 all completed (58 = scaffolding-only; 3.5 follow-up)
Next up: **Phase 3.5 — dual-path QUIC connect race**. With the
advertising layer live, this becomes a focused change: on
CallSetup-with-peer_direct_addr, start a server-capable dual
endpoint, and tokio::select! across (direct dial, relay dial,
inbound accept). Whichever QUIC handshake completes first wins,
the losers drop, 2s direct timeout falls back to relay.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -736,6 +736,15 @@ pub enum SignalMessage {
|
||||
signature: Vec<u8>,
|
||||
/// Supported quality profiles.
|
||||
supported_profiles: Vec<crate::QualityProfile>,
|
||||
/// Phase 3 (hole-punching): caller's own server-reflexive
|
||||
/// address as learned via `SignalMessage::Reflect`. The
|
||||
/// relay stashes this in its call registry and later
|
||||
/// injects it into the callee's `CallSetup.peer_direct_addr`
|
||||
/// so the callee can try a direct QUIC handshake to the
|
||||
/// caller instead of routing media through the relay.
|
||||
/// `None` means "caller doesn't want P2P, use relay only".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
caller_reflexive_addr: Option<String>,
|
||||
},
|
||||
|
||||
/// Callee's response to a direct call.
|
||||
@@ -755,6 +764,13 @@ pub enum SignalMessage {
|
||||
/// Chosen quality profile (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chosen_profile: Option<crate::QualityProfile>,
|
||||
/// Phase 3 (hole-punching): callee's own server-reflexive
|
||||
/// address, only populated on `AcceptTrusted` — privacy-mode
|
||||
/// answers leave this `None` so the callee's real IP stays
|
||||
/// hidden (the whole point of `AcceptGeneric`). The relay
|
||||
/// carries it opaquely into the caller's `CallSetup`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
callee_reflexive_addr: Option<String>,
|
||||
},
|
||||
|
||||
/// Relay tells both parties: media room is ready.
|
||||
@@ -764,6 +780,17 @@ pub enum SignalMessage {
|
||||
room: String,
|
||||
/// Relay address for the QUIC media connection.
|
||||
relay_addr: String,
|
||||
/// Phase 3 (hole-punching): the OTHER party's server-reflexive
|
||||
/// address as the relay learned it from the offer/answer
|
||||
/// exchange. When populated, clients attempt a direct QUIC
|
||||
/// handshake to this address in parallel with the existing
|
||||
/// relay path and use whichever connects first. `None`
|
||||
/// means the relay path is the only option — either because
|
||||
/// a peer didn't advertise its addr (Phase 1/2 relay or
|
||||
/// privacy-mode answer) or because the relay decided P2P
|
||||
/// wasn't viable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
peer_direct_addr: Option<String>,
|
||||
},
|
||||
|
||||
/// Ringing notification (relay → caller, callee received the offer).
|
||||
@@ -961,6 +988,133 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_punching_optional_fields_roundtrip() {
|
||||
// DirectCallOffer with Some(caller_reflexive_addr)
|
||||
let offer = SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: "c1".into(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&offer).unwrap();
|
||||
assert!(
|
||||
json.contains("caller_reflexive_addr"),
|
||||
"Some field must serialize: {json}"
|
||||
);
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => {
|
||||
assert_eq!(caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// DirectCallOffer with None — skip_serializing_if must
|
||||
// OMIT the field from the JSON so older relays that don't
|
||||
// know about caller_reflexive_addr don't see it.
|
||||
let offer_none = SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: "c1".into(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: None,
|
||||
};
|
||||
let json_none = serde_json::to_string(&offer_none).unwrap();
|
||||
assert!(
|
||||
!json_none.contains("caller_reflexive_addr"),
|
||||
"None field must NOT serialize: {json_none}"
|
||||
);
|
||||
|
||||
// DirectCallAnswer with callee_reflexive_addr.
|
||||
let answer = SignalMessage::DirectCallAnswer {
|
||||
call_id: "c1".into(),
|
||||
accept_mode: CallAcceptMode::AcceptTrusted,
|
||||
identity_pub: None,
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
};
|
||||
let decoded: SignalMessage =
|
||||
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallAnswer { callee_reflexive_addr, .. } => {
|
||||
assert_eq!(
|
||||
callee_reflexive_addr.as_deref(),
|
||||
Some("198.51.100.9:4433")
|
||||
);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// CallSetup with peer_direct_addr.
|
||||
let setup = SignalMessage::CallSetup {
|
||||
call_id: "c1".into(),
|
||||
room: "call-c1".into(),
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: Some("192.0.2.1:4433".into()),
|
||||
};
|
||||
let decoded: SignalMessage =
|
||||
serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert_eq!(peer_direct_addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_punching_backward_compat_old_json_parses() {
|
||||
// An older client/relay wouldn't include the new fields at
|
||||
// all — the new code must still accept that JSON because
|
||||
// of #[serde(default)] on the Option<String>.
|
||||
let old_offer_json = r#"{
|
||||
"DirectCallOffer": {
|
||||
"caller_fingerprint": "alice",
|
||||
"caller_alias": null,
|
||||
"target_fingerprint": "bob",
|
||||
"call_id": "c1",
|
||||
"identity_pub": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
"ephemeral_pub": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
"signature": [],
|
||||
"supported_profiles": []
|
||||
}
|
||||
}"#;
|
||||
let decoded: SignalMessage = serde_json::from_str(old_offer_json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => {
|
||||
assert!(caller_reflexive_addr.is_none());
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
let old_setup_json = r#"{
|
||||
"CallSetup": {
|
||||
"call_id": "c1",
|
||||
"room": "call-c1",
|
||||
"relay_addr": "203.0.113.5:4433"
|
||||
}
|
||||
}"#;
|
||||
let decoded: SignalMessage = serde_json::from_str(old_setup_json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert!(peer_direct_addr.is_none());
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_backward_compat_with_existing_variants() {
|
||||
// Adding Reflect/ReflectResponse at the end of the enum must
|
||||
|
||||
Reference in New Issue
Block a user