Skip to main content

WASM parity for free, almost: four targets from one MIR and the seams that weren't free

· 10 min read
deep-dive: Part 3 of 7

The backend is inkwell over LLVM, and the IR I hand it is identical for every target. I lower HIR to MIR once, walk that MIR into textual LLVM IR once, and the .ll file I get out is the same bytes whether the program is heading for a native binary or a .wasm. The four targets in the title are x86_64 and aarch64 on the native side, and freestanding wasm32 and WASI on the other — but the split that matters is native versus wasm, and the only place those diverge is the last step: which TargetMachine consumes the IR. (Almost the only place — object emission rebuilds the LLVM module per target, and runtime FFI declarations pick up the target’s size_t width in that rebuild, i32 on wasm32 against i64 native. For the programs in this post, the rebuild changes nothing.)

Here’s the shape of emit_module, with the forensic detail trimmed:

// Build the textual IR once and reuse it for both targets.
emit_textual(pipeline, module_name, &ll_path)?;

if options.native {
    let triple = native_emission_triple();
    emit_object_in_process(pipeline, module_name, &triple, &obj_path)?;
}

if options.wasm {
    emit_object_in_process(pipeline, module_name, "wasm32-unknown-unknown", &wasm_obj_path)?;
    // ... freestanding wasm-ld pass
}

Two calls into the same emit_object_in_process, the same pipeline, different triple strings. That’s the whole branch. Everything upstream of it — the type checker, the move/init/aliasing checks on MIR, the drop elaboration, the LLVM IR walk — runs once and knows nothing about which target it’s serving.

(Update, June 2026: the real function has since grown an explicit options.target_triple override, so hew build --target arm64-apple-darwin or x86_64-pc-windows-gnu can cross-emit instead of always falling back to the host triple. The shape above is what shipped in May.)

This is why I can say WASM support was mostly free, and mean it. The bulk of a backend is the lowering, and the lowering doesn’t care. A program like this:

// Companion to hello_int.hew that proves the exit code is computed —
// the lowering emits a real integer add rather than a hard-coded constant.
fn main() -> i64 {
    let a = 10;
    let b = 32;
    a + b
}

emits one .ll, and from that one .ll I get a native binary that returns 42 and a wasm32-unknown-unknown object that, linked, does the same. hew run gives me exit code 42; the WASI runtime gives me 42 printed. I didn’t write a second code generator to make that happen. I wrote one, and pointed it at a different triple.

The reason that works at all — the reason a single MIR is even portable — is that Hew has a deterministic, no-GC memory model. There’s no tracing collector that needs a target-specific stack-walking strategy, no write barriers that compile differently per platform, no runtime that has to know the host’s threading primitives just to reclaim memory. Allocation goes through actor arenas with explicit caps, drops are elaborated into the MIR as ordinary instructions, and the lifetime of every value is decided before codegen. Portability isn’t a feature I added to the backend. It’s a consequence of the memory model being boring on purpose.

So that’s the free part. The rest of this post is the parts that weren’t.

Traps have no longjmp on WASM

When an actor exceeds its heap cap, native Hew unwinds through a longjmp seam. The arena allocator returns null, stamps a trap code on the actor’s error_code, and longjmps back to the dispatch boundary, where the supervisor reads the code and turns it into a named ExitReason::HeapExceeded.

WASM has no longjmp. There’s no setjmp/longjmp pair to jump back to; the cooperative scheduler is a normal call stack with no way to non-locally unwind to a saved point. So the same condition has to surface a different way: on wasm32 the arena stamps the same trap code on the current actor and panics, and the activation loop’s catch_unwind boundary catches it and transitions the actor to Crashed. Two completely different mechanisms — longjmp on one side, panic-and-catch on the other — that have to produce the same observable outcome.

The thing that makes them agree is that the trap code is canonical, defined once, and shared by both paths:

pub enum ExitReason {
    HeapExceeded,        // error code 200
    IntegerOverflow,     // error code 201
    DivideByZero,        // 202
    // ...
}

HeapExceeded is 200 whether you got there by longjmp or by panic. The supervisor doesn’t know or care which target it’s running on — it reads error_code, runs it through ExitReason::from_error_code, and gets the same answer. The divergence is entirely below that line.

Integer overflow is the cleaner illustration because it’s the same IR both ways. Here’s a multiply the compiler can’t fold away:

fn squarer(x: i64) -> i64 {
    x * x
}
 
fn main() -> i64 {
    squarer(3037000500)
}

3037000500 squared overflows i64. The lowering emits a checked multiply, and the resulting IR — the one .ll that feeds both targets — looks like this:

%with_overflow = call { i64, i1 } @llvm.smul.with.overflow.i64(i64 %checked_lhs, i64 %checked_rhs)
; ... branch on the overflow bit ...
call void @hew_trap_with_code(i32 201)   ; IntegerOverflow
call void @llvm.trap()

hew run on that program exits 1 — the trap fires, the program dies. The wasm32-unknown-unknown object carries the identical @hew_trap_with_code(i32 201) and @llvm.trap() sequence, because it’s the same IR; what differs is only the runtime that hew_trap_with_code resolves to. The native runtime stamps and longjmps; the wasm runtime stamps and panics. Same code, same ExitReason, two mechanisms.

I’ll be honest about one thing here: a freestanding wasm32-unknown-unknown module imports hew_trap_with_code from the host, so you can’t just wasmtime --invoke main it and read the exit code — it’ll fail to instantiate with an unknown import until the runtime symbols are provided. The genuine end-to-end execution path is the WASI runtime, and the trap-code agreement between the two paths is what the runtime’s parity tests pin down, not a standalone wasmtime invocation. I’d rather say that than show you a fake clean exit code.

Blocking recv is a gap I’d rather catch at compile time

Some things genuinely can’t run on WASM yet, and the worst possible way to handle that is to compile the program, link it, run it, and hit an unreachable!() deep in the runtime. That’s a runtime surprise for something I knew at compile time. So those get a pre-pass that fails closed.

The clearest case is blocking channel receive. On native, Receiver<T>::recv parks the calling context until a message arrives. On wasm32 the cooperative scheduler doesn’t yet have the yield/resume machinery to suspend on an empty-but-live channel — the runtime path is unreachable!(). Rather than let that through, the checker rejects it:

import std.channel;
 
fn main() -> i64 {
    let (tx, rx) = channel.new(8);
    tx.send("ready");
    match rx.recv() {        // blocking — rejected for wasm32
        .Some(_msg) => 0,
        .None => 1,
    }
}

That program parses, and on native there’s a runtime path for it. Point the checker at WASM and it stops before any of that matters:

error: Blocking channel receive operations are not supported on WASM32 —
       Receiver<T>::recv still requires cooperative scheduler yield/resume
       on wasm32; use try_recv or the actor ask pattern instead
 6 |     match rx.recv() {
   |           ^^^^^^^^^^
  = help: See docs/wasm-capability-matrix.md for the capability tier table.
  = help: Consider using basic actors (spawn/send/ask) which work on WASM.

The non-blocking try_recv is allowed, because it never suspends — it returns None on an empty channel instead of parking. So the diagnostic isn’t “channels don’t work on WASM,” it’s “this operation suspends and we can’t suspend yet, here’s the one that doesn’t.”

There’s a maintenance trap in writing a gate like this, which is that the runtime panic sites it’s protecting are still there. I left them in deliberately as defense-in-depth — if anything routes around the checker (raw FFI, an external runtime caller, some future code path I haven’t thought of), the runtime still traps cleanly instead of doing something undefined. The compile-time gate is the lock on the front door; the runtime trap is the deadbolt. I don’t want to remove the deadbolt just because I installed a lock.

There’s a seam in the current implementation: the gate matches on the name recv before the receiver’s resolved type is available at that point in lowering, so a user method literally named recv on a non-channel type would be a false positive. Narrowing it by resolved type is a follow-up. It hasn’t bitten anyone, so it’s waiting its turn.

(Update, June 2026: the type-checker side of this gate is now scoped to the Receiver builtin, so it no longer fires on names alone — but a second, HIR-level gate still does. A record with its own method named recv passes native check and fails wasm32 check with the blocking-recv error. The false positive moved down a layer; it didn’t die.)

Not everything WASM-divergent is an error, though. Timers are a warning, not a reject, because they do have a real implementation on wasm32 — it’s just architecturally different. sleep_ms parks the actor at the message boundary rather than mid-handler, and #[every(duration)] handlers fire only when the host drives the timer queue:

warning: Timer operations are not supported on WASM32 — timers are
         cooperative on wasm32: sleep parks at the message boundary, and
         #[every(duration)] handlers fire only when the host drives the
         timer queue

A duration-based select arm is the portable way to express a deadline without leaning on a native clock call. The compiler turns after 100ms into a timer the scheduler owns, so the same source works on both targets — the warning is just telling you the when is cooperative on WASM, not preemptive:

actor Worker {
    receive fn compute() -> i64 {
        42
    }
}
 
fn main() -> i64 {
    let w = spawn Worker;
    select {
        reply from w.compute() => reply,
        after 100ms => 0,
    }
}

The deadline is expressed in the language, not pulled from clock_gettime, which is precisely why it survives the trip to a target that doesn’t have one.

(Update, June 2026: this particular snippet no longer makes it to the timer warning on wasm32 — it has an actor in it, and actors are now rejected fail-closed for wasm32 at both check and compile; see the updates at the end of the post. The deadline mechanism itself is target-independent — after lowers to a scheduler-owned timer regardless of triple — but you need a non-actor program to carry it onto wasm32 today.)

The bug that only exists on WASM

The most WASM-specific thing I ran into wasn’t a missing feature. It was a use-after-free that the native scheduler structurally can’t have, because the two schedulers tear actors down in a different order.

The wasm scheduler keeps a run queue. When an actor sleeps and its timer later fires, the actor goes back into that queue as Runnable — but it isn’t necessarily drained immediately. If you free the actor’s mailbox while it’s still sitting enqueued, you’ve left a dangling pointer in the queue. Then shutdown comes along, drains the queue, and reads a.mailbox to try a final receive — on memory that’s already gone.

The pointer isn’t even null at that point, so the obvious if !mailbox.is_null() guard sails right past it. It’s non-null and pointing at freed memory. A heap use-after-free that the guard is specifically supposed to prevent, defeated by ordering.

The fix is an ordering invariant: drain or shut down the scheduler before freeing the mailbox, so the queue no longer references it; and on the production cleanup path, null the actor’s mailbox slot before freeing the box, so any straggler read of the slot hits the null-guard instead of freed memory. I wrote it down at the activation site as a contract rather than a comment, because “this pointer must stay live until the actor leaves the run queue” is the kind of thing that’s invisible until you violate it:

// Invariant (teardown-order contract): a non-null `mailbox` pointer must
// remain live for the actor's entire scheduler lifetime — i.e. until the
// actor has been removed from the run queue by a drain or shutdown.
//
// Freeing the mailbox while the actor is still Runnable in RUN_QUEUE is a
// heap-use-after-free — the pointer remains non-null (so this guard does
// not fire) but points to freed memory.

The native scheduler doesn’t have this exact failure mode because it doesn’t park and re-enqueue actors through a single-threaded run queue the same way — its teardown sequencing is different enough that this particular dangling-read window doesn’t open. So this is a bug that lives entirely in the WASM world, found in the WASM world, fixed in the WASM world. “Write once, run anywhere” is mostly true here, and then there’s the scheduler, where the anywhere has teeth.

The seam I’m least happy about

There’s one more place where the WASM target story isn’t as clean as the IR story.

The actor coroutine gate — the pre-pass that rejects actors and supervisors on architectures without coroutine support — keys off the target architecture, and right now hew compile --target wasm32-unknown-unknown runs that gate against the host architecture rather than the requested one. So hew check --target wasm32-unknown-unknown on an actor program will tell you actor 'Counter' requires coroutine support (x86_64/aarch64 only), while hew compile for the same triple happily lowers the actor and emits a .wasm. The two commands disagree about whether the program is allowed, because one of them is asking the wrong question. The check is right; the compile is using the wrong target arch for the gate. That’s a thread that needs pulling — the requested triple should drive the gate everywhere, not just in check.

(Update, June 2026: the thread got pulled. The requested triple now drives the gate everywhere — hew compile --target wasm32-unknown-unknown rejects an actor program exactly as check does, both with error: E_HIR: actor 'Worker' requires the actor runtime ABI (x86_64/aarch64 only). The diagnostic outgrew its “coroutine support” wording along the way. The two commands no longer disagree.)

And the WASI execution path for actors isn’t all the way there either: the runtime archive I link against doesn’t yet export hew_actor_send_by_id for wasm32, so a program that sends between actors links fine natively but fails at wasm-ld time on WASI. Basic actors are on the supported list for the WASI tier; closing the link gap is what makes that list true rather than aspirational.

(Update, June 2026: this one resolved the other way. hew_actor_send_by_id is still dead on wasm32, but you can no longer reach the wasm-ld failure — actors of any kind are rejected fail-closed at HIR lowering for wasm32, and wasm actor parity is deferred as a whole rather than chipped at link error by link error. Basic actors came off the WASI supported list instead of the list becoming true. Not the ending I wanted for this seam, but it’s an honest one: the gap is named and gated up front, not discovered at link time.)

So the headline holds — one MIR, one IR, branch at the TargetMachine, and most of WASM falls out for free. But “for free” is doing some load-bearing work in that sentence. The free part is real and it’s most of the surface. The parts that cost something are the ones where WASM isn’t a different output of the same machine, it’s a different machine: no longjmp, a cooperative scheduler with its own teardown order, a runtime that’s a strict subset of the native one. Those are the seams, and they don’t lower for free no matter how good the IR is.

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