std/supervisor: A Supervisor Is a While Loop

· 9 min read

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.