From a5d0f90fe2d866b240ca59a4428b09da78d077f6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 28 Jun 2026 11:27:55 -0400 Subject: [PATCH] call-model: Channel + ChannelId + ChannelState (signaling embryo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 192 +++++++++++++++++++++++++++ Cargo.toml | 1 + crates/rutster-call-model/Cargo.toml | 13 ++ crates/rutster-call-model/src/lib.rs | 153 +++++++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 crates/rutster-call-model/Cargo.toml create mode 100644 crates/rutster-call-model/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7763238..7f86ef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,119 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rutster-call-model" +version = "0.0.0" +dependencies = [ + "uuid", +] + [[package]] name = "rutster-signaling-sip" version = "0.0.0" @@ -13,3 +126,82 @@ version = "0.0.0" [[package]] name = "rutster-tap" version = "0.0.0" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] diff --git a/Cargo.toml b/Cargo.toml index 191a497..f23d234 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ [workspace] resolver = "2" members = [ + "crates/rutster-call-model", "crates/rutster-signaling-sip", "crates/rutster-tap", "crates/rutster-spend", diff --git a/crates/rutster-call-model/Cargo.toml b/crates/rutster-call-model/Cargo.toml new file mode 100644 index 0000000..90de27b --- /dev/null +++ b/crates/rutster-call-model/Cargo.toml @@ -0,0 +1,13 @@ +# crates/rutster-call-model/Cargo.toml +[package] +name = "rutster-call-model" +version = "0.0.0" +license.workspace = true +edition.workspace = true +repository.workspace = true +description = "The Channel / leg object embryo (signaling-state only in slice 1)." + +[dependencies] +uuid = { workspace = true } + +[dev-dependencies] diff --git a/crates/rutster-call-model/src/lib.rs b/crates/rutster-call-model/src/lib.rs new file mode 100644 index 0000000..ca5291b --- /dev/null +++ b/crates/rutster-call-model/src/lib.rs @@ -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` — second consumer. +/// - `audiohooks: Vec` — escalation rung 2. +/// - `tap: Option` — 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(), + } + } +}