PacketRelayDocs

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_id every 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_hello and spectator_hello carry only session_idsrc/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-spectators defaults to 64, and the global --max-sessions ceiling of 256 applies to broadcasts like everything else. See Limits & capacity.

Steps#

  1. Connect the host client at any non-reserved path, and send host_hello as its first frame, within --hello-timeout (default 5 s).

    {"type":"host_hello","session_id":"demo-broadcast"}
  2. Read session_ready on the host. The envelope is the same one bridge sessions use — the fixture at testdata/session_ready.json:1, asserted at src/protocol.rs:172-191:

    {"type":"session_ready","session_id":"demo-broadcast"}
  3. Connect each spectator and send spectator_hello with the same id.

    {"type":"spectator_hello","session_id":"demo-broadcast"}
  4. Read the spectator's admission frames. If a host is already registered, the spectator receives host_connected first and session_ready second — see below, this ordering is not a typo. If no host is registered, it receives session_ready alone and waits.

  5. Read viewer_count. Every successful registration triggers one, sent to the host and to every spectator — src/connection.rs:297.

  6. 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_readysrc/connection.rs:261-266
Host registers Every spectator host_connectedsrc/connection.rs:290-296
Any registration completes The host and every spectator viewer_countsrc/connection.rs:297, src/connection.rs:573-582
Host's socket closes Every spectator host_disconnected, then viewer_countsrc/connection.rs:430-439
Spectator's socket closes The host and remaining spectators viewer_countsrc/connection.rs:438

host_connected and host_disconnected carry no fields at all; viewer_count carries a single countsrc/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.

Important

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.

Warning

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#

  1. The host is live. The host received session_ready, and a viewer_count after each spectator joined.

  2. A spectator sees the host. Each spectator received host_connected — either before its session_ready (host already present) or after it (host arrived later). A spectator that has session_ready and no host_connected is attached to a session with no publisher.

  3. The count is right and excludes the host. With three spectators attached, every participant — the host included — holds {"type":"viewer_count","count":3}.

  4. 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.

  5. Teardown reaches the audience. Close the host socket. Every spectator must receive host_disconnected followed by a viewer_count.

  6. The server agrees. With RUST_LOG=packet_relay=info, each admission logs one Client registered line carrying the role and the full session_id, and each close logs Client disconnectedsrc/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.

  • 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.