Skip to main content

Sends are method calls, crashes carry codes: the actor and supervision model

· 11 min read
deep-dive: Part 2 of 7

The first thing I did to the actor model in v0.5 was delete an operator.

Hew used to have <- for sends, the way Erlang has ! and Go has ch <- v. It read fine in isolation — counter <- Increment(5) — and it was one of the first pieces of syntax I wrote, back when the language was mostly a wishlist. But every time I came back to a program that used it, I had to remember that the actor on the left was a thing you send to, distinct from every other value, with its own punctuation. The send glyph was advertising a distinction the rest of the language didn’t make.

So the operator is gone. The lexer no longer has a <- token at all, and the parser watches for an adjacent < and - so it can tell you what happened instead of producing a baffling parse error:

E_OPERATOR_REMOVED: the `<-` send operator has been removed;
use `handle(msg)` call syntax instead

What replaces it is a method call. You send to an actor the way you call anything:

actor Counter {
    var count: i64;
 
    receive fn increment(n: i64) {
        count = count + n;
    }
 
    receive fn total() -> i64 {
        count
    }
}
 
fn main() -> i64 {
    let counter = spawn Counter(count: 0);
    counter.increment(10);
    counter.increment(32);
    match await counter.total() {
        .Ok(v) => v,
        .Err(_e) => 0,
    }
}

counter.increment(10) is a send. await counter.total() is an ask. They look almost identical because, at the surface, they are — same dot, same parentheses. The only thing that distinguishes them is that total returns a value and increment doesn’t. This program exits 42.

Dropping the glyph is not dropping the model. The semantics underneath are still the ones Erlang’s OTP got right: each actor is an isolated process with its own mailbox, messages are handled one at a time, and state lives behind the mailbox where nothing else can touch it. Hew’s heritage is OTP semantics, not OTP syntax. counter.increment(5) and counter ! {increment, 5} mean the same thing; one of them just doesn’t make you learn a second way to call something.

The lambda actor and the typed channel underneath

There’s a second actor surface that the operator change cleared room for. A named actor declaration is the heavyweight form — it has fields, lifecycle hooks, a whole protocol. But sometimes you want an actor that’s just a function with a mailbox in front of it. That’s the lambda actor:

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
let double = actor |n: i64| -> i64 {
    n * 2
};

The interesting part is what double is. It isn’t a closure and it isn’t a raw handle — it’s a Duplex<i64, i64>, a typed bidirectional channel where the first type parameter is what you send and the second is what comes back. The -> i64 on the lambda is what makes it askable; drop the return type and you get a Duplex<i64, ()> that only takes tells. The type system carries the shape of the conversation, which means a lambda actor that returns nothing can’t be accidentally awaited, and one that returns an i64 hands you back an i64, not an opaque message.

The capture rule is the part I spent the most care on. A lambda actor captures its environment weakly with respect to itself — the actor’s own handle is bound to the let site, not to anything the body produces. If the body tries to return its own handle, or smuggle out another lambda actor’s handle, the checker stops it:

error: actor lambda body returns a Duplex handle — actor handles cannot
escape the actor boundary via a return value (E_LAMBDA_SELF_ESCAPE)

The reason is lifetime. The handle’s life is tied to where you bound it, so letting the body leak a strong reference to itself out past the actor boundary would mean the actor outlives the binding that owns it, which is exactly the kind of dangling-process bug actors are supposed to make impossible. I’d rather the compiler refuse than hand you a handle to something that might already be gone.

The honest caveat: the lambda actor type-checks today, and the Duplex typing is real, but the codegen path that turns double(21) into a running send isn’t wired all the way down yet. If you compile a program that calls one, you get a fail-closed error naming exactly the piece that’s missing — the SendHalf/RecvHalf lowering — rather than a binary that silently does nothing. The named-actor path below is the one that runs end to end right now.

Update, June 2026: this caveat is dead. The spawn and call-syntax dispatch got wired all the way down to the runtime, and a lambda actor now compiles and runs end to end — both shapes. A tell just goes; an ask hands you its Result straight from the call, so let r = double(21); is the whole round-trip. There’s no await on a Duplex at all — the call is the ask.

What a method call becomes

The thing that makes counter.increment(10) awkward to compile is that it and counter.total() are syntactically identical. One is fire-and-forget, one blocks for a reply, and the only signal is the receive method’s return type. I did not want the back end re-deriving that classification, because then two parts of the compiler would have to agree forever about what counts as a send versus an ask, and two things that have to agree eventually disagree.

So the type checker is the authority. It already knows the receiver is actor-typed and already knows each method’s return type, so it classifies every actor method-call site and writes the verdict into a side table — each entry is either a fire-and-forget send or an ask carrying the method id and the reply type. Lowering reads the verdict and routes to one of three new expression kinds — Spawn, ActorSend, ActorAsk — without re-deciding anything.

From there it’s a straight descent. ActorSend and ActorAsk become terminators backed by runtime calls (hew_actor_send_by_id and hew_actor_ask); spawn becomes a typed handle plus a call to hew_actor_spawn. Then codegen emits, per actor, a dispatch trampoline — a function that switches on the message type and calls the matching receive handler — and field reads and writes inside a handler become offsets into the actor’s state struct. (2cda56c8)

The carrier I underestimated

When an ask handler finishes, it has a value, and that value has to get back to the channel the caller is awaiting on. But the handler doesn’t take the reply channel as a parameter — it just returns a value, like any function. So the channel has to be reachable some other way.

The answer is an execution context: a single struct that carries the reply channel and the ambient runtime state, installed in thread-local storage before a handler runs. The handler’s calling convention knows how to find it, and self-field access derives its struct layout from the same context. One carrier, one place all of it lives, instead of threading each piece through every handler signature separately. That decision paid for itself the moment I got to lifecycle hooks, because they get the carrier for free.

init, on(start), on(stop)

An actor can have an init block and lifecycle hooks, and the dispatch trampoline runs them at the right moments: init with the spawn arguments first, then #[on(start)] before the first user message is allowed to dequeue. Both go through the same calling convention as a normal receive, so the carrier and the state lock apply without special-casing.

#[on(stop)] is a different moment, and it has to fire at normal teardown, hold the state lock while it runs, and — the part that took the longest — not fire on a crash exit. Tearing down a crashed actor by running its stop hook is exactly wrong, so the stop path guards on the actor not being in the crashed state. That guard is the seam where the supervision story begins, because the crash exit it’s avoiding is the thing the next half of this post is about.

ask returns a Result, always

One detail in the counter program is easy to skim past: the ask comes back wrapped.

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
match await counter.total() {
    Ok(n) => n,
    Err(_e) => 0,
}

await counter.total() doesn’t hand you an i64. It hands you a Result whose Ok is the i64. This isn’t ceremony — an ask is the one actor operation that can fail in a way the caller has to deal with, because the actor on the other end might have died between your send and its reply. A fire-and-forget send can’t tell you the recipient is gone; an ask can, and the type makes you acknowledge it. There’s no path where you get a value back without having passed through the possibility that you didn’t.

The same shape shows up in select, which can race asks against each other and against a clock:

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
let winner = select {
    reply from fast.compute(42) => reply,
    reply from slow.compute(99) => reply,
    after 100ms => 0,
};

Each reply from <actor>.<method>(...) arm issues an ask and binds the reply if that arm wins; after 100ms is a timer arm that fires if nobody replies in time. The detail I care about is the loser. When the fast worker wins, the slow worker’s ask is still outstanding — there’s a reply channel sitting there nobody will ever read. The lowering cancels and frees the loser’s channel on the way out, every run, because a select that leaks a channel per losing arm is a slow death under load.

The supervision half

An actor that can crash needs something watching it, and in Hew that something is written in the same language as the actor. A supervisor is a declaration:

actor Ingest {
    receive fn accept(_n: i64) {
        _n;
    }
}
 
actor Reduce {
    receive fn fold(_n: i64) {
        _n;
    }
}
 
supervisor Pipeline {
    strategy: one_for_one;
    intensity: 3 within 60s;
 
    child ingest: Ingest;
    child reduce: Reduce;
}
 
fn main() -> i64 {
    let pipeline = spawn Pipeline;
    supervisor_stop(pipeline);
    0
}

strategy: one_for_one means a crashed child is restarted on its own, without disturbing its siblings. intensity: 3 within 60s is the restart budget — three restarts in a sixty-second window, and if a child blows through that, the supervisor itself gives up rather than restart-looping forever. spawn Pipeline hands back a typed pid, the same spawn you use for a bare actor. supervisor_stop(pipeline) is the orderly-teardown counterpart to that spawn — it brings the tree down cleanly instead of letting it outlive main.

A supervisor is an actor-like that only spawns

Compiling a supervisor reuses almost everything the actor path already built. The declaration lowers to a bootstrap function — a synthesized function that the normal lowering pipeline consumes, with the same dataflow checker, the same execution-context machinery, the same drop elaboration. Its body spawns each declared child in order, and spawn Pipeline is routed to call that bootstrap instead of the actor-spawn primitive, returning a typed pid from the spawn expression. (8db003b2, 1a9e9cf0)

There’s one deliberate asymmetry: a supervisor has no message protocol. It has no receive fn, no message handlers, no msg-id mapping — it’s spawn-only. That absence is load-bearing and written down, not an oversight. The day a supervisor needs to accept a message — a programmatic “restart this child” command — is the day it grows a protocol, and not before. I didn’t want to invent a message surface for a thing that, today, only has one job: bring its children up.

Crashes carry a code

The part of supervision I most wanted to get right is the part where failure is observable, not merely survivable. A supervisor that silently restarts a crashing child is a supervisor that hides a bug from you. So before the restart policy runs, the supervisor invokes the child’s crash hook, if it has one, with the real trap code:

import std.failure;
 
actor Worker {
    receive fn run(_n: i64) {
        _n;
    }
 
    #[on(crash)]
    fn on_fault(info: CrashInfo) -> CrashAction {
        match info.code {
            201 => println("integer overflow"),
            205 => println("index out of bounds"),
            _ => println("other fault"),
        }
        panic("observed")
    }
}
 
supervisor Pipeline {
    strategy: one_for_one;
    intensity: 3 within 60s;
 
    child w: Worker;
}
 
fn main() -> i64 {
    spawn Pipeline;
    42
}

One honesty note on the listing itself: as printed, nothing in it crashes — main brings the tree up and exits 42, so on_fault never fires in this run. The snippet shows the shape of the hook; to watch the code land, send the worker something that traps (an overflow will do) and the matching line prints before the restart.

info.code is the actual trap discriminator the runtime stamped on the crashed actor when it died. 201 is integer overflow, 205 is index-out-of-bounds — they’re real constants the trap machinery sets, not a generic “it crashed” flag. The hook runs in the supervisor’s dispatch context, sees the code, and the restart still happens afterward — the ordering is the whole point. The handler fires before the policy consults the budget, so by the time the actor comes back, you’ve already had a chance to see why it went down. (The runtime pins this with a test: the hook fires exactly once per crash, and the restart proceeds after it.)

Two caveats I’d rather state than bury. First, CrashInfo is an integer-tag-only payload in v0.5 — code is an i64, and that’s it. The richer shape I want, carrying the panic message as a string and a cursor for trampoline diagnostics, is waiting on the codegen spine to accept string fields in structs, which it doesn’t yet. (Update, June 2026: the spine takes string fields in structs now, so that’s no longer the blocker — CrashInfo staying integer-only in v0.5 is sequencing, not a codegen limit.) Second, the hook’s return type is CrashAction — Restart, Escalate, or Kill — but in v0.5 that return value is ignored. The supervisor honours each child’s declared restart policy; the action variants exist so the hook ABI can be spelled honestly, and turning them into a real control surface is deferred. That’s why the body above ends in panic rather than CrashAction::Restart — constructing the variant would imply a control it doesn’t have yet, and the program would rather diverge than pretend.

This is the line I keep walking with this stuff: the hook is real, the trap code is real, the firing-before-restart ordering is real and tested. What isn’t real yet is the hook deciding the restart. I could have wired the return value to do nothing and called it done, and the demo would look complete. It’s more useful to ship the observability that works and say plainly that the decision part is the next piece, than to ship a control surface that quietly drops your decision on the floor.

The send operator is gone, a send is a method call, and a crash is something you can watch land with the code that killed it in your hand. The supervisor brings the child back either way — but it lets you see the body first.

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