ADR-0035: Native transports share the signals core, the client WebSocket limits and draining
| Status | Accepted |
| Date | 2026-10-10 |
| Issues | #26 (follow-up), #25, #27 |
| Pull request | #123 |
| Related | ADR-0033, ADR-0030, ADR-0032, ADR-0027 |
Context and problem statement
The native Protocol v1 transports (ADR-0030: /socket/converger/v1 and the SSE stream) and two
features of the Phoenix binding were built in parallel:
- #25 (ADR-0032) put receipts, typing and presence in
ConvergerWeb.ConvergerChannel, with the participant identity, typing throttling and forwarding, read receipts and the visibility rules as private functions of the channel. - #27 (ADR-0027) put frame size, message rate, slow-consumer protection and draining in
ConvergerWeb.SocketGuard, which wraps the transport callbacks of the Phoenix sockets, and relied on Phoenix's socket drainer, which only knows sockets mounted with the endpointsocketmacro.
So the native endpoint ignored typing and read, pushed no receipts or presence, had its own
frame size settings and no rate limit or slow-consumer close, and was cut with close code 1001 when
Bandit stopped, instead of 1012 with a jittered retryAfterMs.
Decision drivers
- One behaviour for every client transport: a participant must not see different receipts, typing or presence depending on how the other side connected.
- One set of limits and one set of settings (
config :converger, :websocket,WS_*) for every client WebSocket; no second frame size setting. - Rolling restarts hand native clients over as gently as Phoenix clients.
Considered options
- Copy the #25 and #27 logic into the native transports.
- Extract the #25 logic into a shared module; reuse SocketGuard's checks; drain native connections through a Registry (chosen).
- Mount the native endpoint as a
Phoenix.Socket.Transportso SocketGuard and the drainer apply unchanged.
Pros and cons of the options
Option 1: copy
- Good, because nothing in the Phoenix binding changes.
- Bad, because the two copies would drift; the rules (who sees which receipt, typing throttling, forwarding to WhatsApp) are exactly the part that must not differ.
Option 2: extract and reuse
- Good, because the channel, the native socket and the SSE stream call the same functions, and the channel loses ~170 lines.
- Good, because the limits read the same config and emit the same telemetry
(
[:converger, :socket, :limit]withsocket: ConvergerWeb.ProtocolSocketor the SSE controller). - Bad, because the native transports must call the checks themselves (they are not Phoenix sockets), and draining needs its own registry.
Option 3: Phoenix transport
- Good, because the existing wrappers and drainer would apply.
- Bad, because ADR-0030 chose a
WebSockhandler for control over subprotocols, credentials and close codes; this would reopen that decision for an operational detail.
Decision
Chosen option: "extract and reuse":
ConvergerWeb.ConversationSignalsholds the #25 logic:participant/1,presence?/1,subscribe/2,frames/4(which broadcasts a connection sees, tagged reliable or ephemeral),typing/4andstop_typing/3,read/3,track_presence/2andpresence_snapshot/2. The Phoenix channel, the native socket and the SSE stream all use it; the frames stayConvergerWeb.ConvergerFrames.- The native socket applies
SocketGuard.rate_limited/0, the sharedmax_frame_bytesandwebsocket_max_frame_size, drops typing and presence aboveephemeral_drop_queue_lenand closes with 4503 aboveslow_consumer_queue_len, answering with v1errorframes where the Phoenix sockets answer with error replies. The separatemax_frame_bytes/max_frame_hard_byteskeys ofconfig :converger, ConvergerWeb.Protocol(unreleased) are removed. ConvergerWeb.ProtocolConnectionsis a node-local duplicate-keyRegistryof native connections.ConvergerWeb.Drain.terminate/2, after the readiness delay, sends them:socket_drainin batches ofdrain_batch_sizeeverydrain_batch_interval_ms(at mostdrain_shutdown_ms), before the endpoint stops and Phoenix drains its own sockets. A native socket closes with 1012 and{"reason":"unavailable","retryAfterMs":N}; an SSE stream ends with anerrorframeunavailable. While draining, new native connections and streams get HTTP 503 withRetry-After.
Consequences
Positive
- Receipts, typing and presence work identically on every transport, including SSE viewers counted as online.
- Operators tune one set of WebSocket limits and see one telemetry event for all client sockets.
Negative and trade-offs
- Shutdown can take up to one more
drain_shutdown_mswhen a node holds many native connections (theDrainchild's shutdown timeout grows accordingly); the termination grace period guidance in docs/operations/websocket-limits.md is updated. - SSE has no slow-consumer close (an HTTP response has no close code); it only drops ephemeral frames, and a stuck client is noticed on the next write.
Implementation
lib/converger_web/conversation_signals.ex,lib/converger_web/protocol_connections.ex;ConvergerWeb.ConvergerChannel,ConvergerWeb.ProtocolSocket,ConvergerWeb.ConvergerAPI.EventStreamController,ConvergerWeb.ProtocolSocketControllerandConvergerWeb.Drainuse them;ConvergerWeb.SocketGuardexposesrate_limited/0,close_detail/2,retry_after_ms/0,queue_len/1andconfig/1.- Tests: the #25 channel tests run unchanged against the extracted module;
test/converger_web/protocol_socket_test.exs(receipts, typing, presence, rate limit, slow consumer, ephemeral drop, 503 while draining, 1012 on drain) andtest/converger_web/controllers/event_stream_controller_test.exs(signals, presence, drain).
Links
- Protocol v1, sections 2.1, 2.4, 8, 11, 12.3
- WebSocket limits and draining, Real-time