Co-authored-by: Aaron D. Lee <himself@adlee.work> Co-committed-by: Aaron D. Lee <himself@adlee.work>
222 lines
8.4 KiB
Rust
222 lines
8.4 KiB
Rust
//! # 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;
|
|
}
|
|
|
|
#[test]
|
|
fn channel_starts_with_no_tap() {
|
|
let ch = Channel::new_inbound();
|
|
assert_eq!(ch.tap, None);
|
|
}
|
|
|
|
#[test]
|
|
fn tap_handle_is_zero_sized() {
|
|
// Zero-sized type — confirms the "no extra allocation" claim.
|
|
assert_eq!(std::mem::size_of::<TapHandle>(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn channel_carries_wall_clock_start() {
|
|
let before = std::time::SystemTime::now();
|
|
let ch = Channel::new_inbound();
|
|
let after = std::time::SystemTime::now();
|
|
assert!(ch.started_at >= before && ch.started_at <= after);
|
|
}
|
|
}
|
|
|
|
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,
|
|
/// Wall-clock start (slice-5, review M3). `created_at: Instant` above
|
|
/// measures elapsed time (idle timeout); THIS field anchors the CDR —
|
|
/// "when did the call start" as a timestamp a bill or an audit can
|
|
/// use. A monotonic Instant cannot be converted to wall-clock after
|
|
/// the fact, which is why both exist: Instant for arithmetic,
|
|
/// SystemTime for the record.
|
|
pub started_at: std::time::SystemTime,
|
|
/// NEW (slice-2, spec §5.2, §6): None until Connected, set on Connected,
|
|
/// cleared on Closing. State invariant — see the table below.
|
|
pub tap: Option<TapHandle>,
|
|
}
|
|
|
|
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(),
|
|
started_at: std::time::SystemTime::now(),
|
|
tap: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Zero-cost marker that a tap is attached to this `Channel`.
|
|
///
|
|
/// # Why a zero-sized newtype (not a `TapClient` handle / mpsc sender / UUID?)
|
|
///
|
|
/// `Channel` lives in `rutster-call-model`, which is a leaf — no tokio dep
|
|
/// (spec §6, slice-1 §5.3). The live tap connection (mpsc handles, WS
|
|
/// state) lives in the binary's `DashMap<ChannelId, TapConn>` and is
|
|
/// looked up by the channel's existing `ChannelId` (which is also the
|
|
/// wire `session_id` per spec §5.3). The `TapHandle` here is just the
|
|
/// type-system marker that "a tap is attached" — `Option<TapHandle>`
|
|
/// compiles to a single bool, no allocation.
|
|
///
|
|
/// Multi-tap-per-channel (e.g. a recording tap beside a brain tap) is a
|
|
/// future-rung concern; when it appears that's the trigger to mint a
|
|
/// separate `TapId(Uuid)` newtype and key the registry by it. For slice-2
|
|
/// (one brain per call) `ChannelId` is sufficient — YAGNI (spec §6).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct TapHandle(());
|
|
|
|
impl TapHandle {
|
|
/// Construct a marker. Public so the binary (Task 7) can mint one
|
|
/// when the TapEngine spawns; the doc comment + the zero-sized
|
|
/// payload make accidental misuse low-stakes. `pub(crate)` would
|
|
/// restrict to `rutster-call-model` and block the binary; we have no
|
|
/// reason to gate this from the binary that owns the spawn lifecycle.
|
|
pub fn new() -> Self {
|
|
Self(())
|
|
}
|
|
}
|
|
|
|
impl Default for TapHandle {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|