March Docs

Session

Session: the session-type transport capability (Cap(Session.Live)) that generated choreography/session code runs on, plus in_process, a deterministic in-process transport for tests. See the comment block below for the transport dictionary's contract.

Types

typeOpsOps = {#

The transport. suspend(ep, from, handler) installs handler for the next delivery on ep from a given role (0 = any) and yields; the handler receives (from, msg, ep) and returns the next suspension. It is tail-resumptive only: a handler resumes exactly once, in tail position, as a plain call through this record. (A doc is only accepted before a function; on a type it is a parse error, which the stdlib loader reports as "Unknown module".) fail(ep, why): the generated code could not deliver a message to ep's continuation -- it did not decode, or it is one the state cannot receive. The transport decides what that means: a hand-written test transport may just panic (the message is a bug in the program that sent it); in_process below cancels the receiving endpoint; a network one ends the session and reports it (SessionNode: RunError.Protocol). on_cancel(ep, h): install h as the cancel handler for the continuation suspend installs next on ep; returns ep. The transport calls h(role, cause, ep) if that continuation's sender can send nothing more (a transport where nothing fails may ignore it). leave(ep, why): this endpoint exits its session on purpose; the transport tells the peers. Both: specs/todos/2026-09-18-choreography-failure-handling.md. on_crash(ep, role, h): install h as the CRASH continuation for role with the continuation suspend installs next on ep; returns ep. The transport calls h(role, cause, ep) if that continuation waits on role, role can send nothing more and nothing from it is queued: the endpoint then goes on from the crash branch instead of being cancelled (a generated recv_<Msg> with a crash branch installs it; specs/2026-09-19-choreography-access-points-and-crash-branches-design.md Part B). A transport where nothing fails may ignore it. suspend_at_boundary(ep, from, handler): suspend, at a DRAIN POINT -- the head of a loop that is not loop atomic (D27, plan 6.2). A transport whose code epoch is draining may end the session there instead of resuming: the delivery goes back to its sender undelivered and the endpoint's drain handler runs. A transport that never drains treats it as suspend. on_drain(ep, h): install h as the drain handler for the continuation suspend/suspend_at_boundary installs next on ep; returns ep. The transport calls h(role, undelivered, ep) if the session drains while that continuation waits: role is the drained role it was waiting on (0: this endpoint's own boundary), undelivered the messages this endpoint sent that came back. No handler: the endpoint just finishes. Both: specs/progress/2026-09-24-dd-d27-session-drains.md.

typeOutcomeOutcome = Finished | Drained(Int)#

How a session that did not fail ended: every role reached the protocol's end (Finished), or it was ended at a loop boundary by a drain (Drained(n), D27), [n] being how many of this role's own messages came back undelivered. A clean end either way: what run_<Role> returns in Ok.

typeInProcessInProcess = {#
typeInProcessStateInProcessState = {#

Functions

fnattachattach(io : Cap(IO), ops : Ops) : Cap(Session.Live)#

Attach a transport. The only way to obtain a Cap(Session.Live); every other operation here dispatches through the ops given.

let t = Session.in_process()
let s = Session.attach(io, t.ops)
fncloseclose(c : Cap(Session.Live), ep : Int) : ()#

Close endpoint ep: it has finished its part of the protocol. Under in_process the role is gone from then on ("closed"), so a peer still waiting on it is cancelled or takes its crash branch.

fncrash_causecrash_cause(msg : Bytes) : Option(String)#

Some(cause) if msg is a forwarded crash rather than a protocol message, None otherwise. The generated resume of an actor-hosted role asks this before decoding.

Examples

march> Session.crash_cause(Session.crash_payload("node down"))
Some("node down")
march> Session.crash_cause(Session.crash_payload(""))
Some("")
march> Session.crash_cause(Bytes.from_string("{}"))
None
fncrash_payloadcrash_payload(cause : String) : Bytes#

The delivery payload that carries a crash of the sending role, with cause as the transport knows it. A transport that hosts a role in an actor sends this on the delivery route; nothing else produces it.

Examples

march> Bytes.to_string(Session.crash_payload("node down"))
"!session-crash!node down"
fnemitemit(c : Cap(Session.Live), ep : Int, to : Int, msg : Bytes) : Int#

Send msg from ep to role to; returns the continuation endpoint. The transport decides delivery: in_process only enqueues (its drain delivers), and drops messages from or to a role that is gone.

fnepoch_holds_hereepoch_holds_here() : Int#

How many epoch holds the running actor has (0 outside a hosted session). A read-only count, for tests of the hosting patterns.

Examples

march> Session.epoch_holds_here()
0
fnfailfail(c : Cap(Session.Live), ep : Int, why : String) : ()#

A message for ep that its continuation cannot take (undecodable, or one the session state cannot receive): hand the transport [why]. The generated endpoint code calls this; what happens next is the transport's (in_process cancels the endpoint, SessionNode ends the session with Protocol, a hand-written test transport may panic).

fnhold_epochhold_epoch(c : Cap(Session.Live)) : ()#

Take an epoch hold on the running actor while it hosts a started endpoint of the session [c] belongs to, so it stays on the code version the session formed under until the endpoint closes (hot reload's nesting rule, D28). The transport takes it for a hosted endpoint at register; generated code and transports are its callers. Paired with release_epoch. It takes the session capability so that only code holding a session can pin an actor's epoch: calling it anywhere else only delays a deploy (up to its hard drain deadline).

fnin_processin_process() : InProcess#

An in-process transport, for running every role of one session in one program: attach its ops, drive each role to its first suspension, then drain to deliver.

let t = Session.in_process()
let s = Session.attach(io, t.ops)
let _ = cons(s, Stream_Cons.register(s, 0), 2)
let _ = prod(s, Stream_Prod.register(s, 0), 1)
t.drain(())

emit only enqueues; drain delivers in FIFO order until nothing more can run, so the run is deterministic. A delivery its endpoint is not yet waiting for stays queued (queued(()) counts what is left). Failure behaves as on the network (SessionNode): a role that closed, left or crashed is gone, messages to it are dropped, and an endpoint waiting on it takes its crash branch or is cancelled. crash(role, cause) makes a role gone without running it -- a node dying -- and fail cancels the endpoint that got the bad message. One transport carries one session; each role registers once.

An endpoint hosted in an actor (the generated event API) resumes itself from the actor's own turn, so its host routes deliveries instead of draining: take(()) returns the next delivery an endpoint is waiting for, as Some((to, from, msg)), with that endpoint's continuation forgotten, or None when nothing queued can be taken. take runs no failure handling; drain does.

close(()) retires the transport once its session is over; its tables stay registered for the life of the process otherwise.

fnin_process_within_process_with(trace : String -> ()) : InProcess#

in_process(), reporting what the transport itself does to [trace], one line per event: close <ep>, cancel <ep>: <cause>, crash <role>: <cause>, crash branch <ep>: role <role>: <cause>, dropped <from> -> <to>, and stalled: <n> queued when a drain stops with deliveries no endpoint is waiting for. Drains (D27) add draining <role>, undelivered <sender> -> <role>, and drained <ep> at its boundary or drained <ep> waiting on role <role>. A test asserts on the failure paths through it.

let lines = Vault.new("trace")
let t = Session.in_process_with(fn line -> Vault.set(lines, line, 1))
fnleaveleave(c : Cap(Session.Live), ep : Int, why : String) : ()#

Leave the session from endpoint ep on purpose, with a reason the peers are told.

fnon_cancelon_cancel(c : Cap(Session.Live), ep : Int, handler : Int -> String -> Int -> Int) : Int#

Install handler as the cancel handler for the continuation that the next suspend on ep installs, and return ep. It runs as handler(role, cause, ep) if the role that continuation waits on can send nothing more and nothing from it is queued. The generated recv_…_or and offer_…_or call this.

fnon_crashon_crash(c : Cap(Session.Live), ep : Int, role : Int, handler : Int -> String -> Int -> Int) : Int#

Install handler as the crash continuation for role with the continuation that the next suspend on ep installs, and return ep. It runs as handler(role, cause, ep) if that continuation waits on role and role is gone with nothing from it queued; the endpoint stays live and goes on from the protocol's crash branch. The generated recv_… of a message step with or crash do … end calls this.

fnon_drainon_drain(c : Cap(Session.Live), ep : Int, handler : Int -> List(Bytes) -> Int -> Int) : Int#

Install handler as the drain handler for the continuation that the next suspend (or suspend_at_boundary) on ep installs, and return ep. It runs as handler(role, undelivered, ep) if the session drains while that continuation waits: role is the drained role it waited on, or 0 when this endpoint's own loop boundary drained, and undelivered the messages this endpoint sent that came back. The generated recv_…_or_drain and offer_…_or_drain call this.

fnregisterregister(c : Cap(Session.Live), ap : Int, role : Int) : Int#

Join a session at access point ap in role role; returns the endpoint. What the handle means is the transport's: in_process returns the role number itself and panics if that role registers twice. Like every dispatcher here, panics if c carries no transport (which attach rules out).

fnrelease_epochrelease_epoch(c : Cap(Session.Live)) : ()#

Release the hold hold_epoch took for a session [c] belongs to.

fnsuspendsuspend(c : Cap(Session.Live), ep : Int, from : Int, handler : Int -> Bytes -> Int -> Int) : Int#

Install handler for the next delivery on ep and yield. from is the role the continuation expects to hear from -- the projection knows it at every receive -- or 0 for "any", which a same-thread transport and a two-party session pass. A transport that carries several peers uses it to park a delivery that arrives before the continuation that wants it (messages from different peers race; see SessionNode).

fnsuspend_at_boundarysuspend_at_boundary(c : Cap(Session.Live), ep : Int, from : Int, handler : Int -> Bytes -> Int -> Int) : Int#

suspend at a drain point: the receive at the head of a loop that is not loop atomic (D27). The generated receive of a loop-head state calls this instead of suspend; a transport whose code epoch is draining may then end the session here (the delivery returns to its sender undelivered and the endpoint's drain handler runs). A transport that never drains treats it as suspend.