PacketRelayDocs

Limits & capacity#

PacketRelay is one process with fixed ceilings and no external state. Every limit is either a compiled-in constant or a flag with a validated range, and every one of them fails closed — a connection is admitted or it is gone. Nothing degrades quietly, and nothing is queued for later.

This page collects every ceiling in one table, separates the two rate limiters that are routinely confused with each other, and covers the two ways a well-behaved client is still starved: a full outbound queue, and a session reaper that does not care whether the session is busy.

Every ceiling in one place#

Defaults are the shipped values. The container passes no flags at all — the image has an ENTRYPOINT and no CMD — so every default below is what a Docker or Railway deployment runs with until you add arguments.

Limit Default Set by On breach
WebSocket message and frame size 64 KiB --max-message-bytes (hard maximum 64 KiB) Enforced inside tungstenite; the exact wire behaviour is unverified — see below.
HTTP request head 16 KiB compile-time MAX_HTTP_HEAD_BYTESmain.rs:42 The connection is dropped with no response — main.rs:441-443.
Tungstenite read / write buffers 16 KiB each compile-time — main.rs:40, 418-419
Tungstenite max write buffer 128 KiB compile-time — main.rs:41, 420
Global concurrent connections 128 --max-connections Two different mechanisms, two different outcomes — see below.
Concurrent connections per IP 80 --max-connections-per-ip TCP stream dropped, no response — rate_limit.rs:114-116.
New connections per IP per window 240 --max-connect-rate-per-ip TCP stream dropped, no response — rate_limit.rs:107-109.
Connection-rate window 60 s --connect-rate-window
Global sessions 256 --max-sessions Error frame server at capacitysession.rs:130-131.
Room members 32 --max-room-members Error frame room at capacitysession.rs:249-251.
Spectators per broadcast 64 --max-spectators Error frame spectator cap reachedsession.rs:210-212.
session_id and member_id length 64 bytes --max-id-bytes Error frame invalid session ID or invalid member ID.
Request head and first hello deadline 5 s --hello-timeout Dropped with no error frame — connection.rs:491-546.
Messages per connection per window 3,000 --max-messages-per-window Error frame message rate exceeded, then close.
Payload bytes per connection per window 64 MiB --max-bytes-per-window Error frame message byte rate exceeded, then close.
Message-budget window 60 s --message-rate-window
Outbound queue depth per connection 16 --outbound-queue Nothing is dropped; the sender's fan-out blocks — see below.
Session TTL 300 s --session-ttl Session reaped if it also has no participants — session.rs:454-476.
Reaper cadence 30 s --reaper-interval

Ranges and validation for each flag are in Flags & environment. Every violation is caught by clap at startup, so a bad value is a failed launch rather than a surprise at run time — main.rs:212-266.

The 64 KiB frame ceiling is a hard maximum#

--max-message-bytes accepts 1024 to 65536 and nothing above it. The upper bound is not a default that can be raised; a larger value exits with a validation error, and a test pins that rejection — main.rs:39, main.rs:234, test main.rs:527-542. The value sets tungstenite's max_message_size and max_frame_size together — main.rs:417-422 — so there is no fragmented path around it.

Note

What tungstenite 0.26 puts on the wire when a peer exceeds max_frame_size has not been observed on this codebase. Treat an oversized frame as "the connection ends", and do not build a client that depends on receiving a particular close code.

Content larger than the inline ceiling has no transport here. Encrypted artifact references for larger payloads are PlannedBACKLOG.md:75-85.

The connection cap is enforced twice#

--max-connections sizes two independent mechanisms. They count different things, they run at different points in a connection's life, and they fail in visibly different ways. Reading one number in the flags and assuming one mechanism is the usual cause of a confusing capacity report.

Mechanism Counts Runs at On exhaustion
RateLimiter global counter Every accepted TCP connection, including /health and /ready probes and connections that never send a byte Accept, before a single byte is read — main.rs:360-369 The stream is dropped with no response at all — rate_limit.rs:117-119
Legacy route Semaphore Only connections that reached the WebSocket upgrade — main.rs:52-70, 409-412 After the request head is parsed and routed HTTP 503 Service Unavailable, body route capacity reached\n

Health probes consume limiter budget, because the limiter runs at accept before the request line exists — the relay cannot yet know that the connection is a probe. They never consume a semaphore permit, because they are answered and shut down on the Health or Ready route without an upgrade.

The practical consequence: with a busy health check and a low --max-connections, the limiter can sit at capacity while the semaphore has permits to spare. Clients turned away in that state receive nothing at all, while a client that gets far enough to be refused a permit receives a clean 503.

Important

A 503 means the relay is alive and full. Silence means the relay is alive and refusing you at the door. They are different capacity problems and they are tuned with the same flag.

Connection admission is a sliding window#

try_connect runs once per accepted connection and evaluates in a fixed order — rate_limit.rs:92-125:

  1. Snapshot whether the global counter is already at capacity.
  2. Prune this IP's recorded attempt timestamps older than the rate window.
  3. If the remaining timestamps already fill the per-IP rate budget, reject with connection rate exceeded.
  4. Record this attempt.
  5. If the IP is at its concurrent cap, reject with too many concurrent connections.
  6. If the global snapshot said capacity, reject with server connection capacity reached.
  7. Otherwise increment both counters.

Step 4 sits before both capacity checks on purpose: a rejected attempt still counts toward the rate budget, and a test pins it — rate_limit.rs:111-112, test connection_rate_counts_rejected_attempts. Without that ordering a client refused for concurrency could retry without limit and pay nothing for it.

The window slides. Timestamps are pruned individually against the window edge on every call, so a client that spends its budget does not get it back in one lump — it recovers one attempt at a time, one window after each attempt was made.

Warning

None of the three rejection reasons reaches the client. The TCP stream is dropped and a warn! is written to the server log — main.rs:365-369. A client that reconnects in a tight loop on failure spends its own rate budget and converts a transient concurrency rejection into a lasting rate rejection.

disconnect decrements the counters, prunes the IP's history, and removes the entry entirely once it is idle — rate_limit.rs:128-149. The reaper also calls prune_idle, so per-IP history expires on a timer rather than waiting for the address to come back — rate_limit.rs:152-163.

The message budget is a fixed window#

The per-connection traffic budget is a different mechanism with different semantics from connection admission. Confusing the two produces the wrong mental model of when a client recovers.

Property Connection admission Message budget
Scope Per IP, plus a global counter Per connection
Window Sliding — timestamps pruned individually Fixed — counters reset wholesale — rate_limit.rs:214-219
Rejected work Still charged Not applicable; a breach ends the connection
Client feedback None; the stream is dropped An error frame, then close
Recovery Gradual, one attempt at a time All at once, at the next window boundary

The budget is charged once per inbound text frame, using the frame's byte length, before any role dispatch — connection.rs:346. Message count is checked first, giving message rate exceeded; the byte total is checked second with a saturating add, giving message byte rate exceededrate_limit.rs:220-225. On either breach the error frame is sent and the forwarding loop breaks, which closes the connection.

Charging before dispatch has three consequences worth knowing before you tune anything.

  • Keepalive pings are charged. The exact envelope {"type":"ping"} is recognised and dropped rather than forwarded — connection.rs:361, 480-488 — but the charge has already happened. A one-second keepalive costs 60 of the 3,000 messages in every window.
  • Frames that go nowhere are still charged. Spectator-to-host text frames are discarded, because the broadcast forwarding path has no spectator branch — connection.rs:389-394 — and they are charged on the way in.
  • Binary frames are never charged. They are silently discarded in the forwarding loop and never reach the budget — connection.rs:405. WebSocket Ping and Pong control frames are not charged either — connection.rs:397-399.

Backpressure has no timeout and no drop policy#

Each connection owns one bounded tokio::sync::mpsc channel, sized by --outbound-queue and defaulting to 16 — connection.rs:167. Fan-out to a room or a broadcast awaits the send to each recipient sequentially, with no timeout, no deadline and no drop policy — connection.rs:383-394.

A full recipient queue therefore stalls the sender's whole fan-out: the sending task blocks inside the send, later recipients wait behind the slow one, and the sender stops reading its own socket while it waits. Nothing is dropped and no error is raised, so the symptom is latency and stalled delivery rather than a visible failure. A bounded-latency policy for slow consumers is PlannedBACKLOG.md:89-99.

Raising --outbound-queue buys a deeper buffer before the stall, not an escape from it. The ceiling is 256.

Session age is never refreshed by activity#

Every session records its creation instant and never touches it again — session.rs:137-147. The reaper wakes every --reaper-interval and removes sessions whose age exceeds --session-ttl and which currently look unoccupied — session.rs:454-476:

Kind Reaped when
Bridge No mobile peer is registered and age > TTL — even if the desktop socket is still open — session.rs:459-461
Broadcast No host, no spectators, and age > TTL
Room No members, and age > TTL

Because age is measured from creation, a bridge that has been relaying frames for six minutes is exactly as old as an abandoned one. The bridge rule is the sharp edge: a desktop holding a session open while it waits for a phone loses that session at 300 seconds while still connected. Whether activity or reconnection ought to refresh expiry is recorded as an open question — BACKLOG.md:196-204.

Reaping is not the only removal path. A session is deleted the moment its last participant unregisters — both bridge slots empty, a broadcast with no host and no spectators, or an emptied room — session.rs:412-414, 439-441. The reaper exists for sessions that were created and then abandoned before anyone registered, which is exactly what a leaked spectator_hello or a desktop_hello that never finds a peer produces.

Note

The global session ceiling is enforced atomically under the same write lock that performs creation, so concurrent creators cannot race past it — session.rs:130-132, test session.rs:749-784. The 257th session gets server at capacity rather than a corrupted count.

Deliberate absences#

None of these is scheduled work you can wait for, and none of them has a client-visible signal.

Absent Consequence
Post-hello idle timeout — connection.rs:333-409 A connection that completes its hello and then goes silent is held until the peer or the network ends it.
Server-initiated keepalive — connection.rs:397-399 The relay never sends a WebSocket Ping; it only answers one with a Pong. Liveness detection is the client's job.
Drop policy for slow consumers See backpressure above. PlannedBACKLOG.md:89-99.
Graceful shutdown — main.rs:357-381 The accept loop is unconditional and no termination signal is handled. Termination kills in-flight connections and every session with them. PlannedBACKLOG.md:161-166.
Metrics export Active connections, sessions, rejections and queue pressure cannot be observed from outside the process. PlannedBACKLOG.md:169-176.

What to change first#

Symptom Flag to look at
Clients behind one office NAT are refused silently --max-connections-per-ip, --max-connect-rate-per-ip
Reconnect storms turn into lasting rejections --connect-rate-window, and the client's retry backoff
503 route capacity reached under normal load --max-connections
server at capacity when a session is created --max-sessions
A near-idle client is closed with message rate exceeded --max-messages-per-window, and the keepalive interval
Fan-out latency spikes when one peer is slow --outbound-queue, as mitigation rather than a fix
Sessions disappear while a desktop waits for a phone --session-ttl