std/supervisor: A Supervisor Is a While Loop
Erlang’s answer to failure is a process that watches other processes. When one
dies, the supervisor restarts it — a runtime service with a name, a mailbox,
and a place in the process tree. .NET’s Polly brought the same idea in-process:
a RetryPolicy wraps your call at runtime and decides what to do when it
throws. Both are things that act.
Koru now has the third shape: supervision as a compile-time fact. A branch arm declares that its producing call is supervised, and the declaration is elaborated — before the program ever runs — into an ordinary bounded loop:
connect = dial(port)
| ok => ok
| refused |> std/supervisor:supervised
| retry t when t < 5 |> dial(port: port + 1) There is no supervisor object. There is no registry of children. There is no
runtime moment where supervision could fail to happen — supervised does not
request an action, it states a fact about the call, and the compiler emits the
loop that makes the fact true. The policy is not a configuration record —
the arms nested under supervised are the policy, written in the same
grammar as the outcome handling they guard.
The proof, running
Here is the whole program. dial connects if the port is open and refuses
otherwise — and that is all it says. Both branches are void: | refused declares a fact, and | ok would only echo the port the caller handed in —
the caller already knows it, so ok port would report nothing:
pub tor dial { port: i64 }
| ok
| refused
// ports below 8083 refuse; 8083+ are open
dial = std/io:print.ln("dial {{ port:d }}...")
|> if (port < 8083)
| then => refused
| else => ok
pub tor connect { port: i64 }
| ok
| refused
connect = dial(port)
| ok => ok
| refused |> std/supervisor:supervised
| retry t when t < 5 |> dial(port: port + 1)
connect(port: 8080)
| ok |> std/io:print.ln("connected")
| refused |> std/io:print.ln("no service in range") Output:
dial 8080...
dial 8081...
dial 8082...
dial 8083...
connected Read the arms again — nothing is threaded, and that is the point. The
supervised arm binds nothing — a void refused has no payload to bind
(Koru rejects | refused f outright; the binding rule is a pinned
diagnostic, not a convention). | retry t when t < 5 binds t to the
retries already spent — an ordinary when guard is the bound — and the
arm’s own |> continuation is the re-entry call. dial(port: port + 1) reads port ambiently: inside the retry loop port is the argument of
the attempt that just failed, so the expression dials one above the port
that refused — no failure payload, no explicit threading, the reader
environment carries the context the way it carries it everywhere else.
And | ok: dial’s ok is void, so there is nothing to bind — | ok p refuses at a supervised site exactly as it would without supervised (KORU101: branch 'ok' carries no payload, pinned by 320_167). The
generated step’s branches are the child’s declaration verbatim — a void
branch stays void through the fold, and no binding materializes a payload
the producer never declared. The winning port is still said, by the one
place that ever knew it: the dial trace.
What the compiler writes
The elaboration is not a runtime object — it is a second Koru program,
written by the transform out of dial’s declaration and the policy arms.
This is the whole thing, verbatim in shape:
tor __sup_step_L21 { port: i64, t: i64 }
| ok
| refused
| __more { port: i64, t: i64 }
__sup_step_L21 = dial(port)
| ok => ok
| refused when t < 5 => __more { port: port + 1, t: t + 1 }
| refused => refused
connect = #__sup_L21 __sup_step_L21(port, t: 0)
| __more s |> @__sup_L21(s.port, s.t)
| ok => ok
| refused => refused Read it as Koru, because it is Koru — this program compiles and runs,
and produces the same four dials and the same connected. __sup_step_L21 is an ordinary tor whose inputs are dial’s plus the counter. Its body is
an ordinary call. Its branch list is dial’s declaration verbatim — ok stays void, refused stays void — plus one invented branch, __more,
which carries the next attempt’s inputs back to the loop head. The policy
is visible as two arms on refused: when t < 5 produces __more, the
unguarded fall-through forwards refused in kind. The bound is the when,
the state is __more’s payload, the loop is #/@ — the same labeled
fold 320_151 pins written by hand.
This is also where the one subtlety lives. Inside the step, port is the
step’s input — the port this attempt dialed — so port + 1 reads “one
above the port that just refused,” not “one above the original call.” The
re-entry expression never saw the enclosing connect’s port; it saw the
attempt’s, because the step’s inputs are the attempt’s. That is the
reader environment doing what it does everywhere — parameters ambiently in
scope — relocated into a generated tor. If a re-entry expression ever reads
wrong, this section is the model: the arms run in the step’s scope, and the
step’s scope is the last attempt’s arguments.
And the fold at the site is the same mechanism the language already has: #__sup_L21 labels the call, | __more s |> @__sup_L21(s.port, s.t) re-enters it with the carried state, and connect’s own | ok and | refused arms dispatch on the step’s outcomes — same names, same shapes,
checked by the same passes. Nothing about the loop is hidden: no runtime
watches anything, no object survives compilation. The only thing that
exists is this program — which is why the whole feature can be stated in
one sentence: the compiler writes the retry loop you would have written,
and checks it as if you had.
You do not have to take that block on faith. On a program that imports std/explain, koruc <file> explain finds each |> supervised site,
reports the producer, the declared branch vocabulary, the policy it read,
and the fold — then runs the same transform on a copy of the program and
prints what it wrote, module-qualified names and all. The block above is
that report’s elaborated output with the ~ and module: prefixes
trimmed; the pinned form is 320_168_supervised_explain_reports.
What it compiles to
That generated program emits to ordinary Zig. The step’s handler, verbatim:
const result = main_module.dial_event.handler(.{ .port = port });
switch (result) {
.ok => return .{ .ok = .{} },
.refused => {
if (t < 5) {
return .{ .__more = .{ .port = port + 1, .t = t + 1 } };
} else {
return .{ .refused = .{} }; // last failure, in kind
}
},
} And the site — connect’s body — becomes the fold over it, verbatim:
var __sup_L21_port: i64 = port;
var __sup_L21_t: i64 = 0;
var result_0 = main_module.__sup_step_L21_event.handler(.{ .port = __sup_L21_port, .t = __sup_L21_t });
__sup_L21: while (result_0 == .__more) {
const s = result_0.__more;
__sup_L21_port = s.port;
__sup_L21_t = s.t;
result_0 = main_module.__sup_step_L21_event.handler(.{ .port = __sup_L21_port, .t = __sup_L21_t });
continue :__sup_L21;
}
switch (result_0) {
.ok => return .{ .ok = .{} },
.refused => return .{ .refused = .{} },
.__more => unreachable,
} A while loop and a union — the same shape 320_151 pins written by
hand: #label marks the call, @label re-enters it, and the loop is
just re-calling the step with the state __more carried. The retry
policy does not exist at runtime because it was never a runtime thing:
the transform read the arms, enumerated the child’s declared branch
vocabulary, and wrote the control flow that implements the policy — in
Koru, checked by the same compiler passes as anything you would write
yourself.
The guarantees, stated plainly
| retry t when t < N means N retries after the initial call. t counts
retries already spent, and the producing call runs at most N+1 times. There
is no ambiguity about whether the first attempt counts — the bound is a when guard on a counter you can see in the emitted code, incremented once
per re-entry.
Exhaustion forwards the last outcome, in kind. When the budget is spent,
the final failure proceeds upward as what it is. refused is a required
outcome — a caller that declines to write the arm does not compile — so an
exhausted dial cannot quietly evaporate: it arrives at your arm as the
ordinary, must-be-answered failure it always was. (A ?! branch is also
supervisable, and it stays panic-grade — pinned by 320_153 — but refusal is
not one, and the example should not pretend it is.) The supervisor absorbs
and defers; it never rewrites an outcome into something more convenient.
This is the design decision that separates supervision from error handling —
the policy governs how many times the producing call runs, never what a
produced outcome means.
A retried outcome flows through the same arms as a first-call one. The
site’s sibling arms are cloned under the generated call, so ok on the
fourth dial reaches | ok => ok exactly as ok on the first would have.
Supervision is invisible to everything downstream — which is what makes it a
fact about the call rather than a wrapper around it.
And it works on ordinary errors, not just panics. A fetch that answers | err i64 with the attempt that timed out — supervised with | retry t when t < 4 |> fetch(attempt: e + 1) — retries with a bumped
attempt counter, and a service that never comes up ends with gave up after attempt 4: the last err, forwarded to the caller’s own arm,
which decided what failure meant. The caller owns the meaning; the
declaration owned only the bound.
The participle is the point
The spelling went through a real decision. retry(3) was the obvious
candidate — imperative, familiar, Polly-flavored. It was rejected for a
reason that turns out to be load-bearing: an imperative implies an attempt,
and an attempt can fail to happen. Nothing is attempted here. supervised is a past participle — the declaration states that the call is supervised,
the way stored states that a write lands in a store and captured states
that a binding is held. The policy underneath is not an argument list to a
runtime action; it is the content of the fact, checked at compile time or
the program does not compile.
The policy’s spelling went through the same decision twice. It shipped first
as a datablock — { restart: 5, args: { port: port + 1 } } — a record the
transform parsed out of a source: string. It worked, and the datablock
still works, but it was a second grammar inside the first one: a record
describing a decision, when Koru already has a grammar for decisions — arms.
(args: exprs run in the step’s ambient scope — the last attempt’s inputs,
the counter, and the failure payload’s binding when the branch carries one;
on a void refused, an f-reference is refused by name: “args references
‘f’ — ’| refused’ carries no failure payload.”) The
block’s contents were always an ordered rule list in disguise: a bound,
an argument mapping, an implicit terminal. Spelled as arms, every piece is
native — t binds the counter the way any arm binds a payload, when t < 5 is the same when every arm carries, the |> continuation is a real call,
and | exhausted => refused is a real produce. The datablock survives as
the terse spelling — restart: N is | retry t when t < N — but the
arms are where the vocabulary lives: ordered rules, a visible bound, and a
terminal produce you can read.
The refusals are in that spirit too. A | retry with no when is
rejected — an unconditional retry never terminates, and a compiled program
cannot promise keep-alive without an escape. A when that never touches the
counter is rejected — “keep-alive conditions must carry their own escape: | retry t when t < N” — a guard on the failure payload alone cannot bound
the loop. An unconditional | exhausted that isn’t the last policy arm is
rejected — arms after it could never run. A |> on exhausted or a => produce on retry is rejected — the vocabulary is two branches, and each
has its own shape. Every refusal is a koru diagnostic with a location, not
a silent policy change.
A declaration is not a rule
Rules live in the arms. Declarations — data about the policy itself — live in the block, the data slot on the invocation:
connect = dial(port)
| ok => ok
| refused |> std/supervisor:supervised { within: 10 ms }
| retry t when t < 5 |> dial(port: port + 1) within is the first honored declaration: a minimum spacing between
restarts, supplied by the wall clock. The generated unit stamps each
supervised failure’s nanosecond timestamp and gates the next re-entry —
a retry that would fire inside the window is declined before the child
runs at all, keep-alive fails, and the last outcome proceeds upward in
kind, exactly as an unmet bound ends it. The site’s own failure always
passes — there is no previous restart to space from.
The two slots are kinded, not redundant: restart:/args: in the block
spell decisions — the same ones the arms spell — so mixing them with
rule arms refuses. within is a modifier: it parameterizes whatever
rules exist and composes with either spelling, so supervised { restart: 3, within: 10 ms } is the shorthand policy with
spacing, no arms needed.
Add the one line and the same program behaves differently — dial is far
faster than ten milliseconds, so the second restart is declined:
dial 8080...
dial 8081...
no service in range The interesting part is what refuses. within: 10 ticks parses, validates —
and does not compile, because a tick clock has no supplier in call scope: “no tick supplier in scope — tick clocks come from an enclosing signal
model.” The failure stream is the only clock a call natively has, and a
window measured in attempts degenerates — every event on it is a restart,
so “within N attempts” collapses to the bound when already expresses. A
tick means something only where something ticks. The vocabulary is pinned
anyway: the noun is declared, its supplier is named in the refusal, and the
composition boundary lives in a diagnostic rather than a doc.
{ policy: Name } turned out not to need that wait — its supplier is an
ordinary same-module tor, so it landed as the third rule-source spelling.
The policy is an event that consumes the failure stream under the arm
vocabulary and produces the same vocabulary back:
pub tor climb { t: i64 }
| retry i64
| exhausted
proc climb|zig {
if (t < 5) {
return .{ .retry = 8081 + t };
}
return .{ .exhausted = .{} };
}
connect = dial(port)
| ok => ok
| refused |> std/supervisor:supervised { policy: climb } Against a dial that always refuses, the policy’s verdict chain reads
straight off the output — five granted retries, then the terminal forward:
dial 8080...
dial 8081...
dial 8082...
dial 8083...
dial 8084...
dial 8085...
no service in range t is the retries-spent counter and is required — the arms carry their
escape in a when the compiler can read, but inside an opaque tor the
bound must at least be reachable. f — the latest failure payload —
joins the policy’s inputs when the supervised branch carries one; a void refused has nothing to read, and a policy that asks for f anyway is
refused by name. The generated unit calls the policy on every failure and
obeys the verdict: retry’s payload maps onto the child’s inputs
(positional for a single-input child — Koru refuses named single
outcomes, so partial one-field adaptation is unspellable — consistency
preserved), exhausted produces on the supervised branch or, as here,
forwards in kind. within composes: its veto of a granted retry is not a
policy decision, so the pending failure proceeds upward. The stateful
version — a signal model consuming the same stream and producing the same
vocabulary — is what the channel is already shaped for; the model adds
state, not grammar.
The road that got ruled out
The #retry fold the language already has is what it compiles to. The
hand-written version of this exact semantics exists and runs — a drive-step
tor that claims the child’s branches, consults a budget, and produces more (loop) or forwards the outcome (done), folded with a labeled loop
(320_151). That program is the elaboration target — the transform writes it, per site, reading off the child’s declaration what a user would have
to hand-derive: the branch vocabulary, the arm set, the exhaustion
forward. The declaration is shorter and it cannot drift from the
vocabulary it supervises — add a branch to dial and the generated step
gains its forward arm automatically. The loop that runs is the loop the
language already knew how to run; supervision adds nothing but the
spelling.
Why the SiteView discovery matters beyond this feature. The transform
sits on a nested site — an arm inside a call’s result handling — yet it
rewrites the producing call into the labeled fold and injects a whole
new top-level unit (the __sup_step tor). The transform runner hands
nested sites a synthetic view whose flow.body is a copy of the holding
arm, so a transform-computed pointer to the producer is not even a valid
tree address at a flow head — the runner now resolves the producer against
the real tree itself (retarget_producer), and a label_with_invocation landing at a flow head lifts its label into flow.pre_label. Two small
ABI additions, both pinned by the suite, and both matters of plumbing
discipline rather than a whole_program escape — rebuilding the entire
program to add a unit remains what it always was: necessary only for
rewriting program parts unrelated to the site.
The compiler bug this surfaced. Writing the reference semantics by hand
hit an emitter hole first: a self-call nested under a foreign branch arm
emitted a plain nested handler() call under an unused __koru_self_loop label — the detection pass had correctly armed the loop, but four freshly
constructed emission contexts on bail paths dropped the flags before the arm
body reached the reentry checkpoint. The fix threads the flags through every
context-construction site. That defect is fixed and pinned, because the
suite is where defects live:
Why refused is not ?!refused. The first draft of this example declared
the refusal a panic branch — it was wrong, and the correction is worth keeping
because it shows the discipline applied to the feature itself. ?! is for the
failure you did not plan for: decline the arm and the compiler writes a crash
in your place. A refused dial is the expected outcome — the supervised arm
exists precisely to plan for it, and a real socket returns ECONNREFUSED as
routine data, not as a crash. Declaring it ?! would also be the weaker contract, not the stronger one: a required outcome cannot be declined at all,
while a panic branch is one you are permitted to leave unhandled at the price
of stopping. And because the supervised outcome’s kind is cloned from the
child’s declaration — the transform preserves it, it cannot upgrade or
downgrade it — dial’s choice is exported to every caller upward. Refusal is
an ordinary, must-be-answered failure; the example now says so.
What is still fenced off. v1 supervises same-module children only —
cross-module calls are refused with a diagnostic, not silently
half-supervised. A |> re-entry must call the producing event itself —
the retry arms share one outcome union, so cross-event re-entry lands with
whatever solves that, not with a second vocabulary guessed at the site. within honors wall-clock units only — ticks is declared
vocabulary awaiting its supplier. policy: delegates to same-module tors
only — cross-module paths refuse — and a restart-intensity
window in BEAM’s sense (N restarts inside T) still has no clock to count
against. And chain-level restart — “retry the whole pipeline, not just
this call” — is a separate question; re-entry here targets the producing
call, which is usually the honest unit of failure.
The interesting residue is what is absent. A supervision tree, an intensity cap, a restart window, an escalation path — Erlang’s machinery is real machinery, built to keep a running system alive across crashes. Koru’s version keeps the guarantees that survive compilation — bounded retry, payload-derived recovery, kind-preserving exhaustion — and declines the rest on purpose: there is no running system to keep alive, only a program whose control flow the compiler can see whole.