The plan was a custom MLIR dialect. hew.spawn, hew.send, ownership facts as first-class operations, lowered progressively through the standard dialects until they hit LLVM. I wrote that down in a 204-line design document at 1 AM back in February, threw away a working Rust compiler to build it, and had Fibonacci running through the MLIR pipeline twelve hours later. For a while it felt like the right bet — a real IR that could carry semantic meaning all the way down, room for GPU and polyhedral passes later, a verifier at every level.
I deleted all of it this week. 1,568 files — the C++, the TableGen, and the fixture corpus that fed them — gone in one commit. The thing that killed it wasn’t a benchmark or a missing feature. It was a double-free that wouldn’t stay dead.
The bug that took six revisions
Here’s roughly the program that started it — a function returning a string moved out of a record field:
record User {
name: string,
id: i64,
}
fn display_name(u: User) -> string {
u.name
}
fn main() -> i64 {
let u = User { name: "ada", id: 1 };
let n = display_name(u);
n.len()
}u.name reads a field and hands the string back out. Ownership of that allocation has to move out of u — the returned n owns it now, and the u that’s about to go out of scope must not also try to free it. Get that wrong and you get a double-free: two free calls on the same pointer, the second one landing on whatever the allocator handed out in between. The crash never surfaces at the bug. It surfaces three function calls later in something unrelated, which is the worst kind.
I fixed it. Then I fixed it again, because a slightly different shape — a to_string that materialized a fresh owner — was still wrong. That one took four revisions on its own. The borrowed-field case — misnamed, because nothing borrows here, the string moves; but the name stuck and I’m keeping it — took six. And every single revision had the same shape: I’d add another case to a C++ walker that re-derived, at codegen time, which values needed freeing and which had moved their contents elsewhere.
That walker was the problem, and it took me six revisions to see it.
Why the registry kept growing
The architecture had a seam in the middle of it. The Rust frontend type-checked the program, figured out ownership — who owns what, what’s moved, where the drops go — and then serialized all of that across a MessagePack boundary into the C++ MLIR backend. MessagePack is a compact binary format; that part was fine. The problem was what crossed.
The ownership decision didn’t cross. What crossed was enough of the program for the C++ side to re-derive the ownership decision. So the backend had a walker — a registry of pattern handlers — that looked at each value and re-answered “does this need a drop?” by inspecting its shape. The frontend already knew the answer. It threw the answer away and shipped the question.
Every revision of the double-free was me teaching that walker one more case. Borrowed field: add a handler. Materialized to_string: add a handler. The closed-over capture in a lambda that returns a string: add a handler. The registry grew on every fix and shrank on none of them, and that’s the tell. When the fix for a correctness bug is “add another arm to the re-derivation,” the architecture is telling you the authority lives on the wrong side of the boundary. I had two copies of the ownership model — one in the checker, one in the walker — and they only had to disagree once to corrupt memory.
(I argued with this conclusion for a while. The MLIR dialect was a lot of work and it did real things. But “the bug count is a function of how many shapes the walker has seen” is not a property you can patch your way out of.)
What I’d already half-built
The thing that made the decision tractable is that the answer was already in tree, half-finished. The frontend doesn’t just type-check — it lowers to a mid-level IR I’d been calling MIR, and that MIR goes through a checking pass (move, init, aliasing) and then an elaboration pass that computes drops explicitly. The drops were already being decided in the right place. They just weren’t the thing crossing the boundary.
You can see them in the elaborated MIR. For the program above, dumping the elaborated stage shows the drops as first-class instructions:
Drop {
binding: BindingId(2),
name: "n",
ty: String,
},
Drop {
binding: BindingId(1),
name: "u",
ty: Named { name: "User", .. },
},Those aren’t hints. They’re the decision — this binding, this type, gets dropped here, and nothing downstream gets a vote. Note what the two drops actually say: n drops the string, and u drops the record — whose drop no longer covers the name field, because the elaborator already knows that field moved out. One allocation, one owner, one Drop. There’s no walker to disagree with, because there’s no second model to disagree.
So the cutover wasn’t “build a new backend.” It was “stop re-deriving in C++ what MIR already decided, and lower MIR straight to LLVM IR.” No dialect in the middle. No msgpack boundary for ownership to get lost across. One language, frontend to object file.
Convincing myself before deleting anything
Deleting 1,568 files on a hunch is how you ship a miscompiler. Before I touched the subtree I wrote a small set of fixtures whose entire job was to prove the MIR-direct path got ownership right, and to make a miscompile impossible to hide.
The trick is computed exit codes. A v0.5 program’s main returns i64, and on Unix the process exit code is the low 8 bits of that return. So a fixture computes a value that’s only correct if every drop, move, and borrow landed exactly right, returns it, and the test asserts the exact exit code. If ownership is wrong — a string freed twice, a value dropped that should’ve moved — the program either crashes or returns the wrong number. There’s no “looks fine” middle ground.
fn add(a: i64, b: i64) -> i64 {
a + b
}
fn main() -> i64 {
add(40, 2)
}That one exits 42. Trivial, but it’s the spine: build it, run it, check $? == 42. The real fixtures do the same thing through strings, records, vectors of owned elements, and the borrowed-field case that started all this. Five discriminating fixtures, each one built two ways — native and wasm32-unknown-unknown — because a bug that only shows up on one target is still a bug. Zero new side tables. If the MIR-direct path needed me to invent a new channel to carry ownership, that would’ve been the walker all over again, and I’d have stopped.
It didn’t. The drops were already in the MIR. Codegen reads ElabDrop { drop_fn: Some(name), .. } and emits the call — and name comes from MIR, not from re-inspecting the value. For the borrowed-field program, the emitted LLVM IR has exactly one drop call:
call void @hew_string_drop(ptr %drop_f0)One owner, one drop. The u the string moved out of never frees it — u still gets a Drop of its own for the record, but that drop no longer covers the field that moved away. The double-free I’d fixed six times stays fixed because there’s nothing left to get it wrong — codegen is no longer deciding, it’s transcribing.
There’s a belt-and-suspenders detail I kept: codegen validates drop_fn against a closed set of release symbols (hew_string_drop, hew_vec_free, a few others) before emitting any call. If the elaborator ever emits a drop symbol codegen doesn’t recognize, it refuses rather than calling into the unknown. That’s not re-derivation — it’s a fail-closed check that the two sides agree on the vocabulary, not the decision. The decision still only happens once.
(Update, June 2026: drop_fn has since grown from a bare symbol name into a typed drop descriptor — runtime ritual, release call, or user close. The shape of the deal is unchanged: MIR decides, codegen transcribes, and an unrecognized drop still fails closed.)
What I’m giving up
The cost is real.
The MLIR dialect was the path to a lot of things I now don’t have a path to. GPU offload through SPIR-V, polyhedral loop optimization, a multi-level verifier that could catch malformed IR at every lowering step instead of only at the LLVM boundary — those were all downstream of having a real dialect to hang passes on. I’ve traded a framework built for progressive, multi-target, multi-level lowering for a single straight shot from MIR to LLVM IR. If Hew ever wants to target a GPU seriously, this is a bridge I burned, and I’ll have to build a different one.
I also gave up the idea that the optimizer could reason about hew.spawn and hew.send as first-class operations. By the time codegen runs, a spawn is a runtime call like any other. The actor-lifecycle optimizations I imagined back in February need semantic information that no longer survives to a level where MLIR passes could use it — because there are no MLIR passes.
(The actor optimizations were always more aspiration than plan. I never wrote one. But it’s a different thing to defer a feature than to delete the substrate it would’ve needed.)
What I get
One language end-to-end. The whole compiler is Rust now — lexer, parser, checker, MIR, elaboration, codegen. No msgpack boundary in the middle of the pipeline, no second ownership model in a second language, no C++ build that accumulated 15 to 25 GB of cmake artifacts per checkout. The drop decision is made once, in MIR, and carried in the IR itself.
And the targets didn’t collapse to one. The same compile invocation produces native and WASM, and the target triple is the only branch point:
hew compile prog.hew # native
hew compile prog.hew --target wasm32-unknown-unknown # wasmBoth come out of the same MIR, the same drops, the same codegen. There used to be an unconditional dual-emit and a --no-wasm flag to turn half of it off; that’s gone too. WASM is opt-in now, native is the default, and there’s one canonical compile path with no compat shims. Two targets fall out of one MIR — native and WASM — without a dialect to maintain for each, and more would fall out the same way.
I kept one escape hatch, deliberately. The wasm32 runtime can’t provide every substrate symbol — TCP transport, some task-scope machinery — and rather than silently emitting a broken module, codegen fails closed with a specific error and tells you to omit the WASM target. That’s a user-facing toggle, not a compiler-internal place to hide a gap. The difference matters: the old walker was the compiler quietly papering over its own uncertainty, and the whole point of this cutover was to stop doing that.
The honest part
I’m not fully sure I made the right long-term call. The MLIR bet was about the next five years, and I traded it for the next one being correct. If Hew succeeds enough to want a GPU backend or a serious optimization story, someone — probably me — will reread this post and wince.
But I couldn’t ship a backend whose bug count was a function of how many value shapes its walker had been taught. Memory safety isn’t something you converge on by adding handlers. Either the ownership decision is made once and carried, or it’s made twice and occasionally disagrees. I had the once-and-carried version sitting half-built in the MIR the entire time. This week I finished it and deleted the other one.
The double-free is gone because there’s nothing left to make the decision twice.