Skip to content

Internals — Wasm backend (v0.2)

mty-codegen-wasm translates MtyIR into a Component Model component via wasm-encoder + wit-component. Two targets: wasm32-wasi (server-side, WASI Preview 2) and wasm32-web (browser host).

The v0.2 wave-2 release closes amendment A47: mty build --target wasm32-* now emits a Component Model component by default. Pass --no-component to fall back to a bare core module (e.g. for runtimes that don't yet support the Component Model). The generated WIT contract is emitted alongside as part of the artifact metadata (WasmArtifact::wit_text) — see WASM_CM_V0_2_NOTES.md at the repo root for the design log.

High-level pipeline

MtyIR Program ─► core wasm bytes ─┐
                                ├─► embed `component-type` ─► ComponentEncoder ─► component .wasm
generated WIT ──────────────────┘
  1. compile_program_to_bytes lowers MtyIR → core Wasm (slice-8 path).
  2. wit::emit_wit generates a textual WIT document for the package.
  3. wit_component::embed_component_metadata writes the WIT into a component-type custom section on the core module.
  4. wit_component::ComponentEncoder reads the metadata back and produces a valid Component Model component.

Both wit-component and wit-parser are pinned to 0.225 via the workspace Cargo.toml. Bump notes live in WASM_CM_V0_2_NOTES.md.

Module shape

(module
  (type ...)              ; deduped fn signatures
  (import "mighty" "log" (func (param i32 i32)))
  (func ...)              ; user fn declarations
  (memory 16)             ; 16-page initial linear memory
  (export "memory" (memory 0))
  (export "main" (func N))
  (code ...)              ; fn bodies
  (data (i32.const 1024) "...")  ; string literal pool
)

Linear memory layout

Range Use
0..1024 reserved for shadow stack (v0.2 use)
1024.. string literal pool; grown as needed

The shadow stack is unused in slice 8 because aggregate values are not yet lowered. v0.2 will add a shadow-stack-pointer global and spill/load slots for ADTs / strings that exceed register capacity.

Type lowering

MtyIR type Wasm type
Bool, Char, Int(i32 family), IntInfer i32
Int(i64 family), Duration, Size, USize i64
Float(F32) f32
Float(F64), FloatInfer f64
Unit, Never omitted
I128 / U128 unsupported
Aggregates (struct/enum/tuple/array) i32 (linear-memory pointer)
Str / String / Bytes i32 (pointer to linear memory)
Ref, RawPtr, Cap, Dyn, Fn i32 (pointer)

The "everything becomes i32" stance for non-scalar types matches the component-model's borrow/own lowering and keeps the v0.2 wasm backend total — every example produces a validating module.

Best-effort body emission (v0.2)

The lowerer first tries to emit each function as straight-line wasm. On the first MtyIR shape it can't handle (non-chain goto, unsupported terminator, projection-store, etc.), it bails and the function body is reset to a single unreachable instruction. This preserves wasm validation while leaving correctness for compiled-and- run scenarios to the simpler examples; rich agent code still validates but the wasm host hits unreachable if it actually tries to run those handlers.

Future slices will replace the unreachable fallback with a true shadow-stack model that handles aggregates, branches, and the wider MtyIR surface.

Capability imports

Each capability surface the user touches becomes an import. Slice 8 covers log:

(import "mighty" "log" (func (param i32 ptr) (param i32 len)))

WASI bridge: the runtime's WASI host receives the call and routes to fd_write(STDERR_FILENO, ...). The web target uses a JS shim:

const imports = {
  mighty: {
    log(ptr, len) {
      const view = new Uint8Array(instance.exports.memory.buffer, ptr, len);
      console.log(new TextDecoder().decode(view));
    }
  }
};

WIT generation (v0.2)

For every build, the backend now emits a .wit document derived from the MtyIR program. The shape is:

// AUTO-GENERATED by mty-codegen-wasm.
// Source target: wasm32-wasi — Component Model output.
package mighty:<pkg-name>;

world <pkg-name>-world {
  import wasi:cli/log;          // or mighty:web/log for web target
  import mighty:caps/fs;       // for each cap family used in the program
  import mighty:caps/net;
  record point { x: s32, y: s32 }
  enum color { red, green, blue }
  variant shape { circle(f64), square(f64) }
  export main: func();
  export add: func(a: s32, b: s32) -> s32;
  // effects: Net, Time          // informational — WIT has no effect type
}

package mighty:caps { ... }     // bundled stubs so wit-parser is satisfied
package wasi:cli { ... }

Mapping rules

MtyIR shape WIT
fn foo(...) -> T (not _-prefixed) export foo: func(...) -> T; (inline in the world)
struct Point { x: i32, y: i32 } record point { x: s32, y: s32 }
enum Color { Red, Green } (no payloads) enum color { red, green }
enum Shape { Circle(f64) } (with payload) variant shape { circle(f64) }
Cap<family> param import mighty:caps/<family>;
effects(Net, Time) declaration // effects: Net, Time comment

User-authored WIT is a v0.3 task — the hooks are there (WitDocument::resolve) but no CLI surface reads it yet.

See docs/reference/wit/<pkg>.wit.md for the per-pkg template.

Component wrapping

After the WIT document is built, component::wrap_as_component:

  1. Parses the WIT via wit_parser::Resolve::push_str.
  2. Calls wit_component::embed_component_metadata to write the WIT into a component-type custom section on the core module.
  3. Invokes wit_component::ComponentEncoder::default().validate(true).module(&bytes).encode().

The returned bytes start with the component preamble \0asm\x0d\x00\x01\x00 and pass wasm-tools component validate (if installed; optional in CI).

Running the output

wasmtime only accepts components when started with --wasm component-model. The bare-core fallback path:

mty build --no-component --target wasm32-wasi examples/01_hello.sd
wasmtime target/01_hello.wasm

Browser deploys use jco transpile target/widget.wasm -o dist/ to emit ESM glue that loads the component in a Worker.

Conservative lowering

Like the Cranelift backend, the Wasm backend raises WasmError::Unsupported(reason) on shapes it can't handle. Unlike native, there is no interpreter fallback for Wasm — there's no Wasm interpreter to fall back to. Slice-8-covered surface for wasm32-wasi:

  • integer / bool arithmetic and comparisons (i32-flavored)
  • locals (local.get / local.set)
  • log("...") via the import
  • straight-line control flow (block / return)

Out-of-scope:

  • non-linear control flow (loops, multi-target br_table)
  • aggregate construction / projection
  • agent / spawn / send / ask (no wasm runtime in slice 8)
  • effect dispatch beyond log

Validation

Every emitted module is round-tripped through wasmparser::Validator in WasmArtifact::validate(). The conformance tests (tests/conformance/codegen/wasm_*) all validate before signaling success.

File output

let art = compile_program_to_file(&prog, WasmTarget::Wasi, &out_path)?;
println!("wrote {}", art.path.unwrap().display());

The WasmArtifact also returns the bytes in-memory so embedders can avoid the disk round-trip.

Running the output

mty build --target wasm32-wasi examples/01_hello.sd
wasmtime target/01_hello.wasm

(slice 8 ships byte-only Wasm; the runtime's wasmtime integration is a v0.2 task. For now the user runs the emitted module under their own wasmtime/wasmer/JS host.)

cabi_realloc allocator (v0.10, extracted v0.18)

Every emitted module exports a cabi_realloc(old, old_size, align, new) -> ptr function with the canonical-ABI signature. The Component Model lifter calls into this function to allocate space for owned heap values returned by dom.get-text, dom.query, and any future string/list-returning import. Without the export wit-component::ComponentEncoder::encode() rejects the module.

v0.9 shipped a bump-only allocator (old_ptr ignored, no deallocation). v0.10 replaced it with a segregated free-list allocator that recycles freed blocks within their size class. v0.18 extracts the body builder + size-class helper into crate::cabi_realloc so the allocator has a stable review surface independent of the rest of the emitter (KNOWN_ISSUES #1 RESOLVED). The wasm bytes are byte-identical pre/post extraction — the emitter still calls build_cabi_realloc_body() once per module and splices the result into the code section.

Module split (v0.18)

File Owns
src/cabi_realloc.rs layout constants, body builder, emit_size_class helper, unit tests
src/emit.rs::Emitter::emit declares the function type, allocates the function slot, exports cabi_realloc, initialises the bump-pointer global
tests/cabi_realloc.rs v0.18-focused integration tests (8 tests)
tests/cabi_realloc_real.rs v0.10-focused integration tests (9 tests) — kept for historical regression coverage

Memory layout

Range Use
0..1024 shadow-stack scratch (reserved; unused in v0.2 lowerer)
1024..8192 string-literal pool (data section)
8192..8224 legacy JS shim + canonical-ABI return area
8224..32768 slack for data-section growth
32768..32800 allocator state — 8 free-list heads, one i32 per size class
32800.. heap (bump-allocated; freed blocks recycled per class)

The bump pointer itself lives in wasm global 0 (mutable i32), initialised to CABI_REALLOC_HEAP_BASE = 32800.

Size classes

Eight classes, powers of 2 from 8B to 1024B:

Class Size (bytes)
0 8
1 16
2 32
3 64
4 128
5 256
6 512
7 1024

The free-list head for class i is at linear-memory offset CABI_REALLOC_STATE_BASE + i*4. The link in each free block is the first 4 bytes (next pointer; 0 ends the list). Reuse is LIFO — a freshly-freed block is the next one returned to the next allocator call of that class.

Algorithm

cabi_realloc(old, old_size, align, new):
    if new == 0:
        if old != 0: free(old, old_size)
        return 0

    p = malloc(align, new)

    if old != 0:
        memcpy(p, old, min(old_size, new))   // byte-by-byte loop
        free(old, old_size)

    return p

malloc(align, size):
    class = size_class(size)              // -1 if size > 1024
    if class >= 0 and align <= class_size(class):
        head = load(STATE_BASE + class*4)
        if head != 0:
            store(STATE_BASE + class*4, load(head))   // pop
            return head
    return bump(class >= 0 ? class_size(class) : size, align)

free(ptr, size):
    class = size_class(size)
    if class < 0: return                  // large: not freed
    head = load(STATE_BASE + class*4)
    store(ptr, head)                      // next-link
    store(STATE_BASE + class*4, ptr)      // push

bump(size, align):
    mask = align - 1
    $bump = ($bump + mask) & ~mask
    p = $bump
    $bump = $bump + size
    return p

Properties

  • Sound for power-of-2 alignments: free-list reuse only happens when align <= class_size. Since size classes are powers of 2 ≥ 8 and the free block was originally bump-allocated at class-size alignment, the recovered pointer is correctly aligned.
  • No coalescing / no splitting: a freed 1024B block stays a 1024B block. Realistic Mighty programs are dominated by short strings (8–128 B) so internal fragmentation is bounded.
  • Large path is monotonic: requests > 1024B bypass the free-list and bump only. Long-running programs that allocate a steady stream of large objects will eventually exhaust linear memory; the v0.11 upgrade adds either a true free-coalescing large path or a host-side memory.grow hook.
  • Bounded for the common case: the stress_1000_alloc_free_cycles_bounded_growth test in tests/cabi_realloc_real.rs allocates+frees a 32B block 1000 times in a row and verifies the bump pointer never advances past the first allocation. This is the canonical Component-Model pattern (lift string → use → drop).

Upgrade path

For v0.11+, replace the inline emission with one of:

  1. dlmalloc-style boundary-tagged allocator — adds inline metadata to enable splitting + coalescing across classes. Bigger wasm footprint (~2–3 KiB of emitted code) but eliminates the large-bump cliff.
  2. rlsf compiled as no-std + linked in — a third-party Rust crate (TLSF allocator) compiled to wasm and bundled as a precompiled module the codegen imports. Smaller emitted code (we only declare the import) but adds a build-time dep.
  3. cargo-component-generated cabi_realloc — let the official component tooling synthesise the allocator from a vendored Rust source. Most "correct" choice long-term but adds a build dep on a moving target.

See CLEANUP_V0_10_NOTES.md for the v0.10 decision matrix.

Testing

Two integration suites exercise cabi_realloc end-to-end (compile SIR → wasm → instantiate under wasmtime → call the export):

crates/mty-codegen-wasm/tests/cabi_realloc_real.rs (v0.10 — 9 tests):

  • fresh malloc returns aligned non-zero pointer
  • free + re-alloc same class reuses the block (LIFO order)
  • three-deep LIFO discipline within a class
  • size classes have independent free lists
  • 1000 alloc/free cycles keep memory bounded (the stress test)
  • large alloc (> 1024B) uses the bump path
  • realloc grow preserves the old bytes (memcpy correctness)
  • realloc(p, _, _, 0) returns 0 and pushes p onto its class's free list
  • static check that STATE_BASE + 8*4 == HEAP_BASE

crates/mty-codegen-wasm/tests/cabi_realloc.rs (v0.18 — 8 tests):

  • 100 mixed-size allocs return pairwise-disjoint pointers
  • alloc + free + alloc returns the same pointer (LIFO recycle)
  • realloc 16 → 32 preserves the first 16 bytes
  • realloc 64 → 32 preserves the first 32 bytes + frees the old slot
  • 100 alloc/free cycles leave linear-memory page count unchanged
  • the first alloc lands exactly at CABI_REALLOC_HEAP_BASE (bump-path proof)
  • module emission is deterministic byte-for-byte
  • state region at [STATE_BASE, HEAP_BASE) is zero-initialised

Module-local unit tests in crate::cabi_realloc::tests (3 tests) cover the layout invariants:

  • state_region_sized_for_class_heads — STATE_BASE + N*4 == HEAP_BASE
  • large_threshold_matches_top_class — the largest size class equals CABI_REALLOC_LARGE_THRESHOLD
  • build_body_smoke — the body builder runs without panicking

v0.5 dogfood — DOM lowering for wasm32-web

Closes Gap 2 in DEMOS_V0_4_NOTES.md.

The web target now declares four DOM imports under the canonical mighty:web/dom module name. These match the expanded WIT interface so wit-component::ComponentEncoder can wire the import section to the typed DOM surface.

Wasm import name Signature WIT shape
set-text (i32 ptr, i32 len, i32 ptr, i32 len) -> () set-text: func(id: string, text: string)
get-text (i32 ptr, i32 len) -> i32 get-text: func(id: string) -> string
on-click (i32 ptr, i32 len, i32 ptr, i32 len) -> () on-click: func(id: string, callback-tag: string)
query (i32 ptr, i32 len) -> i32 query: func(selector: string) -> option<string>

Each (ptr, len) pair points into the module's linear memory; the JS shim at demos/02_counter_web/web/dom-shim.js decodes them via TextDecoder. Return strings come back through a caller-allocated scratch buffer at offset 8192 (RETURN_BUF_OFFSET in the shim).

The legacy get-element-by-id / set-text-handle imports remain in the WIT for back-compat with the v0.4 loader.

Emitter state

Emitter now carries four optional indices (dom_set_text_idx, dom_get_text_idx, dom_on_click_idx, dom_query_idx), populated inside declare_imports when target == WasmTarget::Web. The emit_dom_call(op, &mut wfn) helper translates a MtyIR method name like dom.set_text into a Call(idx) instruction; it's reserved for use when the MtyIR lowerer wires BuiltinId::Dom(...) calls in v0.6. The v0.5 ship-bar is the import declaration + WIT contract (verified by crates/mty-codegen-wasm/tests/dom_imports.rs); the emitted module exposes the imports so a host shim can answer them even before the MtyIR-side lowering catches up.