soli-sfu

How it works

One OS thread, one UDP socket, and one str0m state machine per person. str0m is sans-IO: it owns no sockets and spawns no tasks. soli-sfu feeds it packets and the clock, and it says what to send.

Two planes

browser ──HTTPS (soli-proxy)──▶ control API  (axum,  :9300)   offers, groups
browser ──UDP, direct────────▶ media engine (str0m, :3478)   RTP / RTCP, one port

The control plane is a small HTTP API for the offer/answer exchange and group updates. It runs on a two-thread async runtime and talks to the media thread over a channel. The media plane is the single UDP socket, and it never goes through a proxy.

The media loop

Every turn of the loop does four things, in order:

  1. Commands. Admit new sessions, apply peers changes, answer stats.
  2. Drive. Poll each session until str0m returns a timeout: send what it asks to send, forward media events, relay keyframe requests. Time is fed to a session only when its own deadline is due.
  3. Read. Wait up to 20 ms for one datagram, then take whatever else is already queued without waiting, at most 64 per turn.
  4. Reap. Drop sessions whose ICE has died, and sessions that never connected within 30 seconds.

The read step is bounded on purpose. Media arrives every few milliseconds, so a loop that reads until the socket goes quiet never gets back to forwarding. A test floods the port faster than the tick and checks that commands are still answered.

Incoming datagrams are matched to a session by source address from a small cache; only the first packets from a new address, or STUN checks, ask every session whether the packet is theirs.

ICE-lite on one port

The SFU is always the reachable side, so it runs ICE-lite: it never starts connectivity checks, it only answers them. Every session shares the one UDP port, and each answer carries a single host candidate, public_ip:udp_port. Browsers need no STUN servers, and TURN only matters on networks that block UDP entirely.

All sessions share one DTLS certificate, generated at startup, so a new join costs no key generation on the media thread. Offers are parsed on the HTTP side before they reach it.

Slots instead of renegotiation

A new speaker normally means a new m-line for every listener, and an SDP renegotiation for each of them. soli-sfu avoids it:

  • Each browser reserves audio_slots (8) and video_slots (4) receive-only m-lines in its first offer.
  • The engine binds speakers to free slots as their packets arrive. A slot whose speaker has been silent for 3 seconds can be taken by a new one.
  • A newly bound video slot asks the speaker for a keyframe, so the new viewer sees a picture at once. Keyframe requests to one track are spaced at least 500 ms apart.
  • An offer with more m-lines than the configured counts is truncated to them.

Group-scoped forwarding

Each session carries a peers set: the user ids it should hear. When a speaker's packet arrives, it goes to room-mates whose set contains that speaker. null means everyone in the room, which suits a plain meeting.

fn hears(peers: &Option<HashSet<String>>, speaker: &str) -> bool {
    match peers {
        None      => true,                     // open room
        Some(set) => set.contains(speaker),    // a circle
    }
}

When a set shrinks, slots bound to people no longer heard are freed at once, so a newcomer finds room.

Session lifecycle

  • Join. POST an offer. A second session for the same user and room replaces the first, so reconnecting is safe.
  • Leave. DELETE it, or simply go away: ICE notices within seconds and the session is reaped, freeing every slot it fed.
  • Never connect. A session with no completed ICE and DTLS after 30 seconds is dropped.
  • Restart. State is in memory only. Live calls drop and clients rejoin.