Cure v0.25.0 :: Typed Supervision Trees
by Aleksei Matiushkin
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:
- The Melquiades Operator
<-|(unicode alias✉) -- a typed send expression. - An
actorcontainer that compiles to a loadedGenServermodule. - A
supcontainer that compiles to a verifiedSupervisorbehaviour module. - Stdlib modules
Std.Actor,Std.Process,Std.Supervisorthat 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. BarePidelaborates to{:pid, :any}, the top of the covariant family, so everything accepted by any inbox remains accepted byPid.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_stopaccept one or more clauses; the clause syntax mirrorson_transitionin FSMs.- The first argument of an
on_messageclause 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:callervia 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> {:ok, pid} = :"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 idflips the lookup to the supervisor namespace.
The supervisor compiler runs Cure.Sup.Verifier before emitting anything. The verifier enforces:
strategyis one of:one_for_one,:one_for_all,:rest_for_one,:simple_one_for_one.intensityis non-negative;periodis positive.- Every child id is unique within the supervisor.
- Each optional
restartis:permanent | :transient | :temporary. - Each optional
shutdownis: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:
{:ok, _pid} = 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/1monitor/1-> returns aRefdemonitor/1trap_exit/1(returns the previous value)exit/2self/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 = [
{Registry, keys: :duplicate, name: Cure.Pipeline.Events.Registry},
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 barePidin 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_messagemisses 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.