PacketRelayDocs

Troubleshooting#

Most PacketRelay failures are silent. The relay sends an error frame for one class of problem — an application-level rejection after a well-formed hello — and for everything else it closes the socket, writes a line to its own log, and tells the client nothing. Several of those silences are deliberate.

This page maps what a client sees to what the relay did, says which symptoms are silent by design, and says where the real reason is recorded when the client is not told.

The connection closed and nothing was sent#

Start here when there is no HTTP response, no error frame and no close code. The relay never sends a WebSocket Close frame carrying a status code — on every rejection path the error frame, if any, is written and the sink is then dropped — so "closed with no close code" is normal and identifies nothing on its own.

What you see What happened
TCP accepted, then closed immediately, no bytes returned The rate limiter rejected the connection at accept, before a single byte was read — main.rs:360-369. One of three reasons: per-IP rate, per-IP concurrency, or global capacity. By design — the reason is never disclosed to the caller.
Connection held for ~5 s, then closed, no HTTP response The request head never completed with \r\n\r\n inside --hello-timeoutmain.rs:389, 431-452.
Connection closed while the request head was still being sent The head exceeded the 16 KiB MAX_HTTP_HEAD_BYTES ceiling — main.rs:42, 441-443.
404 Not Found, body not found The target starts with /health, /ready or /readyz but is not an exact match — /health?detail=1 is a 404, not a health probe — main.rs:479-484.
The upgrade handshake fails The request line was not exactly GET <target starting with /> HTTP/1.1, or tungstenite refused the handshake — main.rs:454-473, main.rs:416.
WebSocket opens, then closes ~5 s later with no error frame No hello arrived inside --hello-timeoutconnection.rs:491-546.
WebSocket opens, first frame sent, closed instantly, no error frame The first frame was binary. The hello must be text — connection.rs:530-533.
Same, with a text first frame The hello was not parseable as one of the five hello envelopes, or the client closed before sending it. Both log and drop with no error frame — connection.rs:491-546.
Note

Unknown extra fields in a hello are ignored rather than rejected — the hello enums do not set deny_unknown_fieldsprotocol.rs:76-80. A hello that fails to parse is missing a required field or has the wrong type, not carrying one field too many.

HTTP responses before the upgrade#

Response Meaning
200 OK, body ok /health or /healthz. The listener is alive — main.rs:396.
200 OK, body ready /ready or /readyz. The process is ready — main.rs:400.
404 Not Found, body not found A near-miss on a reserved path. Routing is exact-match; a query string makes it a 404 — main.rs:479-484.
503 Service Unavailable, body route capacity reached The Legacy route's semaphore has no permits. Every one of the --max-connections WebSocket slots is occupied — main.rs:409-412.

A 503 and the silent drop above are different capacity failures with the same flag behind them. The semaphore counts only connections that reached the upgrade; the rate limiter counts every accepted TCP connection, health probes included. See Limits & capacity for the split.

Error frames the relay sends#

Every message below arrives as {"type":"error","message":"…"} and is followed by the socket being dropped. There is no close code and no retry hint.

Message What happened What to do
invalid session ID session_id was empty, longer than --max-id-bytes, or contained a character outside [A-Za-z0-9._-]connection.rs:41-50, 472-478. Check the id for spaces, newlines, colons or non-ASCII. The charset test covers all of these — connection.rs:584.
invalid member ID The same validation applied to member_id on a room_helloconnection.rs:54-65. As above.
session not found A mobile_hello named a session that does not exist — connection.rs:84-96. The desktop has not connected yet, or the session was reaped, or the id is wrong. Mobile clients never create a session.
session kind mismatch The id already exists as a different kind — a bridge id used for a room, a room id used by a mobile, and so on — session.rs:118-128, 165. One id is one kind for its whole life. Use a fresh id, or fix the client that is claiming the wrong role.
mobile already connected A second mobile_hello on a bridge that already has one — session.rs:176-181. The mobile slot is the only slot that refuses a replacement. If the previous mobile is gone, wait for its teardown to unregister.
spectator cap reached The broadcast is at --max-spectators, default 64 — session.rs:210-212. Raise the cap or reduce viewers.
room at capacity The room is at --max-room-members, default 32 — session.rs:249-251. A reconnecting member with an existing member_id replaces its own slot and does not count against this — session.rs:240-248. Raise the cap, or check for members that never disconnected cleanly.
server at capacity The process is at --max-sessions, default 256, checked atomically at creation — session.rs:130-131. Existing sessions are unaffected; only new ones fail. Check for abandoned sessions waiting on the reaper.
message rate exceeded This connection sent more than --max-messages-per-window text frames in the window — rate_limit.rs:220-222. Count keepalives: {"type":"ping"} frames are charged even though they are dropped.
message byte rate exceeded This connection's text-frame bytes exceeded --max-bytes-per-windowrate_limit.rs:223-225. The window is fixed, so the budget returns in one step at the next boundary rather than gradually.
signed room hello required A room hello arrived without a usable credential path — connection.rs:143-155. Unsigned room hellos are refused outright. This is a breaking cutover, not a fallback.
invalid room hello signature One of three distinct failures — see below. Read the server log; the client message does not say which.
member id bound to a different identity One of two distinct failures — see below. Read the server log.

Room rejections are deliberately coarse#

Five internal reject reasons collapse into two client-facing messages, so a caller probing a room cannot learn which check it failed and cannot use the error text to search for a key — room_auth.rs:81-116, 103-105.

Internal reason Client message Actual cause
MalformedCrypto invalid room hello signature pubkey or sig failed base64 decode, DER parse, or the 64-byte P1363 length check — room_auth.rs:335-349
BadSignature invalid room hello signature The signature did not verify against the presented pubkey
StaleNonce invalid room hello signature The nonce was outside the ±30 s window, or not strictly greater than the stored high-water mark — room_auth.rs:167-169, 190-201
FingerprintMismatch member id bound to a different identity The member_id carries a 16-hex prefix that does not match SHA-256(SPKI-DER)[..8]room_auth.rs:181-188
PubkeyMismatch member id bound to a different identity A pin already exists for this session_id + member_id under a different key
Important

The precise reason is logged server-side and only server-side — connection.rs:128-133. Diagnosing a room authentication failure from the client is not possible and is not meant to be; get the log line.

Two frequent causes that both surface as invalid room hello signature: client clock skew beyond 30 seconds, and a nonce generator that emits seconds where the protocol expects unix-epoch millisecondsroom_auth.rs:313-319. A member that reconnects with a nonce equal to its last one is refused, because the comparison is strictly greater rather than greater-or-equal.

Frames are sent but never arrive#

Nothing here reports an error. The sender's send succeeds and the frame is discarded, forwarded elsewhere, or still queued.

Situation What happened
A spectator sends to the host and nothing arrives Broadcast forwarding has no spectator branch — only the host fans out — connection.rs:389-394. The frame is discarded after being charged to the sender's budget. Broadcast is one-directional.
A binary frame vanishes Only Message::Text is forwarded. Binary, Pong and raw Frame are silently discarded and not even rate-charged — connection.rs:405. Encode payloads as text.
{"type":"ping"} is never delivered to the peer It is recognised as a keepalive and dropped — connection.rs:361, 480-488. Recognition is exact: under 64 bytes, a JSON object, exactly one key, that key type with the value ping. Anything else — an extra field, whitespace pushing it over 64 bytes — is forwarded as an ordinary frame.
A room member never sees its own frames Fan-out excludes the sender by member_idsession.rs:260-275. Echo is the client's job.
Bridge frames go somewhere unexpected after a reconnect A second desktop_hello on the same id replaced the stored sender — session.rs:169-175. Frames now go to the new socket; the old one stays open and receives nothing.
Delivery stalls for every recipient when one peer is slow Fan-out awaits each recipient sequentially with no timeout, and each connection's outbound queue holds only --outbound-queue messages — connection.rs:383-394, 167. One full queue blocks the sender's whole fan-out. Nothing is dropped; it is latency, not loss.
A room frame arrives with an unfamiliar shape Room frames are wrapped as member_frame with the sender's member_id; only bridge and broadcast forward verbatim — connection.rs:367-388.
Warning

A stalled fan-out also stops the sender reading its own socket while it waits. A single unresponsive spectator or room member can look exactly like a relay-wide hang, and no log line marks the moment it starts.

A session vanished mid-use#

What you see What happened
A mobile reconnects and gets session not found The bridge was reaped. Age is measured from creation and is never refreshed by activity, so a bridge with no mobile peer is removed once it is older than --session-ttl — even with the desktop socket still open — session.rs:454-476, 459-461.
A room or broadcast id stops working after a quiet period Same rule: an empty room, or a broadcast with no host and no spectators, is reaped past the TTL.
A session disappears the instant the last participant leaves Not the reaper. Sessions are removed immediately when both bridge slots empty, when a broadcast has no host and no spectators, or when a room empties — session.rs:412-414, 439-441. A room's TOFU pins are dropped with it.
A desktop stops receiving frames but gets no peer_disconnected It was evicted by a second desktop_hello on the same id — session.rs:169-175. Eviction is silent, and the evicted socket is never told. The same applies to a host — session.rs:202-206.
A peer disconnects and the survivor is told twice, or not at all Unlikely by construction: unregister requires the stored channel to be the same channel, so a replaced connection's late teardown is a no-op and emits no spurious disconnect — session.rs:378-451, tests session.rs:586-615, 617-647.
Every session on the relay disappears at once The process restarted. All state is in memory — sessions, TOFU pins, nonce high-water marks and limiter counters — and there is no graceful shutdown, so a deploy or a crash takes everything — session.rs:93, room_auth.rs:141, main.rs:357-381.

Where the real reason is recorded#

When the client is told nothing, the server log is the only evidence. The tracing filter comes from RUST_LOG; on absence or a parse failure it defaults to packet_relay=infomain.rs:281-286.

RUST_LOG=packet_relay=debug packet-relay --port 8080

Two things to expect when reading it. Rate-limit rejections are logged at warn! with the reason that never reached the client — main.rs:365-369. And room session ids are redacted to their first six characters plus …(len=N), because a room id is a capability secret; every other role's session id appears in full — connection.rs:549-559.

There is no metrics export, so active connections, session counts, rejection rates and queue pressure cannot be observed from outside the process. That work is PlannedBACKLOG.md:169-176. Until it lands, the log is the whole of the observability surface.