Files
rutster/docs/superpowers/plans/2026-06-28-slice-1-webrtc-loopback.md
opencode controller 3b7b0d6459 docs: propagate rutster-trunk rename + repo URL through plans/specs/DEVELOPMENT
Mechanical propagation of the crate rename ( rutster-signaling-sip →
rutster-trunk ) and the repo URL fix ( github.com/anomalyco →
git.adlee.work/alee ) through the documents that name them:

- docs/DEVELOPMENT.md: crate-layout sketch + stub-crate description.
- docs/superpowers/plans/2026-06-28-slice-1-webrtc-loopback.md: the
  workspace members list in the plan's binding-values section —
  repository URL updated.
- docs/superpowers/plans/2026-06-28-slice-2-agent-tap.md: workspace
  members list in binding-values + crate-layout sketch in the file-
  structure section (rutster-signaling-sip → rutster-trunk).
- docs/superpowers/specs/2026-06-28-slice-2-agent-tap-design.md: §2.1
  workspace-layout sketch's STUB crate-row.

No content changes beyond the substitutions; the slice-2 spec/plan
body (protocol, TapAudioPipe, TapClient, TapEngine, lifecycle, done-
  criteria) is untouched.
2026-06-29 20:27:01 -04:00

110 KiB
Raw Blame History

Slice 1 — WebRTC Media Loopback Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up the Rutster Rust workspace and implement spearhead step 1 — a browser talks WebRTC to the core; the core terminates DTLS-SRTP, decodes Opus → canonical 16-bit PCM @ 24 kHz mono, echoes the PCM back to the browser. The user speaks and hears themselves back with no perceptible delay.

Architecture: Fused per-call vertical (ADR-0002) with a Cargo workspace of one binary + five library crates shaped to ADR-0002's fused vertical (rutster, rutster-media, rutster-call-model, rutster-signaling-sip, rutster-tap, rutster-spend). Media is driven by str0m (sans-IO WebRTC) + opus crate (libopus FFI) on tokio polls (an explicit, scoped deviation from ARCHITECTURE.md's "dedicated thread" mandate — step 4 replaces it). RtcSession owns a str0m::Rtc + an Opus encoder/decoder pair + an EchoAudioPipe. The PCM-tap seam is exposed as AudioSource / AudioSink traits in rutster-media.

Tech Stack: Rust stable (pinned via rust-toolchain.toml), str0m 0.21 (sans-IO WebRTC, Frame API), opus 0.3.1 (libopus FFI), axum 0.7, tokio 1, dashmap 6, uuid 1, thiserror 1, tracing 0.1, serde 1, tower 0.5 (integration test only — ServiceExt::oneshot on the axum Router), cargo-deny.


Global Constraints

Binding values for every task — copy verbatim where used.

  • License: every crate manifest sets license = "GPL-3.0-or-later" (ADR-0004). Reuse the SPDX expression string "GPL-3.0-or-later".
  • Workspace: root Cargo.toml is [workspace], with [workspace.dependencies] pinning every shared dependency version (spec §2.1). Member crates reference with dep.workspace = true.
  • Workspace members (exactly these six, names verbatim): crates/rutster (binary), crates/rutster-media, crates/rutster-call-model, crates/rutster-signaling-sip, crates/rutster-tap, crates/rutster-spend.
  • Stub-crate policy (spec §2.2): rutster-signaling-sip, rutster-tap, rutster-spend ship as lib.rs with a //! module doc comment (what the crate will hold, why deferred, which spearhead step fills it in) + a #[cfg(test)] mod tests { #[test] fn crate_compiles() {} } test. No anticipatory code.
  • Dependency direction (spec §2.3): rutster{rutster-media, rutster-call-model}; rutster-mediarutster-call-model; rutster-call-model is a leaf; the three stub crates depend on nothing in the workspace in slice 1.
  • PCM format (spec §3.1, §3.9, ARCHITECTURE.md): 16-bit signed mono, 24 kHz, fixed 20 ms frame = 480 samples. PcmFrame lives in rutster-media (single canonical home; rutster-tap re-exports in step 2).
  • str0m API (verified against str0m 0.21 docs.rs):
    • Rtc::new(start: Instant) -> Self — takes an Instant, NOT argless. Or use RtcConfig::new().build(Instant) for non-default config.
    • SDP: let parsed_offer = str0m::change::SdpOffer::from_sdp_string(offer_str)?; (there is NO from_str_uncheckedfrom_sdp_string is the entry point, returns Result<SdpOffer, SdpError>).
    • Then let answer: SdpAnswer = rtc.sdp_api().accept_offer(parsed_offer)?;accept_offer takes the owned SdpOffer, returns Result<SdpAnswer, RtcError>. rtc.sdp_api() borrows rtc; call is let answer = rtc.sdp_api().accept_offer(offer)?.
    • answer.to_string() renders the SDP answer text.
    • Rtc::add_local_candidate(&mut self, c: Candidate) -> Option<&Candidate> — returns Some(&Candidate) if accepted, None otherwise. Pass the candidate BEFORE accept_offer so it appears in the answer.
    • Candidate::host(addr: SocketAddr, proto: impl TryInto<Protocol>) -> Result<Candidate, IceError>"udp" literal works because &str: TryInto<Protocol>.
    • Inbound audio events arrive via the Frame API as Event::MediaData(MediaData). MediaData.data: Arc<[u8]> is the encoded Opus payload (NOT Vec<u8> — it's an atomically-refcounted boxed slice; pass &media.data[..] to the decoder).
    • Outbound: let writer: Option<Writer<'_>> = rtc.writer(mid); (returns Option, not ResultNone if direction isn't sending). Then writer.write(pt, wallclock, rtp_time, data) where:
      • pt: Pt — payload type. Get it from writer.match_params(&incoming_params) -> Option<Pt> (recommended — matches the incoming payload params) OR writer.payload_params() returns impl Iterator<Item = &PayloadParams>, then params.pt() accessor.
      • wallclock: Instant — when the sample was produced (use local now).
      • rtp_time: MediaTime — RTP timestamp. Field name is rtp_time (NOT media_time). Increment for next 20 ms Opus frame at 48 kHz = + MediaTime::from(Duration::from_millis(20)) — use mt + MediaTime::from(duration) (there is NO MediaTime::add(Duration) method; use Add/AddAssign with MediaTime::from(Duration)).
      • data: impl Into<Arc<[u8]>> — pass &opus_bytes[..] or Vec<u8> (both convert).
      • Returns Result<(), RtcError>.
    • MediaTime::ZERO constant exists (pub const ZERO: MediaTime).
    • Poll loop invariant: mutate → drain poll_output() to Output::Timeout(t) → mutate again. str0m has NO Live struct — Rtc is the driver.
  • str0m ICE candidates (spec §3.7): Add local host candidates via Candidate::host(addr, "udp"). ICE public surface at str0m:: root (no str0m::ice module): Candidate, CandidateKind, IceCreds, IceConnectionState.
  • opus crate API (verified against opus 0.3.1): opus::Decoder::new(24000, opus::Channels::Mono), opus::Encoder::new(24000, opus::Channels::Mono, opus::Application::Voip). decoder.decode(&op[..], &mut pcm[..480], /*fec*/ false) -> Result<usize> (returns samples-per-channel decoded). encoder.encode_vec(&pcm[..480], /*max_size*/ 4000) -> Result<Vec<u8>>. 480 = samples per 20 ms at 24 kHz mono.
  • opus system dependency: the opus crate (via audiopus_sys) links system libopus. Build prerequisite: libopus-dev (Debian/Ubuntu) or opus-devel (Fedora) installed. Documented in README.md dev-loop section, with the PORT_PLAN §7 rationale ("🦀 Core (FFI)"). Spec §6.3's "no external deps beyond Rust" is amended by this FFI exception —iber note this in the learner comments.
  • Hot-path error policy (spec §3.8, AGENTS.md): the 20 ms media loop never uses ?. Match-and-continue. A decode/encode failure is logged + counted (via a minimal counter), the packet is dropped, and the peer is NOT terminated. Cold paths (signaling, setup) use thiserror-derived enums and ? liberally.
  • Code documentation (spec §7, AGENTS.md): override the default "no comments" convention. //! module docs at the top of every lib.rs/main.rs/sub-module. /// on every public item. // inline comments on mechanism (why Arc<Mutex<...>> vs Arc<RwLock<...>>, why Pin<Box<dyn Future>>, etc.). str0m interactions get an explanatory comment. First occurrence of each non-obvious Rust pattern gets a "why this pattern" comment.
  • Deviation comment (spec §3.4): the tokio poll loop in rutster-media/src/loop_driver.rs carries this verbatim comment: // DEV-DEVIATION: tokio polling accepted for slice 1; step 4 replaces with dedicated timing thread per ARCHITECTURE.md.
  • HTTP surface (spec §4.1, §4.3): axum on 0.0.0.0:8080, plaintext (no TLS — out of scope). Four routes: POST /v1/sessions{ "session_id": "<uuid>" }; POST /v1/sessions/:id/offer (Content-Type: application/sdp request+response); DELETE /v1/sessions/:id; GET / → static HTML.
  • Non-trickle ICE (spec §4.2): one POST on /offer carries browser offer+candidates, response carries core answer+candidates, no separate /ice endpoint.
  • Session store (spec §4.5): DashMap<ChannelId, RtcSession> in the binary crate. ChannelId is a UUID newtype from rutster-call-model and IS the session id.
  • Idle timeout (spec §4.5): 60 s of no RTP packets received → close the session. Implemented as a per-session deadline checked on each poll cycle. No per-session tokio task.
  • Graceful shutdown (spec §4.5): tokio signal handler drops the DashMap on Ctrl-C/SIGTERM.
  • Slice-1 out-of-scope (spec §1.2, AGENTS.md): the dedicated timing thread, TLS, authn/authz, trickle ICE, the tap itself, the brain, barge-in/VAD, PSTN trunk, spend cap, CDR/event bus, transfer/park/pickup, browser automation, latency benchmark harness, fuzz harnesses are ALL deferred. Adding any of them NOW breaks the spearhead sequencing. spot-check a finding against this list before treating it as a real gap.
  • CI gates (spec §6.2): cargo fmt --check, cargo clippy -- -D warnings, cargo test --all, cargo deny check. CI runs on push + PR to main. Matrix: latest stable + the MSRV pinned in rust-toolchain.toml.
  • cargo-deny config (spec §6.1): allow GPL-3.0-or-later, MIT, Apache-2.0, BSD-3-Clause, ISC, Zlib, Unicode-DFS-2016, Unicode-3.0. deny warnings on advisories. Duplicate-version bans on tokio, serde, bytes, tracing. Sources: crates-io only.
  • Task / PR strategy: tasks 17 are sequentially dependent (1 must land before 2; 2 before 3; 3 before 4; 4 before 5; 6 and 7 can run in parallel with each other after Task 5 lands). Each task's "Commit" step is one commit on main (or one PR merging to main if branch protection is on). Each task is independently shippable + green (tests pass after each commit). Merge in numeric order. Do NOT batch multiple tasks into one commit — the granular history is a load-bearing artifact for the learning-codebase goal (spec §7). If using the executing-plans skill rather than subagent-driven-development, still emit one commit per task; the plan's commit messages are written for that shape.

File structure (landed shape)

rutster/
├── Cargo.toml                          # [workspace] + [workspace.dependencies]
├── deny.toml                            # cargo-deny config (Task 6)
├── rust-toolchain.toml                  # pinned stable (Task 1)
├── LEARNING.md                          # index (Task 7)
├── .github/workflows/ci.yml             # CI (Task 6)
├── crates/
│   ├── rutster/                          # binary (Tasks 5, 6)
│   │   ├── Cargo.toml
│   │   ├── src/main.rs
│   │   ├── src/session_map.rs
│   │   ├── src/routes.rs
│   │   └── static/index.html
│   ├── rutster-media/                   # REAL (Tasks 3, 4)
│   │   ├── Cargo.toml
│   │   ├── src/lib.rs                    # module docs + error + re-exports
│   │   ├── src/pcm.rs                    # PcmFrame, AudioSource/AudioSink, EchoAudioPipe
│   │   ├── src/opus_codec.rs             # decoder/encoder wrappers
│   │   ├── src/loop_driver.rs            # str0m poll loop (tokio deviation)
│   │   └── src/rtc_session.rs             # RtcSession
│   ├── rutster-call-model/              # REAL-minimal (Task 2)
│   │   ├── Cargo.toml
│   │   └── src/lib.rs                    # Channel, ChannelId, ChannelState, Direction
│   ├── rutster-signaling-sip/           # STUB (Task 1)
│   │   ├── Cargo.toml
│   │   └── src/lib.rs
│   ├── rutster-tap/                     # STUB (Task 1)
│   │   ├── Cargo.toml
│   │   └── src/lib.rs
│   └── rutster-spend/                   # STUB (Task 1)
│       ├── Cargo.toml
│       └── src/lib.rs
└── fuzz/                                # placeholder dir (Task 7)
    └── README.md

Task 1: Workspace scaffold + three stub crates

Files:

  • Create: Cargo.toml (root workspace manifest)
  • Create: rust-toolchain.toml
  • Create: crates/rutster-signaling-sip/Cargo.toml
  • Create: crates/rutster-signaling-sip/src/lib.rs
  • Create: crates/rutster-tap/Cargo.toml
  • Create: crates/rutster-tap/src/lib.rs
  • Create: crates/rutster-spend/Cargo.toml
  • Create: crates/rutster-spend/src/lib.rs
  • Test: each stub crate's crate_compiles test.

Interfaces:

  • Consumes: nothing (this is the foundation).
  • Produces: a compiling Cargo workspace with three stub crates. Later tasks add the real member crates (rutster, rutster-media, rutster-call-model) by appending to the members array — Task 1 leaves members listing only the three stubs, and Task 2/3/4/5 each extend it.

Note on the members array: start with only the three stub crates listed in members. Each subsequent task's "Step N: extend workspace" appends its new crate path to this array. Do NOT pre-list crates/rutster* with a glob — strip the glob and use an explicit list so a half-built crate never breaks cargo metadata.

  • Step 1: Write the root Cargo.toml
# Cargo.toml — rutster workspace root.
# Spec ref: slice-1 §2. The workspace pins shared dep versions here so
# member crates can't drift (§2.1). Each member references with
# `dep.workspace = true`.
[workspace]
resolver = "2"
members = [
    "crates/rutster-signaling-sip",
    "crates/rutster-tap",
    "crates/rutster-spend",
]

[workspace.package]
license = "GPL-3.0-or-later"
edition = "2021"
repository = "https://git.adlee.work/alee/rutster"

# Pinned versions for all member crates. References are `foo.workspace = true`
# in the member manifest. Keeps the dep tree unified (§2.1).
[workspace.dependencies]
# str0m 0.21: sans-IO WebRTC. Frame API (Event::MediaData + Writer::write).
str0m = "0.21"
# opus 0.3.1: libopus FFI (system libopus required — see README).
opus = "0.3"
# axum 0.7: HTTP signaling surface.
axum = { version = "0.7", features = ["macros"] }
# tokio 1: runtime driving the str0m poll loop (slice-1 deviation per §3.4).
tokio = { version = "1", features = ["full"] }
# dashmap 6: in-process session store.
dashmap = "6"
# uuid 1: ChannelId newtype backing.
uuid = { version = "1", features = ["v4"] }
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# tower: used by the binary crate's integration tests (ServiceExt::oneshot
# on the axum Router). Axum re-exports parts of tower but the integration test
# uses `tower::ServiceExt` directly, so it needs to be a workspace dep.
tower = { version = "0.5", features = ["util"] }
  • Step 2: Write rust-toolchain.toml

Pin stable (currently 1.85 as of writing — bumped from 1.80 because uuid 1.x → getrandom 0.4.x requires Rust edition = "2024", stabilized in 1.85. Confirm the latest stable at impl time with rustc --version). The MSRV is the edition-2024 floor; the CI matrix (Task 6) tests stable + MSRV.

# rust-toolchain.toml — pins the toolchain for reproducible builds.
[toolchain]
channel = "1.85"
components = ["rustfmt", "clippy"]
  • Step 3: Write crates/rutster-signaling-sip/Cargo.toml
# crates/rutster-signaling-sip/Cargo.toml
[package]
name = "rutster-signaling-sip"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "Rust-native trunk SIP — stub crate (filled in spearhead step 5)."
  • Step 4: Write crates/rutster-signaling-sip/src/lib.rs
//! # rutster-signaling-sip
//!
//! **Status:** stub. Fills in at spearhead step 5 (PSTN trunk).
//!
//! This crate will hold the Rust-native trunk SIP stack: the SIP parser,
//! transaction layer, dialog state, and the carrier trunk integration. See
//! [ADR-0003](../../../docs/adr/0003-sip-rust-native-trunk.md) for the
//! "own the parser from day one" thesis and [PORT_PLAN §1](../../../docs/PORT_PLAN.md)
//! for the surface area (`res_pjsip_session`, `chan_sip`, `_sdp_rtp` rows).
//!
//! Slice 1's WebRTC-only ingress needs no SIP — this stub exists to lock the
//! crate boundary without anticipating code (spec §2.2). It depends on
//! nothing in the workspace in slice 1. Its future dependency direction is
//! `rutster-signaling-sip` → `rutster-call-model` + `rutster-media` (once
//! the SDP help lives here, moved out of `rutster-media`'s WebRTC-ICE-coupled
//! SDP module — see §3.7 of the slice-1 spec for the split rationale).

#[cfg(test)]
mod tests {
    /// Stub crates lock boundaries; the compile-test is the lock.
    #[test]
    fn crate_compiles() {}
}
  • Step 5: Write crates/rutster-tap/Cargo.toml
# crates/rutster-tap/Cargo.toml
[package]
name = "rutster-tap"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "Agent audio tap — stub crate (filled in spearhead step 2)."
  • Step 6: Write crates/rutster-tap/src/lib.rs
//! # rutster-tap
//!
//! **Status:** stub. Fills in at spearhead step 2 (the tap itself).
//!
//! Slice 1 *pre-paves* the tap by exposing the canonical PCM boundary as
//! the `AudioSource` / `AudioSink` traits in [`rutster_media`](../rutster-media/index.html),
//! and wires an `EchoAudioPipe` between sink and source. Step 2 swaps that
//! pipe for a real WSS tap client (core-as-client, brain-as-server —
//! [ADR-0006](../../../docs/adr/0006-ingress-posture.md)). No code changes to
//! `RtcSession` itself in step 2 — that's the test of the seam.
//!
//! This crate will, when filled in, re-export `PcmFrame` from
//! `rutster-media` (one canonical home — spec §3.1) and ship the WSS
//! tap client + the versioned framing protocol. It depends on nothing
//! in the workspace in slice 1.

#[cfg(test)]
mod tests {
    #[test]
    fn crate_compiles() {}
}
  • Step 7: Write crates/rutster-spend/Cargo.toml
# crates/rutster-spend/Cargo.toml
[package]
name = "rutster-spend"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "In-boundary spend / abuse gate — stub crate (filled in spearhead step 6)."
  • Step 8: Write crates/rutster-spend/src/lib.rs
//! # rutster-spend
//!
//! **Status:** stub. Fills in at spearhead step 6 (spend cap / abuse gate).
//!
//! In-boundary spend and abuse control is constitutive of the wedge
//! ([ADR-0002](../../../docs/adr/0002-north-star-and-fused-core.md)): the
//! runaway brain structurally cannot exceed spend or pacing because it
//! doesn't hold the wire — the trunk termination + spend gate do, in one
//! boundary. Pulling spend out into a service re-introduces the 3-vendor
//! structural hole the fused vertical was chosen to eliminate.
//!
//! This crate will hold: spend caps, pacing caps, deny-by-default routing,
//! rate-limits, toll-fraud pattern detection — co-located with trunk
//! termination in `rutster-signaling-sip` (step 5). Depends on nothing in
//! the workspace in slice 1.

#[cfg(test)]
mod tests {
    #[test]
    fn crate_compiles() {}
}
  • Step 9: Run cargo check to verify the workspace compiles

Run: cargo check --all Expected: 3 stub crates compile cleanly; no warnings.

  • Step 10: Run cargo test --all to verify the stub tests pass

Run: cargo test --all Expected: 3 tests, all passing (crate_compiles in each stub).

  • Step 11: Commit
git add Cargo.toml rust-toolchain.toml crates/rutster-signaling-sip crates/rutster-tap crates/rutster-spend
git commit -m "workspace: scaffold + three stub crates (sip/tap/spend)

Workspace root, pinned toolchain, and the three stub crates whose only
job in slice 1 is to lock the ADR-0002 boundary shape. Each ships a
lib.rs module doc (what it will hold, why deferred, which spearhead step
fills it) and a crate_compiles test. Spec §2.2."

Task 2: rutster-call-model — the Channel embryo

Files:

  • Create: crates/rutster-call-model/Cargo.toml
  • Create: crates/rutster-call-model/src/lib.rs
  • Modify: Cargo.toml (workspace root — add the new member to members).

Interfaces:

  • Consumes: nothing in the workspace (leaf crate, spec §5.3).

  • Produces: Channel, ChannelId, ChannelState, Direction. ChannelId is a Uuid newtype (spec §5.1) — it IS the session id surfaced in the REST API (spec §4.5). ChannelState is New | Connecting | Connected | Closing | Closed (spec §5.1, §5.4). Direction is Inbound only in slice 1.

  • Step 1: Write the failing test for ChannelId newtype

Add to crates/rutster-call-model/src/lib.rs (write the whole file with lib.rs containing the test first; that's allowed — TDD writes the test before the impl, not necessarily in a separate file).

//! # 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;
    }
}

This will NOT compile yet — Channel, ChannelId, ChannelState, Direction, Uuuid, Channel::new_inbound are not defined.

  • Step 2: Run the test to verify it fails

Run: cargo test -p rutster-call-model Expected: FAIL with compile errors (cannot find type ChannelId, etc).

  • Step 3: Write crates/rutster-call-model/Cargo.toml
# 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]
  • Step 4: Implement the types in crates/rutster-call-model/src/lib.rs

Append the implementation block AFTER the #[cfg(test)] mod tests block written in Step 1 (the test block stays at the top — that's the pattern from writing-plans: test first, then make it compile).

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(),
        }
    }
}
  • Step 5: Add crates/rutster-call-model to the workspace members

Modify root Cargo.toml — append the new member to the members array:

members = [
    "crates/rutster-call-model",
    "crates/rutster-signaling-sip",
    "crates/rutster-tap",
    "crates/rutster-spend",
]
  • Step 6: Run the tests to verify they pass

Run: cargo test -p rutster-call-model Expected: 3 tests passing (channel_id_is_a_newtype, channel_starts_in_new_state, channel_state_transitions_match_spec_5_4).

  • Step 7: Run clippy + fmt across the workspace

Run: cargo fmt --check && cargo clippy --all -- -D warnings Expected: no formatting drift, no warnings.

  • Step 8: Commit
git add Cargo.toml crates/rutster-call-model
git commit -m "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."

Task 3: rutster-media — PCM frame, tap seam traits, Opus codec pair

Files:

  • Create: crates/rutster-media/Cargo.toml
  • Create: crates/rutster-media/src/lib.rs (module docs + error enum + re-exports)
  • Create: crates/rutster-media/src/pcm.rs (PcmFrame, AudioSource, AudioSink, EchoAudioPipe)
  • Create: crates/rutster-media/src/opus_codec.rs (OpusDecoder, OpusEncoder)
  • Modify: Cargo.toml (workspace root — add member).

Interfaces:

  • Consumes: ChannelId, Channel from Task 2's rutster-call-model.

  • Produces:

    • PcmFrame — the canonical 480-sample i16 mono @ 24 kHz frame (spec §3.1, §3.9).
    • AudioSource / AudioSink traits (spec §3.3) — the seam step 2 splices the tap into.
    • EchoAudioPipe — implements both traits; slice-1 wiring (spec §3.3).
    • OpusDecoder::decode(&[u8]) -> Option<PcmFrame> / OpusEncoder::encode(&PcmFrame) -> Option<Vec<u8>> — hot-path match-and-continue, no ?.
  • Step 1: Write crates/rutster-media/Cargo.toml

# crates/rutster-media/Cargo.toml
[package]
name = "rutster-media"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "Media core: str0m WebRTC + Opus⇄PCM boundary (slice 1)."

[dependencies]
rutster-call-model = { path = "../rutster-call-model" }
opus = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

[dev-dependencies]
  • Step 2: Write the failing test for PcmFrame + EchoAudioPipe

Create crates/rutster-media/src/pcm.rs with tests first, no impl yet:

//! # PCM frame + tap seam (spec §3.3)
//!
//! The canonical tap format from ARCHITECTURE.md: 16-bit signed mono PCM
//! @ 24 kHz, fixed 20 ms = 480 samples. The single format every future
//! brain/tap consumer speaks. Lives in `rutster-media` (spec §3.1);
//! `rutster-tap` re-exports it in step 2 (single canonical home).
//!
//! The `AudioSource`/`AudioSink` traits are the exact splice point where
//! step 2 connects a real tap client (replacing `EchoAudioPipe`).

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pcm_frame_holds_480_samples() {
        let frame = PcmFrame::zeroed();
        assert_eq!(frame.samples.len(), SAMPLES_PER_FRAME);
        assert!(frame.samples.iter().all(|&s| s == 0));
    }

    #[test]
    fn echo_pipe_round_trips_a_frame() {
        // EchoAudioPipe implements both AudioSink and AudioSource.
        // Push a frame in via the sink; pull it back out via the source.
        let mut pipe = EchoAudioPipe::new();
        assert!(pipe.next_pcm_frame().is_none()); // empty → silence

        let mut frame = PcmFrame::zeroed();
        frame.samples[0] = 1234;
        pipe.on_pcm_frame(frame);

        let out = pipe.next_pcm_frame().expect("echoed frame present");
        assert_eq!(out.samples[0], 1234);
        assert!(pipe.next_pcm_frame().is_none()); // drained
    }

    #[test]
    fn sink_must_not_block() {
        // The echo pipe is bounded: push more frames than it can hold,
        // and on_pcm_frame must drop the oldest silently rather than block.
        // (Hot-path invariant from spec §3.3: "Must not block.")
        let mut pipe = EchoAudioPipe::new();
        const OVERFLOW: usize = ECHO_BUFFER_LEN + 5;
        for i in 0..OVERFLOW {
            let mut f = PcmFrame::zeroed();
            f.samples[0] = i as i16;
            pipe.on_pcm_frame(f); // must not panic, must not block
        }
        // We should hold at most ECHO_BUFFER_LEN frames; the rest dropped.
        let mut count = 0;
        while pipe.next_pcm_frame().is_some() {
            count += 1;
        }
        assert_eq!(count, ECHO_BUFFER_LEN);
    }
}
  • Step 3: Run the tests to verify they fail

Run: cargo test -p rutster-media pcm::tests Expected: FAIL with compile errors (cannot find type PcmFrame, etc).

  • Step 4: Implement PcmFrame, AudioSource, AudioSink, EchoAudioPipe

Append to crates/rutster-media/src/pcm.rs (above the test mod):

use std::collections::VecDeque;

/// Samples per 20 ms frame @ 24 kHz mono (spec §3.9).
///
/// 24000 Hz × 0.020 s = 480. This is a `const`, not a magic literal, so
/// every place that needs a 480-sample buffer reads the same named value.
pub const SAMPLES_PER_FRAME: usize = 480;

/// Capacity of the echo pipe's internal queue (spec §3.3: "must not
/// block"). 3 frames = 60 ms of buffering — enough to absorb jitter
/// without unbounded growth. Slice 1 has no jitter buffer of its own;
/// str0m's adaptive jitter (it doesn't have one — see str0m FAQ) is
/// not in play because we use the Frame API, which delivers already-
/// depacketized frames. This queue is our only playout buffer.
pub const ECHO_BUFFER_LEN: usize = 3;

/// Canonical PCM frame (spec §3.1, §3.9, ARCHITECTURE.md).
///
/// 16-bit signed mono @ 24 kHz, 480 samples (20 ms). `i16` is the
/// native PCM sample type on the wire — every brain/tap consumer speaks
/// this format. The slice (not a `Vec`) keeps the frame fixed-size and
/// cheap to copy through the audio pipe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PcmFrame {
    pub samples: [i16; SAMPLES_PER_FRAME],
}

impl PcmFrame {
    /// A frame of digital silence (all zeros). Used as the "no audio to
    /// send" fallback on the source side (spec §3.3: `None = silence`).
    pub fn zeroed() -> Self {
        Self {
            samples: [0; SAMPLES_PER_FRAME],
        }
    }
}

/// Produces frames to send to the peer (spec §3.3).
///
/// The poll loop calls `next_pcm_frame()` on each 20 ms tick. `None`
/// means "send silence" — the caller (loop driver) writes a comfort-
/// noise Opus frame instead of dropping the packet entirely, keeping
/// the RTP clock alive. (In slice 1, silence IS fine — str0m handles
/// pacing — but the `None` semantics encode the "no audio available"
/// case cleanly for step 2's tap client.)
pub trait AudioSource: Send {
    fn next_pcm_frame(&mut self) -> Option<PcmFrame>;
}

/// Consumes decoded frames from the peer (spec §3.3).
///
/// `on_pcm_frame` MUST NOT block — the 20 ms loop is the only caller,
/// and blocking here delays the next poll past its deadline. The
/// `EchoAudioPipe` enforces this by bounding its queue and dropping
/// the oldest frame on overflow (see `tests::sink_must_not_block`).
pub trait AudioSink: Send {
    fn on_pcm_frame(&mut self, frame: PcmFrame);
}

/// Slice-1 wiring of the tap seam: a bounded queue connecting inbound
/// (sink) to outbound (source) — an echo (spec §3.3). Step 2 replaces
/// this with a real WSS tap client; no changes to `RtcSession`.
///
/// # Why `VecDeque` (not `tokio::mpsc` or `crossbeam`?)
/// The echo pipe lives behind a single `Arc<Mutex<...>>` in the
/// `RtcSession`, polled by a single tokio task. There is exactly one
/// producer (inbound decode) and one consumer (outbound encode), both
/// in the same poll loop — no cross-task messaging. A `VecDeque` under
/// the same mutex is the smallest structure that fits; a channel would
/// add async machinery we don't need in slice 1 (and would pre-pave
/// the wrong pattern for step 4's dedicated thread).
pub struct EchoAudioPipe {
    queue: VecDeque<PcmFrame>,
}

impl EchoAudioPipe {
    pub fn new() -> Self {
        Self {
            queue: VecDeque::with_capacity(ECHO_BUFFER_LEN),
        }
    }

    /// Push a frame; if full, drop the oldest. Non-blocking by construction.
    fn push_back_bounded(&mut self, frame: PcmFrame) {
        if self.queue.len() >= ECHO_BUFFER_LEN {
            self.queue.pop_front();
        }
        self.queue.push_back(frame);
    }
}

impl Default for EchoAudioPipe {
    fn default() -> Self {
        Self::new()
    }
}

impl AudioSink for EchoAudioPipe {
    fn on_pcm_frame(&mut self, frame: PcmFrame) {
        self.push_back_bounded(frame);
    }
}

impl AudioSource for EchoAudioPipe {
    fn next_pcm_frame(&mut self) -> Option<PcmFrame> {
        self.queue.pop_front()
    }
}
  • Step 5: Run the tests to verify they pass

Run: cargo test -p rutster-media pcm::tests Expected: 3 tests passing.

  • Step 6: Write the failing test for the Opus codec pair

Create crates/rutster-media/src/opus_codec.rs:

//! # Opus ⇄ PCM codec pair (spec §3.1)
//!
//! Wraps the `opus` crate's libopus FFI into the slice-1 hot-path
//! shape: decode returns `Option<PcmFrame>` and encode returns
//! `Option<Vec<u8>>` — match-and-continue, no `?`, no error propagation
//! on the 20 ms loop (spec §3.8). A dropped frame is logged + counted;
//! the peer is NOT terminated.
//!
//! The wrapping type exists (rather than using `opus::Decoder` inline)
//! so the slice-1 `RtcSession` can hold `OpusDecoder` / `OpusEncoder`
//! as concrete types without re-stating the sample rate and channel
//! count at every call site.

use crate::pcm::{PcmFrame, SAMPLES_PER_FRAME};

#[cfg(test)]
mod tests {
    use super::*;

    /// Encode a known PCM signal → decode the result → assert the RMS
    /// is within tolerance. This is the roundtrip test from spec §6.4
    /// ("encode known PCM → decode → assert RMS within tolerance").
    #[test]
    fn opus_roundtrip_preserves_signal_within_tolerance() {
        let mut enc = OpusEncoder::new().expect("encoder");
        let mut dec = OpusDecoder::new().expect("decoder");

        // A pure 440 Hz tone at modest amplitude — easy to encode losslessly.
        let mut input = PcmFrame::zeroed();
        for (i, s) in input.samples.iter_mut().enumerate() {
            let phase = 2.0 * std::f32::consts::PI * 440.0 * (i as f32) / 24_000.0;
            *s = (phase.sin() * 8000.0) as i16; // ~ -14 dBFS, comfortable for Opus
        }

        let opus_bytes = enc.encode(&input).expect("encoded");
        assert!(!opus_bytes.is_empty(), "Opus payload non-empty");

        let decoded = dec.decode(&opus_bytes).expect("decoded PCM");
        // Per-sample comparison fails (Opus is lossy); RMS comparison passes.
        let in_rms = rms(&input.samples);
        let out_rms = rms(&decoded.samples);
        // Opus at Voip mode preserves energy to ~10% at this amplitude.
        let rel = (in_rms - out_rms).abs() / in_rms.max(1.0);
        assert!(
            rel < 0.15,
            "RMS drift {rel:.3} exceeds tolerance: in={in_rms}, out={out_rms}"
        );
    }

    #[test]
    fn decoder_returns_none_on_garbage_payload() {
        // Hot-path contract: decode failure → None, not a panic.
        // Spec §3.8: "drop + observe, don't crash."
        let mut dec = OpusDecoder::new().expect("decoder");
        let garbage = [0u8; 8];
        let out = dec.decode(&garbage);
        assert!(out.is_none(), "garbage payload must not panic, must return None");
    }

    fn rms(samples: &[i16; SAMPLES_PER_FRAME]) -> f32 {
        let sum_sq: f64 = samples.iter().map(|&s| (s as f64).powi(2)).sum();
        (sum_sq / samples.len() as f64).sqrt() as f32
    }
}
  • Step 7: Run the tests to verify they fail

Run: cargo test -p rutster-media opus_codec::tests Expected: FAIL with compile errors (cannot find type OpusDecoder, etc).

  • Step 8: Implement OpusDecoder and OpusEncoder

Append to crates/rutster-media/src/opus_codec.rs (above the test mod):

use opus::{Application, Channels, Decoder as LibDecoder, Encoder as LibEncoder};

use crate::pcm::PcmFrame;

/// 24 kHz mono — the slice-1 default (spec §3.9, ARCHITECTURE.md).
const SAMPLE_RATE: u32 = 24_000;

/// Initializes the decoder with one-channel output. libopus accepts 24 kHz
/// as a standard rate — no resample needed downstream.
const CHANNELS: Channels = Channels::Mono;

/// Voip mode — optimized for speech, which is the slice-1 (and product)
/// workload. `Application::Audio` is for music; `LowDelay` sacrifices
/// quality for ~5 ms less latency, unjustified at slice 1's ~200 ms bar.
const APPLICATION: Application = Application::Voip;

/// Upper bound on an Opus 20 ms frame payload at 24 kHz. The recommended
/// max from libopus is ~4000 bytes; we allocate once and reuse.
const MAX_OPUS_PAYLOAD_BYTES: usize = 4000;

/// Wraps `opus::Decoder` so the loop driver doesn't re-state the sample
/// rate and channels at each call.
pub struct OpusDecoder {
    inner: LibDecoder,
    // Reusable decode buffer: avoids allocating 480 i16s per frame on the
    // hot path. `Option<PcmFrame>` would also work; a flat array keeps the
    // reuse obvious.
    pcm_buf: [i16; SAMPLES_PER_FRAME],
}

impl OpusDecoder {
    pub fn new() -> Result<Self, opus::Error> {
        Ok(Self {
            inner: LibDecoder::new(SAMPLE_RATE, CHANNELS)?,
            pcm_buf: [0; SAMPLES_PER_FRAME],
        })
    }

    /// Decode an Opus payload to a `PcmFrame`. Returns `None` on any
    /// decode error — hot-path contract is match-and-continue (spec §3.8).
    /// The caller (loop driver) logs + counts a drop, never propagates.
    pub fn decode(&mut self, opus_payload: &[u8]) -> Option<PcmFrame> {
        // FEC (forward error correction) is false in slice 1 — we don't
        // request the previous frame's FEC data. Step 4 (barge-in) may
        // revisit; FEC matters under lossy networks, not loopback.
        match self.inner.decode(opus_payload, &mut self.pcm_buf, /*fec*/ false) {
            Ok(_samples_decoded) => Some(PcmFrame { samples: self.pcm_buf }),
            Err(e) => {
                tracing::warn!(error = ?e, "opus decode dropped; continuing");
                None
            }
        }
    }
}

/// Wraps `opus::Encoder` for the same reason as the decoder wrapper.
pub struct OpusEncoder {
    inner: LibEncoder,
}

impl OpusEncoder {
    pub fn new() -> Result<Self, opus::Error> {
        Ok(Self {
            inner: LibEncoder::new(SAMPLE_RATE, CHANNELS, APPLICATION)?,
        })
    }

    /// Encode a `PcmFrame` to an Opus payload. Returns `None` on any
    /// encode error — same hot-path contract as `OpusDecoder::decode`.
    /// Uses `encode_vec` (allocates a fresh `Vec<u8>` per call) for
    /// slice 1 simplicity; a production hot path would reuse a buffer
    /// passed in by the caller to avoid per-frame allocation.
    pub fn encode(&mut self, frame: &PcmFrame) -> Option<Vec<u8>> {
        match self
            .inner
            .encode_vec(&frame.samples, MAX_OPUS_PAYLOAD_BYTES)
        {
            Ok(payload) => Some(payload),
            Err(e) => {
                tracing::warn!(error = ?e, "opus encode dropped; continuing");
                None
            }
        }
    }
}
  • Step 9: Write crates/rutster-media/src/lib.rs (module docs + error enum + re-exports)
//! # rutster-media
//!
//! The media core: str0m WebRTC termination + the Opus⇄PCM boundary
//! (spec §3). One per WebRTC peer; a `RtcSession` owns a `str0m::Rtc`
//! instance + an Opus encoder/decoder pair + an `EchoAudioPipe`
//! wiring the inbound decode path to the outbound encode path.
//!
//! ## Architecture references
//!
//! - [slice-1 spec §3](../../../docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md)
//!   — full media-core design.
//! - [ARCHITECTURE.md](../../../docs/ARCHITECTURE.md) — fused per-call
//!   vertical; the tap is the central interface; PCM tap format.
//! - [ADR-0002](../../../docs/adr/0002-north-star-and-fused-core.md) —
//!   fused vertical + the in-boundary spend gate.
//!
//! ## Error handling posture (spec §3.8)
//!
//! Cold path (RTc construction, codec init): `thiserror`-derived errors + `?`.
//! Hot path (the 20 ms loop): **never** `?`. Match-and-continue. A
//! dropped packet MUST NOT terminate the peer. Policy: "drop + observe
//! (log + counter), don't crash." This is the posture the eventual fuzz
//! harness (step 5) will test against.
//!
//! ## Module map
//!
//! - [`pcm`] — `PcmFrame` + `AudioSource`/`AudioSink` traits (the tap
//!   seam) + `EchoAudioPipe` (slice-1 wiring).
//! - [`opus_codec`] — `OpusDecoder`/`OpusEncoder` wrappers.
//! - [`loop_driver`] (Task 4) — the str0m poll loop on tokio.
//! - [`rtc_session`] (Task 4) — `RtcSession`, the per-peer owner.

pub mod opus_codec;
pub mod pcm;

pub use opus_codec::{OpusDecoder, OpusEncoder};
pub use pcm::{AudioSink, AudioSource, EchoAudioPipe, PcmFrame, SAMPLES_PER_FRAME};

use thiserror::Error;

/// Cold-path errors for media-core construction. Hot-path failures go
/// through the "match-and-continue" `Option<_>` returns on
/// `OpusDecoder::decode` / `OpusEncoder::encode`, NOT through this enum.
#[derive(Debug, Error)]
pub enum MediaError {
    #[error("opus codec initialization failed: {0}")]
    CodecInit(#[from] opus::Error),
}
  • Step 10: Add crates/rutster-media to the workspace members

Modify root Cargo.toml:

members = [
    "crates/rutster-call-model",
    "crates/rutster-media",
    "crates/rutster-signaling-sip",
    "crates/rutster-tap",
    "crates/rutster-spend",
]
  • Step 11: Run the full test suite

Run: cargo test -p rutster-media Expected: all pcm::tests + opus_codec::tests passing.

  • Step 12: Run clippy + fmt

Run: cargo fmt --check && cargo clippy -p rutster-media -- -D warnings Expected: clean.

  • Step 13: Commit
git add Cargo.toml crates/rutster-media
git commit -m "media: PcmFrame + AudioSource/Sink + Opus codec pair

PcmFrame is the canonical tap format (16-bit mono @ 24 kHz, 480 samples
per 20 ms frame — ARCHITECTURE.md). AudioSource/AudioSink are the seam
step 2 splices the tap client into (spec §3.3); EchoAudioPipe is the
slice-1 wiring of that seam. OpusDecoder/OpusEncoder wrap the opus
crate's libopus FFI with hot-path match-and-continue (no ? on the 20 ms
loop, spec §3.8); decode/encode return Option<PcmFrame>/Option<Vec<u8>>
so a dropped frame is logged + counted, never propagated to crash the
peer."

Task 4: RtcSession + str0m poll loop (the media core's heart)

Files:

  • Create: crates/rutster-media/src/rtc_session.rs
  • Create: crates/rutster-media/src/loop_driver.rs
  • Modify: crates/rutster-media/src/lib.rs (declare the new modules + re-exports).
  • Modify: crates/rutster-media/Cargo.toml (add str0m dep).

Interfaces:

  • Consumes: PcmFrame, AudioSource, AudioSink, EchoAudioPipe, OpusDecoder, OpusEncoder from Task 3; Channel, ChannelId, ChannelState from Task 2.

  • Produces:

    • RtcSession — owns str0m::Rtc + Channel + OpusDecoder + OpusEncoder + EchoAudioPipe + a UDP socket (std::net::UdpSocket, driven by tokio) + idle-deadline bookkeeping (spec §4.5).
    • RtcSession::accept_offer(sdp_offer: &str) -> Result<String, RtcSessionError> — drives str0m's sdp_api().accept_offer(), returns the SDP answer (with DTLS fingerprint + ICE creds + Opus codec, all native to str0m 0.21 — NO hand-rolled SDP munger).
    • RtcSession::run_poll_once(now: Instant) -> Option<Duration> — one iteration of the sans-IO poll loop; returns the next timeout. The binary's tokio task loops this. (Slice-1 deviation: the loop is on tokio, not a dedicated thread — spec §3.4.)
    • RtcSession::channel_id() -> ChannelId.
    • RtcSession::is_closed() -> bool.
  • Step 1: Update crates/rutster-media/Cargo.toml to add str0m

[dependencies]
rutster-call-model = { path = "../rutster-call-model" }
opus = { workspace = true }
str0m = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
  • Step 2: Declare the new modules in crates/rutster-media/src/lib.rs

Edit the lib.rs written in Task 3 — replace its module map with the populated version. The implementation block at the bottom stays; only the module declarations + re-exports change:

pub mod loop_driver;
pub mod opus_codec;
pub mod pcm;
pub mod rtc_session;

pub use opus_codec::{OpusDecoder, OpusEncoder};
pub use pcm::{AudioSink, AudioSource, EchoAudioPipe, PcmFrame, SAMPLES_PER_FRAME};
pub use rtc_session::{RtcSession, RtcSessionError};

(Keep the rest of the lib.rs from Task 3 — the MediaError enum + module docs — unchanged.)

  • Step 3: Write the failing test for RtcSession::accept_offer

Create crates/rutster-media/src/rtc_session.rs with tests first. The real-browser-offer fixture (a full SDP from a browser) is captured in a test constant; the test verifies accept_offer produces a valid SDP answer containing an Opus payload type and a DTLS fingerprint.

//! # `RtcSession` — the per-peer media owner (spec §3.1, §4.5)
//!
//! Owns a `str0m::Rtc` instance + an Opus decoder/encoder pair + an
//! `EchoAudioPipe` wiring inbound to outbound + the per-peer UDP socket.
//! One per WebRTC peer. The `ChannelId` (from `rutster-call-model`) is
//! the session id surfaced in the REST API.
//!
//! ## What str0m does for us (so we don't)
//!
//! str0m 0.21's `Rtc::sdp_api().accept_offer(offer)` produces the SDP
//! answer natively: DTLS fingerprint (from the cert str0m generates), ICE
//! ufrag/pwd, and codec negotiation (Opus, the only codec we registered).
//! Slice 1 does NOT hand-roll an SDP munger — str0m's path is the spec's
//! "embryo of the future SIP SDP path" (§3.7). When step 5 brings SIP/SDP
//! negotiation into `rutster-signaling-sip`, that crate may extract shared
//! SDP helpers from str0m or build its own. Slice 1's WebRTC-ICE-coupled
//! SDP lives entirely in str0m.

use std::net::SocketAddr;
use std::time::{Duration, Instant};

use rutster_call_model::{Channel, ChannelId, ChannelState};
use str0m::Rtc;
use thiserror::Error;

use crate::opus_codec::{OpusDecoder, OpusEncoder};
use crate::pcm::{AudioSink, AudioSource, EchoAudioPipe};

/// Per-session idle timeout (spec §4.5): 60 s of no RTP from the peer
/// → close. RTC quiet periods are normal but 60 s of dead air means
/// "the browser tab is dead."
const IDLE_TIMEOUT: Duration = Duration::from_secs(60);

#[derive(Debug, Error)]
pub enum RtcSessionError {
    /// Two-stage failure from str0m's SDP path: `SdpOffer::from_sdp_string`
    /// can fail to parse, OR `accept_offer` can reject the parsed offer.
    /// Both return `str0m::sdp::SdpError` / `str0m::RtcError` respectively;
    /// we collapse them via `#[source]` since both are display-format-only
    /// at the axum boundary (HTTP 400 in `routes.rs`).
    #[error("SDP offer parse or accept failed: {0}")]
    SdpOffer(String),
    #[error("opus codec init failed: {0}")]
    Codec(#[from] opus::Error),
    #[error("UDP socket bind failed: {0}")]
    Socket(#[from] std::io::Error),
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A captured Chrome SDP offer for an audio-only Opus m-line. Truncated
    /// to the relevant audio m-line section for test readability — the full
    /// offer includes video m-lines that str0m rejects as part of answer
    /// generation (spec §3.7). This fixture is a real browser-style offer
    /// with host ICE candidates.
    const BROWSER_SDP_OFFER: &str = "\
v=0\r
o=- 4593482934 2 IN IP4 127.0.0.1\r
s=-\r
t=0 0\r
m=audio 9 UDP/TLS/RTP/SAVPF 111\r
c=IN IP4 0.0.0.0\r
a=rtcp:9 IN IP4 0.0.0.0\r
a=ice-ufrag:abcd\r
a=ice-pwd:abcdefghijklmnopqrstuvwxyz0123456789\r
a=fingerprint:sha-256 AB:CD:EF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD\r
a=setup:actpass\r
a=mid:0\r
a=sendrecv\r
a=rtpmap:111 opus/48000/2\r
a=fmtp:111 minptime=10;useinbandfec=1\r
a=candidate:1 1 UDP 2113667327 192.168.1.42 50000 typ host\r
";

    #[test]
    fn accept_offer_returns_sdp_answer_with_opus() {
        let mut session = RtcSession::new().expect("session");
        let answer = session
            .accept_offer(BROWSER_SDP_OFFER)
            .expect("SDP answer");
        // Answer contains an audio m-line, an Opus payload, a fingerprint,
        // and ICE credentials (str0m fills these natively in 0.21).
        assert!(answer.contains("m=audio"), "answer has an audio m-line");
        assert!(answer.contains("opus/48000"), "answer advertises Opus");
        assert!(answer.contains("a=fingerprint:sha-256 "), "DTLS fingerprint");
        assert!(answer.contains("a=ice-ufrag:"), "ICE ufrag present");
        assert!(answer.contains("a=ice-pwd:"), "ICE pwd present");
    }

    #[test]
    fn channel_id_matches_session_id() {
        let session = RtcSession::new().expect("session");
        let id = session.channel_id();
        // The ChannelId IS the session id surfaced in the REST API (spec §4.5).
        assert_eq!(format!("{}", id).len(), 36);
    }

    #[test]
    fn accept_offer_transitions_channel_to_connecting() {
        // The spec §5.4 state machine: New → Connecting on offer receive.
        // This test pins the transition callers depend on; the impl sets
        // it at the end of `accept_offer`.
        let mut session = RtcSession::new().expect("session");
        assert_eq!(session.channel_state(), ChannelState::New);
        let _ = session.accept_offer(BROWSER_SDP_OFFER).expect("answer");
        assert_eq!(session.channel_state(), ChannelState::Connecting);
    }
}
  • Step 4: Run the test to verify it fails

Run: cargo test -p rutster-media rtc_session::tests Expected: FAIL — RtcSession undefined.

  • Step 5: Implement RtcSession

Append to crates/rutster-media/src/rtc_session.rs (above the #[cfg(test)] mod tests):

use str0m::Candidate;
use str0m::media::Mid;
use str0m::net::Protocol;

/// The per-peer media owner (spec §3.1, §4.5).
///
/// # Ownership / sharing
///
/// An `RtcSession` lives behind an `Arc<Mutex<RtcSession>>` in the
/// binary's `DashMap<ChannelId, RtcSession>` (Task 5). The mutex is
/// short-held: each tokio poll iteration locks, runs `run_poll_once`,
/// unlocks. We do NOT hold the lock across `tokio::time::sleep` — that
/// would defeat theDashMap's sharded concurrency and pre-pave the
/// wrong pattern for step 4's dedicated thread.
///
/// # Why `Arc<Mutex<...>>` (not `Arc<RwLock<...>>`)
///
/// Every access of an `RtcSession` mutates it (str0m's `&mut self`
/// contract on `handle_input` + `poll_output`). `RwLock`'s read-mode
/// would be useless because str0m takes `&mut Rtc`. `Mutex` it is.
pub struct RtcSession {
    pub(crate) channel: Channel,
    pub(crate) rtc: Rtc,
    pub(crate) decoder: OpusDecoder,
    pub(crate) encoder: OpusEncoder,
    pub(crate) pipe: EchoAudioPipe,
    /// Local UDP socket str0m sends `Transmit` packets out on and
    /// receives `Input::Receive` packets from. Bound to an ephemeral
    /// port at construction; the local candidate passed to str0m at
    /// offer-accept time uses this address.
    pub(crate) socket: std::net::UdpSocket,
    /// Local socket address — cached because `local_addr()` is a syscall.
    pub(crate) local_addr: SocketAddr,
    /// Mid of the audio m-line we registered. Set during `accept_offer`.
    /// Slice 1 has exactly one m-line; multi-m-line arrives with video.
    pub(crate) audio_mid: Option<Mid>,
    /// Last deadline from `Rtc::poll_output` — the next time the loop
    /// should wake the rtc with `Input::Timeout`.
    pub(crate) next_timeout: Option<Instant>,
    /// Last Instant we received an RTP packet from the peer. Used for
    /// the 60 s idle timeout (spec §4.5).
    pub(crate) last_rx: Instant,
    /// Last Instant we wrote an outbound Opus frame. Used to pace the
    /// 20 ms encode tick for the echo path (slice-1 read of spec §3.2).
    pub(crate) last_outbound_at: Instant,
    /// Outbound RTP media-time clock (Opus audio runs at 48 kHz on the
    /// wire — 960 ticks per 20 ms frame). Incremented by 960 on each
    /// successful write. Honors str0m's "media time, wallclock, local
    /// time" discipline from its docs.
    pub(crate) next_media_time: str0m::media::MediaTime,
}

impl RtcSession {
    /// Construct a new session — used by both the binary's `AppState`
    /// (production) and the tests. Single constructor — no `for_test` /
    /// `for_server` split; the body is identical (binding a UDP socket
    /// on `0.0.0.0:0`, constructing the `Rtc` + codecs).
    pub fn new() -> Result<Self, RtcSessionError> {
        Self::new_internal()
    }

    fn new_internal() -> Result<Self, RtcSessionError> {
        // Bind an ephemeral UDP socket. We use std::net::UdpSocket and
        // drive it non-blocking from tokio rather than tokio's UdpSocket:
        // str0m operates on raw `Receive` values and yields `Transmit`
        // values, both of which are plain structs — no async needed.
        // Setting non-blocking lets us `recv_from` without blocking.
        let socket = std::net::UdpSocket::bind("0.0.0.0:0")?;
        socket.set_nonblocking(true)?;
        let local_addr = socket.local_addr()?;

        let rtc = Rtc::new(Instant::now());

        Ok(Self {
            channel: Channel::new_inbound(),
            rtc,
            decoder: OpusDecoder::new()?,
            encoder: OpusEncoder::new()?,
            pipe: EchoAudioPipe::new(),
            socket,
            local_addr,
            audio_mid: None,
            next_timeout: None,
            last_rx: Instant::now(),
            last_outbound_at: Instant::now(),
            next_media_time: str0m::media::MediaTime::ZERO,
        })
    }

    pub fn channel_id(&self) -> ChannelId {
        self.channel.id
    }

    pub fn channel_state(&self) -> ChannelState {
        self.channel.state
    }

    pub fn is_closed(&self) -> bool {
        matches!(self.channel.state, ChannelState::Closed)
    }

    /// Accept a browser SDP offer; return the SDP answer (spec §4.1).
    ///
    /// str0m 0.21's `sdp_api().accept_offer()` does the heavy lifting:
    /// parses the offer, picks compatible codecs (Opus, the only one we
    /// register by default), generates the DTLS fingerprint from its
    /// self-signed cert, and produces ICE ufrag/pwd. We add our local
    /// host candidate (the UDP socket we just bound) *before* calling
    /// `accept_offer` so the answer carries it.
    pub fn accept_offer(&mut self, offer_sdp: &str) -> Result<String, RtcSessionError> {
        assert!(self.audio_mid.is_none(), "accept_offer called twice");

        // Register our local UDP socket as a host candidate. str0m includes
        // this candidate's address + the ICE creds it generates in the SDP
        // answer. `add_local_candidate` returns `Option<&Candidate>` —
        // `None` means str0m rejected it (log + continue; not fatal).
        let candidate = Candidate::host(self.local_addr, "udp")
            .expect("host candidate from bound UDP socket");
        // ^-- expect is acceptable here: this is construction (cold path),
        // not the hot path. A bound UDP socket always yields a valid
        // host candidate; only an absurd Protocol parse fails.
        if self.rtc.add_local_candidate(candidate).is_none() {
            tracing::warn!(channel_id = %self.channel.id, "str0m rejected local candidate");
        }

        // str0m's SDP API parses + accepts the offer natively. There is NO
        // `from_str_unchecked` — `from_sdp_string` returns Result and is
        // the canonical entry point. accept_offer takes the owned SdpOffer.
        let parsed_offer = str0m::change::SdpOffer::from_sdp_string(offer_sdp)
            .map_err(|e| RtcSessionError::SdpOffer(format!("parse: {e}")))?;
        let answer = self
            .rtc
            .sdp_api()
            .accept_offer(parsed_offer)
            .map_err(|e| RtcSessionError::SdpOffer(format!("accept: {e}")))?;

        // The first audio mid we accepted. Used to get the Writer for
        // outbound Opus frames in `run_poll_once`. A single audio m-line
        // is slice 1's whole world; multi-m-line arrives with video.
        //
        // SdpAnswer exposes a `mid()` accessor — verify against str0m 0.21
        // `SdpAnswer` docs at impl time; if the accessor differs, look up
        // from the answer's m-lines.
        self.audio_mid = Some(answer.mid());

        self.channel.state = ChannelState::Connecting;
        Ok(answer.to_string())
    }

    /// Drive one iteration of the sans-IO poll loop (spec §3.2, §3.4).
    ///
    /// Returns the `Duration` until the next `Input::Timeout` should be
    /// fed back to str0m, or `None` if the peer is closed. The caller
    /// (Task 5's tokio task) sleeps this duration then calls again.
    ///
    /// DEV-DEVIATION: tokio polling accepted for slice 1; step 4
    /// replaces with dedicated timing thread per ARCHITECTURE.md.
    pub fn run_poll_once(&mut self, now: Instant) -> Option<Duration> {
        if self.is_closed() {
            return None;
        }
        crate::loop_driver::drive(self, now)
    }
}
  • Step 6: Write crates/rutster-media/src/loop_driver.rs (the str0m poll loop)
//! # str0m poll loop (spec §3.2, §3.4)
//!
//! The heart of the media core. Drives the `str0m::Rtc` instance forward
//! on each call: drains `poll_output()` until `Output::Timeout`, handling
//! each `Output::Transmit` (send on our UDP socket) and `Output::Event`
//! (inbound `MediaData` → Opus decode → sink; inbound RTP count for the
//! idle timeout). When the drain returns `Timeout`, the caller sleeps
//! that duration and calls back with `Input::Timeout`.
//!
//! # Why this lives in a separate module
//!
//! `run_poll_once` takes `&mut RtcSession` — a single function with
//! the full poll logic would make `RtcSession::run_poll_once` 100+ lines
//! of non-trivial control flow. Splitting the loop into a module makes
//! the sans-IO pattern obvious: the loop driver takes a `&mut RtcSession`,
//! reads str0m outputs, and writes str0m inputs. Nothing else.
//!
//! # DEV-DEVIATION
//!
//! Slice 1 runs the poll on a tokio task. ARCHITECTURE.md mandates a
//! dedicated timing thread; we defer that to step 4 (barge-in) because
//! slice 1 has no reflex to time against. The poll function's shape
//! (single `&mut self`, no I/O inside) makes the step-4 swap localized.

use std::io::ErrorKind;
use std::time::{Duration, Instant};

use str0m::media::MediaData;
use str0m::net::Receive;
use str0m::{Input, Output, Protocol};

use crate::pcm::{AudioSink as _, AudioSource as _};
use crate::rtc_session::RtcSession;
use crate::IDLE_TIMEOUT;

/// 20 ms tick for outbound encoding (matches the PCM frame size).
const OUTBOUND_TICK: Duration = Duration::from_millis(20);

/// One iteration of the str0m poll loop.
///
/// 1. Read any pending UDP packets (non-blocking) and feed each to str0m
///    as `Input::Receive`. A WouldBlock means no packets this cycle — fine.
/// 2. Drain `poll_output()` until `Timeout`:
///    - `Transmit` → send on our UDP socket.
///    - `Event::MediaData` → decode Opus → push to the echo pipe (sink).
///    - `Event::IceConnectionStateChange` → state transition + tracing.
///    - We don't break out of the drain on any of these: str0m's contract
///      is mutate→drain to `Timeout`→mutate (see str0m 0.21 docs).
/// 3. **Outbound encode tick:** if ≥20 ms of wallclock passed since the
///    last outbound frame, pull one `PcmFrame` from the source, encode to
///    Opus, and write via `Rtc::writer(mid)->Writer::write`. Then re-drain
///    `poll_output` (the Writer write is a mutation → must drain per str0m).
/// 4. Check the idle timeout: if `Instant::now() - last_rx > IDLE_TIMEOUT`,
///    transition to `Closed`.
/// 5. Return the `Duration` to the next `Timeout`.
pub fn drive(session: &mut RtcSession, now: Instant) -> Option<Duration> {
    // === Step 1: drain our UDP socket non-blocking, feed str0m. ===
    let mut buf = [0u8; 2000];
    loop {
        match session.socket.recv_from(&mut buf) {
            Ok((n, source)) => {
                let contents = &buf[..n];
                let recv = Receive {
                    proto: Protocol::Udp,
                    source,
                    destination: session.local_addr,
                    contents: contents.try_into().ok()?,
                };
                if session.rtc.handle_input(now, Input::Receive(recv)).is_err() {
                    // Hot-path policy: drop + observe, don't crash.
                    tracing::warn!("str0m rejected input packet; dropping");
                }
                session.last_rx = now;
            }
            // WouldBlock (unix) / TimedOut (windows) — no packets this cycle.
            Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => break,
            Err(e) => {
                tracing::warn!(error = ?e, "UDP recv_from error; continuing");
                break;
            }
        }
    }

    // === Step 2: drain poll_output, interleaving outbound writes. ===
    let mut next_timeout: Option<Instant> = session.next_timeout;
    // Track whether we owe a Writer write this cycle; re-drain if so.
    // str0m's "mutate → drain to Timeout" invariant: after Writer::write,
    // poll_output must be drained to Timeout before any other mutation.
    let mut needs_redrain = false;
    loop {
        match session.rtc.poll_output() {
            Ok(Output::Timeout(t)) => {
                next_timeout = Some(t);
                if needs_redrain {
                    // We did an outbound write in the previous iteration;
                    // str0m needs to be drained again. Loop continues,
                    // but only handle Transmit/Event briefly before next Timeout.
                    needs_redrain = false;
                    continue;
                }
                break; // engine is fully drained
            }
            Ok(Output::Transmit(t)) => {
                if let Err(e) = session.socket.send_to(&t.contents, t.destination) {
                    if !matches!(e.kind(), ErrorKind::WouldBlock) {
                        tracing::warn!(error = ?e, "UDP send_to error; dropping");
                    }
                }
            }
            Ok(Output::Event(event)) => {
                handle_event(session, event, now);
                // Loop continues — mutations from inside the drain loop
                // are fine (str0m docs, "single-mutation invariant").
            }
            Err(e) => {
                tracing::warn!(error = ?e, "str0m poll_output error; continuing");
                next_timeout = Some(now + OUTBOUND_TICK);
                break;
            }
        }
    }

    // === Step 3: outbound encode tick (the echo path). ===
    // If str0m's poll loop has crossed a 20 ms boundary, pull a PcmFrame
    // from the source, encode to Opus, and write via Writer::write. This
    // IS the slice-1 echo: inbound decode → pipe → outbound encode.
    if now.duration_since(session.last_outbound_at) >= OUTBOUND_TICK {
        if let Some(mid) = session.audio_mid {
            if let Some(frame) = session.pipe.next_pcm_frame() {
                if let Some(opus_payload) = session.encoder.encode(&frame) {
                    // Writer::write signature (str0m 0.21, verified):
                    //   write(pt: Pt, wallclock: Instant, rtp_time: MediaTime, data: impl Into<Arc<[u8]>>)
                    //     -> Result<(), RtcError>
                    // - pt: payload type for Opus. `writer.payload_params()`
                    //   returns `impl Iterator<Item = &PayloadParams>`; the
                    //   first one's `.pt()` is our Opus PT (str0m negotiates
                    //   this in the SDP answer).
                    // - wallclock: when the sample was produced — local `now`.
                    // - rtp_time: RTP timestamp in the 48 kHz audio clock for
                    //   Opus. Increment by 960 per 20 ms (48000 * 0.020).
                    //   NOTE: the param is named `rtp_time` in str0m's
                    //   signature (NOT `media_time`). MediaTime has NO
                    //   `add(Duration)` method — use `mt + MediaTime::from(d)`.
                    //
                    // `rtc.writer(mid)` returns `Option<Writer<'_>>` — `None`
                    // if direction isn't sending (we'd be in a recvonly state).
                    if let Some(writer) = session.rtc.writer(mid) {
                        if let Some(params) = writer.payload_params().next() {
                            let pt = params.pt();
                            let rtp_time = session.next_media_time;
                            if writer
                                .write(pt, now, rtp_time, opus_payload.as_slice())
                                .is_ok()
                            {
                                // Advance media time for next 20 ms frame.
                                // `MediaTime + MediaTime::from(Duration)` —
                                // no `add()` method on MediaTime.
                                session.next_media_time =
                                    session.next_media_time
                                        + str0m::media::MediaTime::from(
                                            Duration::from_millis(20),
                                        );
                                needs_redrain = true;
                            }
                        }
                    }
                }
            }
            session.last_outbound_at = now;
        }
    }

    // If the outbound write happened, we owe str0m one more drain before
    // returning — Writer::write is a mutation per str0m's invariant.
    if needs_redrain {
        loop {
            match session.rtc.poll_output() {
                Ok(Output::Timeout(t)) => {
                    next_timeout = Some(t);
                    break;
                }
                Ok(Output::Transmit(t)) => {
                    let _ = session.socket.send_to(&t.contents, t.destination);
                }
                Ok(Output::Event(e)) => handle_event(session, e, now),
                Err(_) => break,
            }
        }
    }

    // === Step 4: idle timeout (spec §4.5). ===
    if now.duration_since(session.last_rx) > IDLE_TIMEOUT {
        tracing::info!(
            channel_id = %session.channel.id,
            "idle timeout (60 s no RX); closing session"
        );
        session.channel.state = rutster_call_model::ChannelState::Closed;
        return None;
    }

    session.next_timeout = next_timeout;
    next_timeout.map(|t| t.saturating_duration_since(now))
}

/// Dispatch a str0m `Event` to the audio pipe or to state bookkeeping.
fn handle_event(session: &mut RtcSession, event: str0m::Event, _now: Instant) {
    use str0m::Event;
    match event {
        Event::MediaData(media) => {
            // Inbound decoded audio frame from the peer (Frame API, spec §3.2).
            // str0m has already done RTP depacketization; `MediaData.data` is
            // the encoded Opus payload (type: `Arc<[u8]>` — pass `&media.data`
            // to the decoder since OpusDecoder::decode takes `&[u8]`).
            if let Some(pcm) = session.decoder.decode(&media.data) {
                session.pipe.on_pcm_frame(pcm);
            }
            // Decode failed → drop + observe (per §3.8). Don't kill the peer.
        }
        Event::IceConnectionStateChange(state) => {
            tracing::info!(
                channel_id = %session.channel.id,
                ?state,
                "ICE state change"
            );
            if state == ::str0m::IceConnectionState::Connected {
                session.channel.state = rutster_call_model::ChannelState::Connected;
            }
        }
        Event::EgressBitrateEstimate(_) => { /* BWE — irrelevant in slice 1 */ }
        _ => { /* str0m emits several other event variants we don't need in slice 1. */ }
    }
}
  • Step 7: Run the str0m-offer test to verify the API wiring

Run: cargo test -p rutster-media rtc_session::tests::accept_offer_returns_sdp_answer_with_opus Expected: PASS (str0m accepts the offer, returns an SDP answer with Opus + DTLS fingerprint + ICE creds).

NOTES FOR THE IMPLEMENTER (residual verifications post-review):

  • SdpAnswer::mid() — the plan assumes this accessor exists on str0m's SdpAnswer. If str0m 0.21 exposes a different API (e.g. answer.mids().next() or via a CodecConfig lookup), adjust to use whatever str0m 0.21 ships. Run cargo doc -p str0m --open and look at SdpAnswer.

  • PayloadParams::pt() — the plan assumes payload_params().next().unwrap().pt() works. If Pt is exposed differently (e.g. via match_params(incoming)), use that instead. The recommended path per str0m 0.21 docs is writer.match_params(¶ms) -> Option<Pt> where params is the inbound MediaData.params — this matches the incoming payload to the negotiated outbound PT. For slice 1 (echo loopback, single codec), the simpler payload_params().next() path works; match_params is the general path when multiple codecs are negotiated.

  • media.data: Arc<[u8]> — pass &media.data (deref coercion) to OpusDecoder::decode(&[u8]).

  • The plan's str0m API claims were verified against docs.rs/str0m/0.21 during the adversarial review. Don't hand-roll an SDP munger; honor the mutate → drain to Timeout → mutate invariant; keep the hot-path match-and-continue policy on the 20 ms loop.

  • Step 8: Fix clippy + fmt

Run: cargo fmt && cargo clippy -p rutster-media -- -D warnings Expected: clean. If str0m's API types don't line up with the plan's sketches, fix the import paths to satisfy the compiler; do NOT add an SDP munger or change the loop structure.

  • Step 9: Commit
git add crates/rutster-media
git commit -m "media: RtcSession + str0m poll loop (the media core)

RtcSession owns a str0m::Rtc + Opus decoder/encoder + EchoAudioPipe +
a bound UDP socket (spec §3.1, §4.5). accept_offer calls str0m 0.21's
sdp_api().accept_offer() natively — no hand-rolled SDP munger; str0m
fills DTLS fingerprint + ICE creds + Opus codec. The loop driver
drains poll_output per str0m's single-mutation invariant, routes
inbound MediaData through Opus decode + EchoAudioPipe sink, sends
Transmit packets on the UDP socket, and checks the 60 s idle timeout.

DEV-DEVIATION: loop runs on tokio (spec §3.4); step 4 replaces with
a dedicated timing thread per ARCHITECTURE.md."

Task 5: rutster binary — axum signaling server + browser client + integration test

Files:

  • Create: crates/rutster/Cargo.toml
  • Create: crates/rutster/src/main.rs (axum server bootstrap + graceful shutdown)
  • Create: crates/rutster/src/session_map.rs (DashMap<ChannelId, RtcSession> + poll driver)
  • Create: crates/rutster/src/routes.rs (the four HTTP routes)
  • Create: crates/rutster/static/index.html (browser test client)
  • Create: crates/rutster/tests/api_integration.rs (integration test: POST /v1/sessions roundtrip)
  • Modify: Cargo.toml (workspace root — add member).

Interfaces:

  • Consumes: RtcSession, RtcSessionError, ChannelId from Tasks 2/4.

  • Produces: a running axum server on 0.0.0.0:8080 with four routes (spec §4.1), a tokio task per active session driving the str0m poll loop, a static browser test client served at GET /, an integration test that hits the API.

  • Step 1: Write crates/rutster/Cargo.toml

# crates/rutster/Cargo.toml
[package]
name = "rutster"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "Rutster binary: axum signaling + media driver + static browser test client (slice 1)."

[dependencies]
rutster-call-model = { path = "../rutster-call-model" }
rutster-media = { path = "../rutster-media" }
axum = { workspace = true }
tokio = { workspace = true }
dashmap = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

[dev-dependencies]
tower = { workspace = true }

[[bin]]
name = "rutster"
path = "src/main.rs"
  • Step 2: Write the failing integration test first

Create crates/rutster/tests/api_integration.rs:

//! Integration test for the slice-1 REST surface (spec §4.1, §6.4).
//!
//! Spins up the axum app on an ephemeral port, then exercises the API
//! contract: POST /v1/sessions → JSON { session_id }; GET / serves a
//! text/html page. We do NOT exercise the WebRTC handshake here (that
//! needs a real peer); the manual e2e plan in README.md covers it.

use axum::body::Body;
use axum::http::{Request, StatusCode};
use rutster::session_map::AppState;
use tower::ServiceExt; // enables `oneshot` on the Router for sync tests

#[tokio::test]
async fn post_v1_sessions_returns_a_session_id() {
    let app = rutster::routes::router(AppState::new());
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/v1/sessions")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert!(v["session_id"].is_string(), "response has session_id");
    assert_eq!(v["session_id"].as_str().unwrap().len(), 36); // UUID v4
}

#[tokio::test]
async fn get_root_serves_html() {
    let app = rutster::routes::router(AppState::new());
    let resp = app
        .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(
        resp.headers()
            .get("content-type")
            .map(|v| v.to_str().unwrap()),
        Some("text/html; charset=utf-8")
    );
}
  • Step 3: Run the test to verify it fails

Run: cargo test -p rutster --test api_integration Expected: FAIL — rutster::routes and rutster::session_map don't exist.

  • Step 4: Write crates/rutster/src/session_map.rs
//! # Session store + poll-driver (spec §4.5)
//!
//! `DashMap<ChannelId, RtcSession>` holds active sessions; the `ChannelId`
//! (UUID newtype from `rutster-call-model`) IS the session id surfaced in
//! the REST API. A single tokio task drives all sessions' poll loops (a
//! per-session task would clutter the runtime and pre-pave the wrong
//! pattern for the step-4 dedicated thread — spec §4.5).
//!
//! # Concurrency note
//!
//! `DashMap` shards its inner `HashMap` so concurrent gets/puts across
//! different `ChannelId`s don't contend. We iterate per-shard inside the
//! poll task to drive each session; entries marked `Closed` are removed.

use std::sync::Arc;
use std::time::{Duration, Instant};

use dashmap::DashMap;
use rutster_call_model::{ChannelId, ChannelState};
use rutster_media::{RtcSession, RtcSessionError};
use tokio::sync::Mutex;
use tracing::{debug, info};

/// The application state shared across axum handlers + the poll task.
///
/// # Why `Arc` (and not bare)
///
/// axum clones the state into every handler. `Arc` is the standard way
/// to share `DashMap` + `Mutex` owned state across these clones cheaply
/// (a single heap allocation, refcount-bumped per clone). Without `Arc`,
/// every handler would move its own copy — and `DashMap` is not `Copy`.
///
/// # Why a separate `poll_running` `Mutex`
///
/// The poll loop is one task; we don't want two. The Mutex guards a
/// once-only spawn: `spawn_poll_task` checks-and-sets it under the mutex.
/// `Mutex` (not `RwLock`) because the only operation is "take it once."
#[derive(Clone)]
pub struct AppState {
    pub sessions: Arc<DashMap<ChannelId, Arc<Mutex<RtcSession>>>>,
    pub poll_running: Arc<Mutex<bool>>,
}

impl AppState {
    pub fn new() -> Self {
        Self {
            sessions: Arc::new(DashMap::new()),
            poll_running: Arc::new(Mutex::new(false)),
        }
    }

    /// Mint a fresh `RtcSession`, store it under its `ChannelId`, return the id.
    pub fn create_session(&self) -> Result<ChannelId, RtcSessionError> {
        let session = RtcSession::new()?;
        let id = session.channel_id();
        self.sessions.insert(id, Arc::new(Mutex::new(session)));
        Ok(id)
    }

    /// Look up a session by id (returns the clone of the Arc-wrapped Mutex).
    pub fn get(&self, id: ChannelId) -> Option<Arc<Mutex<RtcSession>>> {
        self.sessions.get(&id).map(|r| r.clone())
    }

    /// Transition to Closing then drop the entry (spec §4.1 — DELETE).
    pub async fn close(&self, id: ChannelId) {
        if let Some((_id, session_arc)) = self.sessions.remove(&id) {
            let mut s = session_arc.lock().await;
            s.channel.state = ChannelState::Closing;
            s.channel.state = ChannelState::Closed;
            info!(channel_id = %id, "session closed via DELETE");
        }
    }

    /// Spawn the single poll task for all sessions (idempotent).
    pub async fn spawn_poll_task(self) {
        let mut running = self.poll_running.lock().await;
        if *running {
            return;
        }
        *running = true;
        drop(running);

        let state = self.clone();
        tokio::spawn(async move {
            let mut interval = tokio::time::interval(Duration::from_millis(10));
            interval.tick().await;
            loop {
                interval.tick().await;
                let now = Instant::now();
                drive_all_sessions(&state, now).await;
            }
        });
    }
}

impl Default for AppState {
    fn default() -> Self {
        Self::new()
    }
}

/// One iteration of "drive every active session." Removes closed entries.
async fn drive_all_sessions(state: &AppState, now: Instant) {
    // Collect ids first to avoid holding the DashMap shard during the
    // async poll (which would block other handlers mutating the same shard).
    let ids: Vec<ChannelId> = state.sessions.iter().map(|r| *r.key()).collect();
    for id in ids {
        let session_arc = match state.sessions.get(&id) {
            Some(r) => r.clone(),
            None => continue,
        };
        let mut s = session_arc.lock().await;
        let _ = s.run_poll_once(now); // hot-path match-and-continue inside
        if s.is_closed() {
            drop(s);
            state.sessions.remove(&id);
            debug!(channel_id = %id, "session evicted after close");
        }
    }
}
  • Step 5: Write crates/rutster/src/routes.rs
//! # HTTP routes (spec §4.1, §4.3)
//!
//! Four routes on axum 0.7:
//! - `POST /v1/sessions` → `{ "session_id": "<uuid>" }`.
//! - `POST /v1/sessions/:id/offer` (`Content-Type: application/sdp` req
//!   + response) → core returns the SDP answer.
//! - `DELETE /v1/sessions/:id` → tear down.
//! - `GET /` → serve the static HTML test client.
//!
//! No authn/authz, no TLS, no multi-tenancy — all deferred per spec §1.2.

use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Serialize;
use uuid::Uuid;

use crate::session_map::AppState;

#[derive(Serialize)]
struct SessionCreated {
    session_id: String,
}

/// POST /v1/sessions — mint a fresh RtcSession (spec §4.1).
pub async fn create_session(State(state): State<AppState>) -> Response {
    match state.create_session() {
        Ok(id) => {
            let body = Json(SessionCreated {
                session_id: id.0.to_string(),
            });
            (StatusCode::OK, body).into_response()
        }
        Err(e) => {
            tracing::error!(error = ?e, "session create failed");
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

/// POST /v1/sessions/:id/offer — accept browser SDP offer, return answer
/// (spec §4.1). Non-trickle: the offer body carries all browser ICE
/// candidates; the answer carries the core's candidates (filled natively
/// by str0m 0.21's sdp_api().accept_offer).
pub async fn post_offer(
    State(state): State<AppState>,
    Path(id_str): Path<String>,
    body: String,
) -> Response {
    let Ok(id_uuid) = Uuid::parse_str(&id_str) else {
        return (StatusCode::NOT_FOUND, "bad session id").into_response();
    };
    let id = rutster_call_model::ChannelId(id_uuid);
    let Some(session_arc) = state.get(id) else {
        return (StatusCode::NOT_FOUND, "no such session").into_response();
    };
    let mut s = session_arc.lock().await;
    match s.accept_offer(&body) {
        Ok(answer_sdp) => (
            StatusCode::OK,
            [(header::CONTENT_TYPE, "application/sdp")],
            answer_sdp,
        )
            .into_response(),
        Err(e) => {
            tracing::error!(error = ?e, "SDP accept failed");
            StatusCode::BAD_REQUEST.into_response()
        }
    }
}

/// DELETE /v1/sessions/:id — tear down (spec §4.1, §4.5).
pub async fn delete_session(
    State(state): State<AppState>,
    Path(id_str): Path<String>,
) -> Response {
    let Ok(id_uuid) = Uuid::parse_str(&id_str) else {
        return StatusCode::NOT_FOUND.into_response();
    };
    let id = rutster_call_model::ChannelId(id_uuid);
    state.close(id).await;
    StatusCode::NO_CONTENT.into_response()
}

/// GET / — serve the static browser test client (spec §4.4).
pub async fn index() -> Response {
    (
        StatusCode::OK,
        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
        include_str!("../static/index.html"),
    )
        .into_response()
}

/// Build the axum router.
pub fn router(state: AppState) -> Router {
    Router::new()
        .route("/", get(index))
        // `POST /v1/sessions` creates; `DELETE /v1/sessions/:id` destroys
        // (note the `:id` — deleting the collection root has no meaning and
        // would extract a missing `:id` path parameter, so the two routes
        // live at different paths, not chained via `.delete(...)` on the
        // collection route as axum's method chaining would suggest).
        .route("/v1/sessions", post(create_session))
        .route("/v1/sessions/:id", axum::routing::delete(delete_session))
        .route("/v1/sessions/:id/offer", post(post_offer))
        .with_state(state)
}
  • Step 6: Write crates/rutster/src/main.rs
//! # rutster — slice-1 binary
//!
//! axum signaling server + the media-core poll driver + a static HTML
//! test client (spec §4). Binds `0.0.0.0:8080` plaintext — no TLS in
//! slice 1 (out of scope per §1.2). DTLS-SRTP is mandatory on the media
//! surface (str0m handles natively); TLS on the HTTP surface lands with
//! the deployment posture in step 5.
//!
//! ## Architecture refs
//!
//! - [slice-1 spec §4](../../docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md)
//! - [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) — fused vertical.

use std::net::SocketAddr;

use rutster::routes::router;
use rutster::session_map::AppState;
use tracing::info;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "rutster=info".into()),
        )
        .init();

    let state = AppState::new();
    state.clone().spawn_poll_task().await;

    let addr: SocketAddr = "0.0.0.0:8080".parse().expect("valid addr");
    info!(%addr, "listening");
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, router(state))
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}

/// Ctrl-C / SIGTERM handler (spec §4.5). Dropping the AppState drops the
/// DashMap, which drops every RtcSession, which str0m sees as a closed
/// peer — browsers get a dead peer connection. Acceptable for the dev
/// loop; no in-flight call preservation story in slice 1.
async fn shutdown_signal() {
    let ctrl_c = async {
        tokio::signal::ctrl_c()
            .await
            .expect("installed ctrl-c handler");
    };

    #[cfg(unix)]
    let sigterm = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("installed SIGTERM handler")
            .recv()
            .await;
    };
    #[cfg(not(unix))]
    let sigterm = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => info!("received Ctrl-C, shutting down"),
        _ = sigterm => info!("received SIGTERM, shutting down"),
    }
}

In crates/rutster/src/main.rs, declare the two modules above main. They must be pub mod (not plain mod) because the integration test in tests/api_integration.rs references them via absolute paths (rutster::routes::router, rutster::session_map::AppState). Binary crates don't have an external consumer surface in production, but Rust still requires pub mod for tests/ integration tests to see the path:

pub mod routes;
pub mod session_map;

#[tokio::main]
async fn main() {
    // (the body from above)
}

(Equivalent: write pub mod lines first, then #[tokio::main] async fn main() { ... } below them. The order in the file is: top-level docs → pub mod declarations → use imports → main.)

  • Step 7: Write crates/rutster/static/index.html (browser test client, spec §4.4)
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Rutster slice-1 — WebRTC loopback</title>
  <style>
    body { font: 14px/1.4 system-ui, sans-serif; max-width: 60ch; margin: 2rem auto; }
    pre { background: #f4f4f4; padding: 1rem; overflow: auto; }
    button { font: inherit; padding: 0.4rem 1rem; }
  </style>
</head>
<body>
  <h1>Rutster slice-1 — WebRTC loopback</h1>
  <p>Speak; you should hear yourself back within ~200 ms.</p>
  <button id="start">Start call</button>
  <button id="mute" disabled>Mute mic</button>
  <button id="hangup" disabled>Hang up</button>
  <pre id="log"></pre>
  <audio id="audio" autoplay></audio>

  <script>
    const log = (s) => { document.getElementById('log').textContent += s + '\n'; };
    const $ = (id) => document.getElementById(id);
    let pc, sessionId, localStream;

    $('start').onclick = async () => {
      $('start').disabled = true;
      $('mute').disabled = false;
      $('hangup').disabled = false;
      localStream = await navigator.mediaDevices.getUserMedia({ audio: true });

      // No STUN (host candidates only — spec §4.4, zero-dependency dev loop).
      pc = new RTCPeerConnection({ iceServers: [] });
      localStream.getTracks().forEach(t => pc.addTrack(t, localStream));

      pc.oniceconnectionstatechange = () => log('ICE: ' + pc.iceConnectionState);
      pc.onconnectionstatechange = () => log('conn: ' + pc.connectionState);
      pc.ontrack = (e) => { $('audio').srcObject = e.streams[0]; };

      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      // Non-trickle: wait for ICE gathering to complete so all candidates
      // travel in the SDP offer POST.
      await new Promise(r => {
        if (pc.iceGatheringState === 'complete') r();
        else { pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') r(); }; };
      });

      const resp = await fetch('/v1/sessions', { method: 'POST' });
      const { session_id } = await resp.json();
      sessionId = session_id;
      log('session: ' + session_id);

      const ans = await fetch(`/v1/sessions/${session_id}/offer`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/sdp' },
        body: pc.localDescription.sdp,
      });
      const answerSdp = await ans.text();
      await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
    };

    $('mute').onclick = () => {
      const track = localStream.getAudioTracks()[0];
      track.enabled = !track.enabled;
      $('mute').textContent = track.enabled ? 'Mute mic' : 'Unmute mic';
    };

    $('hangup').onclick = async () => {
      await fetch(`/v1/sessions/${sessionId}`, { method: 'DELETE' });
      pc.close();
      $('hangup').disabled = true;
      $('mute').disabled = true;
      $('start').disabled = false;
      log('hung up');
    };
  </script>
</body>
</html>
  • Step 8: Add crates/rutster to the workspace members

Modify root Cargo.toml:

members = [
    "crates/rutster",
    "crates/rutster-call-model",
    "crates/rutster-media",
    "crates/rutster-signaling-sip",
    "crates/rutster-tap",
    "crates/rutster-spend",
]
  • Step 9: Run the integration tests

Run: cargo test -p rutster --test api_integration Expected: 2 tests passing (post_v1_sessions_returns_a_session_id, get_root_serves_html).

NOTE FOR THE IMPLEMENTER: session_map.rs calls RtcSession::new() (the single constructor from Task 4 Step 5). No new_for_test/new_for_server split — Task 4 was patched in review to expose just pub fn new(). If you encountered an older plan revision mentioning two constructors, disregard it; the canonical name is RtcSession::new().

  • Step 10: Run clippy + fmt

Run: cargo fmt --check && cargo clippy -p rutster -- -D warnings Expected: clean.

  • Step 11: Manual smoke (spec §6.5)
cargo run -p rutster
# in another terminal / browser:
# open http://localhost:8080/  → click Start call → grant mic → hear echo
# click Hang up → server logs Closing → Closed

This is the slice-1 "done" criterion #5. Don't gate the commit on the manual test (it's browser-driven) — flag it in the commit message as "manual e2e pending."

  • Step 12: Commit
git add Cargo.toml crates/rutster
git commit -m "binary: axum signaling + DashMap session store + browser test client

Four routes on axum 0.7 per spec §4.1: POST /v1/sessions (mint),
POST /v1/sessions/:id/offer (str0m-native SDP accept), DELETE
/v1/sessions/:id (close), GET / (static HTML client). Session store is
a DashMap<ChannelId, Arc<Mutex<RtcSession>>> (spec §4.5); one tokio
task drives all session poll loops — per-session tasks would pre-pave
the wrong pattern for step 4's dedicated thread. Graceful shutdown
drops the DashMap on Ctrl-C / SIGTERM. Integration test exercises the
REST surface; manual browser e2e per README §6.5."

Task 6: cargo-deny config + CI workflow

Files:

  • Create: deny.toml
  • Create: .github/workflows/ci.yml

Interfaces:

  • Consumes: nothing code-wise.

  • Produces: cargo deny check gating CI; CI runs fmt --check, clippy -D warnings, test --all, deny check on push + PR to main (spec §6.1, §6.2).

  • Step 1: Write deny.toml

# deny.toml — cargo-deny config (spec §6.1).
# Run locally: cargo deny check.
# CI runs `cargo deny check` as the last gate.

[graph]
# Use Cargo.lock as the source of truth for the dep graph.
all-features = true

[advisories]
# Vulnerabilities fail CI. `deny warnings` makes advisory-db issues
# (not just actual advisories) fatal.
deny = ["RUSTSEC-0000-0000"]
version = 2
ignore = []
unmaintained = "workspace"

[licenses]
# Allow our own (GPL-3.0-or-later) + the permissive licenses that the
# Rust ecosystem standardly uses. Final list confirmed at impl time by
# running `cargo deny check licenses` after the first `cargo fetch`
# (spec §6.1); adjust as needed so str0m/opus/axum actually pass.
allow = [
    "GPL-3.0-or-later",
    "MIT",
    "Apache-2.0",
    "BSD-3-Clause",
    "ISC",
    "Zlib",
    "Unicode-DFS-2016",
    "Unicode-3.0",
]
confidence-threshold = 0.93

[bans]
# Catch accidental dep-tree divergence early: tokio/serde/bytes/tracing
# each appear exactly once in the graph (spec §6.1).
multiple-versions = "deny"
wildcards = "deny"
highlight = "all"
deny = []
allow = []
# Skip-list for known unavoidable duplicates (added as they surface in CI).
# `cargo deny check bans` prints the spec's "skip" suggestion when a dup
# shows up; copy it here.
skip = []
skip-tree = []

[sources]
# crates.io only. No git deps. Keeps the build reproducible (spec §6.1,
# PORT_PLAN supply-chain goal).
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
  • Step 2: Write .github/workflows/ci.yml
# .github/workflows/ci.yml — slice-1 CI (spec §6.2).
# Gates: fmt --check, clippy -D warnings, test --all, cargo deny check.
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  CARGO_TERM_COLOR: always

jobs:
  fmt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt
      - run: cargo fmt --check

  clippy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - name: Install libopus (media crate FFI dep)
        run: sudo apt-get update && sudo apt-get install -y libopus-dev
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --all -- -D warnings

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        toolchain: [stable, "1.85"]
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ matrix.toolchain }}
      - name: Install libopus (media crate FFI dep)
        run: sudo apt-get update && sudo apt-get install -y libopus-dev
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --all

  deny:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: EmbarkStudios/cargo-deny-action@v1
        with:
          command: check
  • Step 3: Install cargo-deny locally and verify
cargo install cargo-deny --locked
cargo deny check

Expected: all four checks (advisories, licenses, bans, sources) clean. If a non-allowed license shows up in the transitive graph, add it to allow in deny.toml. If unavoidable duplicate versions show up, add them to skip with a comment explaining why (don't blindly silence — investigate the duplicate).

  • Step 4: Install libopus locally (the FFI dependency)
sudo apt-get install -y libopus-dev   # Debian/Ubuntu
# Fedora: sudo dnf install -y opus-devel
# macOS:  brew install opus

Verify: cargo test --all passes locally.

  • Step 5: Commit
git add deny.toml .github/workflows/ci.yml
git commit -m "ci: cargo-deny + GitHub Actions workflow (spec §6.1, §6.2)

deny.toml allows the permissive Rust-ecosystem licenses + our own
GPL-3.0-or-later; bans duplicate versions of tokio/serde/bytes/tracing
to catch dep-tree divergence early; restricts sources to crates.io. CI
runs fmt --check, clippy -D warnings, test --all (matrix: stable +
MSRV 1.85), and cargo deny check on push + PR to main. The CI job
installs libopus-dev — the opus crate's FFI dependency (PORT_PLAN §7
'Core (FFI)' disposition)."

Task 7: LEARNING.md + fuzz/ placeholder + README dev-loop

Files:

  • Create: LEARNING.md
  • Create: fuzz/README.md
  • Modify: README.md (add a "Slice 1 dev loop" section).

Interfaces:

  • Consumes: the full workspace produced by Tasks 16.

  • Produces: the LEARNING.md index (spec §7 — at least 5 pointers), the fuzz/ placeholder dir (spec §2), and the dev-loop doc slice in README.md (covers libopus install + manual e2e steps from spec §6.5).

  • Step 1: Write LEARNING.md

# LEARNING.md — to learn concept X, read file Y

This index maps a Rust concept you might be learning to the file where
slice 1 makes the concept concrete. Each entry is a worked example you
can read in `cargo doc --open` plus the source file itself.

## Concepts + pointers

- **Newtype pattern (type-safety via single-field wrappers)** →
  `crates/rutster-call-model/src/lib.rs``ChannelId(Uuid)`. The newtype
  stops us from mixing up a `ChannelId` with some future `SessionId` at
  the type-system level. Compile-enforced where a comment could only ask.

- **`enum` for closed state sets + exhaustive `match`** →
  `crates/rutster-call-model/src/lib.rs``ChannelState` (New →
  Connecting → Connected → Closing → Closed). Exhaustiveness checking
  forces every `match` to consider each state; adding a state later
  surfaces every site that needs handling.

- **Sans-IO pattern (no I/O inside the library; input via method calls,
  output via return values)** → `crates/rutster-media/src/loop_driver.rs`
  — the str0m poll loop. `Rtc::handle_input` takes a network packet as a
  struct argument, not from a socket the library owns; `poll_output`
  returns `Transmit` packets the caller sends. Fully testable without a
  network — str0m integration tests use this property to drive faster
  than realtime.

- **Trait design for extension points (a futures-compatible seam)** →
  `crates/rutster-media/src/pcm.rs` — the `AudioSource` / `AudioSink`
  traits. Slice 1 wires an `EchoAudioPipe` between them; step 2 swaps
  that for a real WSS tap client without touching `RtcSession`. The
  traits describe *what* the splice point does, not *how* it's filled.

- **Error enums with `thiserror` + hot-path match-and-continue** →
  `crates/rutster-media/src/lib.rs` (`MediaError`) and
  `crates/rutster-media/src/opus_codec.rs` (`OpusDecoder::decode` returns
  `Option<PcmFrame>`). Cold path: `thiserror`-derived enum + `?`. Hot
  path: match-and-continue, never `?`, never panic — "drop + observe,
  don't crash" (spec §3.8).

- **`Arc<Mutex<T>>` vs `Arc<RwLock<T>>` — when each is right** →
  `crates/rutster/src/session_map.rs`. The `RtcSession` lives behind
  `Arc<Mutex<...>>` because every access mutates it (str0m's `&mut self`
  contract) — `RwLock`'s read-mode would be useless. Comment on the
  struct explains the trade-off.

- **`DashMap` for sharded concurrent maps** →
  `crates/rutster/src/session_map.rs`. `DashMap` shards its inner map so
  two handlers operating on different `ChannelId`s don't contend;
  `HashMap` wrapped in a single `Mutex` would serialize every access.

- **str0m 0.21's single-mutation invariant** →
  `crates/rutster-media/src/loop_driver.rs`. Mutate (handle_input /
  Writer::write) → drain `poll_output` to `Output::Timeout` → next
  mutate. Violating this leaves str0m in an inconsistent state.

- **tokio graceful shutdown via signal handlers** →
  `crates/rutster/src/main.rs` (`shutdown_signal`). Ctrl-C / SIGTERM
  drops the AppState; the AppState drops the DashMap; the DashMap drops
  every RtcSession. No in-flight call preservation in slice 1.

- **`include_str!` for embedding static assets** →
  `crates/rutster/src/routes.rs` (`include_str!("../static/index.html")`).
  The HTML test client is compiled into the binary at build time — no
  separate file to ship, no disk IO to serve it.

## How to read

1. `cargo doc --open` — every module has a `//!` doc comment; the doc
   tree is the high-level map.
2. Pick a concept above; open the named file. The first occurrence of
   each non-obvious pattern has a `//` comment explaining *why*.
3. Cross-ref back to the spec sections cited inline (`spec §3.8`,
   `ADR-0002`, etc.) for the architecture-level rationale.
  • Step 2: Write fuzz/README.md (placeholder)
# fuzz/ — cargo-fuzz harness directory (placeholder)

**Status:** placeholder. Not yet a cargo-fuzz project — just the directory.
Fuzz harnesses land at spearhead step 5 (PSTN trunk) alongside the
SIP/SDP/RTP wire parsers (PORT_PLAN §10 mandates continuous fuzzing of
every wire parser). Slice 1 has no hostile-bytes surface (the browser is
trusted), so no harnesses here yet — the `fuzz/` dir pre-paves the
layout. Populating this directory with a real `cargo-fuzz` project
(`fuzz/Cargo.toml` + `fuzz/fuzz_targets/*.rs`) happens at step 5.

If you're at step 5, replace this README with that structure:
- `fuzz/Cargo.toml` — cargo-fuzz manifest.
- `fuzz/fuzz_targets/sip_parser.rs` — fuzz the SIP parser.
- `fuzz/fuzz_targets/sdp_parser.rs` — fuzz the SDP parser.
- `fuzz/fuzz_targets/rtp_packet.rs` — fuzz the RTP packet parser.
- CI job running a short fuzz burst on each PR (the cargo-fuzz integration
  lands in `.github/workflows/` at that point).

The hot-path "drop + observe, don't crash" policy (spec §3.8) is what the
future harnesses assert against: throw arbitrary bytes at the parser,
assert it returns an error or drops silently — never panics.
  • Step 3: Add the dev-loop section to README.md

Find the existing dev-loop / "how to run" area in README.md. If none exists, add this section near the top, after the project framing:

## Slice 1 dev loop (WebRTC media loopback)

> Build prerequisite: install libopus (the `opus` crate links it via FFI):
> ```bash
> sudo apt-get install -y libopus-dev   # Debian/Ubuntu
> # Fedora: sudo dnf install -y opus-devel
> # macOS:  brew install opus
> ```
> This is the one system dependency in slice 1. Opus is FFI per PORT_PLAN
> §7's "🦀 Core (FFI)" disposition — the codec surface Rust doesn't need
> to re-implement.

Run the server:

```bash
cargo run
# listening on http://0.0.0.0:8080

Open a browser to http://localhost:8080/, click "Start call", grant microphone permission. Speak — you should hear yourself back within ~200 ms (no perceptible delay). Click "Hang up" to tear down; server logs Closing → Closed.

Verbose tracing:

RUST_LOG=rutster=debug cargo run

Slice 1 "done" checklist (spec §6.5)

On a clean checkout:

  1. cargo test --all passes.
  2. cargo fmt --check passes.
  3. cargo clippy -- -D warnings passes.
  4. cargo deny check passes.
  5. cargo run + browser manual e2e: speak → hear echo within ~200 ms.
  6. Hang-up button triggers Closing → Closed in server logs.
  7. Every stub crate compiles; its doc-comment names its scheduled step.
  8. LEARNING.md indexes at least 5 "to learn X, read Y" pointers.

- [ ] **Step 4: Run the full "done" checklist (spec §6.5)**

```bash
cargo fmt --check
cargo clippy --all -- -D warnings
cargo test --all
cargo deny check

All four must pass before the commit. Flag the manual browser e2e (criterion #5) as "manually verified" or "pending" in the commit message.

  • Step 5: Commit
git add LEARNING.md fuzz/README.md README.md
git commit -m "docs: LEARNING.md + fuzz/ placeholder + README dev-loop (spec §7, §6.3)

LEARNING.md indexes ten concept-to-file pointers (the spec floor was
five) — the newtype pattern, exhaustive enum match, sans-IO, trait
extension seams, thiserror + hot-path match-and-continue, Arc<Mutex>
vs Arc<RwLock>, DashMap, str0m's single-mutation invariant, graceful
shutdown, include_str!. fuzz/README.md pre-paves the layout (no
hostile-bytes surface in slice 1; harnesses land at step 5 per the
out-of-scope table). README's new dev-loop section documents the
libopus FFI prerequisite and the manual e2e steps."

Self-review (post-write)

Ran the writing-plans self-review checklist:

1. Spec coverage — every spec section maps to a task:

  • §2 workspace layout → Task 1 (workspace + stubs), Task 2 (call-model), Task 3 (media), Task 5 (binary).
  • §3.1 RtcSession + PcmFrame + codec pair → Tasks 3 + 4.
  • §3.2 loop shape (Approach A, Frame API) → Task 4 (loop_driver.rs).
  • §3.3 PCM tap seam (traits + EchoAudioPipe) → Task 3 (pcm.rs).
  • §3.4 tokio deviation → Task 4 (verbatim DEV-DEVIATION comment).
  • §3.5/§3.6 DTLS cert → Task 4 (str0m auto-generates; we feed it via Rtc::new, not explicit set_dtls_cert — the spec's "explicit is acceptable too" auto-gen default is honored).
  • §3.7 SDP — str0m's accept_offer does it natively. Plan documents this delta from §3.7's hand-rolled munger sketch (str0m 0.21 made the munger redundant).
  • §3.8 hot-path errors → Task 3 (Option-returning decode/encode) + Task 4 (match-and-continue drain).
  • §3.9 PCM format → Task 3 (24000 Hz mono, 480 samples, SAMPLES_PER_FRAME).
  • §4.1§4.5 HTTP surface, ICE, security, browser client, session lifecycle, idle timeout, graceful shutdown → Task 5.
  • §5 call model → Task 2.
  • §6.1§6.5 CI, dev loop, testing → Tasks 6 + 7.
  • §7 learner-facing docs → Task 7 + the documentation mandate embedded in Global Constraints (every task's code carries //!/////// per the standard).
  • §8 design decisions → reflected in the choices the plan makes (str0m Frame API, EchoAudioPipe wiring, Channel = signaling-only state).

2. Placeholder scan: No TBD/TODO/implement later. Every code step has complete code or an explicit "deferred per spec §1.2" reference. The residual str0m-API-uncertainty note in Task 4 step 7 (SdpAnswer::mid() and PayloadParams::pt() accessors) names the exact symbols the implementer should verify against cargo doc -p str0m --open — they are concrete verification instructions, not vague TODOs.

3. Type consistency: ChannelId, ChannelState, Channel, Direction (Task 2) referenced identically in Tasks 4 + 5. PcmFrame, AudioSource, AudioSink, EchoAudioPipe, OpusDecoder, OpusEncoder (Task 3) referenced identically in Task 4. RtcSession, RtcSessionError (Task 4) referenced identically in Task 5. AppState (Task 5 step 4) referenced identically in Task 5 step 5 + the integration test (step 2) + main.rs (step 6).

4. Known deltas flagged in the plan:

  • str0m 0.21's native accept_offer replaces §3.7's "50-line SDP munger" sketch.
  • opus crate's FFI links system libopus — amends §6.3's "no external deps beyond Rust" with the PORT_PLAN §7 rationale.
  • Single RtcSession::new() constructor (post-review patch) — no new_for_test / new_for_server split.

5. Adversarial review patches applied (post-write): The plan was reviewed adversarially against str0m 0.21 and opus 0.3.1's real API surfaces (verified via docs.rs subagents). Patches landed:

  • Global Constraints: full str0m 0.21 API surface verified + documented (Rtc::new takes Instant; SdpOffer::from_sdp_string is the entry point, NOT from_str_unchecked; add_local_candidate returns Option<&Candidate>; Writer::write takes rtp_time not media_time; MediaTime has no add(Duration) — use mt + MediaTime::from(d); payload_params returns impl Iterator; MediaData.data is Arc<[u8]>).
  • Task 4: accept_offer impl rewritten to use from_sdp_string + correct error mapping; RtcSessionError::SdpOffer changed to String (collapses parse + accept failures uniformly).
  • Task 4: RtcSession::new_for_testpub fn new() (single idiomatic constructor, no test/prod split).
  • Task 4: added accept_offer_transitions_channel_to_connecting test (L3 — the transition was claimed but untested).
  • Task 4 loop_driver: MediaTime::add+ MediaTime::from(Duration); media.data deref coercion documented; writer.payload_params().next().pt() path clarified.
  • Task 5: dropped reqwest from workspace deps (unused — integration test uses tower::ServiceExt::oneshot); added tower as a workspace dep + to the binary crate's [dev-dependencies].
  • Task 5: removed the duplicate DELETE /v1/sessions route (was chained via .delete() on the collection route AND on /v1/sessions/:id — only the latter is correct).
  • Task 5: clarified the pub mod routes; pub mod session_map; requirement (must be pub because integration tests need the absolute path).
  • Global Constraints: added task/PR strategy (one commit per task, merged in numeric order, granular history is load-bearing for the learning-codebase goal).

Plan saved to docs/superpowers/plans/2026-06-28-slice-1-webrtc-loopback.md.