Skip to Content
ArchitectureSignaling server

Signaling server

server/ - crate omnicall-signaling, ~240 lines of Rust across two files.

server/ ├── Cargo.toml # axum 0.8, tokio (rt-multi-thread), tower-http, serde, tracing ├── src/ │ ├── main.rs # process bootstrap: tracing, router, bind, graceful shutdown │ └── signaling.rs # rooms + relay: all the protocol logic └── public/ # minimal 2-person web client (test tool)

Dependencies

axum = { version = "0.8", features = ["ws"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs"] } tower-http = { version = "0.6", features = ["fs", "trace"] } futures-util = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [profile.release] lto = true codegen-units = 1 strip = true

Bootstrap (main.rs)

let state = Arc::new(AppState::default()); let app = Router::new() .route("/", any(root_handler)) .layer(TraceLayer::new_for_http()) .with_state(state); let host = env::var("HOST")... .unwrap_or(IpAddr::from([0, 0, 0, 0])); let port = env::var("PORT")... .unwrap_or(8080); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) // Ctrl-C .await
  • HOST / PORT are the only configuration. Defaults: 0.0.0.0:8080.
  • One route. root_handler checks for Upgrade: websocket. If present it performs WebSocketUpgrade::from_request_parts and hands the socket to signaling::handle_socket. If absent it returns 404 Not Found.
  • Both the Android app and the web client open their socket against /, so the root has to serve the upgrade.

TLS terminates in front

The binary speaks plain HTTP/WS. In production a reverse proxy (Caddy, nginx, or the platform’s load balancer) terminates TLS and forwards to 127.0.0.1:8080. See deployment.

State model (signaling.rs)

pub struct AppState { rooms: RwLock<HashMap<String, HashMap<String, Peer>>>, } struct Peer { name: String, tx: mpsc::UnboundedSender<Message>, // into this peer's writer task }
  • Outer key: room code (any string the client sends).
  • Inner key: peer id (the client generates a stable id, e.g. android_063ece02).
  • Peer.tx: every task that wants to send to a peer pushes into this channel. A single dedicated writer task per connection drains it and owns the socket sink, so relaying tasks never contend for the sink.

Per-connection task

handle_socket splits the socket, spawns the writer task, then loops on tokio::select! between “next inbound frame” and “the writer task finished.”

loop { tokio::select! { incoming = ws_stream.next() => match incoming { Some(Ok(Message::Text(text))) => handle_text(&text, &state, &tx, &mut session).await, Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, Some(Ok(_)) => {} // ping/pong/binary ignored }, _ = &mut writer => break, } } handle_leave(&state, &session).await; // always runs on disconnect

A Session { room: Option<String>, peer: Option<String> } records the identity this connection claimed in its join. It is the source of truth for senderPeerId on every relayed frame - a client cannot spoof another peer because the server ignores peerId on relay messages and uses the session instead.

Message handling

match data.msg_type.as_str() { "join" => join(state, tx, session, data).await, "offer" | "answer" | "candidate" | "chat" | "screen-share" | "video" => relay(state, session, data).await, "leave" => { handle_leave(state, session).await; session.room = None; session.peer = None; } other => warn!("Unknown message type: {other}"), }

join

  1. Read roomId, peerId, and payload.name (default "Anonymous").
  2. Set session.room / session.peer.
  3. Take the write lock, rooms.entry(room_id).or_default().
  4. Snapshot the existing peers before inserting the newcomer.
  5. Insert { name, tx }.
  6. Send room-joined to the newcomer with the snapshot (peers: [{peerId, name}]).
  7. Broadcast user-joined to everyone already in the room.

The snapshot order matters: the newcomer learns about existing peers via room-joined and becomes the offerer toward them; existing peers learn about the newcomer via user-joined and wait for the offer. This is the glare-avoidance rule - see call lifecycle.

relay

let frame = json!({ "type": data.msg_type, "roomId": room_id, "senderPeerId": session.peer, // authoritative "payload": data.payload, }); match data.target_peer_id.and_then(|t| room.get(&t)) { Some(target) => target.tx.send(frame), // unicast None => for (id, peer) in room { if id != sender { peer.tx.send(frame.clone()) } }, // broadcast }
  • offer / answer carry targetPeerId → unicast.
  • chat, video, screen-share are sent without a target → broadcast to the rest of the room.
  • candidate today is sent without a target (broadcast). With one remote peer this is harmless; the multi-party design makes it targeted because each PeerConnection has its own ICE credentials.

leave / disconnect

handle_leave removes the peer, broadcasts user-left to the remaining peers, and if the room is now empty, removes the room entirely. It runs on an explicit leave frame and unconditionally when the socket closes for any reason.

What the server deliberately does not do

  • No authentication or room passwords - a room code is the shared secret.
  • No rate limiting or message-size caps.
  • No TURN - it is signaling only.
  • No history, no read receipts, no typing indicators.
  • No participant cap yet - the multi-party work adds const MAX_PEERS: usize = 5 and a room-full rejection.

Building locally

The Windows dev box has a link.exe collision (GNU coreutils link shadows the MSVC linker), so the server is built and run on the Linux host, not the dev laptop. On Linux/macOS:

cd server cargo run # 0.0.0.0:8080 HOST=127.0.0.1 PORT=9000 cargo run RUST_LOG=debug cargo run # verbose signaling logs cargo build --release # ./target/release/omnicall-signaling