From a Protocol to a Cluster
Getting programs on different machines to cooperate is mostly a matter of agreement. Each side has to know whose turn it is, what the next message means, and what to do when the other side goes quiet. That agreement usually lives in a document, a set of integration tests and the heads of the people who wrote both ends. It holds until one end changes, or one machine dies halfway through a conversation.
March puts the agreement in the program. You write the conversation once, the compiler checks every participant against it, and the runtime uses the same description to connect the machines and to end a conversation cleanly when one of them fails. This page follows one small system from that description to a running cluster.
Three programs on four machines take orders, check stock and keep a ledger. This is everything that says how they talk to each other:
@[endpoints]
protocol Order do
role Stock needs IO.Mut
role Ledger needs IO.Console
loop do
want: Shop -> Stock : Int
choose by Stock:
have -> Stock -> Shop : String
book: Stock -> Ledger : Int
booked: Ledger -> Shop : String
out -> Stock -> Shop : String
skip: Stock -> Ledger : Int
stop
end
end
end
It is 16 lines, and it is the only place the conversation is written down. It is dense, so here it is again a few lines at a time.
protocol Order names a conversation and describes it from above, once,
for everyone in it.
@[endpoints] asks the compiler to generate the code each participant
runs.
There are three roles: Shop, Stock and Ledger. A role exists because a step mentions it.
These two lines say what a role's code may do besides talk. Stock may use shared mutable state, and Ledger may print. Shop is granted nothing.
The conversation repeats. One session carries order after order until a branch says
stop.
The top of the loop is also where a running session can be ended cleanly when new code is deployed.
A step: Shop sends Stock an Int, the item it wants.
The label want names the message on the wire and the functions generated
for it, send_Want and recv_Want.
A decision, and whose it is. Only Stock may choose. The other two have to be ready for either answer.
If Stock has the item, it tells Shop so and tells Ledger to book it, and Ledger confirms to Shop. Then the loop goes round.
A branch must open with a message from the chooser. That message is how the others learn which way the choice went.
If Stock is out, it tells Shop, tells Ledger there is nothing to book, and
stop ends the conversation for all three.
Ledger is told either way, so it never waits for a booking that is not coming.
That is the whole agreement. There are no sockets in it, no retries, no message formats and no state machines, and nothing about which machine runs which role.
Those come from the compiler, the runtime and a separate topology file.
The compiler reads those lines and produces a typed program for each of the three roles. A role that speaks out of turn, skips a step or stops halfway does not compile. The programs it accepts run as a cluster that loses machines and carries on.
This page follows those 16 lines the whole way: what they rule
out, what the compiler makes of them, where the state lives, and what happens when a
machine dies. The app is real. It is
examples/lab_app,
the one the multi-host lab deploys. A further section, on
deploying new code to the cluster while it runs, is not written yet;
Hot Code Reload covers that ground meanwhile.
What the declaration rules out
Each role has its own view of the conversation: the steps it takes, in order. Shop can be at any of 4 positions in its view, Stock at 5 and Ledger at 3. Three programs written by hand could be at any combination of those at the same moment.
Here is every combination: 60 cells, one for each way the three positions can line up. Blocks are Shop's position, columns are Stock's, rows are Ledger's.
Nothing stops ordinary networked programs from reaching any of them, including the ones where one side waits for a message the other will never send, or both sides wait.
The protocol reaches 14 of them. They light in the order the protocol can first reach them, counting a role as moved once it has sent, even if its peer has not received yet.
The other 46 cannot happen. A program that would get there does not compile.
Stock has the order and must now choose have or out. This
version skips the choice and tells Ledger to book.
-- Stock tells Ledger to book before it has chosen `have` or `out`.
pfn stock(s : Cap(Session.Live), _m : Cap(IO.Mut), st : Order_Stock.Entry) : Order_Stock.Yield do
Order_Stock.recv_Want(s, st, fn (n, st1) ->
stock(s, _m, Order_Stock.send_Book(s, st1, n)))
end
Role `Stock` tries to send an `Int` to `Ledger` here, but at this point protocol `Order` says it must choose `have` or `out`. [protocol_step_order]
What the compiler prints
-- PROTOCOL ----------------------------- skip_step.march
Role `Stock` tries to send an `Int` to `Ledger` here, but at this point protocol `Order` says it must choose `have` or `out`. [protocol_step_order]
25 | stock(s, _m, Order_Stock.send_Book(s, st1, n)))
^^^
Take that step with `Order_Stock.choose_have` first.
The conversation so far, in protocol `Order`:
loop do (repeats)
want: Shop -> Stock : Int done
choose by Stock: have | out <- `Stock` must do this next
`Stock` is here: it must choose `have` or `out`:
11 | choose by Stock:
^^^^^
`st1` is the state bound here:
24 | Order_Stock.recv_Want(s, st, fn (n, st1) ->
^^^
run `march --explain protocol_step_order`
Stock may answer out. This Shop handles have and nothing else.
-- Shop handles the `have` answer and has nothing for `out`.
pfn shop(s : Cap(Session.Live), st : Order_Shop.Entry) : Order_Shop.Yield do
let st1 = Order_Shop.send_Want(s, st, 1)
Order_Shop.offer_have_out(s, st1,
fn (_v, st2) -> Order_Shop.recv_Booked(s, st2, fn (_b, st3) -> shop(s, st3)))
end
`Order_Shop.offer_have_out` takes one callback for each branch `Stock` can choose in protocol `Order` (`have` and `out`), but this call leaves out `out`. [session_offer_nonexhaustive]
What the compiler prints
-- PROTOCOL ------------------- offer_misses_branch.march
`Order_Shop.offer_have_out` takes one callback for each branch `Stock` can choose in protocol `Order` (`have` and `out`), but this call leaves out `out`. [session_offer_nonexhaustive]
25 | Order_Shop.offer_have_out(s, st1,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
After `out` the conversation ends, so the fix closes it; put any other work for that branch before the `close`.
the choice is declared here:
11 | choose by Stock:
^^^^^
fix (`forge fix` applies it):
26 - fn (_v, st2) -> Order_Shop.recv_Booked(s, st2, fn (_b, st3) -> shop(s, st3)))
26 + fn (_v, st2) -> Order_Shop.recv_Booked(s, st2, fn (_b, st3) -> shop(s, st3)), fn (_v, st) -> Order_Shop.close(s, st))
run `march --explain session_offer_nonexhaustive`
Ledger confirms a booking before anyone has asked for one.
-- Ledger confirms a booking nobody has asked it to make.
pfn ledger(s : Cap(Session.Live), c : Cap(IO.Console), st : Order_Ledger.Entry) : Order_Ledger.Yield do
ledger(s, c, Order_Ledger.send_Booked(s, st, "booked"))
end
Role `Ledger` tries to send a `String` to `Shop` here, but at this point protocol `Order` says it must wait for `Stock` to choose `have` or `out`. [protocol_step_order]
What the compiler prints
-- PROTOCOL ---------------------- send_out_of_turn.march
Role `Ledger` tries to send a `String` to `Shop` here, but at this point protocol `Order` says it must wait for `Stock` to choose `have` or `out`. [protocol_step_order]
24 | ledger(s, c, Order_Ledger.send_Booked(s, st, "booked"))
^^
Take that step with `Order_Ledger.offer_have_out` first.
The conversation so far, in protocol `Order`:
loop do (repeats)
want: Shop -> Stock : Int
choose by Stock: have | out <- `Ledger` must do this next
`Ledger` is here: it must wait for `Stock` to choose `have` or `out`:
11 | choose by Stock:
^^^^^
`st` is the state bound here:
23 | pfn ledger(s : Cap(Session.Live), c : Cap(IO.Console), st : Order_Ledger.Entry) : Order_Ledger.Yield do
^^
run `march --explain protocol_step_order`
Each message is in the protocol's own terms: the role, what it tried, and what it must do at that point. The marked cell is where the program stood.
None of these needed a test, a second machine or a timeout to find. The next section is how the compiler does it.
What the compiler makes of it
March has no separate checker for conversations. The compiler turns the declaration into ordinary types and functions, then lets the checks it already has do the work. These are its steps, in order.
Parse. The protocol block is a declaration like a function
or a type. @[endpoints] asks for typed code to be generated from it.
Each bar is one of its 16 lines, at its real length.
Project. Each role gets its own view: only the steps it takes. Shop's view has 4 steps, Stock's 5 and Ledger's 3.
The views are checked against each other before any role is written: every message
one role sends must be one its peer is waiting to receive
(lib/typecheck/typecheck_session.ml).
Generate. While desugaring, the compiler writes
5 modules of ordinary March beside yours: one for the messages
and their wire encoding, one for each role, and one that runs a role on a node
(lib/desugar/desugar_endpoints.ml).
Every step of a role's view becomes its own linear type: a value that must be used exactly once. There are 12 of them, and 3 more for a session parked inside an actor.
Every move is a function that consumes one state and returns the next. In all, 152 functions come out of the 16 lines.
Typecheck, as usual. Stock holds a value of type
S_choose_have_out. The only steps forward from it are
choose_have and choose_out. send_Book wants an
S_send_Book, and only choose_have returns one.
So order is enforced by an ordinary type mismatch, and exactly-once use by linearity.
A table kept beside the types (lib/ast/protocol_states.ml) lets the error
name roles and steps instead of generated types.
The line role Stock needs IO.Mut becomes a parameter. Stock's body is
handed a capability for exactly that, and the compiler checks that everything the body
reaches fits under it.
fn stock(env : Env, s : Cap(Session.Live), m : Cap(IO.Mut), st : Order_Stock.Entry) : Order_Stock.Yield
A role with no needs line is handed its session and nothing else.
Generate main. The lab app has none. A topology file says
which pool of machines runs which role, and from it the compiler writes the entry point:
start the node, narrow each pool's capabilities, open an offer for each role
(lib/desugar/desugar_topology.ml).
Compile. From here it is the normal back half: typed IR, reference counting, LLVM, a native binary. Pools that share a build run the same binary, and each machine is told which pool it is.
The build also records the protocol's fingerprint,
fbc92492…, a digest of its roles, steps and
payload types. Two nodes compare fingerprints before they exchange a message.
The 16 lines are now a typed program for each role, on 4 machines. The next two sections are what runs there.
Inside one node: the actor
A node is one process. Inside it, the role that keeps state is an actor. This is the head of the lab app’s Ledger:
-- Order.Ledger, bound to an actor: one per offer, spawned with the pool's
-- environment; it keeps the running total across sessions, so a Ledger
-- that moves to another node starts again from 0.
actor LedgerActor do
state { total : Int, node : String, sessions : LinearMap(String, Order_Ledger.Parked_Ledger) }
init(env : Env) { total: 0, node: env.node, sessions: LinearMap.empty_string() }
An actor is a mailbox, a piece of state and a set of handlers. Messages for the Ledger queue in the order they arrive.
It handles one message at a time, to the end, and the handler returns the next
state. Nothing else can touch total, so there is no lock to take and no
race to reason about.
Other actors on the node run in parallel with it.
Every open session's state lives inside this one actor, parked in a map keyed by session. A delivery takes its session out, takes the next step of the protocol, and parks it again.
The parked state is a linear type, the same kind as in the last section. The actor cannot drop a session or use one twice by accident.
If a handler hits a bug, the actor crashes. It does not try to repair itself.
Its mailbox and its state are gone, and the sessions parked in it are cancelled. Messages sent to it meanwhile are dropped.
The node's generated main watches the actors that host roles. When one
dies it opens the offer again with a fresh actor, and the next session finds it. No
other node is involved.
The fresh actor starts from init, so the total is back at 0. The same
happens when the Ledger moves to another machine; the app's own comment says so.
State that must outlive its actor has to be stored outside it. March does not do that for you.
More on both halves of this: Actors and Supervision Trees.
The cluster: nodes down and up
lab-1 starts a Shop session for each order. lab-2, lab-3, lab-4 each offer the Stock role, with room for 32 sessions. One of lab-3 and lab-4 holds the single Ledger, which has room for 64 sessions. A session forms when the caller has found an offer for every role.
What follows is a simulation. It obeys the rules March documents, at 14 sessions a second so there is something to see; the lab itself starts one every second or two.
A dot is a session being set up or finishing. A filled square is a session in progress: one square on the host playing Stock for it, and one on the Ledger.
The caller tries the hosts in turn, so the work spreads without a load balancer.
An offer has a capacity. Past it, the host answers "full" and the caller tries the next offer.
Here the load is raised until the Ledger's 64 slots run
out. There is one Ledger, so there is no next offer, and the caller gets
NoOffer with what each offer said. It does not wait for a timeout.
lab-2 crashes. Its connections close, so the sessions it was serving are cancelled at once.
They are not resumed. A cancelled session is discarded and the caller starts another, which the two remaining hosts take. A reply the dead host had already sent is still delivered.
A machine that hangs is harder, because nothing closes. The cluster node is the failure detector. It probes each peer every 1 second and waits 500 ms for the answer. A peer that misses one is suspected, and has 3 seconds to show it is alive.
Meanwhile lab-2's sessions are frozen and a caller that tries it moves on to the next host. When the time is up it is declared dead and its sessions are cancelled, with that as the cause: at most about 4.5 seconds after it hung.
The host holding the Ledger stops. The topology asks for one Ledger on a host
labelled books, so the other such host opens the offer once membership has
given up on the first. Sessions are refused in between.
The Ledger keeps a running total in memory, and the total does not move with the role. The new Ledger starts from 0.
The stopped host comes back as a new incarnation, so nothing can reach it through an old session. It offers Stock again, and no other node had to coordinate that.
It does not take the Ledger back at once. A node that rejoins counts for placement only after a settle period of 15 seconds, so a host that keeps dropping out cannot pull a role back and forth. After that, if it still ranks first, the role returns to it, and the total starts from 0 a second time.
Both books hosts are down. Nothing can play Ledger, so every caller gets
NoOffer naming that role.
Stock is still on offer, but a session needs every role before it starts. None runs half-formed.
These behaviours are tested on every pull request by scenarios under test/two_node/,
which run the same cluster runtime with other protocols: cluster_ap_retry (a full offer
is passed over, and NoOffer when nothing is left), cluster_crash (a killed node is
declared dead), cluster_session_silent (a hung node is declared dead and its session
cancelled), topology_move (a single-holder role moves when its holder dies),
topology_rejoin (the returning node waits out the settle period, then takes the role
back) and cluster_ap_restart (a restarted node offers again and the next session finds
it).
The lab scenario that runs all of this on the app above is run by hand. Its one recorded
run (2026-10-05) did not pass: the Ledger moved in 1
second with no session failed, but the returning host took the role back at once and
sessions stopped. Those bugs have since been fixed and are guarded by topology_rejoin.
The lab has not been run again.
What March does not do
Everything above comes with limits. These are the ones a reader is most likely to assume away.
- A failed session is not resumed. It is cancelled and discarded, and the caller starts another. Nothing replays it.
- An actor’s state dies with the actor. The Ledger’s total starts again from 0 after a crash or a move. Durable state is yours to store.
- Roles run over the network only when compiled. The interpreter checks the same types and runs the in-process test transport, but not the sockets.
- Every pair of roles that talk needs a direct connection. There is no relaying through a third node.
The reference chapters go further on each part: Choreography, Session Types, Clustering, Topology and Hot Code Reload.
How the numbers on this page were measured
Every number, excerpt and compiler message above is generated, not typed. One script
regenerates them all, and this page was last generated at commit
128b0af38 on 2026-11-06:
scripts/gen-explainer-data.py
- Modules, state types and functions:
march --emit-core-ast examples/lab_app/src/lab_app.march, counting the declarations in eachOrder_*module. - Fingerprint and message tags:
march --check --emit-protocols DIR examples/lab_app/src/lab_app.march, which writesDIR/Order.json. - Compiler messages:
march --checkon each program indocs/assets/explainer/programs/. - The 60 combinations and the 14 reachable
ones: computed by the script from the step tree in
Order.json. - Probe, ack and suspect times: the cluster node's defaults, from the
Clustering chapter. Settle period: from the Topology chapter.
Capacities and hosts:
examples/lab_app/topology.tomlandtopology.lab.toml. - Simulations: seeded (20261105), at 14
sessions a second. Their rules are tested in
test/explainer/; they are not recordings.