Broadcast sessions#
A broadcast is PacketRelay's one-to-many session: one host, up to
--max-spectators spectators, and a strictly one-directional flow of frames
from the host to all of them. Spectators are receivers and nothing else — the
forwarding loop has no spectator branch, so a spectator's frames go nowhere.
Broadcast sessions are Implemented, and like every other protocol here
they are selected by the first application message rather than by the URL.
This page opens a broadcast step by step, lists the presence and count events in the order they are actually sent, and documents four behaviours that read one way in the protocol names and work another way in the code.
Before you start#
- A relay reachable over WebSocket. See Run the relay.
- WebSocket clients that can send and receive text frames. A binary first
frame is refused and the connection closed —
src/connection.rs:530-533. - A
session_idevery participant agrees on in advance: non-empty, at most--max-id-bytes(default 64) bytes, charset[A-Za-z0-9._-]—src/connection.rs:41-50,src/connection.rs:472-478. - An understanding that nothing here is authenticated.
host_helloandspectator_hellocarry onlysession_id—src/protocol.rs:87-91. Possession of the id plus self-selection of a role is the whole admission boundary. See Security model. - Headroom for the audience:
--max-spectatorsdefaults to 64, and the global--max-sessionsceiling of 256 applies to broadcasts like everything else. See Limits & capacity.
Steps#
Connect the host client at any non-reserved path, and send
host_helloas its first frame, within--hello-timeout(default 5 s).{"type":"host_hello","session_id":"demo-broadcast"}Read
session_readyon the host. The envelope is the same one bridge sessions use — the fixture attestdata/session_ready.json:1, asserted atsrc/protocol.rs:172-191:{"type":"session_ready","session_id":"demo-broadcast"}Connect each spectator and send
spectator_hellowith the same id.{"type":"spectator_hello","session_id":"demo-broadcast"}Read the spectator's admission frames. If a host is already registered, the spectator receives
host_connectedfirst andsession_readysecond — see below, this ordering is not a typo. If no host is registered, it receivessession_readyalone and waits.Read
viewer_count. Every successful registration triggers one, sent to the host and to every spectator —src/connection.rs:297.Send frames from the host. Each text frame the host writes is fanned out verbatim to every spectator currently registered —
src/connection.rs:389-394.
What the relay sends, in order#
| Moment | Goes to | Frame |
|---|---|---|
| Spectator registers while a host is present | That spectator | host_connected, sent inside the registration arm — src/connection.rs:187-190 |
| Any hello accepted | That client | session_ready — src/connection.rs:261-266 |
| Host registers | Every spectator | host_connected — src/connection.rs:290-296 |
| Any registration completes | The host and every spectator | viewer_count — src/connection.rs:297, src/connection.rs:573-582 |
| Host's socket closes | Every spectator | host_disconnected, then viewer_count — src/connection.rs:430-439 |
| Spectator's socket closes | The host and remaining spectators | viewer_count — src/connection.rs:438 |
host_connected and host_disconnected carry no fields at all;
viewer_count carries a single count — src/protocol.rs:125-132. There is
no spectator-joined or spectator-left event. The count is the only signal a
host gets that its audience changed, and it cannot tell which member of it.
The viewer count counts spectators, and the host receives it#
viewer_count reports spectator_txs.len() — the host is not counted —
src/session.rs:370-376. The recipients are a different set from the subject:
notify_viewer_count sends to broadcast_targets, which is the host's sender
followed by every spectator's — src/session.rs:347-360,
src/connection.rs:573-582.
That split is deliberate and useful: a host with three spectators receives
{"type":"viewer_count","count":3}, not 4, so it can render an audience
size without subtracting itself.
A count is emitted on the way down too, but only when the unregister actually
removed a slot — the send sits inside if store.unregister(…) at
src/connection.rs:430-439. When the last participant leaves, the session is
removed from the map entirely (src/session.rs:439-441), so
broadcast_targets is empty and the final viewer_count reaches nobody.
Counter-intuitive behaviour#
Each of these is current behaviour, confirmed in the source. The repository's
own README.md describes the first one incorrectly; the code is authoritative.
A spectator creates the session it joins#
Both host_hello and spectator_hello fall into the same
SessionKind::Broadcast arm, and that arm calls ensure_session(Broadcast) —
src/connection.rs:99-107. A spectator_hello for an id nothing holds does
not fail. It creates an empty broadcast session, registers itself into it,
and receives session_ready for a session with no host and no content.
The relay's README.md describes this hello as joining a broadcast. It does
not merely join, and a client written against that description will not
distinguish "connected to the stream" from "invented a stream". Nothing in the
admission path can make that distinction for you: session_ready is sent
either way, and the absence of host_connected is the only clue.
A mistyped session id on a spectator produces a healthy-looking
session that nothing will ever publish to. Wait for host_connected before
reporting a spectator as connected — session_ready alone proves only that
the relay has a session under that id, possibly because you just made one.
The ghost session is not permanent. A broadcast with no host and no spectators
is reaped once it exceeds --session-ttl (default 300 s) —
src/session.rs:454-476. It counts against --max-sessions until then, and
session age is measured from created_at and never refreshed by activity —
src/session.rs:137-147.
host_connected arrives before session_ready#
A spectator that finds a host already registered receives host_connected
before its own session_ready. This is not a race — it is where the send
sits. The spectator's host_connected is emitted inside the registration arm
at src/connection.rs:187-190, which runs at step 2 of the handler, while the
ready send does not happen until step 3 at src/connection.rs:266.
The inversion applies only to that one path. A spectator that arrives before
the host gets session_ready first and host_connected later, from the
host's own registration at src/connection.rs:290-296. A client that treats
session_ready as the gate on its event handler therefore drops the
host_connected of exactly the case it most cares about — joining a live
broadcast. Install the handler before sending the hello.
Only the host fans out#
The forwarding loop dispatches on role, and the broadcast branch tests
matches!(role, Role::Host) — src/connection.rs:389-394. There is no
Role::Spectator branch anywhere in that loop. A text frame from a spectator
falls through every arm and is discarded: no forward, no error frame, no log
line, no close.
It is not free, though. Every inbound text frame is charged to the connection's
MessageBudget at src/connection.rs:346, before any role dispatch. A
spectator's frames therefore consume --max-messages-per-window and
--max-bytes-per-window and then evaporate — and a chatty spectator can trip
"message rate exceeded" and have its own connection closed for sending
traffic that never reached anybody.
There is no spectator-to-host channel. If your design needs one, the spectator must open a second session of another kind — a bridge or a room. Sending upstream on the broadcast socket fails silently and still spends the sender's rate budget.
The keepalive envelope is not filtered here#
{"type":"ping"} is recognised and dropped in the bridge branch
(src/connection.rs:361) and the room branch (src/connection.rs:370). The
host branch at src/connection.rs:389-394 has no such guard. A host that
sends the keepalive envelope on its own socket fans it out to every spectator
as an ordinary frame, and each spectator must be prepared to receive and
ignore it.
The spectator cap refuses, and a host reconnect replaces#
Two capacity behaviours, opposite in kind. Past --max-spectators,
register_broadcast returns "spectator cap reached" and the new connection
is closed — src/session.rs:208-214. Existing spectators are untouched; the
cap protects the incumbents.
The host slot does the reverse. session.host_tx = Some(tx) is assigned
unconditionally, so a second host_hello on the same id silently replaces the
incumbent host's sender with no proof of identity — src/session.rs:202-206.
The displaced host's socket stays open and its frames are still charged, but
they arrive at a slot it no longer owns. Its later cleanup is a no-op, because
every unregister path requires same_channel(tx) — src/session.rs:378-451.
Spectators are not told: they receive a second host_connected with no
intervening host_disconnected. The same edge exists for the desktop role in
Bridge sessions.
Verify#
The host is live. The host received
session_ready, and aviewer_countafter each spectator joined.A spectator sees the host. Each spectator received
host_connected— either before itssession_ready(host already present) or after it (host arrived later). A spectator that hassession_readyand nohost_connectedis attached to a session with no publisher.The count is right and excludes the host. With three spectators attached, every participant — the host included — holds
{"type":"viewer_count","count":3}.Fan-out works, and only downward. Send a text frame from the host and confirm every spectator receives identical bytes. Then send one from a spectator and confirm the host receives nothing and no error is returned. The silence is the correct result.
Teardown reaches the audience. Close the host socket. Every spectator must receive
host_disconnectedfollowed by aviewer_count.The server agrees. With
RUST_LOG=packet_relay=info, each admission logs oneClient registeredline carrying the role and the fullsession_id, and each close logsClient disconnected—src/connection.rs:246-252,src/connection.rs:409-414,src/connection.rs:549-559.
If it does not work#
| What you see | What happened |
|---|---|
{"type":"error","message":"spectator cap reached"} |
The session already holds --max-spectators (default 64) spectators. src/session.rs:208-214 |
{"type":"error","message":"session kind mismatch"} |
The id is already held by a bridge or a room. One session_id is one kind. src/session.rs:118-128 |
{"type":"error","message":"server at capacity"} |
The global --max-sessions ceiling (default 256) is reached — including sessions auto-created by stray spectators. src/session.rs:130-132 |
{"type":"error","message":"invalid session ID"} |
The id was empty, over --max-id-bytes, or carried a character outside [A-Za-z0-9._-]. src/connection.rs:41-50 |
session_ready but never host_connected |
No host is registered under that id. Most often the id is wrong and the spectator created the session itself. |
| Spectator sends frames, host receives nothing, no error | Working as built. There is no spectator branch in the forwarding loop. src/connection.rs:389-394 |
A spectator is closed after "message rate exceeded" |
Its own upstream frames exhausted the per-connection budget, even though none of them were forwarded. src/connection.rs:346, src/rate_limit.rs:220-225 |
| Spectators stop receiving while the host still holds its socket | Another client sent host_hello for the same id and took the host slot. src/session.rs:202-206 |
| The socket closes with no frame at all | The hello deadline expired, the first frame was binary, or the connection-rate limiter dropped the TCP stream before a byte was read. src/connection.rs:491-546, src/main.rs:365-369 |
No refusal carries a WebSocket Close status code — the relay sends an error
text frame where it has one and then drops the sink, so a client inspecting
only close codes sees each row above as an unexplained disconnect.
Related#
- Bridge sessions — the one-to-one protocol, and the same silent-takeover edge on its desktop role.
- Authenticated rooms — the N:N protocol, and the only one that verifies who is speaking.
- Message reference — every hello and every response, field by field.
- Limits & capacity — spectator caps, message budgets, backpressure and the reaper.
- Security model — why an unauthenticated host slot is replaceable by design.
- Troubleshooting — the failures that produce no frame at all.