When things fail
Coming from Go? This is the part that’s more Erlang than Go. Alongside the value-level
falliblechannel you already know, a long-running locus has a structural failure channel: when an invariant it promised to keep breaks, the failure flows up to its parent, which decides recovery — restart, quarantine, or escalate. Supervisors, let-it-crash, and typed recovery policy, built into the language.
Two channels, on purpose
Section titled “Two channels, on purpose”Hale keeps two failure mechanisms strictly separate:
- The value channel —
fallible(E)+or, from the basics. “This call didn’t produce a value; the caller decides what to do.” Routes up the call stack, addressed inline. - The structural channel — a locus’s declared invariant
breaks, the runtime builds a typed event and routes it up the
locus tower to the parent’s
on_failure. “A promised property no longer holds; the supervisor decides.”
There’s no panic, no assert, no exceptions. Every legitimate
failure is one of these two, and they only meet at the program’s
root.
Declaring an invariant: closure
Section titled “Declaring an invariant: closure”A closure is a property a locus promises to keep, checked by
the runtime at a declared moment:
locus Account { params { debits: Decimal = 0.00d; credits: Decimal = 0.00d; }
closure balanced { self.debits ~~ self.credits within 0.01d; epoch tick; }}~~ is “approximately equal, within tolerance.” The epoch
says when to check — tick (each event-loop iteration), birth,
dissolve, duration(1m), or inline (only when fired by
hand). If the assertion holds, nothing happens; closures are
silent on success. If it breaks, the runtime constructs a typed
ClosureViolation and routes it to the parent’s on_failure.
Handling failure: on_failure
Section titled “Handling failure: on_failure”The parent is the supervisor. It decides policy per child type:
locus Bank { accept(a: Account) { }
on_failure(a: Account, err: Error) { match err { Error::ClosureViolation(v) -> quarantine(a) for 60s, _ -> bubble(err), } }}The recovery primitives:
- absorb — just return; the failure is noted and contained.
restart(child)— dissolve and re-create it fresh.restart_in_place(child)— reset it, keeping its region.quarantine(child) for d— pause it, preserving state for inspection, optionally auto-restarting afterd.bubble(err)— pass it up to this locus’s parent.dissolve(child)— force it down.
If a failure bubbles past the root with no one absorbing it, the process exits non-zero with a structured report. That’s the only way a Hale program “crashes” — and it’s a deliberate, typed event, not a surprise. This is Erlang’s let-it-crash, but the recovery policy is typed and written next to the locus it governs.
Crossing from value to structural
Section titled “Crossing from value to structural”Sometimes a method catches a value-level error and decides it’s
fatal — the right move is to stop this locus and let the
supervisor take over. You bridge with an inline closure and the
violate statement:
locus DbConnection { params { last_error: String = ""; }
closure fatal_io { captures: last_error; epoch inline; }
// an error-check fn: takes the error, returns the success type, // and either substitutes a value or escalates. fn handle_io(e: IoError) -> Row { self.last_error = e.kind; if e.kind == "broken_pipe" { violate fatal_io; // diverges — escalate structurally } return Row { data: "" }; // transient — substitute and continue }
fn on_query(q: Query) { let r = send_query(self.conn_fd, q) or self.handle_io(err); if !self.draining { QueryResult <- r; } }}closure fatal_io { ... epoch inline; }is a named structural failure with no assertion — it only fires when you say so. Thecaptures:clause snapshots locus state into the violation payload.violate fatal_io;fires it. It’s divergent (theNevertype, likefailandbubble), so the branches that violate need noreturn. The locus enters drain at the next yield; the parent’son_failuregets the typed violation with the captured state.self.drainingis a Bool every locus can read — true once it’s decided to wind down. Use it to stop publishing after the decision.
That’s the canonical “catch an error and shut this locus down”
shape: one closure, one error-check method, one violate. You
don’t reach for a hand-rolled should_exit flag and a polling
loop — these primitives are the supported form.
Next: splitting a program across processes — Across binaries.