call-model: Channel + ChannelId + ChannelState (signaling embryo)

rutster-call-model is real-but-minimal (spec §5): the unifying leg
object the future API exposes. ChannelId is a Uuid newtype for
type-safety (the slice-1 worked example of the newtype pattern).
Channel is signaling-state only — media lives in rutster-media as a
leaf concern of the Channel, surfaced only when a second consumer needs
to observe it (spec §5.3). ChannelState matches the New→Connecting→
Connected→Closing→Closed flow from §5.4.
This commit is contained in:
adlee-was-taken
2026-06-28 11:27:55 -04:00
parent 39245a6553
commit a5d0f90fe2
4 changed files with 359 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
//! # rutster-call-model
//!
//! The unifying leg object: a `Channel` is one peer / one leg, the object
//! the future API will model (PORT_PLAN §3 — "the unifying leg object").
//! Building a throwaway `LoopbackPeer` for slice 1 and refactoring it
//! later is the exact failure mode the design rules warn against, so the
//! slice-1 peer *is* a `Channel` (spec §5.2).
//!
//! Slice 1 ships the signaling-state embryo only (spec §5.4). Media state
//! is internal to `rutster-media`; the split — "Channel = signaling state;
//! media = leaf concern" — matches ARCHITECTURE.md's "call model as the
//! unifying object." Media state moves UP into the `Channel` only when a
//! second consumer (the API, the tap, an audiohook) needs to observe it.
#[cfg(test)]
mod tests {
use super::*;
/// ChannelId must be a newtype around Uuid, NOT a bare Uuid — the
/// newtype pattern prevents us from mixing up a ChannelId with some
/// future SessionId at the type-system level. The compiler enforces
/// what a comment can only ask for.
#[test]
fn channel_id_is_a_newtype() {
let id = ChannelId::new();
// Newtype wraps Uuid; we can reach the inner id but the outer
// type is what the API surface speaks in.
let _inner: Uuid = id.0;
assert_eq!(format!("{}", id.0).len(), 36); // canonical UUID v4 length
}
#[test]
fn channel_starts_in_new_state() {
let ch = Channel::new_inbound();
assert_eq!(ch.state, ChannelState::New);
assert_eq!(ch.direction, Direction::Inbound);
}
#[test]
fn channel_state_transitions_match_spec_5_4() {
let mut ch = Channel::new_inbound();
assert_eq!(ch.state, ChannelState::New);
ch.state = ChannelState::Connecting;
ch.state = ChannelState::Connected;
ch.state = ChannelState::Closing;
ch.state = ChannelState::Closed;
}
}
use std::time::Instant;
use uuid::Uuid;
/// Newtype wrapping a `Uuid` for the channel id.
///
/// # Why a newtype (not a bare `Uuid`?)
/// Newtypes give zero-cost type safety. If we used bare `Uuid` everywhere,
/// nothing in the type system would stop us from passing a `SessionId`
/// into a function expecting a `ChannelId`. With `ChannelId(Uuid)`, the
/// compiler rejects that mixup at the call site. The pattern is taught
/// in the Rust Book's "Using the Newtype Pattern for Type Safety and
/// Abstraction" section — `ChannelId` is the slice-1 worked example.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ChannelId(pub Uuid);
impl ChannelId {
/// Mint a fresh `ChannelId`. Slice 1 uses UUID v4 — opaque, random,
/// no coordination. A future multi-tenant deployment would scope by
/// tenant prefix; that lands with authz (step 6).
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for ChannelId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for ChannelId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Signaling state machine for a `Channel` (spec §5.4, slice 1).
///
/// `New → Connecting → Connected → Closing → Closed`
///
/// # Why an enum (not a struct with a `kind: &str` field?)
/// Enums model a closed set of states; exhaustiveness checking forces
/// every `match` to consider each state explicitly. When step 4 adds
/// `Closing`'s sub-state for "graceful close in flight," it'll be a new
/// variant or a wrapping struct; either way, the compiler tells us
/// every site that needs updating. A `kind: String` field would let
/// new states slip in silently.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelState {
/// `POST /v1/sessions` created the Channel; no offer yet.
New,
/// Offer received, ICE gathering / DTLS handshake in progress.
Connecting,
/// ICE+DTLS connected, RTP flowing, audio echoing.
Connected,
/// `DELETE /v1/sessions/:id` or peerconnectionclose; cleaning up.
Closing,
/// Resources dropped, entry removed from the DashMap.
Closed,
}
/// Direction of the leg (spec §5.1).
///
/// Slice 1 is browser-initiated → `Inbound` only. `Outbound` lands with
/// the dialer (later rung). The enum exists now so the API has a stable
/// shape — adding `Outbound` later is a non-breaking addition.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Inbound,
// Outbound lands with the dialer (later). NOT present in slice 1.
}
/// The unifying leg object — one peer = one `Channel` (spec §5.1).
///
/// Slice 1 carries signaling state only. Fields that arrive later, listed
/// in spec §5.6, are absent by design — adding them is a backwards-
/// compatible field add:
/// - `media: Option<MediaLeg>` — second consumer.
/// - `audiohooks: Vec<AudiohookHandle>` — escalation rung 2.
/// - `tap: Option<TapHandle>` — step 2.
#[derive(Debug)]
pub struct Channel {
pub id: ChannelId,
pub state: ChannelState,
pub direction: Direction,
/// For the 60 s idle timeout (spec §4.5). `Instant` is a monotonic
/// clock — choosing it over `SystemTime` means we're measuring
/// elapsed wall-time within this process, NOT a calendar time the
/// user could change mid-call. The monotonic clock is the right
/// tool for "has this peer been silent for 60 seconds?"
pub created_at: Instant,
}
impl Channel {
/// Construct a fresh inbound channel — the only slice-1 path.
pub fn new_inbound() -> Self {
Self {
id: ChannelId::new(),
state: ChannelState::New,
direction: Direction::Inbound,
created_at: Instant::now(),
}
}
}