feat: WarzonePhone lossy VoIP protocol — Phase 1 complete
Rust workspace with 7 crates implementing a custom VoIP protocol designed for extremely lossy connections (5-70% loss, 100-500kbps, 300-800ms RTT). 89 tests passing across all crates. Crates: - wzp-proto: Wire format, traits, adaptive quality controller, jitter buffer, session FSM - wzp-codec: Opus encoder/decoder (audiopus), Codec2 stubs, adaptive switching, resampling - wzp-fec: RaptorQ fountain codes, interleaving, block management (proven 30-70% loss recovery) - wzp-crypto: X25519+ChaCha20-Poly1305, Warzone identity compatible, anti-replay, rekeying - wzp-transport: QUIC via quinn with DATAGRAM frames, path monitoring, signaling streams - wzp-relay: Integration stub (Phase 2) - wzp-client: Integration stub (Phase 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
204
crates/wzp-proto/src/session.rs
Normal file
204
crates/wzp-proto/src/session.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Session state machine for a call.
|
||||
///
|
||||
/// ```text
|
||||
/// Idle → Connecting → Handshaking → Active ⇄ Rekeying → Active
|
||||
/// ↓
|
||||
/// Closed
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SessionState {
|
||||
/// No active call. Waiting for initiation.
|
||||
Idle,
|
||||
/// Transport connection being established (QUIC handshake).
|
||||
Connecting,
|
||||
/// Crypto handshake in progress (X25519 key exchange, identity verification).
|
||||
Handshaking,
|
||||
/// Call is active — media flowing.
|
||||
Active,
|
||||
/// Rekeying in progress (forward secrecy rotation). Media continues flowing.
|
||||
Rekeying,
|
||||
/// Call has ended.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Events that drive session state transitions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SessionEvent {
|
||||
/// User initiates a call.
|
||||
Initiate,
|
||||
/// Transport connection established.
|
||||
Connected,
|
||||
/// Crypto handshake completed successfully.
|
||||
HandshakeComplete,
|
||||
/// Rekey initiated (local or remote).
|
||||
RekeyStart,
|
||||
/// Rekey completed successfully.
|
||||
RekeyComplete,
|
||||
/// Call ended (local hangup, remote hangup, or error).
|
||||
Terminate { reason: TerminateReason },
|
||||
/// Transport connection lost.
|
||||
ConnectionLost,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TerminateReason {
|
||||
LocalHangup,
|
||||
RemoteHangup,
|
||||
Timeout,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Session state machine.
|
||||
pub struct Session {
|
||||
state: SessionState,
|
||||
/// Unique session identifier (random, generated at call initiation).
|
||||
session_id: [u8; 16],
|
||||
/// Timestamp of the last state transition (ms since epoch).
|
||||
last_transition_ms: u64,
|
||||
/// Number of successful rekeys in this session.
|
||||
rekey_count: u32,
|
||||
}
|
||||
|
||||
/// Error when a state transition is invalid.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("invalid transition from {from:?} on event {event}")]
|
||||
pub struct TransitionError {
|
||||
pub from: SessionState,
|
||||
pub event: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(session_id: [u8; 16]) -> Self {
|
||||
Self {
|
||||
state: SessionState::Idle,
|
||||
session_id,
|
||||
last_transition_ms: 0,
|
||||
rekey_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> SessionState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> &[u8; 16] {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
pub fn rekey_count(&self) -> u32 {
|
||||
self.rekey_count
|
||||
}
|
||||
|
||||
/// Process an event and transition state.
|
||||
pub fn transition(
|
||||
&mut self,
|
||||
event: SessionEvent,
|
||||
now_ms: u64,
|
||||
) -> Result<SessionState, TransitionError> {
|
||||
let new_state = match (&self.state, &event) {
|
||||
(SessionState::Idle, SessionEvent::Initiate) => SessionState::Connecting,
|
||||
|
||||
(SessionState::Connecting, SessionEvent::Connected) => SessionState::Handshaking,
|
||||
(SessionState::Connecting, SessionEvent::Terminate { .. })
|
||||
| (SessionState::Connecting, SessionEvent::ConnectionLost) => SessionState::Closed,
|
||||
|
||||
(SessionState::Handshaking, SessionEvent::HandshakeComplete) => SessionState::Active,
|
||||
(SessionState::Handshaking, SessionEvent::Terminate { .. })
|
||||
| (SessionState::Handshaking, SessionEvent::ConnectionLost) => SessionState::Closed,
|
||||
|
||||
(SessionState::Active, SessionEvent::RekeyStart) => SessionState::Rekeying,
|
||||
(SessionState::Active, SessionEvent::Terminate { .. }) => SessionState::Closed,
|
||||
(SessionState::Active, SessionEvent::ConnectionLost) => SessionState::Closed,
|
||||
|
||||
(SessionState::Rekeying, SessionEvent::RekeyComplete) => {
|
||||
self.rekey_count += 1;
|
||||
SessionState::Active
|
||||
}
|
||||
(SessionState::Rekeying, SessionEvent::Terminate { .. })
|
||||
| (SessionState::Rekeying, SessionEvent::ConnectionLost) => SessionState::Closed,
|
||||
|
||||
_ => {
|
||||
return Err(TransitionError {
|
||||
from: self.state,
|
||||
event: format!("{event:?}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
self.state = new_state;
|
||||
self.last_transition_ms = now_ms;
|
||||
Ok(new_state)
|
||||
}
|
||||
|
||||
/// Whether the session is in a state where media can flow.
|
||||
pub fn is_media_active(&self) -> bool {
|
||||
matches!(self.state, SessionState::Active | SessionState::Rekeying)
|
||||
}
|
||||
|
||||
/// Duration since last state transition.
|
||||
pub fn time_in_state_ms(&self, now_ms: u64) -> u64 {
|
||||
now_ms.saturating_sub(self.last_transition_ms)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_session() -> Session {
|
||||
Session::new([0u8; 16])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path() {
|
||||
let mut s = make_session();
|
||||
assert_eq!(s.state(), SessionState::Idle);
|
||||
|
||||
s.transition(SessionEvent::Initiate, 0).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Connecting);
|
||||
|
||||
s.transition(SessionEvent::Connected, 100).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Handshaking);
|
||||
|
||||
s.transition(SessionEvent::HandshakeComplete, 200).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Active);
|
||||
assert!(s.is_media_active());
|
||||
|
||||
s.transition(SessionEvent::RekeyStart, 60_000).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Rekeying);
|
||||
assert!(s.is_media_active()); // media continues during rekey
|
||||
|
||||
s.transition(SessionEvent::RekeyComplete, 60_100).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Active);
|
||||
assert_eq!(s.rekey_count(), 1);
|
||||
|
||||
s.transition(
|
||||
SessionEvent::Terminate {
|
||||
reason: TerminateReason::LocalHangup,
|
||||
},
|
||||
120_000,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.state(), SessionState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_transition() {
|
||||
let mut s = make_session();
|
||||
let result = s.transition(SessionEvent::Connected, 0);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_lost_from_active() {
|
||||
let mut s = make_session();
|
||||
s.transition(SessionEvent::Initiate, 0).unwrap();
|
||||
s.transition(SessionEvent::Connected, 100).unwrap();
|
||||
s.transition(SessionEvent::HandshakeComplete, 200).unwrap();
|
||||
|
||||
s.transition(SessionEvent::ConnectionLost, 5000).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Closed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user