Skip to main content

State Machines Reach Codegen and Closures Become First-Class Values

· 7 min read
daily-progress: Part 5 of 11

For a while now, machine declarations have been a checked-only feature. You could write a state machine, the type checker would verify the states, events, and transitions, and then the compiler would hand you back a binary where the machine did nothing. The step function was a trap shell — reach it and the program aborts.

That was always meant to be temporary. A state machine that compiles but won’t run is a diagram with extra steps. Today the dispatch lowers all the way down, so you can actually step one.

Here’s a turnstile:

machine Turnstile {
    events {
        Push;
        Coin;
    }
 
    state Locked;
    state Unlocked;
 
    on Coin: Locked => Unlocked {
        Unlocked
    }
    on Push: Unlocked => Locked {
        Locked
    }
    on Push: Locked => Locked reenter {
        Locked
    }
    on Coin: Unlocked => Unlocked reenter {
        Unlocked
    }
}
 
fn main() {
    var gate = Locked;
    println(gate.state_name());   // Locked
    gate.step(Coin);
    println(gate.state_name());   // Unlocked
    gate.step(Push);
    println(gate.state_name());   // Locked
}

gate.step(Coin) and gate.state_name() go through MIR and codegen like any other method call now. The transitions you declared turn into a real dispatch tree.

How a transition lowers

A machine is a tagged union: a discriminant tag plus a payload big enough for the largest variant. The compiler synthesises a <Name>__step function, and what that function does, structurally, is two nested switches. Load the current state tag, branch by state. Inside each state, load the event tag, branch by event. Each arm is the body you wrote, which constructs the next-state value and returns it.

Undeclared (state, event) pairs don’t fall through to whatever’s next in memory. They reach a typed trap — MachineDispatchUnreachable. If you declared a transition for Coin in Locked but not in some other state, stepping that other state with Coin is a defined, diagnosable abort, not a silent wrong answer.

The part that took the most care wasn’t the control flow — it was the layout. The payload field needs the ABI-padded size, not the sum of the field widths. A variant carrying {i8; i64} is 16 bytes, not 9, because the i64 wants 8-byte alignment and the i8 gets padded out behind it. Summing widths and trusting it would have produced a struct that LLVM lays out differently from what the dispatch code reads back, and that’s the kind of bug that passes every small test and corrupts the high half of a field on the one input nobody tried.

So the size comes from asking the target data layout for the ABI size of each fully-constructed variant struct, and the tag bit width and payload element type are chosen so the whole thing is naturally aligned. The load-bearing invariant the tests guard is the structural shape of the emitted struct, not the alignment annotations — LLVM treats those as a floor, not a contract.

There’s a reenter marker in the turnstile. A self-transition — Locked to Locked — normally skips the entry and exit hooks, because you didn’t actually leave. Mark it reenter and the hooks fire anyway. The TCP-handshake fixture leans on this for protocol violations: a stray SYN while already established stays put but re-runs the entry effect.

Closures stop being a special case

The other thing that landed: pipe-form closures are first-class expression values now. You can write one inline, bind it, pass it to a function that takes a fn type.

fn apply(f: fn(i64) -> i64, x: i64) -> i64 {
    f(x)
}
 
fn main() {
    let base = 10;
    let shift = |n: i64| base + n;
    println(apply(shift, 5));            // 15
    println(apply(|n: i64| n * 3, 7));   // 21
}

shift captures base. Copyable values like this i64 are copied into the closure; move still parses when you want to force a moving capture, but it buys nothing here, so I left it out. The old (params) => body paren-lambda spelling is gone; programs that used it now fail closed with a typed diagnostic pointing at the pipe form, and the standard library and the test fixtures all moved over.

One spelling for pointers

Raw pointers had two spellings, which is one too many. Bare *T parsed as silently-immutable, and *var T meant mutable. Two ways to write the same idea, where picking the wrong one silently picks the wrong mutability — that’s a trap, not a convenience.

The canonical spelling is now *const T and *mut T, and nothing else. The parser rejects *T and *var T with diagnostics that name the form you want, and the formatter, hover, and doc extractor all emit the canonical pair so the whole tool surface speaks one language. mut is a reserved keyword now — a repo-wide grep confirmed nothing was using it as an identifier first.

These are FFI-scoped types; you see them in extern blocks and unsafe handle bindings, not in ordinary code. Dereferencing one fails closed:

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
extern "rt" {
    fn hew_task_scope_new() -> *mut i64;
}
 
fn main() {
    let handle: *mut i64 = unsafe {
        hew_task_scope_new()
    };
    let seen = unsafe {
        *handle               // error: raw pointer dereference is not lowered
    };
    println(seen);
}
v0.6+ — current idiomatic form.
// Raw pointer types work at FFI boundaries — obtaining one compiles fine.
// Dereferencing is a separate story; see below.
extern "C" {
    fn hew_alloc(size: u64) -> *mut i64;
}
 
fn main() {
    let _handle: *mut i64 = unsafe {
        hew_alloc(8)
    };
    // Dereferencing the pointer is rejected even inside unsafe { }:
    //   let seen = unsafe { *_handle };
    //   error: raw pointer dereference is not lowered to HIR/MIR/codegen
}

Note that the deref is rejected even though it’s inside unsafe { }. Outside unsafe, you’d get the “wrap this in an unsafe block” diagnostic that extern calls already produce. Inside it, you get a different one: the compiler has no HIR/MIR/codegen lowering for a raw-pointer dereference yet, so rather than emit something plausible-looking, it refuses. The unsafe block is the right place for the operation to live once it’s real; it just isn’t real for v0.5. Marking it rejected now means the day someone tries it, they get told, instead of getting a binary that reads garbage off an integer the runtime handed back.

Closures that are actors

The closure work has a runtime half I’m more excited about than the syntax. There’s now a C-ABI for spawning an actor from a closure — a lambda-actor — with ask/tell dispatch. At the language level it looks like this:

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
let worker = actor |msg: i64| {
    println(msg * 2);
};
worker.send(21);

tell enqueues a message and returns. ask frames a reply-channel pointer ahead of the payload, blocks until the body answers, and hands back the reply. The interesting constraint was lifetime. The external handle owns the send half and the inner state; the dispatch thread holds only the inner state. So when the last external handle drops, the mailbox closes, and the body thread exits on its own — no hold-open cycle where the actor keeps itself alive forever because it has a reference to its own mailbox. That’s the weak-stop guarantee: dropping your last handle is sufficient to stop it.

The body runs across the FFI boundary declared extern "C-unwind", so a Rust panic in the body unwinds instead of aborting the process, and the dispatch layer catches it. A panicked body retires its reply channel, and an ask against a retired channel comes back as a distinct OrphanedAsk error rather than a clean Ok with junk in it. The reply payload is libc-malloc’d with a deep copy, so it crosses the boundary owned by the allocator the caller will free it with — and there’s a dedicated free function for reply payloads, because calling the duplex free on a reply payload would be the wrong allocator and exactly the kind of mismatch that doesn’t crash until it does.

TCP as a stream

Last one, and this is the piece that’s furthest ahead on paper relative to what actually runs.

The idea is that a TCP connection should be consumable as a Stream<bytes> — network reads flowing through the same surface as every other stream, so a select{} arm can multiplex a socket against other streams without anyone writing a poll loop by hand. The surface looks like this:

import std.net;
 
fn main() {
    let conn = net.connect("127.0.0.1:9000");
    let (incoming, _outgoing) = conn.into_stream_sink();
    match incoming.recv() {
        .Some(chunk) => println(chunk.len()),
        .None => println("peer closed"),
    }
}

into_stream_sink consumes the connection and hands back a (Stream<bytes>, Sink<bytes>) pair backed by the same socket — two clones over one fd, with the original handle released from the connection table so the clones own it. On the runtime side, the stream loops on EINTR, surfaces WouldBlock as EAGAIN and a read timeout as ETIMEDOUT through a thread-local errno, and reports clean EOF as errno zero — so a transient pause is distinguishable from the peer actually hanging up. On wasm32 the type checker rejects connection methods outright, before any codegen runs.

I’m being careful with the tense here because the type surface and the runtime backing both landed today, but composing them end-to-end through codegen isn’t finished — try to run that recv and you’ll hit a fail-closed boundary, not a byte. The select{} stream-next arm is what would make the multiplexing actually pay off; I expected that to be same-day work. The shape is right; the wiring is in progress.

(Update, June 2026: half right. The wiring caught up — that exact snippet compiles and runs natively now, printing peer closed when nothing is listening. The select{} stream arm went the other way: edition 2026 sealed select to three arm sources — actor ask, channel recv(), and after — and a stream-next arm is rejected at check time, deferred to a future edition with the rest of its substrate. So the multiplexing payoff predicted here didn’t land in v0.5.)

A turnstile that steps and a closure you can pass around both run today, though. That part I checked by running them.

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