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>
205 lines
6.3 KiB
Rust
205 lines
6.3 KiB
Rust
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);
|
|
}
|
|
}
|