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 ──────────────────┘
compile_program_to_byteslowers MtyIR → core Wasm (slice-8 path).wit::emit_witgenerates a textual WIT document for the package.wit_component::embed_component_metadatawrites the WIT into acomponent-typecustom section on the core module.wit_component::ComponentEncoderreads 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:
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:
- Parses the WIT via
wit_parser::Resolve::push_str. - Calls
wit_component::embed_component_metadatato write the WIT into acomponent-typecustom section on the core module. - 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:
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¶
(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.growhook. - Bounded for the common case: the
stress_1000_alloc_free_cycles_bounded_growthtest intests/cabi_realloc_real.rsallocates+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:
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.rlsfcompiled 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.cargo-component-generatedcabi_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_BASElarge_threshold_matches_top_class— the largest size class equalsCABI_REALLOC_LARGE_THRESHOLDbuild_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.