Up until this morning, an actor declaration was a shape the parser recognized and the type checker had opinions about, and then nothing. You could write counter.increment(10), and the checker would nod, and the back end would have no idea what to do with it. Actors were a syntax with no process behind them.
That changed today. counter.increment(10) now lowers to a real send, await counter.total() lowers to a real ask, and spawn Counter(count: 0) actually starts a process. The round trip works end to end — I compiled the counter example to a native binary and it exited with the right number.
Here’s the example I’ve been using as the acceptance test:
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(20);
counter.increment(12);
match await counter.total() {
.Ok(v) => v,
.Err(_e) => 0,
}
}spawn starts the actor and hands back a typed handle. The three increment calls are fire-and-forget sends — they go into the mailbox and main doesn’t wait. total() is different: it returns a value, so it’s an ask, and await blocks until the reply comes back. Compile that, run it, and the process exits 42 — 10 + 20 + 12, read back out of the actor’s own state.
The hard part was deciding what a method call is
The thing that makes actors awkward to compile is that counter.increment(10) and counter.total() look identical at the call site. Same dot, same parens. One is a send and one is an ask, and the only thing that distinguishes them is whether the receive method returns something.
I did not want the back end re-deriving that. The type checker already knows the receiver is actor-typed and already knows each method’s return type — making the MIR layer reconstruct that classification would mean two places in the compiler that have to agree on what counts as an actor, and they would eventually disagree.
So the checker is the authority. It classifies every method-call site and writes the verdict into a side table — a HashMap keyed by call-site, where each entry is either Fire or Ask(method_id, reply_ty). HIR lowering reads the side table and routes to the right node without re-classifying anything. That gave me three new HIR expression kinds — Spawn, ActorSend, ActorAsk — and lowering just looks up which one the checker decided on. (fcbcc9e2)
From there it’s a straight descent. ActorSend and ActorAsk become MIR terminators backed by runtime ABI calls (hew_actor_send_by_id and hew_actor_ask); Spawn becomes a 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. Field reads and writes inside a handler (count = count + n) become GEP offsets into the actor’s state struct. (2cda56c8)
The execution context
The piece I underestimated was the carrier.
When an ask handler finishes, it has a value, and that value has to go somewhere — back to the channel the caller is awaiting on. The handler doesn’t take the reply channel as an argument; it just returns a value, the same as any other function. So the reply 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, threaded through thread-local storage and installed before a handler runs. The handler’s calling convention — I’m calling it the ActorHandler ABI — knows how to find it. Self-field access derives its struct layout from the same context. One carrier, one place all of this lives, instead of plumbing several separate things through every handler signature.
That decision paid off immediately when I got to lifecycle hooks, because they get the carrier for free.
init and on(start) at spawn time
An actor can have an init block and lifecycle hooks. Today init and #[on(start)] actually fire:
actor Worker {
var jobs: i64;
init(start: i64) {
jobs = start;
}
receive fn run() {
jobs = jobs + 1;
}
receive fn done() -> i64 {
jobs
}
#[on(start)]
fn boot() {
println("worker starting");
}
#[on(stop)]
fn shutdown() {
println("worker stopping");
}
}The dispatch trampoline calls init with the spawn arguments first, then fires #[on(start)] before the first user message is allowed to dequeue. Both run through the same ActorHandler ABI as a normal receive, so the carrier and the state lock apply automatically — I didn’t have to special-case them. (b0eb93e0)
One consequence worth knowing before you run the Worker example: you’ll see worker starting but not worker stopping, because the stop hook fires at actor teardown, and main returning ends the process before that.
#[on(stop)] was a separate problem because “stop” is a different moment than “start.” It has to fire at normal teardown, hold the actor’s state lock while it runs (it’s mutating actor state), and — this is the part that took the longest — not fire on a crash exit. Tearing down a crashed actor by running its stop hook is exactly the wrong thing; the stop path guards on the actor not being in the Crashed state, the same guard the state-drop path already uses.
The other thing the stop hook needs is the carrier, but the teardown trampoline is a plain C-ABI function — there’s no ActorHandler convention installing the context for it. So I added hew_require_execution_context() as a runtime export, and the trampoline calls it to retrieve the context that teardown already installed. It acquires the state lock, calls the stop handler, releases the lock. And if the stop body panics, the lock gets released anyway — a panic that leaves the state lock held would wedge the actor permanently, so the teardown path releases on the unwind. (c9ef2372)
#[on(stop)] works, but #[on(crash)] and #[on(upgrade)] do not yet. I could have stubbed them to silently do nothing, which is the tempting thing when you want the demo to look complete. I didn’t. They still hit a hard UnsupportedNode error at the MIR layer — if you write #[on(crash)] today, the compiler refuses to lower it and tells you so. A compiler that quietly drops a hook you wrote is worse than one that admits it can’t handle it yet. The fail-closed guard stays until the runtime wiring for those two actually lands.
[Update, June 2026: the wiring landed for one of them. #[on(crash)] now works end to end in v0.5.0 — it gets a CrashInfo payload and declares a CrashAction return, though for now the return is observation-only and the supervisor still follows the declared restart policy. #[on(upgrade)] is still reserved, but the refusal moved up out of the MIR layer: the checker now rejects it with a diagnostic pointing at the v0.6 upgrade-runtime track.]
select grows ask arms and a timer
select already existed as a block, but now it can race actor asks against each other and against a timer:
actor FastWorker {
receive fn compute(query: i64) -> i64 {
query
}
}
actor SlowWorker {
receive fn compute(query: i64) -> i64 {
sleep_ms(50);
query
}
}
fn main() -> i64 {
let fast = spawn FastWorker;
let slow = spawn SlowWorker;
let winner = select {
reply from fast.compute(42) => reply,
reply from slow.compute(99) => reply,
after 100ms => 0,
};
winner
}Each reply from <actor>.<method>(...) arm issues an ask and binds the reply if that arm wins. after 100ms is a timer arm — it fires if nothing else replies in time. FastWorker replies immediately and SlowWorker parks for 50ms, so the fast arm wins every run and the timer is just a safety net.
The detail I care about here is the loser. When FastWorker wins, SlowWorker’s ask is still outstanding — there’s a reply channel sitting there that nobody will ever read. The lowering cancels and frees the loser’s channel on the way out (hew_reply_channel_cancel then hew_reply_channel_free). Every run of this example exercises that cleanup, because the same worker always loses. A select that leaks a channel per losing arm would be a slow death under load, so the cancel path isn’t optional.
Closures, captured fail-closed
Separately from the actor work, captured closure environments now lower the whole way down. A closure that captures a local binding gets that capture materialized into a typed environment struct at the point the closure is created, and the body loads its captures out of that struct when it runs:
fn apply(f: fn(i64) -> i64, x: i64) -> i64 {
f(x)
}
fn main() -> i64 {
let base = 100;
let add_base = |x: i64| -> i64 {
x + base
};
apply(add_base, 5)
}add_base captures base. That capture is real — it goes into an environment struct, and apply calling f(x) reads base back out. This one exits 105. (38bd6655)
The thing I want to flag is a property I’ve been calling binding-accurate capture. The capture set is computed from what the body actually references, and the MIR layer has a gate that fails closed if the captures it’s handed don’t line up with the bindings in scope — if the analysis and the environment disagree about what’s being captured, the compiler stops rather than emitting a struct with the wrong fields. Capturing the wrong slot, or capturing a slot that’s already gone, is the kind of bug that produces garbage at runtime and no diagnostic at all. I’d rather the compiler refuse.
One compile path
The last thing I did today was unrelated to actors but had been bothering me: there were two compile entry points. The old build command and a legacy compile::compile() function lived alongside the new IR-ladder path, and every bare-file invocation had to decide which one it meant.
So I deleted the old one. There’s a single compile command now, bare-file invocations rewrite to it, and the dead build body and the legacy entry point are gone. WASM emission used to happen unconditionally on every compile, dual-emitting whether you wanted it or not; now it’s opt-in behind --target wasm32-unknown-unknown, and the --no-wasm escape hatch that existed to turn off the thing you never asked for is gone with it. (ef4d260a)
While I was in there, I also seeded a small catalog of stdlib builtins into the function registry — assert, exit, panic, and per-type print — so the things every program reaches for resolve without an import. The counter example couldn’t even println its state before this; now it can.
One canonical path through the compiler, no compat shim deciding which half to run. It’s a smaller change than the actor work but it’s the one that made the rest of the day feel less like walking around a fork in the road every time I ran the binary.
(Update, June 2026: hew build exists again in v0.5.0 — not the old pre-IR-ladder command deleted here, but a new hew build that is the native-binary front end to the IR-ladder path, the same way go build sits in front of go compile. hew compile and hew build are both real commands now.)