Backend WebSocket connection registry — manages server-side WebSocket connections
with session tracking, revocation support, a per-account connection cap,
and two-phase registration.
Two-phase registration
An upgrade resolves its credential before the socket exists, and every
revocation closes the connections *registered* under the revoked
credential. A one-step registration therefore has a window: a revocation
landing between the credential check and the insert closes nothing, and the
upgrade is admitted on a dead credential — for the socket's lifetime, since
per-message dispatch never re-reads the session or token.
Registration is split to close it:
ConnectionRegistry.register_pending inserts the entry
un-admitted. It is closeable from that instant — close_connections
matches pending entries deliberately — and is otherwise inert: it evicts
nothing, the cap does not count it, no broadcast / send_to_account /
request_connection reaches it, and the count accessors and
is_registered do not see it (pending_connection_count does).- The caller re-checks the credential against the database
(revalidate_resolved_auth in auth/resolved_auth.ts).
ConnectionRegistry.admit flips the entry to admitted — or
refuses when a revocation closed it in the meantime, evicting nothing.
The cap's eviction happens here, so a connection that is never admitted
never closes a live one, and any number of pending registrations on one
account leave its admitted connections alone.
A revocation whose close ran after step 1 finds the entry, pending or
admitted, and closes it; one that committed before step 2 is seen by the
re-read, which refuses. Nothing is delivered to a pending entry rather than
queued for it, so a refusal never has to promise that a queue a revoked
credential would have read is dropped unread, and a broadcast's returned
count names only connections that can receive it. The cost is a slightly
wider blind spot at connect: a server-initiated message sent between the
handshake and admission — one credential re-check later — is not delivered,
just as one sent before the handshake is not.
add_connection stays as the one-step form (register and admit together)
for a connection with no revocable credential behind it — tests and
harnesses that register a socket directly. An upgrade that authenticated a session or token
uses the two-phase form. The twin of the Rust fuz_realtime
ConnectionRegistry.
Outbound bound
Every frame the server sends a connection goes through one path —
send_to, which responses, error frames and socket-scoped notifications
use, and the fan-outs (broadcast, broadcast_filtered, send_to_account,
request_connection) — into the connection's WsOutboundWriter
(actions/ws_outbound_writer.ts). A send never waits and never throws: one
recipient that stopped reading must not slow a producer or another
recipient.
What waits for a socket is held to a byte budget, outbound_queue_bytes
(DEFAULT_WS_OUTBOUND_QUEUE_BYTES): each waiting frame is charged its UTF-8
length plus OUTBOUND_FRAME_OVERHEAD_BYTES, and a frame is always accepted
when nothing is waiting. Nothing counts frames. A frame sent to an idle
connection goes straight to the socket; the rest of a burst sent in one
synchronous run waits, as does anything sent while the socket's own buffer
is backed up.
A frame that does not fit closes the connection rather than being
dropped alone: the connection is removed in the same synchronous step — the
overflowing send does not count it, later sends miss it, the count accessors
and the cap no longer see it — its controller is aborted, what was waiting
is discarded, and the socket is closed with WS_CLOSE_OUTBOUND_OVERFLOW.
Frames already handed to the socket may still reach the client ahead of the
close frame; nothing follows it, the response of the request whose handler
overflowed the connection included. One warn line names the connection,
its account, and what was waiting; no audit row is written — a client can
provoke an overflow at will, and a write per overflow would amplify it.
A socket that holds unwritten bytes past WS_WRITE_STALL_TIMEOUT is dropped
with no close frame, and removed the same way, with one warn line. Every
other close the registry makes (a revocation, the cap, the shutdown,
close_connection) hands the frames still waiting to the socket ahead of
its close frame while the socket's buffer is below
WS_WRITE_HIGH_WATER_BYTES, discards the rest, and cuts the socket if the
close frame is still unwritten after WS_CLOSE_WRITE_TIMEOUT. A reply sent
to an otherwise idle connection just before such a close reaches the client;
one queued behind a backed-up socket does not.
A connection the registry closed or dropped is out of its map, so the
per-account cap does not count it while its socket is still closing. What
the runtime holds for such a socket is bounded by the writer
(actions/ws_outbound_writer.ts, "Closing" and "Runtimes").
The twin of the Rust fuz_realtime outbound queue — same budget, charge,
close code and deadlines. Where they differ: the Rust spine discards every
frame queued before any close, and queues every frame, where this registry
writes the first frame of a run straight through and, at a close that is
not an overflow, the waiting frames its socket has room for.
Shutdown
close_all_connections — the close AppServer.close runs — closes every
connection, pending or admitted, with WS_CLOSE_GOING_AWAY, and leaves the
registry closing: from then on every registration is born closed. A
register_pending or add_connection inserts nothing — it aborts the
caller's controller and closes the socket with WS_CLOSE_GOING_AWAY at once
— so an upgrade still in flight when the server shuts down is closed rather
than outliving it. An upgrade registered before the close-all is closed by
it; one registered after is born closed; none is admitted. The flag is set
in the same synchronous step that closes the live connections, so an admit
that finds its entry gone and reads is_closing() sees the shutdown — a
shutdown is never read as a revocation (a revocation the shutdown overtook
reads as the shutdown). Closing is permanent: a closed registry hosts no
further connections.