Skip to main content

Supervisors That Wire Themselves and Crash Hooks With Real Trap Codes

· 7 min read

A supervisor in Hew is a declaration, not a library call. You list the children, the strategy, and the restart budget, and the compiler is supposed to turn that into a running tree. Until this week the declaration parsed and type-checked and then mostly sat there — the part that actually spawns the children and connects them was a stub.

Here’s the declaration I want to compile:

actor Ingest {
    var pending: i64;
    receive fn accept() {
        pending = pending + 1;
    }
}
 
actor Writer {
    var written: i64;
    receive fn flush() {
        written = written + 1;
    }
}
 
supervisor Pipeline {
    strategy: one_for_one;
    intensity: 5 within 60s;
 
    child ingest: Ingest(pending: 0)
    child writer: Writer(written: 0)
}
 
fn main() {
    let pipe = spawn Pipeline;
    let w = pipe.writer;       // typed handle to the supervised child
    w.flush();
}

spawn Pipeline doesn’t hand back some opaque process id. It returns a LocalPid<Pipeline>, and pipe.writer resolves to a typed handle on the Writer child. The supervisor knows its children by name and by type, and so does the caller.

What the declaration lowers to

A supervisor is a spawn-only actor-like. It has no mailbox, no receive fn, no message protocol of its own — so it deliberately gets no protocol descriptor. The absence is the point: a supervisor’s whole job is to bring children up and keep them up, not to be sent messages. (There’s an obvious future where you’d send a supervisor a “restart child X” message, and that’s exactly the point where it would grow a descriptor. Not today.)

The declaration compiles to a bootstrap function — Pipeline__bootstrap — whose body spawns each child in order. The interesting constraint is ordering. Children can reference earlier-spawned siblings (a wired_to: clause), so the bootstrap has to spawn them in dependency order, and a cycle in those references is a compile error, not a deadlock at runtime. I run Kahn’s topological sort over the children inside the compiler as a structural backstop — the type checker already rejects wired cycles, but I wanted the lowering itself to refuse a cycle rather than emit a tree that can’t come up.

The thing I’m happiest about: the bootstrap is a synthesized function that goes through the same lowering pipeline as a hand-written one. Same dataflow checks, same drop elaboration. A sibling reference resolves to the earlier child’s handle and rides the existing spawn machinery as an init argument — no new instruction in the IR. It’s substrate I already had, pointed at a new declaration.

The bootstrap returns LocalPid<Sup> now, so let pipe = spawn Pipeline binds the right type. One honest gap: spawning a supervisor with init arguments isn’t wired yet — it compiles to a clear “unsupported” diagnostic with a note rather than a guess. That’s the next slice. (Update, June 2026: the slice resolved as a “no” rather than a feature — supervisors take their child specs declaratively in the supervisor body, so spawn-time init args have no defined semantics. The diagnostic now says exactly that and tells you to spawn Pipeline with no arguments.)

Crash hooks that know why

Here’s the part I actually care about. A supervisor that restarts a child blindly is barely better than while true. The whole “let it crash” idea only earns its keep if the supervisor can tell why a child died — an arithmetic overflow and a buffer overrun are different failures, and you might want to restart one and escalate the other.

So a child actor can declare a crash hook:

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
import std::failure;
 
actor Writer {
    var written: i64;
    receive fn flush() {
        written = written + 1;
    }
 
    #[on(crash)]
    fn on_crash(info: CrashInfo) -> CrashAction {
        match info.code {
            201 => CrashAction::Restart,   // integer overflow — transient, retry
            205 => CrashAction::Escalate,  // index out of bounds — a real bug
            _ => CrashAction::Restart,
        }
    }
}
v0.6+ — current idiomatic form.
import std.failure;
 
actor Writer {
    var written: i64;
    receive fn flush() {
        written = written + 1;
    }
 
    #[on(crash)]
    fn on_crash(info: CrashInfo) -> CrashAction {
        // info.code carries the trap class: 201=overflow, 205=bounds, etc.
        // CrashAction return is observation-only in v0.5; supervisor follows
        // the declared restart_policy regardless.
        let _code = info.code;
        panic("crash observed — see info.code for class")
    }
}

Those numbers are real trap codes, not placeholders I made up for the example. The runtime assigns each trap class a code — 201 is integer overflow, 202 divide-by-zero, 205 index-out-of-bounds, and so on — and until this week none of them reached the hook. The supervisor invoked crash handling with a hardcoded 11 (SIGSEGV), a leftover from when every fault looked the same from the outside.

Now the trap code threads all the way through: a Trap terminator emits the real code, it lands on the crashed actor’s error_code slot, the supervisor reads it into the child event, and it’s passed to the hook before the restart policy is consulted. The circuit breaker that counts crashes against the budget sees the real code too. Escalation paths that genuinely don’t have a single code — a child-supervisor running out of restart budget — honestly pass 0 rather than inventing one.

The hook runs as a non-unwinding extern "C" call into the supervisor’s recovery frame. If the hook itself faults, it doesn’t get swallowed by a catch_unwind — it counts against the supervisor’s restart budget, the same as any other crash. Fail closed, no silent recovery.

Where this actually stands

This is the bottom half of the failure model, and I want to be precise about what “bottom half” means. The runtime delivers the real trap code to a crash handler — that’s tested end to end. The hook is wired through the type checker and into the IR: the on_crash declaration above type-checks cleanly, and the synthesized handler the compiler generates for it carries the right ABI signature.

The top half is the part that isn’t finished: a Hew-source hook that returns a CrashAction and compiles all the way to native code. The first thing to know is that the CrashAction the hook returns is, at the moment, ignored. v0.5 honours each child’s declared restart policy and treats the hook as side-effects-only. The hook can observe the crash; it can’t yet steer the supervisor with its return value.

The reason is an ABI seam. The user-visible signature is (CrashInfo) -> CrashAction, while the runtime ABI for the handler is plain integers. I coerce the synthesized handler to that integer ABI so the wiring lands, but emitting native code for a hook that materializes a CrashAction value and hands it back is the seam I’m working through next.

Using that return value as an actual control surface is a v0.6 problem. (Update, June 2026: both forward claims aged. The codegen seam stayed open through v0.5 — the hook ships side-effects-only — and the spec reserves the return value as a future control surface with no version pinned. “v0.6” was my guess, not a plan.)

So the story a supervisor can tell today is “I know exactly why my child died, and I’ll apply the policy you declared.” The story it can’t tell yet is “let the hook override the policy on the fly.” I’d rather ship the half that’s true than fake the half that isn’t.

Widths are explicit now

While I was in the type checker I removed int and uint. They were aliases — int meant i64, uint meant u64 — and I’ve decided aliases for integer width are a mistake in a language that’s supposed to be honest about memory.

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
// Before: int was an alias for i64
fn total(count: int) -> int { count + 1 }   // rejected now
 
// After: say the width you mean
fn total(count: i64) -> i64 { count + 1 }
v0.6+ — current idiomatic form.
// Before: int was an alias for i64 — now rejected:
//   fn total(count: int) -> int { count + 1 }
 
// After: say the width you mean
fn total(count: i64) -> i64 { count + 1 }

The old spelling doesn’t quietly become i64 — it’s an error:

error: unknown type `int`; use `i64` for fixed 64-bit integers
       or `isize` for pointer-sized integers

This is a breaking change and I’m making it on purpose. The whole pitch of the numeric types is that i64 and isize are different things — one is a fixed width, the other tracks the pointer size — and an alias called int papers over exactly the distinction I want people to think about. The error names both replacements so the fix is obvious. (I’d rather take the breakage now, while the language is small, than carry the alias forever because removing it later would annoy more people.)

Two smaller things underneath

Calls like math.sqrt(2.0) resolve now. A module-qualified call — a dotted name where the left side is a module, not a value — used to only work for a handful of built-ins. I wired the general checker-to-IR rewrite so the compiler recognizes module.fn(args) against a real module, looks up the qualified math.sqrt symbol, and lowers it to an ordinary call with no phantom receiver injected:

import std.math;
 
fn main() {
    let r = math.sqrt(2.0);
    println(r);
}

math.sqrt is also where the other piece shows up. The stdlib needed a way to say “this function has no body — the compiler emits a machine instruction for it” without a parallel hand-maintained table that drifts out of sync. So there’s now a typed #[intrinsic("name")] attribute:

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
#[intrinsic("math.sqrt")]
pub fn sqrt(x: f64) -> f64 {}

The attribute names a catalog key; the compiler validates the key, skips lowering the (empty) body, and routes the call to the matching instruction. An unknown key is rejected rather than silently ignored. This is deliberately not a user-facing surface — declaring #[intrinsic("…")] outside a stdlib-floor module is a hard error, with a diagnostic that tells you to call the stdlib function instead. Memory and math intrinsics are the compiler’s business; user code calls math.sqrt and never has to know there’s no body behind it.

The dotted-name resolution feeds both of these — it’s the same symbol-mangling path that turns math.sqrt the call into math.sqrt the catalog key. Getting one centralized place to mangle a module-qualified name meant the intrinsic lookup and the call lowering agree on the spelling, which is the kind of boring consistency that stops being boring the first time the two disagree.

Hew Notes

Language development, engineering articles, releases and community projects. About once a month, with an extra issue for a significant release. Written in English.

Subscribe to Hew Notes