id: SMH-RESEARCH-WASM0001 type: research title: "Embedded language: wasm components"
Embedded language: wasm components
Question
Replace the planned mlua + LuaJIT plugin runtime with wasmtime + WIT components?
Requirements:
- perf
- type safety
- tooling
- ecosystem reuse
- sandbox (RULES: plugins run sandboxed)
- determinism (MISSION: CBOR sessions, replay)
Candidate matrix
Scores 0-3, judgement, not measurement.
| candidate | perf | types | tooling | reuse | sandbox | determinism | migration cost |
|---|---|---|---|---|---|---|---|
| LuaJIT + LuaLS | 2 | 1 | 1 | 0 | 1 | 2 | 0 |
| Luau via mlua | 2 | 2 | 2 | 0 | 3 | 3 | 1 |
| wasm components | 2.5 | 3 | 3 | 2 | 3 | 3 | 3 |
| native Rust dylibs | 3 | 3 | 3 | 3 | 0 | 3 | 2 |
| V8 / TS | 2 | 2 | 3 | 2 | 2 | 1 | 3 |
Decision (human): wasm components, Rust guests v1.
Current state in repo
- mlua + mlua-pkg removed from root Cargo.toml (2026 wasm decision).
- Zero
.rsfiles ever referenced mlua or lua. - Migration cost was dep swap + law text. No code migration.
- Migration cost = dep swap + law text. No code migration.
Runtime: wasmtime vs wasmi
Facts:
- wasmtime: Cranelift JIT, component model support, WIT bindgen host API in
wasmtime::component. - wasmi: pure interpreter, 1.0 stable 2025-12, wasmtime-compatible API direction.
- Interpreter vs JIT gap: JIT ~4x faster on ARM, ~8x on x86 (wasmi benchmark blog, 2024; wasmi 1.0 thread, 2025).
- Both pure Rust → build on aarch64 termux without luajit cross-compile pain.
- wasmi lacks full component model support → guest WIT bindings story weaker.
Candidates:
- wasmtime: primary. Components + determinism knobs + resource limiters.
- wasmi: fallback only if binary size or build time becomes blocking; loses component model.
To measure on target device (aarch64 termux):
- release build time delta, mlua/luajit out vs wasmtime in
- binary size delta
- layout callback batch latency
Determinism knobs (wasmtime)
From wasmtime docs (Deterministic Wasm Execution):
- All imports must be deterministic → smith host API only, no ambient WASI.
Config::cranelift_nan_canonicalization→ canonical NaN.Config::relaxed_simd_deterministic(slower) or disable relaxed SIMD.- Memory/table growth nondeterminism →
Store::limiterrejects growth, or require max == min at validation via wasmparser. - Interruption: fuel = deterministic, epoch = non-deterministic → use fuel.
- WASI clocks/fs are nondeterministic → if WASI ever needed, virtualize via wasi-virt. Not needed: plugins get explicit host capabilities only.
Candidate policy:
- fuel metering everywhere (interactive + replay use same mechanism).
- NaN canonicalization on, relaxed SIMD off.
- modules validated: memories/tables max == min.
- time injected as explicit argument by host, never ambient.
- host↔guest calls recorded in CBOR session → replay verifiable.
Sandbox model
- wasm isolation: no fs/net/proc unless host grants.
- capability grants per plugin: declared in manifest, checked at instantiation, host fns gated per grant.
- resource limits:
ResourceLimiterfor memory/tables; fuel for cpu. - zed precedent: extensions run in stricter sandbox with limited capabilities, allowlist discussion in zed#12358.
Host API sketch (WIT)
Not binding. Spec phase decides.
package smith:plugin@0.1.0;
world smith-plugin {
import host; // log, granted caps: fs-scope, net-scope, secret-proxy
export init; // manifest-driven
export tools; // list, describe, invoke (recorded io)
export layout; // frame batch in, layout batch out
export themes;
export prompts;
export keymap; // predicates + bindings
}
Boundary cost
- JIT'd wasm call ≈ ns-scale for entry; cost driver is marshalling.
- Layout: batch per frame (one call, dirty regions in, constraints + widgets out) → avoids per-widget calls.
- Tools: invoke per tool call, io already event-driven → negligible.
- Keymap predicates: called per input event → keep signature tiny.
- Perf-critical widget rendering stays Rust (RULES: Rust widgets).
Guest toolchain
- cargo-component (bytecodealliance): cargo subcommand, Rust guests, wit-bindgen generated stubs.
- wasm-tools: component tooling, validation.
- jco: JS/TS guests later → npm ecosystem.
- smith ships thin wrappers only:
smith plugin init/build/check→ wrap cargo + wasm-tools. No own-language tooling.
Zed precedent
- Zed extensions = Rust compiled to wasm component, WIT-defined host API, sandboxed, extension trait guest-side.
- Same shape as smith needs: editor callbacks, themes, languages as plugins.
- Lesson: keep host API small + versioned; async boundary complexity is real → prefer batched sync calls for v1.
Open questions
- Component API stability: pin wasmtime minor version; WIT world versioning policy.
- Hot reload cost: reinstantiate module per change → expected ms-scale, to measure.
- Prompt-template heavy plugins: pure compute in wasm → fine; string-heavy marshalling cost to measure.
- User config format: decided KDL (human decision, wasm transition). Recorded in SPEC0001.
- Data-only theme/keymap packages: rejected by human. All extensions are compiled plugins.
Candidate conclusions
- wasmtime + WIT, Rust-only guests v1. → spec SMH-SPEC-WASM0001.
- No ambient WASI. Host capabilities via WIT imports only.
- Fuel metering, NaN canonicalization, no relaxed SIMD, max==min memories.
- Layout API = batched frame interface.
- Drop mlua, mlua-pkg from root Cargo.toml (needs human approval: root Cargo.toml is human-owned). → applied 2026.
- Concurrency: defer implementation, pin model now. One instance = single-threaded actor; parallelism = host-orchestrated lanes; intra-guest threads rejected.
- Versioning: additive-only minors; optional new exports; versioned worlds with deprecation window; manifest declares world version; catch-all unknown case in shared types from v1.
- Additional guest languages cost a scaffold template + bindgen only; host unchanged. js/ts via jco, go, python are first candidates.
- Plugin identity = artifact + KDL manifest; artifact content hash pinned in session traces. Runtime never sees source; provenance optional.
- No recompilation at load time. Old artifacts keep running across additive world updates; EOL artifacts fail with explicit rebuild path.
- Diagnostics contract: every lifecycle stage names stage, cause, fix. No heuristic errors.
- Registration is explicit-only: init/build never register; dev, link, install do. Dev links load by reference, override installed plugins, pin artifact hash per load.
- TypeScript via jco = compatibility canary in CI from v1. Prevents rust coupling: WIT ergonomics, promise interop, fuel ceilings, memory preallocation, language-pluggable tooling.
- To measure on jco canary: artifact size, quickjs fuel burn ratio, componentize-js memory preallocation vs max==min.
Links
- wasmtime deterministic execution: https://docs.wasmtime.dev/examples-deterministic-wasm-execution.html
- wasmtime security: https://docs.wasmtime.dev/security.html
- wasmi new engine: https://wasmi-labs.github.io/blog/posts/wasmi-v0.32/
- wasmi 1.0: https://www.reddit.com/r/rust/comments/1pd61oi/
- cargo-component: https://github.com/bytecodealliance/cargo-component
- zed extensions: https://zed.dev/blog/zed-decoded-extensions
- zed sandbox allowlist: https://github.com/zed-industries/zed/issues/12358