This guide covers the integration of the Gun HTTP/WebSocket client in ZenWebsocket, focusing on connection management, process monitoring, and ownership transfer.
Table of Contents
Overview
ZenWebsocket uses Gun as its underlying transport layer for WebSocket connections. Gun provides robust HTTP and WebSocket protocol implementation with features like:
- HTTP/1.1, HTTP/2, and WebSocket support
- Automatic reconnection capabilities
- Comprehensive TLS options
- Message streaming and multiplexing
The ZenWebsocket.Client module is a GenServer that owns the Gun connection and manages message routing.
Process Monitoring vs. Linking
Gun gives developers the choice between using process links and monitors for tracking connection processes:
Why ZenWebsocket Uses Monitors
ZenWebsocket uses Erlang's process monitoring (Process.monitor/1) instead of process linking for tracking Gun connections for several reasons:
- Resilience: If a Gun process crashes, the monitoring process receives a message rather than crashing itself
- Control: More granular control over error handling and recovery
- Ownership Transfers: Easier to manage process relationships during ownership changes
Current Implementation in Client
# In ZenWebsocket.Client.connect/2
{:ok, gun_pid} = :gun.open(host_charlist, port, gun_opts)
monitor_ref = Process.monitor(gun_pid)
%Client{
gun_pid: gun_pid,
monitor_ref: monitor_ref,
state: :connecting,
# ...
}Using Gun's Await Functions
Gun provides await functions for synchronous operations, but they require careful handling of the monitor reference.
The Monitor Reference Requirement
Gun's await functions check that the calling process has a monitor on the Gun connection:
# Client does not :gun.await the WebSocket upgrade. Reconnection opens Gun,
# then Client completes the upgrade asynchronously:
def handle_info(
{:gun_upgrade, gun_pid, stream_ref, ["websocket"], _headers},
%{gun_pid: gun_pid, stream_ref: stream_ref} = state
) do
new_state =
state
|> Map.merge(%{state: :connected, retry_count: 0})
|> HeartbeatManager.start_timer()
{:noreply, new_state}
endCommon Pitfalls
- Missing Monitor Reference: Calling await without the monitor reference will fail
- Wrong Monitor Reference: Using a monitor reference from a different connection
- Monitor After Connect: The monitor must be established before calling await functions
Ownership Transfer
One of Gun's most powerful features is the ability to transfer connection ownership between processes. The Client GenServer owns the connection so Gun messages land in the process that routes heartbeats and user frames.
When to Transfer Ownership
Ownership transfer is useful when:
- Client GenServer needs to receive Gun messages for integrated heartbeat processing
- Reconnection creates a new Gun process that needs proper ownership
- Moving connections between supervision trees
Implementation for Integrated Heartbeat
defmodule ZenWebsocket.Client do
use GenServer
# Client GenServer owns the Gun connection
def init(config) do
{:ok, gun_pid} = :gun.open(host, port, opts)
monitor_ref = Process.monitor(gun_pid)
# Client GenServer (self()) owns the connection
# All Gun messages come to this process
state = %{
gun_pid: gun_pid,
monitor_ref: monitor_ref,
heartbeat_manager: nil,
# ...
}
{:ok, state}
end
# Route Gun messages through MessageHandler, then Client's data-frame router
def handle_info({:gun_ws, gun_pid, stream_ref, frame}, %{gun_pid: gun_pid, stream_ref: stream_ref} = state) do
case MessageHandler.decode_and_handle_control({:gun_ws, gun_pid, stream_ref, frame}) do
{:ok, {:data, decoded_frame}} ->
{:noreply, route_data_frame(decoded_frame, state)}
{:ok, :control_frame_handled} ->
{:noreply, state}
{:error, {:protocol_error, _} = error} ->
handle_frame_error(state, error)
end
end
endReconnection Flow with Ownership
def handle_info({:DOWN, ref, :process, pid, reason}, %{gun_pid: pid, monitor_ref: ref} = state) do
# Connection lost — Client classifies the error and may retry
handle_connection_error(state, {:connection_down, reason})
end
# Reconnection.establish_connection/1 runs inside the Client GenServer
# so the new Gun process sends messages to this process.
defp start_gun_attempt(state) do
case Reconnection.establish_connection(state.config) do
{:ok, gun_pid, stream_ref, monitor_ref} ->
{:noreply, begin_attempt(state, gun_pid, stream_ref, monitor_ref)}
{:error, reason} ->
{:noreply, %{state | state: :disconnected}, {:continue, {:connection_failed, reason}}}
end
endBest Practices
1. Always Use Monitors
# Good - ZenWebsocket.Client pattern
{:ok, gun_pid} = :gun.open(host, port, opts)
monitor_ref = Process.monitor(gun_pid)
# Bad - no visibility into connection failures
{:ok, gun_pid} = :gun.open(host, port, opts)2. Handle Monitor Messages
# In Client GenServer
def handle_info({:DOWN, ref, :process, pid, reason}, %{gun_pid: pid, monitor_ref: ref} = state) do
handle_connection_error(state, {:connection_down, reason})
end3. Client GenServer Owns Gun Connection
The Client GenServer must own the Gun connection to receive messages:
defmodule ZenWebsocket.Client do
use GenServer
# Gun messages come to the GenServer process
def handle_info({:gun_ws, gun_pid, stream_ref, frame} = msg, state) do
case MessageHandler.decode_and_handle_control(msg) do
{:ok, {:data, decoded_frame}} ->
{:noreply, route_data_frame(decoded_frame, state)}
{:ok, :control_frame_handled} ->
{:noreply, state}
{:error, {:protocol_error, _} = error} ->
handle_frame_error(state, error)
end
end
end4. Clean Reconnection
defp start_gun_attempt(state) do
case Reconnection.establish_connection(state.config) do
{:ok, gun_pid, stream_ref, monitor_ref} ->
{:noreply, begin_attempt(state, gun_pid, stream_ref, monitor_ref)}
{:error, reason} ->
{:noreply, %{state | state: :disconnected}, {:continue, {:connection_failed, reason}}}
end
end5. Test Connection Failures
Always test how your application handles:
- Gun process crashes during active trading
- Network disconnections during heartbeat sequences
- Reconnection with subscription restoration
- Message routing after reconnection
Summary
Gun's process monitoring and ownership features are critical for ZenWebsocket's architecture. By having the Client GenServer own the Gun connection, we enable:
- Integrated heartbeat processing and user message handling
- Seamless reconnection with state preservation
- Reliable heartbeat handling for financial trading
- Clean separation of concerns between modules
The key insight is that Gun sends messages to the process that owns the connection, which is why the Client GenServer must open (or re-open) Gun itself.