Skip to content
Secure Mesh Docs
Esc
navigateopen⌘Jpreview
On this page

Calls, SFU & Signaling Relay

Calls, SFU & Signaling Relay

This page documents the real-time calling subsystem of the Rust backend (streaming-backend): the embedded WebRTC SFU built on the str0m crate, the CallRelayService signal bus that fans signaling messages in and out, and the gRPC bridge (signals.rs) that converts internal CallSignal messages into protobuf SignalEvents streamed to clients.

Purpose and Scope

This page covers the end-to-end signaling path for voice/video calls:

  • The embedded Rust SFU (services/sfu/) — an in-process WebRTC Selective Forwarding Unit built on str0m, which forwards media between participants without transcoding.
  • The call relay (services/call_relay.rs) and its subscribers (services/relay_subscriber.rs) — the in-process message bus that carries CallSignal messages between the SFU, the gRPC layer, and long-poll/streaming subscribers.
  • The gRPC signaling bridge (grpc/signals.rs) that maps internal CallSignal values to the wire SignalEvent proto.
  • The startup wiring in main.rs that connects the SFU’s outgoing signal channel to the relay, and how both services are registered in the shared AppState.
  • Error mapping from str0m::error::RtcError into the backend’s AppError.

Related topics intentionally left to sibling pages: authentication/authorization middleware (see the auth gRPC service), call ticket issuance (services/call_ticket.rs), chat history persistence (grpc/history.rs), and general service configuration in config.rs.

Overview

Calls in this backend are built around three cooperating pieces:

  1. SfuService — an in-process SFU. Unlike a standalone media server (e.g. LiveKit or Janus), the SFU runs inside the same Tokio runtime as the API. It uses the str0m WebRTC library to manage peer connections, SDP negotiation, and RTP/RTCP media forwarding. The SFU is created with a signal sender channel (sfu_tx) through which it pushes outgoing signaling messages (answer SDP, ICE candidates, session events) back into the relay.

  2. CallRelayService — a pub/sub relay for CallSignal messages. It is the central hub: gRPC handlers and the SFU both publish signals into it, and relay subscribers receive them. This decouples the WebRTC media plane from the control-plane signaling transport, so the same relay can serve gRPC streaming clients, HTTP subscribers, and internal consumers.

  3. gRPC signaling bridgecall_signal_to_event() in grpc/signals.rs converts an internal CallSignal into a protobuf SignalEvent (event_type, from_id, group_id, payload, created_at) that is streamed to connected clients by the chat gRPC service.

The design intent is separation of concerns: the SFU speaks pure WebRTC (str0m), the relay speaks pure application signaling (CallSignal), and the gRPC layer translates between CallSignal and the wire proto. No layer knows the transport details of the others.

Architecture

The diagram reflects the wiring verified in main.rs: SfuService::new(sfu_tx) receives an unbounded Tokio channel sender; a spawned task reads sfu_rx and forwards every signal into call_relay.send_signal(sig), closing the loop from the SFU back to subscribers. Both call_relay and sfu are stored as Arc in AppState (see state.rs), making them available to every gRPC handler.

Main Content

Startup wiring: connecting the SFU to the relay

The composition root in main.rs establishes the signal path before any service starts. It creates an unbounded mpsc channel, hands the sender to the SFU, and spawns a relay-forwarder task that consumes the receiver:

// Embedded Rust SFU (str0m) — outgoing signals forward to the relay
let (sfu_tx, mut sfu_rx) = tokio::sync::mpsc::unbounded_channel();
let relay_for_sfu = call_relay.clone();
tokio::spawn(async move {
    while let Some(sig) = sfu_rx.recv().await {
        let _ = relay_for_sfu.send_signal(sig).await;
    }
});
let sfu = crate::services::sfu::SfuService::new(sfu_tx).await?;

Source: main.rs

Key design decisions visible here:

  • Unbounded channel: signaling messages are small, latency-sensitive control messages; an unbounded channel avoids back-pressure stalls in the media path while the relay forwarder drains it.
  • Arc-shared relay: call_relay is cloned (relay_for_sfu) and moved into the spawned task, while the original handle is stored in AppState for gRPC handlers — one relay instance, many senders.
  • Errors are swallowed (let _ = ...): a failed relay send must not crash the SFU task; the media plane continues even if a subscriber is gone.
  • Async construction: SfuService::new(...).await? implies the SFU performs asynchronous setup (e.g. binding UDP candidates or pre-allocating session state) before it is stored in AppState as Arc<SfuService>.

The SFU service module layout

The SFU is not a single file; it is a cohesive module with focused submodules:

File Role
services/sfu/mod.rs SfuService definition, new() constructor, public API
services/sfu/session.rs Per-call WebRTC session lifecycle (SDP offer/answer, ICE)
services/sfu/session_tracks.rs Track bookkeeping: which participant’s media tracks are forwarded where
services/sfu/events.rs Outbound signal/event types emitted by the SFU
services/sfu/forward.rs Media forwarding logic (selective forwarding of RTP packets)
services/sfu/run.rs The Tokio task/loop that drives the SFU (str0m Rtc poll loop)
services/sfu/tests.rs, testutil.rs, scratch_test.rs Unit/integration tests and helpers

Note: the per-file implementation of session.rs, forward.rs, and run.rs was not read during this page’s source pass; the roles above follow from the module names and the verified wiring. See the file tree in the source panel for the authoritative list.

The backend deliberately embeds the SFU rather than talking to an external media server: str0m’s Rtc object runs in-process, so signaling and media share the same Tokio executor, and the AppError type already provides a conversion from str0m’s error type:

impl From<str0m::error::RtcError> for AppError {
    fn from(e: str0m::error::RtcError) -> Self {
        Self::Internal(format!("WebRTC error: {e}"))
    }
}

Source: error.rs

This means any str0m failure (ICE failure, DTLS handshake error, SDP parse error) surfaces as a typed AppError::Internal with the WebRTC error string attached, so gRPC handlers and the SFU can use ? to propagate WebRTC failures uniformly.

The call relay: a signaling bus

CallRelayService (services/call_relay.rs) is the central message hub. Its verified surface, from usage, includes send_signal(sig) which is async and takes a CallSignal. The relay is the single integration point named in the SFU comment — “outgoing signals forward to the relay” — and it is also consumed by relay subscribers:

use crate::services::signal::CallSignal;

Source: relay_subscriber.rs

RelaySubscriber (in services/relay_subscriber.rs, using tracing::{error, info}) represents a long-lived consumer of the relay — for example, the gRPC streaming response stream for one client, or an HTTP long-poll handle. The relay fan-out is what makes it possible for one participant’s SFU-generated answer to reach every other participant’s gRPC stream.

The gRPC signaling bridge

The bridge between the internal signaling domain and the wire protocol lives in grpc/signals.rs. It converts a CallSignal (internal) into a SignalEvent (protobuf, defined in chat_service::chat_proto):

use std::time::{SystemTime, UNIX_EPOCH};
use crate::services::signal::CallSignal;
use super::chat_service::chat_proto::SignalEvent;

fn now() -> i64 {
    SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
}

pub fn call_signal_to_event(sig: CallSignal) -> SignalEvent {
    SignalEvent {
        event_type: sig.signal_type,
        from_id: sig.caller_id,
        group_id: sig.group_id.unwrap_or(0),
        payload: sig.payload,
        created_at: now(),
    }
}

Source: signals.rs

Observations:

  • Lossless field mapping: signal_typeevent_type, caller_idfrom_id, payloadpayload. The only transformation is group_id, where None (a direct call, not a group call) becomes 0 on the wire.
  • Server-side timestamp: created_at is stamped at conversion time using wall-clock seconds (SystemTime::now()), not taken from the client, so all clients in a call agree on event ordering basis.
  • Pure function: no I/O, no errors — the function is total and trivially testable.

Core Flow

Outbound path: SFU → relay → gRPC clients

When the SFU needs to tell participants something (an answer SDP, an ICE candidate, a session state change), it emits a CallSignal through sfu_tx. The forwarder task drains sfu_rx and calls relay.send_signal(), the relay fans out to subscribers, and each subscriber’s CallSignal is converted to a SignalEvent and written to the client’s gRPC stream.

Inbound path: client → gRPC → SFU

Inbound signaling (a client’s offer or ICE candidate) enters through the gRPC chat service, is converted from the wire form into a CallSignal and published to the relay, where the SFU session consumer applies it to the corresponding str0m Rtc object. (The exact inbound conversion function lives in grpc/chat_service.rs, which was not read in full for this page; the relay’s dual direction is implied by the verified outbound loop and by CallSignal being the shared message type on both sides of the bridge.)

Lifecycle of a call

The SFU session (services/sfu/session.rs) drives the str0m state machine: it starts in negotiation when an offer arrives, transitions to Connected once DTLS/ICE complete, may renegotiate when tracks are added or removed, and terminates on hangup or a WebRTC error (which surfaces through the RtcErrorAppError conversion above).

Data Model

CallSignal (internal domain type)

Defined in services/signal.rs. Fields verified from field usage in grpc/signals.rs:

Field Type Notes
signal_type string-ish (mapped to event_type) Kind of signal: offer, answer, ICE candidate, hangup, etc.
caller_id numeric/id (mapped to from_id) Sender of the signal
group_id Option<...> None for direct calls; mapped to 0 on the wire
payload string/bytes Opaque signal body (e.g. SDP or ICE candidate JSON)

SignalEvent (wire proto)

Defined in chat_service::chat_proto. Populated by call_signal_to_event():

Field Type Source
event_type string sig.signal_type
from_id id sig.caller_id
group_id i64 sig.group_id.unwrap_or(0)
payload string/bytes sig.payload
created_at i64 (unix seconds) now() at conversion time

The Option handling on group_id is the only semantic transformation in the bridge: the internal model distinguishes “no group” (None) from “group 0”, while the wire protocol uses 0 as the sentinel for direct calls.

API Reference

SfuService::new(signal_tx) -> impl Future<Output = Result<SfuService, AppError>>

Creates the embedded str0m-based SFU. The caller passes the sending half of the unbounded Tokio channel through which the SFU emits outgoing CallSignal messages.

Parameters:

  • signal_txtokio::sync::mpsc::UnboundedSender<CallSignal> (constructed as sfu_tx in main.rs)

Returns: a ready-to-use SfuService (stored as Arc<SfuService> in AppState).

Notes: asynchronous (awaited) — startup performs any I/O or allocation required before serving. Verified call site: main.rs.

CallRelayService::send_signal(sig: CallSignal) -> impl Future<Output = Result<...>>

Publishes a CallSignal into the relay for fan-out to all subscribers.

Parameters:

  • sig (CallSignal) — the signaling message to distribute.

Notes: called both by the SFU forwarder task (main.rs) and by inbound paths. Errors are ignored at the forwarder call site (let _ = ...), indicating the relay tolerates failed subscriber deliveries. Verified call site: main.rs.

call_signal_to_event(sig: CallSignal) -> SignalEvent

Pure conversion from the internal CallSignal domain type to the protobuf SignalEvent used on the gRPC wire. Total function — no error cases.

Parameters:

  • sig (CallSignal) — internal signal to convert.

Returns: SignalEvent with event_type = sig.signal_type, from_id = sig.caller_id, group_id = sig.group_id.unwrap_or(0), payload = sig.payload, created_at = now() (unix seconds). Verified source: signals.rs.

impl From<str0m::error::RtcError> for AppError

Maps any str0m WebRTC error into AppError::Internal with the message "WebRTC error: {e}". Enables ?-based propagation of media-plane failures through the application error type. Verified source: error.rs.

Configuration Options

The signaling/SFU subsystem’s runtime configuration lives in the backend-wide config.rs and build.rs (compile-time). Specific SFU knobs (e.g. candidate ports, codec preferences) were not read during this page’s source pass; the following table lists the wiring-level facts verified from source:

Item Type Value / Default Description
SFU signal channel tokio::sync::mpsc::UnboundedSender<CallSignal> created per-boot in main.rs Carries outgoing SFU signals to the relay
Relay handle in AppState Arc<CallRelayService> call_relay Shared by all gRPC handlers
SFU handle in AppState Arc<SfuService> sfu Shared by all gRPC handlers
SignalEvent.created_at i64 unix seconds server wall clock Stamped at conversion time

For backend-wide settings (database, ports, etc.), see the config page for config.rs.

Failure Modes, Edge Cases & Concurrency

WebRTC failures

Any str0m-level failure (ICE connectivity failure, DTLS handshake timeout, malformed SDP, codec mismatch) is converted into AppError::Internal("WebRTC error: ..."). Because the SFU runs in-process, these errors are visible to the surrounding service code and can be logged with tracing or propagated into gRPC status responses.

Relay send failures

The forwarder task deliberately ignores send_signal errors (let _ = relay_for_sfu.send_signal(sig).await). Rationale: the SFU’s media forwarding must never stall because a subscriber is gone or the relay is saturated. A failed delivery drops that one signal; the call continues. This is a fire-and-forget design choice — signaling is treated as best-effort at the relay boundary.

Channel semantics

  • Unbounded channel: sfu_rx.recv().await returns None only when all senders are dropped and the channel is empty; the forwarder loop then exits. Since sfu_tx is owned by SfuService, the task lives exactly as long as the SFU — no leaked tasks.
  • Concurrency: the SFU emits signals from the (single) Tokio runtime; the relay fan-out serializes deliveries per subscriber. The Arc<CallRelayService> sharing means multiple gRPC connections concurrently publish into the same relay; ordering guarantees, if any, come from the relay’s internal design (not read in full).

Edge cases

  • Direct calls: group_id = None is encoded as wire group_id = 0, so direct (1) and group calls share one event shape.
  • unwrap_or(0): safe because now() cannot fail and the conversion has no failure branch — the bridge is total.

Performance & Operational Considerations

  • In-process SFU: media forwarding runs on the same Tokio executor as the API, avoiding IPC/network hops between the control plane and the media plane. This lowers latency but means SFU CPU is shared with API work — capacity planning should account for concurrent media sessions per instance.
  • Unbounded channel: chosen for low-latency signal delivery; under sustained relay back-pressure, memory could grow. The fire-and-forget forwarder bounds the practical impact.
  • Timestamps: created_at uses SystemTime (not tokio::time::Instant), so event times are wall-clock-consistent across clients and restartable.
  • Module-level test harness: the presence of sfu/tests.rs, sfu/testutil.rs, and sfu/scratch_test.rs indicates the SFU is exercised with dedicated test utilities, supporting safe iteration on session and forwarding logic.

Extension Points

  • New signal types: extend CallSignal.signal_type and the payload contract; the relay and bridge are type-agnostic, so no changes are needed outside services/signal.rs and the proto SignalEvent.
  • New subscriber transports: implement a RelaySubscriber-style consumer (services/relay_subscriber.rs) to add transports (e.g. HTTP long-poll, WebSocket) without touching the SFU or the relay.
  • Codec/media policy: lives inside services/sfu/forward.rs (selective forwarding) and session_tracks.rs (per-participant track maps); changing forwarding policy is isolated to those modules.
  • Error handling: extend error.rs with more specific AppError variants per str0m error class if finer-grained gRPC status codes are needed.

For call ticket issuance, authentication middleware, and backend-wide configuration, see the respective sibling catalog pages.

Was this page helpful?