Raxol.AgentClientProtocol.Transport.Paired (Raxol Agent Client Protocol v0.1.0)

Copy Markdown View Source

An in-process, zero-copy pair of Raxol.AgentClientProtocol.Transport handles. This is the test backbone for the rest of the library and the BEAM-local wiring for an agent/client pair that live in the same VM.

create_pair/0 starts two lightweight GenServers — one per side — and links them to each other as peers. A message sent on the left handle is delivered to the right handle's owner (and vice versa), preserving send order. No JSON encoding happens anywhere in this module: Elixir maps pass straight through by reference. Byte-level codec correctness (encoding, framing, partial reads) is exercised by the stdio transport instead, not here.

Ownership

A freshly created handle has no owner (nil), so a supervisor can create the pair before the Connection process that will adopt it exists. Whatever arrives before an owner is set — peer frames and a peer close — is held, and set_owner/2 delivers it to the new owner in arrival order before anything later: the other side may start sending (e.g. a client's initialize) before this side's Connection has adopted its handle. Call set_owner/2 to adopt (or re-adopt — handoff to a new owner is supported at any time, including mid-stream).

Ordering

Erlang guarantees that messages sent between any two given processes arrive in send order. send_message/2 accepts a frame locally (a single, non-nested GenServer.call/2) and hands it off to the peer's side with GenServer.cast/2. Because a side handles its own incoming :send calls one at a time, in arrival order, the casts it issues to the peer land on the peer's mailbox in that same order — so a single sending process sees strict order preserved end to end, with no explicit sequence number needed.

This deliberately avoids a synchronous call into the peer from inside a side's own handle_call/3: two sides calling each other synchronously at the same time is a classic mutual-deadlock — each side blocks inside its callback waiting on the other, and neither can drain its mailbox to answer. Keeping the peer hop async (cast) is what makes simultaneous bidirectional traffic (both ends sending at once) safe.

Closing

close/1 is idempotent and marks the local side closed immediately. It also notifies the peer (a cast), which marks itself closed and delivers {:closed, :peer_closed} to its own owner — the side that calls close/1 does not message its own owner. Once a side's closed flag is set (locally, by close/1, or asynchronously via the peer notification), further send_message/2 calls through it return {:error, :closed}, and any :deliver that arrives after closing is dropped rather than reaching the owner. The underlying processes are not killed by close/1; a handle remains a valid (but permanently closed) tuple that can still be inspected.

Summary

Functions

Returns a specification to start this module under a supervisor.

Create a connected pair of transport handles: {left, right}.

Set (or replace) the owner process for a handle. The owner is the process that will receive {:acp_transport, transport_ref, ...} messages for this handle going forward, starting with any held since the handle was created.

Types

t()

@type t() :: %Raxol.AgentClientProtocol.Transport.Paired{pid: pid()}

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

create_pair()

@spec create_pair() :: {t(), t()}

Create a connected pair of transport handles: {left, right}.

Neither handle has an owner yet; inbound traffic is held until set_owner/2 adopts the handle.

set_owner(paired, owner)

@spec set_owner(t(), pid()) :: :ok

Set (or replace) the owner process for a handle. The owner is the process that will receive {:acp_transport, transport_ref, ...} messages for this handle going forward, starting with any held since the handle was created.