muro-lang.dev / docs

State machines

--backend fsm prints one module for the book’s sessions. A host that already has a transport (an RPC, an HTTP exchange, a queue, a websocket, a call between nodes made by other means) steps that module around the messages it sends and receives. The module does not start a process, and it does not talk to a peer.

This is not a replacement for --backend otp. The OTP image is a GenServer that owns a mailbox. The state machine is the check that the messages a host already exchanges follow the protocol the meeting proved. The OTP image is unchanged.

One end

meet.muro declares session back := dual ping. back sends a nat and then receives one. That is the client’s end of an RPC.

mix muro.emit examples/meet.muro --backend fsm

The module is MeetFsm. The child is MeetFsm.Back. One session is one end, so the module is named for the session, not for a client/server pair the book did not declare.

state = MeetFsm.Back.init()
# expected: {:send, :nat}
{:ok, state} = MeetFsm.Back.send(state, 1)
# reply = MyTransport.call(peer, 1)
{:ok, state} = MeetFsm.Back.recv(state, 0)
true = MeetFsm.Back.done?(state)

That walk prints done: true. Calling recv before the send is the error:

{:error,
 {:unexpected, "examples/meet.muro:6 session back: first send Nat",
  {:send, :nat}, 0}}

send/2 and recv/2 return {:ok, state} or {:error, {:unexpected, step, expected, value}}. value is the value the host passed, unchanged. A bad value, a value after end, and a call in the wrong direction are that error. They do not raise.

Muro.emit_fsm(path, module, opts \\ []) returns {:ok, source} or {:error, message}. It does not write a file. mix muro.gen writes the file when the manifest says kind: :fsm. The default module is Muro.Emit.fsm_module/1: the stem plus Fsm (meet.muro is MeetFsm).

The step

step is a binary. It names the book, the line of the session declaration, the session, and the action:

<file>:<line> session <name>: <ordinal> <action>

<file> is the book’s label: relative to the project root, / separators, never an absolute path. A session that comes from an imported file uses that file’s label. <ordinal> counts actions of the same kind in the written protocol, in source order: first, second, third, then 4th, 5th. Both arms of a choose, a branch, a case, and an on are counted. <action> is the wording of one protocol step (recv Nat, send (n : Nat), case (f n), end).

A step inside rec has the same description on every pass. The description names the source step. After end the step is "<file>:<line> session <name>: end" and expected is :done. Sending again on ping is:

{:error, {:unexpected, "examples/meet.muro:4 session ping: end", :done, 9}}

The same book emits the same text on every run, including from two working directories.

expected

expected/1 is plain data:

State Term
send a payload {:send, type}
receive a payload {:recv, type}
this end chooses {:send_pick, [:b0, :b1]}
the other end chooses {:recv_pick, [:b0, :b1]}
end :done

type is :nat, :bit, :unit, {:pair, a, b}, {:record, [{name, type}]} with the field name a string, {:vec, type, length} with the length already a number, or {:data, "Name", [type]}. That term is also the third element of the error. A choice is its own step, {:pick, :b0} or {:pick, :b1}, passed to send by the end that chooses and to recv by the end that branches. A state expects a payload or a label, not both. A bit index (on) is the bit :b0 or :b1, not a pick.

check_trace

check_trace/1 takes a list of {:send, value} and {:recv, value} in the order this end performed them. It returns :ok, or {:error, {index, reason}} for the first bad step. index is zero-based. reason is the same {:unexpected, step, expected, value}.

A prefix is a valid trace. A log may be cut short, and a rec protocol never reaches end. check_trace(trace, complete: true) also requires done? and otherwise returns {:error, {length, {:incomplete, step, expected}}}.

:ok = MeetFsm.Ping.check_trace([{:recv, 0}, {:send, 1}], complete: true)
{:error,
 {1,
  {:incomplete, "examples/meet.muro:4 session ping: first send Nat",
   {:send, :nat}}}} = MeetFsm.Ping.check_trace([{:recv, 0}], complete: true)

A session with parameters has init/n, one nat per parameter, and check_trace/2 with args: [..]. check_trace/1 is not defined for it. A missing args: raises ArgumentError. An argument that is not a nat raises ArgumentError from init/n.

Wire form

A value is accepted only when the whole value is of that step’s payload type, in the form the OTP image carries.

Payload On the wire
Nat a non-negative integer
Bit :b0 or :b1
Unit :tt
a pair a two-element tuple
a record a map whose keys are exactly the field names
Vec a list of the length the type says
a nullary constructor an atom (:bytes, and true / false for those constructors)
a constructor with fields a tagged tuple ({:just, 0})

A vector length is the numeral in the type, or the nat received earlier in this protocol. The machine keeps that binding. A rec loop rebinds it on each pass. sized after recv of 2 expects {:send, {:vec, :nat, 2}} and accepts [0, 0].

An evidence payload is the nat alone. The evidence is not on the wire. The machine also checks the bound that evidence stands for, a numeral or an earlier nat. window after receiving 8 expects {:send, {:nat_at_most, 8}}, and 9 is :unexpected. This is stricter than the OTP image, which checks only that the value is a nat. A binder erased in any other way is not on the wire.

A bit index selects the first arm on :b0 and the second on :b1. A case on the constructor just exchanged takes that constructor’s arm, in declaration order. case (f n) applies the emitted run f to the value just exchanged and takes the arm of the result. The run functions live in the parent module. They take the emitter’s data (a nat is 0 or {:suc, n}), so the call converts at that boundary only. A nat above 100_000 is :unexpected there, with {:nat_at_most, 100_000}, and no Peano term is built. expected/1 still describes the protocol step.

repr

When the book has a repr for a payload type (#131), that type’s host form is the repr. send and recv call the book’s decode_<type>/1. That call returns {:ok, value, table}. The machine checks value against the wire form above and drops the table. A value the codec rejects is :unexpected. The third element names the type and its repr, for example {:recv, {:repr, {:data, "Box", []}, "Box"}}. The codec’s own reason is not part of the error. A type with no repr stays on the wire form, and {:box, 3} is the value of box : Nat → Box.

State

State is opaque. Store it, thread it, and compare it with ==. Do not depend on its shape. It is plain data: atoms, integers, binaries, and lists. A value that is not a state of this session is :unexpected, with this session’s end step, and done? is false. Nothing raises.

What emit refuses

The refusal is the whole module. A book that does not check is refused by the checker, with the checker’s message. A book that checks, and that the machine cannot represent, is refused with <file>:<line> session <name>: <reason> (a missing session name has no line).

Reason When
<file> has no session to emit the book declares no session
seen is not a state machine the protocol is a talk
cannot evaluate bound <ix> an evidence bound is not a numeral, a name, or suc of one
cannot evaluate vector length <ix> a length is not one of those
cannot evaluate argument <ix> a jump argument is not one of those
cannot call run <f> a case (f n) run is not one nat, bit, unit, pair, or declared payload, or its result has no constructors
payload <type> is not supported a payload outside the table above
ill-formed protocol a protocol form the opener does not know
unguarded session call a session calls itself with no action in between
unguarded rec rec X. X
unknown session <name> dual of a session that is not declared
session declared later a call of a session declared further down the book
wrong number of session arguments the call’s arity is not the declaration’s
dual cycle two sessions that are each other’s dual with no body

examples/talk.muro and examples/seated.muro are refused because the protocol is seen. examples/meet.muro, examples/indexed.muro, and examples/recursive.muro emit completely, and so do the other session examples whose protocols are the forms above. --node with this backend is refused (--node writes the placement calls). --imports with this backend is refused.

Manifest

{"priv/muro/meet.muro", Host.MeetFsm, "lib/host/meet_fsm.ex", [kind: :fsm]}

impl: and imports: on a :fsm entry are errors, and the message names the entry index. They remain valid only for kind: :exunit. The header, the hashes, written / unchanged, --check, a stale book, and a hand edit are the same as for the other kinds.

The child of session ping is Host.MeetFsm.Ping. A session name whose function spelling is plain snake case (lowercase words of letters and digits joined by single underscores) camelizes: ping is Ping and foo_bar is FooBar. Any other name is M plus the function spelling (grant-end is Mmuro__grantzdend). Two sessions that camelize to the same name are Ping and Ping_2.

Source on GitHub · fetched from murolang/muro manual/