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.

The 16 lines of the Order protocol, as shown in the code block above.

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.

A grid of 60 cells, one for every combination of the three roles' positions; the 14 the protocol can reach are filled.

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.

The 16 lines of the Order protocol above the five modules the compiler generates from them, with a square for each state type and each function.

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() }
The Ledger actor: a queue of messages on the left, and on the right the actor with its running total and a square for each session parked in it.

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.

Four machines: lab-1 starting sessions, and three hosts with a square per session slot. The host that held the Ledger is marked dead and the Ledger is on another host.

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 each Order_* module.
  • Fingerprint and message tags: march --check --emit-protocols DIR examples/lab_app/src/lab_app.march, which writes DIR/Order.json.
  • Compiler messages: march --check on each program in docs/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.toml and topology.lab.toml.
  • Simulations: seeded (20261105), at 14 sessions a second. Their rules are tested in test/explainer/; they are not recordings.