Snodo.Transport.Stdio (snodo v0.4.3)

Copy Markdown View Source

Concurrent newline-delimited JSON-RPC transport.

A small coordinator owns framing and serializes complete stdout writes. Work is admitted through the optional, transport-neutral Snodo.Server.Executor, so the coordinator never awaits a handler and execution policy is reusable by other transports.

:write_timeout is a positive millisecond limit, defaulting to 5,000. A single linked, monitored helper performs each write while the coordinator waits boundedly; there is no writer queue. Cancellation and EOF handling can wait up to this limit during a blocked write. A timeout or output error is terminal: the transport cancels its work and closes subscriptions, without attempting further writes. A timed-out device may already have accepted some bytes, so the transport never retries. Supplied I/O devices are never stopped or killed.

:max_line_bytes limits one input message, counting its newline, and defaults to 2,000,000 bytes to match the HTTP listener's :max_body_bytes. A longer line is answered with -32600 and a null id without being decoded, the rest of it is discarded, and the next line is served normally.

How much of a long line is held in memory depends on the input. The default :stdio input is read in chunks that hold at most :max_line_bytes of a line when the VM was started with -noinput, and on OTP 28 and later when standard input is a stream socket, which is what Node.js clients provide. Any other input is read a line at a time, and the device holds each line in full before returning it; the VM holds a line from standard input as a character list, many times its size. That covers a pipe on standard input, which Python and Erlang clients provide, unless the VM was started with -noinput, for example in a release's vm.args. With -noinput, nothing else in the VM can read standard input.

An initialize-era client (2025-11-25 or 2025-06-18, when the runtime enables those dialects) negotiates its version once with initialize. The transport records the version from a successful result and gives every later message on the connection an mcp-protocol-version request header, the header HTTP requests carry, so dialect selection and validation are shared with HTTP. A 2026-07-28 client does not send initialize, and its messages carry no such header.

Application notifications

An application process can send a server-to-client notification on an open connection with notify/3, for example a vendor or experimental hint while the connection is idle. The handle is the transport's pid or registered name: the pid returned by start_link/1, or the :name passed to serve/2 or start_link/1.

Only methods listed in the :notification_methods option may be sent. The option is a list of method names, each starting with notifications/, and defaults to [], so nothing is sendable unless the application enables it. The names of notifications the protocol defines, such as notifications/progress and notifications/cancelled, are refused at startup. The frame is {"jsonrpc", "method", "params"} with no id, written through the same serialized, deadline-bound path as responses, so frames never interleave.

notify/3 is synchronous and unbuffered: the caller waits for its own write, so at most one write is in flight and pending callers wait in the transport's mailbox, each bounded by its call timeout. It returns {:error, :closed} if the connection is gone, or {:error, {:transport_down, reason}} if the transport stopped abnormally while the call was waiting. A failed or timed-out write is terminal for the connection exactly as for a response, and the caller gets {:error, {:output_failed, reason}}. If the caller's own call timeout expires first it gets {:error, :timeout}, but the request stays in the transport's mailbox and the frame may still be written afterward; treat :timeout as "unknown". Invalid input returns {:error, %Snodo.Error{}} and writes nothing. Delivery is a hint: nothing is retried or queued across a failure.

Summary

Functions

Returns a specification to start this module under a supervisor.

Sends a server-to-client notification on an open connection.

Runs the stdio transport until EOF and all admitted requests finish.

Types

state()

@type state() :: %{
  runtime: Snodo.Server.Runtime.t(),
  input: IO.device(),
  output: IO.device(),
  writer: {IO.device(), pos_integer()},
  executor: pid(),
  executor_monitor: reference() | nil,
  serve_owner_monitor: reference() | nil,
  owns_executor?: boolean(),
  request_timeout: Snodo.Server.Executor.execution_timeout() | :default,
  reader: pid() | nil,
  connection_ref: reference(),
  executions_by_id: map(),
  executions_by_ref: map(),
  subscriptions_by_id: map(),
  subscriptions_by_worker: map(),
  max_subscriptions: pos_integer(),
  max_line_bytes: pos_integer(),
  notification_methods: [String.t()],
  negotiated_version: String.t() | nil,
  log_level: Snodo.Logging.level() | nil,
  eof?: boolean()
}

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

notify(server, method, params \\ %{}, timeout \\ 10000)

@spec notify(GenServer.server(), String.t(), map(), timeout()) ::
  :ok
  | {:error,
     Snodo.Error.t()
     | :closed
     | :timeout
     | {:output_failed, term()}
     | {:transport_down, term()}}

Sends a server-to-client notification on an open connection.

server is the transport's pid or registered name. method must be listed in the transport's :notification_methods; params is a JSON-encodable map. See the module documentation for the failure behavior.

serve(runtime, opts \\ [])

@spec serve(Snodo.Server.Runtime.t(), keyword()) :: :ok | {:error, term()}

Runs the stdio transport until EOF and all admitted requests finish.