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 onstr0m, 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 carriesCallSignalmessages between the SFU, the gRPC layer, and long-poll/streaming subscribers. - The gRPC signaling bridge (
grpc/signals.rs) that maps internalCallSignalvalues to the wireSignalEventproto. - The startup wiring in
main.rsthat connects the SFU’s outgoing signal channel to the relay, and how both services are registered in the sharedAppState. - Error mapping from
str0m::error::RtcErrorinto the backend’sAppError.
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:
-
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
str0mWebRTC 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. -
CallRelayService — a pub/sub relay for
CallSignalmessages. 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. -
gRPC signaling bridge —
call_signal_to_event()ingrpc/signals.rsconverts an internalCallSignalinto a protobufSignalEvent(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_relayis cloned (relay_for_sfu) and moved into the spawned task, while the original handle is stored inAppStatefor 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 inAppStateasArc<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, andrun.rswas 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_type→event_type,caller_id→from_id,payload→payload. The only transformation isgroup_id, whereNone(a direct call, not a group call) becomes0on the wire. - Server-side timestamp:
created_atis 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 RtcError → AppError 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_tx—tokio::sync::mpsc::UnboundedSender<CallSignal>(constructed assfu_txinmain.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().awaitreturnsNoneonly when all senders are dropped and the channel is empty; the forwarder loop then exits. Sincesfu_txis owned bySfuService, 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 = Noneis encoded as wiregroup_id = 0, so direct (1) and group calls share one event shape. unwrap_or(0): safe becausenow()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_atusesSystemTime(nottokio::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, andsfu/scratch_test.rsindicates the SFU is exercised with dedicated test utilities, supporting safe iteration on session and forwarding logic.
Extension Points
- New signal types: extend
CallSignal.signal_typeand the payload contract; the relay and bridge are type-agnostic, so no changes are needed outsideservices/signal.rsand the protoSignalEvent. - 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) andsession_tracks.rs(per-participant track maps); changing forwarding policy is isolated to those modules. - Error handling: extend
error.rswith more specificAppErrorvariants perstr0merror class if finer-grained gRPC status codes are needed.
Related Links
- main.rs — SFU/relay composition root
- grpc/signals.rs — signaling bridge
- services/call_relay.rs — CallRelayService
- services/relay_subscriber.rs — relay subscriber
- services/sfu/ — SFU module (mod, session, session_tracks, events, forward, run)
- error.rs — WebRTC error mapping
- state.rs — AppState registration
- services/signal.rs — CallSignal type
- grpc/chat_service.rs — chat gRPC service and
SignalEventproto
For call ticket issuance, authentication middleware, and backend-wide configuration, see the respective sibling catalog pages.