Snodo.Transport.Stdio (snodo v0.4.1)

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.

Summary

Functions

Returns a specification to start this module under a supervisor.

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(),
  negotiated_version: String.t() | nil,
  eof?: boolean()
}

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

serve(runtime, opts \\ [])

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

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