← All posts

Cure v0.25.0 :: Typed Supervision Trees

by Aleksei Matiushkin

release actors supervision otp melquiades genserver

v0.24.0 rewrote the read-eval-print loop. The compiler was untouched; every change landed on the interactive side. That left the language itself in a place where most of the declarative story was already written -- types, refinements, pattern matching, records, protocols, finite state machines -- but the imperative story was thin. Cure programs that needed long-lived processes had two options: a callback-mode FSM (excellent for transition-heavy code, overbuilt for a plain worker) or an @extern-wrapped Elixir GenServer (fine, but outside the type system).

v0.25.0 closes that gap.

The shape of the release

Four pieces land together:

  1. The Melquiades Operator <-| (unicode alias ) -- a typed send expression.
  2. An actor container that compiles to a loaded GenServer module.
  3. A sup container that compiles to a verified Supervisor behaviour module.
  4. Stdlib modules Std.Actor, Std.Process, Std.Supervisor that expose the runtime from Cure source.

None of the four is useful in isolation. Together they turn Cure into a first-class environment for writing OTP-style supervision trees without ever dropping into Elixir or Erlang.

The Melquiades Operator

pid <-| message sends message to pid, returns the message, and (matching Erlang's !) never raises for a dead receiver. The ASCII spelling and its unicode alias are interchangeable:

pid <-| :hello
pid   :hello

Both forms lower to the bang operator in Erlang's abstract form ({:op, Line, :!, PidForm, MsgForm}), so the runtime cost is exactly the same as erlang:send/2. The lexer preserves the author's choice through a :melquiades_form meta key (:ascii, :unicode, or :keyword), so the printer round-trips it faithfully.

The operator is non-associative and binds one notch below |>:

request
|> encode()
|> worker_pid <-| _

The last line is worker_pid <-| encode(request) -- pipelines feed into sends naturally.

Why "Melquiades"?

Named for the ghost-mailman of One Hundred Years of Solitude, who keeps delivering letters even after his own death. The arrow points into the inbox on the left: pid <-| message reads "the pid gets this message".

The keyword form still works

send target, msg is preserved as a synonymous statement form so existing Std.Fsm clients and FSM lifecycle hooks keep compiling. It desugars to the same {:send, …} MetaAST node, so there is a single codegen path for both.

Typed primitives

The type system adds two primitives:

  • Pid(Inbox) -- a typed pid. Bare Pid elaborates to {:pid, :any}, the top of the covariant family, so everything accepted by any inbox remains accepted by Pid.
  • Ref -- a monitor reference.

The checker has a dedicated clause for {:send, …} that unifies the message type against the receiver's declared inbox and emits E046 Inbox Mismatch on conflict. Effect inference attracts the :state effect for every send.

Actors

An actor container declares a typed process:

actor Counter with 0
  on_start
    (state) -> state
  on_message
    (:inc, n)   -> n + 1
    (:dec, n)   -> n - 1
    (:get, n) ->
      notify(%[:value, n])
      n
  on_stop
    (reason, _state) -> notify(%[:stopped, reason])
  • with <expr> seeds the actor's initial payload.
  • on_start, on_message, on_stop accept one or more clauses; the clause syntax mirrors on_transition in FSMs.
  • The first argument of an on_message clause is the incoming message; the second is the current payload.
  • notify(message) inside any clause body sends to the spawning caller. The helper resolves the actor's registered :caller via the process dictionary, so it needs no arguments.
  • The return value of a clause becomes the new payload. Returning a full %Cure.Actor.State{} struct replaces the entire runtime state instead.

Each container compiles to a loaded GenServer module, named Cure.Actor.<Name>:

iex> &lbrace;:ok, pid&rbrace; = :"Cure.Actor.Counter".start_link(0)
iex> send(pid, :inc)
iex> :"Cure.Actor.Counter".get_state(pid)
1

From Cure, the runtime is reached through Std.Actor:

let pid = Std.Actor.spawn(:"Cure.Actor.Counter")
pid <-| :inc
pid <-| :inc
let current = Std.Actor.get_state(pid)      # => 2
let _       = Std.Actor.stop(pid)

Cure.Actor.Runtime is started automatically by Cure.Application alongside Cure.FSM.Runtime. It tracks spawned actors in an ETS registry, monitors every pid, and cleans up on DOWN. list_actors/0 returns every live instance for introspection.

Supervisors

A sup container declares a supervisor module:

sup App.Root
  strategy  = :one_for_one
  intensity = 3
  period    = 5
  children
    Counter      as counter
    Counter      as counter_b (restart: :transient)
    App.External as external  (restart: :permanent, shutdown: 10000)
    sup Workers  as workers

Settings default to :one_for_one, 3, and 5. The children block introduces one child spec per line. Child module resolution uses two conventions and one escape hatch:

  • A dotted path is used verbatim (App.External -> :"App.External").
  • A bare name resolves to :"Cure.Actor.<Name>" for workers and :"Cure.Sup.<Name>" for supervisors.
  • The soft keyword sup <Name> as id flips the lookup to the supervisor namespace.

The supervisor compiler runs Cure.Sup.Verifier before emitting anything. The verifier enforces:

  • strategy is one of :one_for_one, :one_for_all, :rest_for_one, :simple_one_for_one.
  • intensity is non-negative; period is positive.
  • Every child id is unique within the supervisor.
  • Each optional restart is :permanent | :transient | :temporary.
  • Each optional shutdown is :brutal_kill | :infinity | pos_integer.
  • The supervisor does not list itself as a direct child.

On success, the compiler emits an Elixir module via Code.compile_string/2 and returns {:ok, {:supervisor, module()}}. Cure.Sup.Runtime wraps it in a lazy ETS registry so a tree can be reached by module atom:

&lbrace;:ok, _pid&rbrace; = Cure.Sup.Runtime.start(:"Cure.Sup.App.Root")
Cure.Sup.Runtime.which_children(:"Cure.Sup.App.Root")
:ok = Cure.Sup.Runtime.stop(:"Cure.Sup.App.Root")

From Cure:

let tree = :"Cure.Sup.App.Root"
let _pid = Std.Supervisor.start(tree)
let kids = Std.Supervisor.which_children(tree)
let _    = Std.Supervisor.stop(tree)

sup is a soft keyword

Existing Cure programs happily used sup as an ordinary identifier -- the superdiagonal row in a tridiagonal system in examples/cure_spline/cure_src/spline.cure, for example. Reserving sup at the lexer level would have broken every such program. Instead, the lexer keeps sup as an identifier, and the parser dispatches sup <Name> to parse_supervisor/1 only at statement-prefix position when the next token is an identifier. In every other context -- field names, variables, pattern puns, function arguments -- sup is a plain identifier.

Links, monitors, trap_exit

Std.Process exposes the raw BEAM primitives directly via @extern:

  • link/1, unlink/1
  • monitor/1 -> returns a Ref
  • demonitor/1
  • trap_exit/1 (returns the previous value)
  • exit/2
  • self/0, is_alive/1

monitor and trap_exit go through small wrappers in Cure.Process.Builtins so the Cure signatures can stay idiomatic ((Pid) -> Ref rather than the two-argument erlang BIFs). Everything else is a direct :erlang BIF call.

Runtime boot

The application supervisor now starts Cure.Actor.Runtime alongside Cure.FSM.Runtime:

children = [
  &lbrace;Registry, keys: :duplicate, name: Cure.Pipeline.Events.Registry&rbrace;,
  Cure.FSM.Runtime,
  Cure.Actor.Runtime
]

Cure.Sup.Runtime uses lazy ETS-table creation on first call, so there is nothing to wire during boot when no supervisor trees are in use.

Error catalog additions

Six new codes for the supervision surface. cure explain <code> prints the full text with examples:

  • E045 Untyped Send -- <-| on a bare Pid in strict mode (warning).
  • E046 Inbox Mismatch -- message not a subtype of the receiver's inbox ADT.
  • E047 Supervisor Unknown Child -- child resolves to no compiled module.
  • E048 Supervisor Cycle -- supervisor references itself transitively.
  • E049 Actor Handler Non-Exhaustive -- on_message misses an inbox variant.
  • E050 Invalid Supervisor Strategy -- unknown strategy, restart, or shutdown value.

examples/cure_colony/

A minimal supervision tree that ships with the release:

actor Worker with 0
  on_start
    (state) -> state
  on_message
    (:inc, n)   -> n + 1
    (:reset, _n) -> 0
    (:get, n) ->
      notify(%[:value, n])
      n

actor Echo with nil
  on_message
    (msg, _payload) ->
      notify(%[:echo, msg])
      msg

sup Colony
  strategy  = :one_for_one
  intensity = 3
  period    = 5
  children
    Worker as worker_a
    Worker as worker_b (restart: :transient)
    Echo   as echo     (restart: :permanent, shutdown: 2000)

The README walks through compiling the file, starting the tree, sending messages to workers, and reading back state.

Design choices worth calling out

Actors reuse FSM callback-mode machinery. Both compile to a GenServer whose state is a small struct with caller, meta, and payload fields. The clause-parsing code in the parser is shared with on_transition. The Cure-AST-to-Elixir translator in Cure.FSM.Compiler was promoted to a public function so Cure.Actor.Compiler can reuse it without duplication.

Supervisors compile to real Supervisor modules. No custom registry, no home-grown restart logic. Supervisor.init/2 does the work; the Cure compiler only writes the child spec list and the strategy. That keeps the generated code auditable and means OTP's hot-reload semantics just work.

Everything is loaded eagerly. Like callback-mode FSMs, actor and supervisor containers are compiled and :code.load_binary'd at the end of their respective codegen passes. The compiler orchestrator returns a {:actor, module} or {:supervisor, module} marker instead of Erlang abstract forms, so the module is already live in the VM by the time cure compile returns.

The type checker already knows about Pid(Inbox). The Phase 2 work that landed earlier in the v0.25.0 cycle introduced {:pid, inbox} covariance and the E046 Inbox Mismatch diagnostic. The parser emits {:send, …} nodes with full metadata so the checker's dedicated clause unifies message types against declared inboxes. Actors and supervisors plug directly into that machinery.

Getting started

git clone https://github.com/am-kantox/cure-lang.git
cd cure
mix deps.get && mix test            # 1310 tests pass (3 doctests + 1307 tests)
mix escript.build
./cure version                      # Cure 0.25.0

Then jump into examples/cure_colony/ or read the new Actors page for the tour, docs/SUPERVISION.md for the on-disk reference, and the CHANGELOG for the full changeset.

What's next (v0.26.0 and beyond)

Two pieces are scheduled next:

Typed-inbox inference for actor containers. v0.25.0 lays the Pid(Inbox) plumbing but leaves the inbox ADT declaration to the user. v0.26.0 will infer the inbox from the on_message clause patterns so most actors get exhaustiveness checking for free.

Actor verifier exhaustiveness pass. E049 Actor Handler Non-Exhaustive is in the catalog but not yet wired into a pipeline pass. The v0.26.0 work plugs Cure.Types.PatternChecker into Cure.Actor.Verifier so missing inbox variants emit compile-time warnings at the same level as non-exhaustive match expressions.

The long-term radar from v0.24.0 is unchanged: monomorphisation, profile-guided optimisation, broader IDE reach, REPL-level hot reload. Each remains on the roadmap without a fixed slot.

The repository is at github.com/am-kantox/cure-lang. v0.24.0 gave Cure the loop you actually use; v0.25.0 gives it the processes you actually supervise.