When things fail

Coming from Go? This is the part that’s more Erlang than Go. Alongside the value-level fallible channel 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:

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:

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; }
}
}

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.