SvPortSim.Protocol (SvPortSim v0.1.0)

Copy Markdown View Source

Runtime wire-format contract for communication between Elixir and the C++ wrapper process.

MVP wire format

SvPortSim protocol v1 uses a 4-byte big-endian length-prefixed frame followed by a UTF-8 JSON payload.

frame = uint32_be(payload_length) <> payload
payload = UTF-8 JSON object

The payload is a JSON object with this common envelope:

{
  "v": 1,
  "id": 1,
  "kind": "request",
  "op": "hello",
  "body": {}
}

JSON object member order is not significant.

Port framing

Elixir code should open the wrapper port with:

[:binary, {:packet, 4}, :exit_status]

With {:packet, 4}, Elixir sends and receives the JSON payload bytes. The BEAM adds the 4-byte length prefix on writes and strips it on reads. The C++ wrapper must read and write the 4-byte big-endian length prefix explicitly.

Wrapper stdin/stdout is a byte stream, not a text stream. Wrapper implementations must preserve every byte of the length prefix and payload and must not route framed output through text or Unicode-transcoding APIs. For example, an Elixir fixture should open /dev/stdin and /dev/stdout with :raw and :binary and use :file.read/2 and :file.write/2 for frames. If a prefix byte such as 0x93 becomes the UTF-8 byte sequence 0xC2 0x93, {:packet, 4} decodes the wrong payload length and the request times out.

Limits

The maximum JSON payload size is 1 MiB. A zero-length payload is invalid. A payload larger than the maximum size is a fatal protocol error.

Runtime errors, timeouts, and simulator exits

Runtime calls expose exactly two Elixir return shapes:

  • {:ok, body} for a successful wrapper response envelope with kind: "response"
  • {:error, error_body} for wrapper-side errors and Elixir-side runtime failures

The canonical runtime error body is a JSON object with string keys:

{
  "code": "invalid_signal",
  "message": "signal is not readable",
  "details": {"signal": "enable"},
  "fatal": false
}

Non-fatal errors are ordinary command failures. The wrapper must emit one kind: "error" envelope with the same request id and op, keep the simulator process running, and accept the next request. Non-fatal codes are "invalid_command", "unsupported_command", "invalid_request", "invalid_signal", "invalid_value", "invalid_state", and "unsupported_feature".

Fatal errors make the current simulator process unusable. The Elixir side must close the port, discard pending state, and require the caller to start a new simulator before retrying. Fatal codes are "protocol_error", "timeout", "malformed_output", "simulator_exit", "simulator_failure", "port_closed", and "wrapper_fault".

Elixir calls default to 5_000 milliseconds. A caller may configure a positive integer timeout in milliseconds or :infinity. A timeout is fatal because a late wrapper response can no longer be safely matched to the synchronous request stream.

If the simulator process exits, crashes, closes its port, closes stdout/stderr, or returns malformed output, Elixir reports a fatal runtime error and does not reuse that process. Unsupported SystemVerilog/runtime features are reported with "unsupported_feature" and are non-fatal when the wrapper can still identify the request.

Examples

The MVP protocol version is 1.

iex> SvPortSim.Protocol.version()
1

The maximum payload size is 1 MiB.

iex> SvPortSim.Protocol.max_payload_size()
1_048_576

The recommended Elixir port options use binary mode and 4-byte packet framing.

iex> SvPortSim.Protocol.port_options()
[:binary, {:packet, 4}, :exit_status]

A valid envelope can be encoded to JSON payload bytes and decoded back.

iex> message = %{
...>   "v" => 1,
...>   "id" => 1,
...>   "kind" => "request",
...>   "op" => "hello",
...>   "body" => %{"client" => "sv_port_sim"}
...> }
iex> {:ok, payload} = SvPortSim.Protocol.encode_payload(message)
iex> is_binary(payload)
true
iex> SvPortSim.Protocol.decode_payload(payload) == {:ok, message}
true

A full wire frame is a 4-byte big-endian length prefix followed by the JSON payload bytes.

iex> payload = ~s({"v":1,"id":1,"kind":"request","op":"hello","body":{"client":"sv_port_sim"}})
iex> byte_size(payload)
76
iex> {:ok, frame} = SvPortSim.Protocol.frame_payload(payload)
iex> <<length::32, rest::binary>> = frame
iex> {length, rest == payload}
{76, true}

A zero-length payload is rejected.

iex> SvPortSim.Protocol.frame_payload("")
{:error, :empty_payload}

An unsupported protocol version is rejected.

iex> SvPortSim.Protocol.validate_envelope(%{
...>   "v" => 2,
...>   "id" => 1,
...>   "kind" => "request",
...>   "op" => "hello",
...>   "body" => %{}
...> })
{:error, {:unsupported_version, 2, 1}}

Malformed JSON payloads are rejected.

iex> match?({:error, {:json_decode_failed, _}}, SvPortSim.Protocol.decode_payload("{"))
true

Normal runtime errors are non-fatal.

iex> {:ok, error} = SvPortSim.Protocol.error_body("invalid_signal", "unknown signal", %{"signal" => "missing"})
iex> {error["code"], error["fatal"]}
{"invalid_signal", false}

Fatal runtime failures map to {:error, error_body} and require a new simulator process.

iex> {:error, timeout} = SvPortSim.Protocol.runtime_failure({:timeout, 7, "tick", 5_000})
iex> {timeout["code"], timeout["details"]["id"], timeout["fatal"]}
{"timeout", 7, true}

Unsupported features have explicit, non-fatal semantics.

iex> {:error, unsupported} = SvPortSim.Protocol.runtime_failure(:unsupported_feature, %{"feature" => "struct"})
iex> {unsupported["code"], unsupported["fatal"]}
{"unsupported_feature", false}

Rationale

The MVP uses length-prefixed JSON rather than line-based JSON so frame boundaries and message-size limits are explicit. It avoids committing to a bespoke binary command schema before the command protocol, signal metadata schema, and supported SystemVerilog data subset are finalized.

Summary

Functions

Decodes JSON payload bytes into a protocol envelope.

Returns the default Elixir runtime call timeout in milliseconds.

Encodes a protocol envelope into JSON payload bytes.

Builds a canonical runtime error body.

Looks up one runtime failure mapping.

Returns the runtime failure-to-return-value mapping table.

Returns whether code is fatal by default.

Returns runtime error codes that make the current simulator process unusable.

Builds a full wire frame for documentation and low-level tests.

Returns the maximum JSON payload size in bytes.

Returns runtime error codes that keep the current simulator process usable.

Normalizes a valid runtime error body to the canonical shape.

Normalizes timeout configuration for runtime calls.

Returns the recommended Elixir port options for the MVP wire format.

Returns all canonical runtime error codes.

Maps an Elixir-side or wrapper-side runtime failure to {:error, error_body}.

Maps a decoded wrapper envelope into the Elixir public return contract.

Validates the common protocol envelope.

Validates a runtime error body.

Returns the MVP protocol version.

Types

envelope()

@type envelope() :: %{required(String.t()) => term()}

error_body()

@type error_body() :: %{required(String.t()) => term()}

failure_mapping()

@type failure_mapping() :: %{
  failure: runtime_failure_name(),
  code: runtime_error_code(),
  message: String.t(),
  fatal: boolean(),
  cleanup: :keep_running | :close_port,
  retry: :retry_after_fix | :new_simulator
}

kind()

@type kind() :: String.t()

operation()

@type operation() :: String.t()

request_id()

@type request_id() :: non_neg_integer()

runtime_error_code()

@type runtime_error_code() :: String.t()

runtime_failure_name()

@type runtime_failure_name() :: atom()

timeout_ms()

@type timeout_ms() :: pos_integer() | :infinity

version()

@type version() :: 1

Functions

decode_payload(payload)

@spec decode_payload(binary()) :: {:ok, envelope()} | {:error, term()}

Decodes JSON payload bytes into a protocol envelope.

Examples

iex> payload = ~s({"v":1,"id":1,"kind":"request","op":"hello","body":{}})
iex> SvPortSim.Protocol.decode_payload(payload)
{:ok, %{"body" => %{}, "id" => 1, "kind" => "request", "op" => "hello", "v" => 1}}

iex> match?({:error, {:json_decode_failed, _}}, SvPortSim.Protocol.decode_payload("{"))
true

default_timeout()

@spec default_timeout() :: pos_integer()

Returns the default Elixir runtime call timeout in milliseconds.

Examples

iex> SvPortSim.Protocol.default_timeout()
5_000

encode_payload(message)

@spec encode_payload(envelope()) :: {:ok, binary()} | {:error, term()}

Encodes a protocol envelope into JSON payload bytes.

This returns the payload only. When using {:packet, 4}, do not manually prepend the length prefix before passing data to Port.command/2.

Examples

iex> message = %{
...>   "v" => 1,
...>   "id" => 1,
...>   "kind" => "request",
...>   "op" => "hello",
...>   "body" => %{"client" => "sv_port_sim"}
...> }
iex> {:ok, payload} = SvPortSim.Protocol.encode_payload(message)
iex> SvPortSim.Protocol.decode_payload(payload) == {:ok, message}
true

error_body(code, message, details \\ %{}, opts \\ [])

@spec error_body(term(), term(), map(), keyword()) ::
  {:ok, error_body()} | {:error, term()}

Builds a canonical runtime error body.

The default "fatal" value is derived from the error code. Pass fatal: true or fatal: false to override the default when a wrapper has more specific information.

Examples

iex> {:ok, body} = SvPortSim.Protocol.error_body("invalid_signal", "unknown signal", %{"signal" => "count"})
iex> {body["code"], body["details"]["signal"], body["fatal"]}
{"invalid_signal", "count", false}

iex> {:ok, body} = SvPortSim.Protocol.error_body("wrapper_fault", "segmentation fault", %{})
iex> {body["code"], body["fatal"]}
{"wrapper_fault", true}

iex> SvPortSim.Protocol.error_body("bad_code", "bad", %{})
{:error, {:invalid_error_code, "bad_code"}}

failure_mapping(failure)

@spec failure_mapping(term()) :: failure_mapping() | {:error, term()}

Looks up one runtime failure mapping.

Examples

iex> mapping = SvPortSim.Protocol.failure_mapping(:timeout)
iex> {mapping.code, mapping.fatal, mapping.cleanup, mapping.retry}
{"timeout", true, :close_port, :new_simulator}

iex> mapping = SvPortSim.Protocol.failure_mapping(:invalid_signal)
iex> {mapping.code, mapping.fatal, mapping.cleanup, mapping.retry}
{"invalid_signal", false, :keep_running, :retry_after_fix}

iex> SvPortSim.Protocol.failure_mapping(:not_a_failure)
{:error, {:unknown_runtime_failure, :not_a_failure}}

failure_mappings()

@spec failure_mappings() :: [failure_mapping()]

Returns the runtime failure-to-return-value mapping table.

Each entry fixes the canonical error code, default fatality, cleanup action, and retry expectation for one wrapper-side or Elixir-side failure.

Examples

iex> SvPortSim.Protocol.failure_mappings() |> Enum.map(& &1.failure) |> Enum.member?(:timeout)
true

fatal_runtime_error?(code)

@spec fatal_runtime_error?(term()) :: boolean()

Returns whether code is fatal by default.

Examples

iex> SvPortSim.Protocol.fatal_runtime_error?("timeout")
true

iex> SvPortSim.Protocol.fatal_runtime_error?("invalid_value")
false

fatal_runtime_error_codes()

@spec fatal_runtime_error_codes() :: [runtime_error_code()]

Returns runtime error codes that make the current simulator process unusable.

Examples

iex> SvPortSim.Protocol.fatal_runtime_error_codes() |> Enum.member?("malformed_output")
true

frame_payload(payload)

@spec frame_payload(binary()) :: {:ok, binary()} | {:error, term()}

Builds a full wire frame for documentation and low-level tests.

In normal Elixir port usage with {:packet, 4}, callers should send only the JSON payload. This helper exists to pin the externally visible frame format.

Examples

iex> payload = ~s({"v":1,"id":1,"kind":"request","op":"hello","body":{"client":"sv_port_sim"}})
iex> {:ok, frame} = SvPortSim.Protocol.frame_payload(payload)
iex> <<length::32, rest::binary>> = frame
iex> {length, rest == payload}
{76, true}

iex> SvPortSim.Protocol.frame_payload("")
{:error, :empty_payload}

max_payload_size()

@spec max_payload_size() :: pos_integer()

Returns the maximum JSON payload size in bytes.

Examples

iex> SvPortSim.Protocol.max_payload_size()
1_048_576

normal_runtime_error_codes()

@spec normal_runtime_error_codes() :: [runtime_error_code()]

Returns runtime error codes that keep the current simulator process usable.

Examples

iex> SvPortSim.Protocol.normal_runtime_error_codes() |> Enum.member?("invalid_signal")
true

normalize_error_body(body)

@spec normalize_error_body(term()) :: {:ok, error_body()} | {:error, term()}

Normalizes a valid runtime error body to the canonical shape.

Missing "details" becomes %{}. Missing "fatal" is derived from the error code.

Examples

iex> {:ok, body} = SvPortSim.Protocol.normalize_error_body(%{"code" => "timeout", "message" => "slow"})
iex> {body["details"], body["fatal"]}
{%{}, true}

normalize_timeout(opts)

@spec normalize_timeout(keyword() | timeout_ms() | term()) ::
  {:ok, timeout_ms()} | {:error, term()}

Normalizes timeout configuration for runtime calls.

Accepts a positive integer timeout in milliseconds, :infinity, or a keyword list with a :timeout entry. An empty keyword list uses default_timeout/0.

Examples

iex> SvPortSim.Protocol.normalize_timeout([])
{:ok, 5_000}

iex> SvPortSim.Protocol.normalize_timeout(timeout: 250)
{:ok, 250}

iex> SvPortSim.Protocol.normalize_timeout(:infinity)
{:ok, :infinity}

iex> SvPortSim.Protocol.normalize_timeout(timeout: 0)
{:error, {:invalid_timeout, 0}}

port_options()

@spec port_options() :: [:binary | :exit_status | {:packet, 4}]

Returns the recommended Elixir port options for the MVP wire format.

Examples

iex> SvPortSim.Protocol.port_options()
[:binary, {:packet, 4}, :exit_status]

runtime_error_codes()

@spec runtime_error_codes() :: [runtime_error_code()]

Returns all canonical runtime error codes.

Examples

iex> "unsupported_feature" in SvPortSim.Protocol.runtime_error_codes()
true

iex> "timeout" in SvPortSim.Protocol.runtime_error_codes()
true

runtime_failure(failure, details \\ %{})

@spec runtime_failure(term(), map()) :: {:error, error_body()} | {:error, term()}

Maps an Elixir-side or wrapper-side runtime failure to {:error, error_body}.

The returned error body is canonical and includes "details" and "fatal".

Examples

iex> {:error, body} = SvPortSim.Protocol.runtime_failure(:port_closed, %{"stream" => "stdout"})
iex> {body["code"], body["details"]["stream"], body["fatal"]}
{"port_closed", "stdout", true}

iex> {:error, body} = SvPortSim.Protocol.runtime_failure({:exit_status, 1})
iex> {body["code"], body["details"]["status"], body["fatal"]}
{"simulator_exit", 1, true}

to_elixir_return(message)

@spec to_elixir_return(term()) :: {:ok, map()} | {:error, error_body()}

Maps a decoded wrapper envelope into the Elixir public return contract.

Successful responses become {:ok, body}. Error envelopes become {:error, error_body}. Malformed envelopes become a fatal "malformed_output" error.

Examples

iex> SvPortSim.Protocol.to_elixir_return(%{"kind" => "response", "body" => %{"cycle" => 1}})
{:ok, %{"cycle" => 1}}

iex> {:ok, error} = SvPortSim.Protocol.error_body("invalid_value", "bad value", %{"signal" => "d"})
iex> {:error, returned} = SvPortSim.Protocol.to_elixir_return(%{"kind" => "error", "body" => error})
iex> {returned["code"], returned["fatal"]}
{"invalid_value", false}

iex> {:error, returned} = SvPortSim.Protocol.to_elixir_return(%{"kind" => "response", "body" => "not an object"})
iex> {returned["code"], returned["fatal"]}
{"malformed_output", true}

validate_envelope(message)

@spec validate_envelope(term()) :: :ok | {:error, term()}

Validates the common protocol envelope.

Examples

iex> SvPortSim.Protocol.validate_envelope(%{
...>   "v" => 1,
...>   "id" => 1,
...>   "kind" => "request",
...>   "op" => "hello",
...>   "body" => %{}
...> })
:ok

iex> SvPortSim.Protocol.validate_envelope(%{
...>   "v" => 2,
...>   "id" => 1,
...>   "kind" => "request",
...>   "op" => "hello",
...>   "body" => %{}
...> })
{:error, {:unsupported_version, 2, 1}}

iex> SvPortSim.Protocol.validate_envelope(%{
...>   "v" => 1,
...>   "id" => 1,
...>   "kind" => "command",
...>   "op" => "hello",
...>   "body" => %{}
...> })
{:error, {:invalid_kind, "command"}}

validate_error_body(body)

@spec validate_error_body(term()) :: :ok | {:error, term()}

Validates a runtime error body.

"details" and "fatal" are optional for backward compatibility with early command-layer drafts. Use normalize_error_body/1 to obtain the canonical shape with both fields present.

Examples

iex> SvPortSim.Protocol.validate_error_body(%{"code" => "invalid_request", "message" => "missing field", "details" => %{}, "fatal" => false})
:ok

iex> SvPortSim.Protocol.validate_error_body(%{"code" => "invalid_request", "message" => "missing field"})
:ok

iex> SvPortSim.Protocol.validate_error_body(%{"code" => "invalid_request", "message" => "missing field", "fatal" => "no"})
{:error, {:invalid_field, "error", "fatal", "no"}}

version()

@spec version() :: version()

Returns the MVP protocol version.

Examples

iex> SvPortSim.Protocol.version()
1