DpExchange.Coinbase.FrameSender (DpExchangeCoinbase v0.1.1)

Copy Markdown View Source

Sends a WebSocket frame without letting a slow socket kill the caller.

The hazard

WebSockex.send_frame/2 is not a plain function call. It is:

{:ok, res} = :gen.call(client, :"$websockex_send", frame)

with :gen.call's default 5000 ms timeout and no way to override it, and on timeout it does not return an error — it exits:

exit({reason, {WebSockex, :call, [client, frame]}})

That exit propagates into whatever called it. When the caller is the process managing the connection, a socket that takes longer than five seconds to accept a frame destroys the connection it was being sent on.

Why five seconds is not enough

This is the half that matters, and the half a careless copy loses.

A subscribe on a book channel makes the venue reply with a full snapshot — Gemini's opening l2 frame measured 39,804 bytes, and a 50-symbol batch is fifty of those. The socket process is single-threaded: while it decodes that burst it cannot service the next send_frame, which then times out.

Observed 2026-08-10, and it is self-reinforcing:

batch subscribe exited: {:timeout, {WebSockex, :call, ...}}   # 50 pairs
batch subscribe exited: {:noproc, ...}                        # socket dead
subscribed 0 pairs

Raising the timeout in the layer above cannot work while the layer beneath exits at five, which is why the same failure kept coming back after being "fixed".

What this does instead

Catches the exit and returns {:error, :send_timeout}. A slow socket becomes a failed batch, which a caller can report and retry, rather than a dead connection that takes every later batch with it.

This does not mean the frame was not sent. :gen.call timing out means we stopped waiting for the acknowledgement; the socket may deliver it a moment later. Subscribes are idempotent on every venue in this family, so a duplicate is harmless — whereas a lost connection is not.

Why this module is here and not in the contract

Core ships no transport dependency at any strength, so it cannot ship this. Each venue that speaks WebSocket carries its own copy. This is the first one written; the other frame-WebSocket venue in the family copies this module and this moduledoc. Copy the reason, not just the code — without it the guard reads as defensive padding and the next person tidying up deletes it.

Summary

Functions

Sends frame to pid, converting a send timeout into an error return.

Types

frame()

@type frame() :: {:text, String.t()} | {:binary, binary()} | atom()

Anything WebSockex.send_frame/2 accepts.

Functions

send(pid, frame, context \\ "frame")

@spec send(pid(), frame(), String.t()) :: :ok | {:error, term()}

Sends frame to pid, converting a send timeout into an error return.

Returns :ok, {:error, reason} from the socket itself, or {:error, :send_timeout} when the socket did not acknowledge within WebSockex's five-second window.