New SignalMessage variants for P2P quality coordination: UpgradeProposal/UpgradeResponse/UpgradeConfirm (#28): - Consensual quality upgrade flow — proposer sends desired profile, peer accepts/rejects based on own conditions, confirm commits both - All carry call_id for relay routing QualityCapability (#30): - Peer reports its max sustainable profile — enables asymmetric encoding where each side uses its own best quality instead of forcing everyone to the weakest link Relay forwards all 4 signals to the call peer (same pattern as MediaPathReport, CandidateUpdate, HardNatProbe). Desktop signal recv loop handles all 4 with debug logging. Encoder switching TODOs noted for wiring into CallEngine. 4 new serde roundtrip tests. 603 total, 0 regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,10 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::CandidateUpdate { .. } => CallSignalType::IceCandidate, // mid-call re-gather
|
||||
SignalMessage::HardNatProbe { .. } => CallSignalType::IceCandidate, // hard NAT coordination
|
||||
SignalMessage::HardNatBirthdayStart { .. } => CallSignalType::IceCandidate, // birthday attack
|
||||
SignalMessage::UpgradeProposal { .. }
|
||||
| SignalMessage::UpgradeResponse { .. }
|
||||
| SignalMessage::UpgradeConfirm { .. }
|
||||
| SignalMessage::QualityCapability { .. } => CallSignalType::Offer, // quality negotiation
|
||||
SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,6 +1019,61 @@ pub enum SignalMessage {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<String>,
|
||||
},
|
||||
|
||||
// ── Quality upgrade negotiation (#28, #29) ──────────────────
|
||||
|
||||
/// Peer proposes upgrading to a higher quality profile.
|
||||
/// The other side can accept or reject based on its own network
|
||||
/// conditions. Used for consensual upgrades that require both
|
||||
/// sides to agree (e.g., switching from Opus24k to Studio48k).
|
||||
UpgradeProposal {
|
||||
call_id: String,
|
||||
/// Unique ID for this proposal (to match response).
|
||||
proposal_id: String,
|
||||
/// The profile being proposed.
|
||||
proposed_profile: crate::QualityProfile,
|
||||
/// Current local network quality to justify the upgrade.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
local_loss_pct: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
local_rtt_ms: Option<u32>,
|
||||
},
|
||||
|
||||
/// Response to an UpgradeProposal.
|
||||
UpgradeResponse {
|
||||
call_id: String,
|
||||
proposal_id: String,
|
||||
/// true = accepted, both sides switch. false = rejected.
|
||||
accepted: bool,
|
||||
/// Reason for rejection (if any).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<String>,
|
||||
},
|
||||
|
||||
/// Confirmation that the upgrade is committed — both sides
|
||||
/// should switch encoder at the next frame boundary.
|
||||
UpgradeConfirm {
|
||||
call_id: String,
|
||||
proposal_id: String,
|
||||
confirmed_profile: crate::QualityProfile,
|
||||
},
|
||||
|
||||
// ── Per-participant quality (#30) ───────────────────────────
|
||||
|
||||
/// Peer reports its own quality capability — allows asymmetric
|
||||
/// encoding where each side uses the best quality its connection
|
||||
/// supports, rather than forcing all to the weakest link.
|
||||
QualityCapability {
|
||||
call_id: String,
|
||||
/// The best profile this peer can sustain based on its
|
||||
/// current network conditions.
|
||||
max_profile: crate::QualityProfile,
|
||||
/// Current loss/RTT for context.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
loss_pct: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
rtt_ms: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the callee responds to a direct call.
|
||||
@@ -1851,6 +1906,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quality negotiation roundtrip tests (#28, #29, #30) ─────
|
||||
|
||||
#[test]
|
||||
fn upgrade_proposal_roundtrip() {
|
||||
let msg = SignalMessage::UpgradeProposal {
|
||||
call_id: "c1".into(),
|
||||
proposal_id: "p1".into(),
|
||||
proposed_profile: crate::QualityProfile::STUDIO_48K,
|
||||
local_loss_pct: Some(0.5),
|
||||
local_rtt_ms: Some(25),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::UpgradeProposal { proposal_id, proposed_profile, .. } => {
|
||||
assert_eq!(proposal_id, "p1");
|
||||
assert_eq!(proposed_profile, crate::QualityProfile::STUDIO_48K);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_response_roundtrip() {
|
||||
let msg = SignalMessage::UpgradeResponse {
|
||||
call_id: "c1".into(),
|
||||
proposal_id: "p1".into(),
|
||||
accepted: true,
|
||||
reason: None,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::UpgradeResponse { accepted, .. } => assert!(accepted),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_confirm_roundtrip() {
|
||||
let msg = SignalMessage::UpgradeConfirm {
|
||||
call_id: "c1".into(),
|
||||
proposal_id: "p1".into(),
|
||||
confirmed_profile: crate::QualityProfile::STUDIO_64K,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::UpgradeConfirm { confirmed_profile, .. } => {
|
||||
assert_eq!(confirmed_profile, crate::QualityProfile::STUDIO_64K);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_capability_roundtrip() {
|
||||
let msg = SignalMessage::QualityCapability {
|
||||
call_id: "c1".into(),
|
||||
max_profile: crate::QualityProfile::GOOD,
|
||||
loss_pct: Some(2.5),
|
||||
rtt_ms: Some(80),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::QualityCapability { max_profile, loss_pct, .. } => {
|
||||
assert_eq!(max_profile, crate::QualityProfile::GOOD);
|
||||
assert!((loss_pct.unwrap() - 2.5).abs() < 0.01);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 8: Tailscale-inspired signal roundtrip tests ──────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1446,7 +1446,11 @@ 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::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) {
|
||||
|
||||
@@ -1451,6 +1451,42 @@ fn do_register_signal(
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Some(SignalMessage::UpgradeProposal { call_id, proposal_id, proposed_profile, local_loss_pct, local_rtt_ms })) => {
|
||||
tracing::info!(%call_id, %proposal_id, ?proposed_profile, "signal: UpgradeProposal from peer");
|
||||
emit_call_debug(&app_clone, "recv:UpgradeProposal", serde_json::json!({
|
||||
"call_id": call_id, "proposal_id": proposal_id,
|
||||
"proposed_profile": format!("{proposed_profile:?}"),
|
||||
"peer_loss_pct": local_loss_pct, "peer_rtt_ms": local_rtt_ms,
|
||||
}));
|
||||
// TODO: auto-accept if our own quality supports it,
|
||||
// or surface to UI for manual accept/reject
|
||||
}
|
||||
Ok(Some(SignalMessage::UpgradeResponse { call_id, proposal_id, accepted, reason })) => {
|
||||
tracing::info!(%call_id, %proposal_id, accepted, ?reason, "signal: UpgradeResponse from peer");
|
||||
emit_call_debug(&app_clone, "recv:UpgradeResponse", serde_json::json!({
|
||||
"call_id": call_id, "proposal_id": proposal_id,
|
||||
"accepted": accepted, "reason": reason,
|
||||
}));
|
||||
// TODO: if accepted, send UpgradeConfirm + switch encoder
|
||||
}
|
||||
Ok(Some(SignalMessage::UpgradeConfirm { call_id, proposal_id, confirmed_profile })) => {
|
||||
tracing::info!(%call_id, %proposal_id, ?confirmed_profile, "signal: UpgradeConfirm");
|
||||
emit_call_debug(&app_clone, "recv:UpgradeConfirm", serde_json::json!({
|
||||
"call_id": call_id, "proposal_id": proposal_id,
|
||||
"confirmed_profile": format!("{confirmed_profile:?}"),
|
||||
}));
|
||||
// TODO: switch encoder to confirmed_profile at next frame boundary
|
||||
}
|
||||
Ok(Some(SignalMessage::QualityCapability { call_id, max_profile, loss_pct, rtt_ms })) => {
|
||||
tracing::info!(%call_id, ?max_profile, "signal: QualityCapability from peer");
|
||||
emit_call_debug(&app_clone, "recv:QualityCapability", serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"peer_max_profile": format!("{max_profile:?}"),
|
||||
"peer_loss_pct": loss_pct, "peer_rtt_ms": rtt_ms,
|
||||
}));
|
||||
// TODO: adjust our encoder to not exceed peer's max_profile
|
||||
// (asymmetric quality — each side encodes at its own best)
|
||||
}
|
||||
Ok(Some(SignalMessage::HardNatBirthdayStart { call_id, acceptor_port_count, acceptor_ports, external_ip })) => {
|
||||
tracing::info!(
|
||||
%call_id,
|
||||
|
||||
Reference in New Issue
Block a user