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

Copy Markdown View Source

A pure, process-free NDJSON (newline-delimited JSON) line framer.

ACP's stdio transport is a stream of bytes, not a stream of messages: reads arrive in arbitrary chunk sizes (one byte, one line, ten lines, half a line) with no guarantee that chunk boundaries line up with frame boundaries. This module buffers partial data and yields exactly the complete lines a chunk boundary reveals — nothing more, nothing less.

This is intentionally just the byte-splitting layer: it does not parse JSON (that is Raxol.AgentClientProtocol.Rpc.Message.decode/1's job) and it is not a process (no GenServer, no mailbox) — a caller (e.g. the stdio transport) owns reading bytes off the wire and threads the returned state through successive push/2 calls.

Line splitting

Frames are delimited by \n (LF). A trailing \r immediately before the \n is trimmed, so CRLF- and LF-terminated peers interoperate transparently — including within the same stream.

Empty lines (a bare \n, or \r\n with nothing before it) are skipped rather than yielded as empty frames: some peers send bare newlines as a keep-alive, and an empty line is never a valid JSON-RPC frame.

Oversized frames

:max_frame_bytes (default 16777216 = 16 MiB) bounds how much unterminated data this module will buffer while waiting for the next \n. A line whose length crosses that bound yields {:error, {:frame_too_large, size}} in the returned list, in the position that frame would otherwise have occupied — and then the framer resyncs: it discards bytes up through the next \n it finds (which may span several more push/2 calls) and resumes normal framing from there. One oversized frame never corrupts, drops, or merges with the frames that follow it. size is the number of bytes observed at the moment the bound was crossed (for a frame whose terminator hasn't arrived yet, this is a lower bound on the frame's true length, not necessarily its final length).

Usage

framer = Framer.new()
{frames, framer} = Framer.push(framer, "{\"a\":1}\n{\"b\":2")
# frames == ["{\"a\":1}"]
{frames, framer} = Framer.push(framer, "}\n")
# frames == ["{\"b\":2}"]

Summary

Types

One complete, decoded-from-bytes frame (still a raw JSON string, not yet parsed).

A frame, or an oversized-frame error occupying that frame's position in the list.

t()

Functions

Create a fresh framer with an empty buffer.

Feed a chunk of bytes (of any size, including empty) into the framer.

Types

frame()

@type frame() :: binary()

One complete, decoded-from-bytes frame (still a raw JSON string, not yet parsed).

frame_or_error()

@type frame_or_error() :: frame() | {:error, {:frame_too_large, non_neg_integer()}}

A frame, or an oversized-frame error occupying that frame's position in the list.

t()

@type t() :: %Raxol.AgentClientProtocol.Transport.Framer{
  buffer: binary(),
  max_frame_bytes: pos_integer(),
  skipping: boolean()
}

Functions

new(opts \\ [])

@spec new(keyword()) :: t()

Create a fresh framer with an empty buffer.

Options:

  • :max_frame_bytes — maximum bytes buffered for a single unterminated line before it is reported as oversized (see moduledoc). Defaults to 16777216 (16 MiB).

push(framer, chunk)

@spec push(t(), binary()) :: {[frame_or_error()], t()}

Feed a chunk of bytes (of any size, including empty) into the framer.

Returns {frames, framer}: frames is the (possibly empty) list of complete frames and/or oversized-frame errors revealed by this chunk, in wire order; framer is the updated state to pass to the next call.