XTurn Sockets

A format-agnostic Elixir socket library with pluggable packet framing and a single reusable drain engine for UDP, TCP, TLS, DTLS, and SCTP.

Features

Installation

def deps do
[
{:xturn_sockets, "~> 2.0"},
{:telemetry, "~> 1.0"}
]
end

Quick start

1. Implement a handler

defmodule MyApp.Handler do
@behaviour Xirsys.Sockets.Handler
alias Xirsys.Sockets.Conn
@impl true
def handle_connect(conn), do: {:ok, conn.assigns}
@impl true
def handle_packet(packet, _meta, conn, state) do
IO.inspect({packet, conn.client_ip, conn.client_port})
{:ok, state}
end
end

2. Start a plain-UDP listener

{:ok, pid} =
Xirsys.Sockets.DatagramServer.start_link(
ip: {0, 0, 0, 0},
port: 3478,
handler: MyApp.Handler,
accumulator: {Xirsys.Sockets.Accumulator.LengthPrefixed, header_size: 2},
assigns: %{}
)

Each UDP datagram is framed and fully drained (multiple coalesced packets in one datagram are all dispatched).

3. Start a TCP/TLS/SCTP/DTLS acceptor

# Ensure the connection supervisor is running (tests start this automatically)
{:ok, _} = Xirsys.Sockets.SockSupervisor.start_link()
{:ok, pid} =
Xirsys.Sockets.Acceptor.start_link(
transport: Xirsys.Sockets.Transport.TCP,
ip: {0, 0, 0, 0},
port: 3478,
handler: MyApp.Handler,
accumulator: {Xirsys.Sockets.Accumulator.LengthPrefixed, header_size: 2},
assigns: %{}
)

Each accepted connection runs in its own Xirsys.Sockets.Connection process, accumulating stream bytes across reads and fully draining after every chunk (pipelined packets in one TCP read are all dispatched).

Reordering

Xirsys.Sockets.Accumulator.Reorder wraps any inner accumulator (typically LengthPrefixed or Raw), tags each whole packet with an integer key via key_fun/2, and holds out-of-order packets until contiguous keys can be released, a window or time limit is hit, or the packet is marked :unordered (immediate passthrough).

accumulator: {
Xirsys.Sockets.Accumulator.Reorder,
name: :rtp,
inner: Xirsys.Sockets.Accumulator.LengthPrefixed,
inner_opts: [header_size: 2],
key_fun: &MyApp.RTP.sequence/2,
window: 32,
max_delay_ms: 100,
on_overflow: :flush_oldest
}

Tunable keys (window, max_delay_ms, on_overflow, enabled) merge with library defaults and application config:

config :xturn_sockets,
config_app: :my_app,
reorder: [
rtp: [window: 32, max_delay_ms: 150]
]
config :my_app,
reorder: [
rtp: [window: 48]
]

Precedence: library defaults < config :xturn_sockets, :reorder, name: [...] < config :config_app, :reorder, name: [...] < explicit keys in the accumulator spec. key_fun, inner, and name are always supplied in code — never read from application config.

When an accumulator may hold packets across reads (e.g. waiting for a missing sequence number), pass tick_interval_ms to Connection.start_link/1 or DatagramServer.start_link/1. The process periodically calls the drain engine without new inbound data so time-based flushing (max_delay_ms) can run. Opt-in and zero-cost when unset.

Pipelines

Multi-tier processing uses use Xirsys.Sockets.Pipeline to declare tiers and handler {:descend, tier, payload, state} to route a packet's payload into another tier's accumulator/handler pair:

defmodule MyApp.Pipeline do
use Xirsys.Sockets.Pipeline
tier :root,
accumulator: {Xirsys.Sockets.Accumulator.LengthPrefixed, header_size: 2},
handler: MyApp.Handlers.Stun
tier :rtp,
accumulator: Xirsys.Sockets.Accumulator.Raw,
handler: MyApp.Handlers.Rtp
end
{:ok, pid} =
Xirsys.Sockets.Acceptor.start_link(
transport: Xirsys.Sockets.Transport.TCP,
ip: {0, 0, 0, 0},
port: 3478,
pipeline: MyApp.Pipeline,
assigns: %{}
)

Semantics:

Only :root receives handle_connect/1. Descended tiers start with nil handler state on first :descend. On disconnect, handle_disconnect/2 is invoked for every tier that has been activated.

Dispatch strategies

Each pipeline tier accepts :dispatch (:inline default, :task, or :pool) and optional :pool_size for :pool dispatch (defaults to Config.tier_pool_size/0).

Start the tier supervisors alongside SockSupervisor:

{:ok, _} = Xirsys.Sockets.SockSupervisor.start_link()
{:ok, _} = Xirsys.Sockets.TierSupervisor.Task.start_link()
{:ok, _} = Xirsys.Sockets.TierSupervisor.Pool.start_link()

An explicit {:close, state} from an async tier sends {:tier_close, tier, reason} to the owning Connection/DatagramServer, which stops like a transport close. Crashed or saturated async tiers emit telemetry; packets queued in a crashed TierSession mailbox are lost. When the tier supervisor is unavailable, descends to async tiers are dropped rather than processed inline in the owner process.

Bounded buffers

Every built-in Accumulator supports :max_size. Overflow is surfaced once from the next pop/1 as {:error, :buffer_overflow, acc}; the engine emits [:xturn_sockets, :frame_error] and continues draining. Accumulator.Reorder also aliases :max_size to its reorder window and bounds the ready output queue separately from the reorder :window.

Benchmarks

Compare root-only pipeline dispatch against hand-rolled framing:

cd xturn-sockets
mix deps.get
mix run bench/pipeline_bench.exs

Sample results (Apple Silicon, OTP 29, Elixir 1.20, mix run bench/pipeline_bench.exs):

Jobipsavgvs baseline
hand_rolled_framing~2.4 M~0.42 μs
pipeline_engine~69 K~14 μs~34× slower

The benchmark allocates a fresh session and runs full Engine.push_and_drain/7 each iteration, so it measures framework overhead rather than steady-state relay throughput. Root-only :inline pipelines remain behaviour-identical to Phase 3 (see mix test); use this bench to track regressions in the engine path, not as an absolute TURN relay ceiling.

Telemetry

Per-tier events (when :telemetry_enabled is true):

Configuration

Host applications override library defaults by setting :config_app:

config :xturn_sockets,
config_app: :my_app,
buffer_size: 262_144,
listener_buffer_size: 4_194_304,
ssl_handshake_timeout: 10_000,
rate_limit_enabled: true,
telemetry_enabled: true
config :my_app,
buffer_size: 131_072

Lookup precedence: config :config_app, keyconfig :xturn_sockets, key → default.

TLS/DTLS listeners require certificate configuration under the :certs application env (same as previous releases).

Architecture

ModuleRole
Transport.*Protocol-specific socket I/O + mailbox normalization
Accumulator.*Framing / boundary detection
HandlerYour application logic per tier
PipelineDeclarative multi-tier tier graph
TierSessionAsync per-tier worker for :task/:pool dispatch
TierSupervisor.Task / .PoolSupervisors for tier session workers
EngineShared push-and-drain loop
ConnectionOne process per stream/association
DatagramServerOne process per plain-UDP socket
AcceptorAccept loop for connection-oriented transports
SockSupervisorDynamicSupervisor for Connection children

Protocol-specific framing (STUN/TURN, RTP, etc.) belongs in your application as custom Accumulator and Handler modules — this library ships only the generic mechanism.

Testing

mix test

License

Apache 2.0 — see LICENSE.md.