There’s a category of bug that doesn’t show up as a failed test. The compiler accepts your program, emits code, links it, runs it — and then somewhere deep in the runtime it hits a path that was never finished and calls unimplemented!(). The OS prints a panic and your process is gone. The user did nothing wrong. The compiler told them everything was fine right up until it wasn’t.
I spent these two days closing those seams rather than adding anything new. No feature a user would notice in a demo. Just the compiler getting harder to fool, and easier to trust.
The thing that started it
The coroutine runtime has two architectures it actually supports: x86_64 and aarch64. The context-switch code is hand-written assembly, and on any other target the relevant function is a unimplemented!(). Compile an actor program for wasm32 and the build succeeds. Run it and the first context switch aborts the process.
That’s the worst shape a compiler can have. It promises something it can’t deliver, and the failure lands at runtime, far from the line that caused it.
The rule I settled on: if the compiler can know at compile time that a program will hit an unfinished runtime path, it has to stop there and say so. Not emit code that aborts. Not emit a warning. Refuse, with a diagnostic that points at the construct.
So a pre-pass now runs over the HIR (the high-level IR, right after the type checker) and walks every declaration looking for shapes the current target can’t honour. The most severe ones I treated as P0 — the coroutine target gate and the wasm blocking-receive gate. An actor or supervisor declaration compiled for a target without coroutine support is rejected at its declaration span. A blocking .recv() on a channel, compiled for wasm, is rejected at the call. The runtime panics those gates correspond to — coro.rs and the channel-receive entry points — are now unreachable from a program that passed the check.
There’s a known sharp edge in the wasm gate I left documented rather than papered over. At this point in lowering the receiver’s resolved type isn’t available yet, so the gate matches on the method name recv syntactically. A user method literally named recv on some non-channel type would be a false positive. Narrowing it with the resolved type is follow-up work — but a spurious compile error is a far better failure than a runtime abort, so the gate ships as-is and the narrowing is a note for later.
The P1 tier
Below the aborts there’s a softer tier: shapes where the lowering machinery itself bails with a fail-closed diagnostic deep in MIR (the mid-level IR), where the error has no source context and reads like an internal compiler message. Ten of those sites lived behind task, fork, and deadline lowering, and two more behind specific call shapes: a call inside a machine transition guard, and a bare actor send on a receiver whose handler doesn’t return unit.
The fix wasn’t to remove the MIR defenses. It was to hoist the rejection earlier, into the same HIR pre-pass, so the user sees an error tied to their actual source line instead of a bare assertion three layers down. The MIR checks stay as defense in depth — if something slips past the HIR gate, MIR still catches it rather than miscompiling.
Here’s one of those gates firing. This is a range operator used in value position — 0..10 bound to a variable:
fn main() -> i64 {
let _r = 0..10;
0
}fn main() -> i64 {
var total: i64 = 0;
for i in 0..10 {
total = total + i;
}
total
}range.hew:2:14: error: E_HIR: binary operator `..` is not lowered to MIR
in value position. Range operators are accepted only as the iterable of a
`for` loop. LESSONS `boundary-fail-closed`.
2 | let _r = 0..10;
| ^^^^^
= help: HIR kind: BinaryOperatorUnsupportedInMirThe gate is precise about what it forbids. The same range, as the iterable of a loop, is completely fine:
fn main() -> i64 {
var total: i64 = 0;
for i in 0..10 {
total = total + i;
}
total
}A range value is just not a thing the backend can materialize yet, so the compiler won’t pretend it can. (The LESSONS reference in the diagnostic is me pointing future-me at the note in LESSONS.md that explains why the boundary exists, so the next time I read this code I don’t try to “fix” the rejection by removing it.)
The memory-safety sweep
The other half of these two days was a pass through the runtime looking for the ways it could corrupt memory under crash and restart conditions. Three findings stood out.
The first was an allocator-pairing problem. The runtime mixes three allocator families — libc malloc/free, Rust’s GlobalAlloc via Box, and Arc reference counts — and a pointer allocated by one must be freed by the same one. There was a local tracker in one module that asserted this for a single case; I promoted it to a runtime-wide debug-only tracker and then went through every cross-allocator call site and tagged it with a comment classifying which family it belongs to. That came out to a couple hundred sites, most of them split between libc and GlobalAlloc, plus a handful of Arc. The tracker itself is behind debug_assertions, so production builds carry zero overhead — it’s a tripwire for development and staging, where you want a loud assertion the instant a pointer crosses allocator families, not a silent heap corruption months later.
The second was a double-panic in lambda-actor teardown. When a lambda actor drops, it runs the user’s state-drop function. If that function panicked while the drop was already running during stack unwinding, you’d get a double-panic, which Rust resolves by aborting the whole process. The fix wraps the user state-drop in catch_unwind, extracts and logs the panic payload, and lets teardown continue. It required changing the drop function’s ABI to extern "C-unwind" so the panic can actually be intercepted before it reaches the C boundary — a panic hitting a plain extern "C" frame is an abort in its own right.
The third was a use-after-free hiding in supervisor restart. A supervisor copies a child’s spec bytes — including any pointers in its state fields — into its internal child record. If a permanent-restart child owns heap state (a Vec, a string, a HashMap), mutates it, then crashes, the original allocation gets freed on the way down, but the supervisor still holds the original pointer bytes. On restart it byte-copies those dangling bytes into every fresh actor, and the next teardown double-frees them.
The proper fix is a deep-clone slot emitted per actor — and that’s tracked as real architectural work. But supervisors are central to what Hew is for, and shipping a known UAF in them while the deep-clone lands wasn’t acceptable. So the checker now refuses, at compile time, the exact combination that triggers it: a permanent-policy supervisor child whose actor type has an owned-heap state field. The check is deliberately narrow — it matches the known-owned-heap top-level types and nothing else, zero false positives on copy-only types or user records. There’s a residual case it doesn’t catch: a user record that wraps an owned-heap field, used as actor state, looks like a plain named type from the outside and slips through. I wrote a test that asserts the check does not fire on that shape, precisely so the boundary is visible to whoever picks up the deep-clone work — the narrow gate and its gap are both pinned down, not left to discover.
hew check now runs the real thing
For a while hew check was lighter than hew run. It ran the type checker and stopped, which meant a program could pass check and then fail when you actually compiled it — the HIR and MIR gates I just described only ran on the full path. That’s the wrong shape for a check command. The whole point is to tell you whether your program is going to compile.
So check now runs through HIR and MIR, the same gates the build runs, and renders MIR diagnostics with source context — the line, the caret underline, the help notes — instead of the bare internal-assertion text they used to print. The range error above is check output. Before this, that diagnostic had no source line attached at all; it was an E_HIR string with a span the renderer didn’t know how to resolve.
The same diagnostic rendering is now wired into the language server, so these errors surface as you type rather than only when you run the compiler from a terminal. A fail-closed gate is only as useful as the diagnostic it produces, and a diagnostic the editor never shows you may as well not exist.
Two surfaces that got more honest
A couple of smaller things landed in the same window because they’re the same kind of work — making the compiler’s output match what it actually knows.
Node.lookup is the mesh-discovery call: you ask the node for an actor registered under a name, on this node or a gossiped peer. It used to hand back a pid directly, which is a lie — the lookup can fail, and the only honest return type is a Result. It now returns Result<RemotePid<T>, LookupError>, and the caller has to deal with the miss. Most of the program below is node setup; the part that changed is the match on found:
record Ping {
n: i64,
}
actor Worker {
var count: i64;
receive fn ping(msg: Ping) {
count = count + msg.n;
}
}
impl ActorMsg for Worker {
type Msg = Ping;
type Reply = i64;
}
fn main() -> i64 {
Node.set_transport("tcp");
Node.start("127.0.0.1:0");
let worker = spawn Worker(count: 0);
Node.register("counter", worker);
let found: Result<RemotePid<Worker>, LookupError> = Node.lookup("counter");
let status = match found {
.Ok(pid) => {
match pid.tell(Ping { n: 42 }) {
.Ok(_) => 0,
.Err(_) => 1,
}
},
.Err(_) => 2,
};
Node.shutdown();
status
}(Update, June 2026: the count field above now reads var — let actor fields became immutable after this was written, so a field you reassign in a handler has to say so. Current builds also emit a spurious orphan-rule warning on the impl ActorMsg block; Worker is defined right there in the same file, so if you run this and see it, ignore it.)
LookupError has a single variant today — NotFound. The runtime collapses every failure (null name, no node, unknown name) into one return code, so exposing more variants than the runtime can distinguish would itself be dishonest. The enum stays minimal until there’s a real distinguishing case to surface. The RemotePid<T> it hands back is a genuinely distinct type from a LocalPid<T> — remote actors don’t coerce to local ones, because the send path through them is different.
The other one is static trait dispatch. When a generic function bounded by a trait calls a method from that trait, the compiler has to figure out which concrete implementation to call:
trait Show {
fn show(val: Self) -> string;
}
type Color { r: i64; g: i64; b: i64; }
impl Show for Color {
fn show(c: Color) -> string { "Color" }
}
fn display<T: Show>(item: T) -> string {
item.show()
}
fn main() -> i64 {
let c = Color { r: 255, g: 0, b: 0 };
let s = display(c);
if s == "Color" { 0 } else { 1 }
}This resolves at monomorphization time — there’s no vtable, no runtime indirection. The dispatch goes through the impl metadata the checker already produced: it knows T is Color, it knows Color’s Show impl, it threads that resolution down through HIR and MIR as structured metadata rather than reconstructing the method symbol from the receiver’s name the way an earlier version did. And if that metadata is missing — no impl, no monomorphization — MIR fails closed with a diagnostic rather than guessing a symbol. The same instinct as everything else this week: when the compiler doesn’t actually know, it says so instead of inventing an answer.
None of this is glamorous. It’s the kind of work where the visible result of two days is that some programs that used to compile now don’t — which, when those programs were going to abort at runtime anyway, is exactly the point.