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 objectThe 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 withkind: "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()
1The maximum payload size is 1 MiB.
iex> SvPortSim.Protocol.max_payload_size()
1_048_576The 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}
trueA 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("{"))
trueNormal 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
@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 }
@type kind() :: String.t()
@type operation() :: String.t()
@type request_id() :: non_neg_integer()
@type runtime_error_code() :: String.t()
@type runtime_failure_name() :: atom()
@type timeout_ms() :: pos_integer() | :infinity
@type version() :: 1
Functions
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
@spec default_timeout() :: pos_integer()
Returns the default Elixir runtime call timeout in milliseconds.
Examples
iex> SvPortSim.Protocol.default_timeout()
5_000
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
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"}}
@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}}
@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
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
@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
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}
@spec max_payload_size() :: pos_integer()
Returns the maximum JSON payload size in bytes.
Examples
iex> SvPortSim.Protocol.max_payload_size()
1_048_576
@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
@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}
@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}}
@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]
@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
@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}
@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}
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"}}
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"}}
@spec version() :: version()
Returns the MVP protocol version.
Examples
iex> SvPortSim.Protocol.version()
1