WASM Component ABI — mapping a fidius interface to WIT¶
Status: design (FIDIUS-I-0021 Phase 2, T-0101). The WASM executor (T-0102) and loader (T-0103) implement this. Path B (Component Model + WIT) was chosen in ADR FIDIUS-A-0003 for polyglot authoring.
This explains how a fidius plugin interface projects onto a WebAssembly component described in WIT, how the host dispatches calls into it, and how it stays consistent with the cdylib and Python backends.
Bindings strategy (decided)¶
For Phase 2: hand-authored WIT + dynamic dispatch. No build-time codegen.
- The plugin's contract is a
.witfile. A plugin author (in any language) implements that world and ships a.wasmcomponent. - The host does not generate per-interface Rust bindings with
wit-bindgen. InsteadWasmComponentExecutordispatches dynamically through wasmtime'scomponent::Func::call(&mut store, &[Val], &mut [Val]), looking the export up by name (declaration order = the same index the cdylib vtable and the Python loader use). This matches fidius's existing by-indexcall_method(index, ..)model and avoids a build-time codegen step in the host.
Implemented (FIDIUS-I-0023): Rust authors get their .wit for free — a
build.rs calling fidius_build::emit_wit() (or the fidius wit CLI) renders the
WIT and the generated↔author conversions from the trait + #[derive(WitType)]
types, which #[plugin_impl]'s adapter consumes. (A proc-macro can't read external
type definitions, so this runs from build.rs rather than the macro itself.) The
dynamic host path is unaffected — it still consumes any conforming component by index.
Rationale: dynamic Val keeps the host generic over interfaces (one code path
for all plugins, dispatched by index), mirrors the cdylib/Python dispatch, and
sidesteps committing the host build to a specific WIT toolchain version. The
cost — runtime Value ↔ Val marshalling instead of compile-time-typed
bindings — is the same shape of work the other backends already do.
Dispatch model¶
A fidius trait method at vtable index i maps to a component export, in
declaration order. The host's generic call_method<I, O> already tuple-packs a
method's arguments into one value; through the executor that becomes:
call_method::<I,O>(i, &args)
-> to_value(&args) // I -> fidius_core::Value (Value::List of positional args)
-> WasmComponentExecutor::call(i, value)
-> map Value::List elements -> &[component::Val] (positional params)
-> func_i.call(&mut store, ¶ms, &mut results)
-> results[0] : component::Val -> fidius_core::Value
-> from_value::<O>(value) // Value -> O
#[wire(raw)] methods bypass the typed path: call_method_raw(i, &[u8])
dispatches an export whose signature is func(list<u8>) -> list<u8>, so opaque
bulk bytes cross as a WIT list<u8> with no per-element marshalling. Opaque
bytes are language-neutral, so this stays uniform with cdylib/Python.
Type mapping (fidius Value ↔ WIT)¶
The fidius_core::Value variant set was deliberately shaped to the Component
Model value space (FIDIUS-T-0096), so the mapping is close to 1:1:
fidius Value |
serde / Rust source | WIT type | wasmtime Val |
|---|---|---|---|
Bool |
bool |
bool |
Bool |
S8/S16/S32/S64 |
i8..i64 |
s8/s16/s32/s64 |
S8..S64 |
U8/U16/U32/U64 |
u8..u64 |
u8/u16/u32/u64 |
U8..U64 |
F32/F64 |
f32/f64 |
f32/f64 |
Float32/Float64 |
Char |
char |
char |
Char |
String |
String/&str |
string |
String |
Bytes |
&[u8] (serde_bytes) |
list<u8> |
List of U8 |
List |
Vec<T>, tuples |
list<T> / tuple<..> |
List / Tuple |
Record |
structs, string maps | record { .. } |
Record |
Option(_) |
Option<T> |
option<T> |
Option |
Variant{name,..} |
enums | variant { .. } / enum |
Variant / Enum |
Unit |
(), unit structs |
(empty tuple) | Tuple([]) |
Notes:
- Maps — HashMap<K, V> / BTreeMap<K, V> have no native WIT type; they
project to list<tuple<K, V>> (PC.1), with any key type, not just strings.
A string-keyed map may surface as a Record; both lower to the same list<tuple>,
and a returned list<tuple> deserializes back into either a HashMap or a
Vec<(K, V)>. Insertion order is not preserved.
- Tuples — (A, B, …) map to WIT tuple<a, b, …>. Because a Rust tuple and a
Vec both surface as Value::List, the executor uses the export's wasmtime param
types to lower a tuple to Val::Tuple (vs a list to Val::List).
- The executor derives each export's expected param/result types from the
component's own type information (wasmtime exposes them), and uses those to
drive the Value → Val lowering (e.g. a u32 param vs s64, or a tuple vs a list).
Not supported at the boundary¶
A few Component-Model shapes have no fidius_core::Value representation and are not
supported in a plugin interface today (FIDIUS-T-0158):
flags(a named bit-set) andresource(an opaque handle) — fidius interfaces are authored in Rust and don't surface these, so there is noValuemapping. ⚠️ If a component does return one,val_to_valuecurrently has no arm for it and falls through to a debug-string (Value::String("Flags(..)")) — i.e. it is not round-trippable. Don't putflags/resourcein an interface. (fidius's own streaming resources are an internal mechanism — see streaming — and are not user-facing types.)- Reference / borrowed arguments (
&str,&[u8], …) — rejected by#[plugin_impl]; take owned types (String,Vec<u8>). Data is copied across the sandbox boundary regardless, so a borrow would save nothing.
User-defined types — records & variants (FIDIUS-I-0023)¶
A plugin author's own struct/enum types in an interface map to WIT
record/variant when annotated with #[derive(WitType)]:
| Rust | WIT |
|---|---|
struct P { x: i32, y: i32 } |
record p { x: s32, y: s32 } |
enum S { Circle(u32), Rect(P), Dot } |
variant s { circle(u32), rect(p), dot } |
Two constraints shape the implementation:
-
A proc-macro can't see external type definitions.
#[plugin_impl]sees only the method signatures (type names), not the fields ofP. Andwit_bindgen::generate!{ inline }needs the complete WIT as a literal at expansion. So the records/variants can't be assembled inside the macro — they are generated from the source by a build step (fidius_build::emit_wit()inbuild.rs, sharing thefidius-witgenerator with thefidius witCLI). It writeswit/<iface>.wit; the adapter consumes it viagenerate!{ path: "wit" }. -
wit-bindgen won't remap an exported interface's types onto your structs (its
withoption is for imports). The guest therefore uses wit-bindgen's generated types, andfidius-witalso emitsFromconversions both ways (exports::…::P ↔ crate::P, recursing throughVec/Option/nested types). Thebuild.rswrites them to$OUT_DIR; the adapterinclude!s them and converts at theGuestboundary.#[derive(WitType)]itself is just a marker the generator reads — it emits no code.
Name normalization. WIT uses kebab-case; serde produces snake_case fields and
PascalCase enum variants. The executor normalizes record-field and variant-case
names at the Value ↔ Val boundary (to_kebab inbound; kebab → snake for
fields and kebab → PascalCase for variants outbound), so a host Shape::Circle
matches the WIT circle case and a y_pos field matches y-pos.
The same #[derive(WitType)] type still crosses the cdylib/Python boundary
unchanged (via serde/bincode) — the records/variants are the WASM projection
only.
Enum case shapes. A unit case → case; a single-field case → case(type);
a struct-style case (Case { .. }) synthesizes a record <enum>-<case> and
maps to case(<enum>-<case>). A multi-field tuple case (Case(A, B)) is
rejected: a WIT case takes one payload, and serde serializes a multi-field tuple
as a sequence (not a record), so it can't round-trip — use a struct case.
Where types live. #[derive(WitType)] types may be in submodules; the
generator follows inline mod m { .. } and external mod m; files
(m.rs / m/mod.rs) and emits conversions against each type's real module path
(crate::<mod::path>::<T>). Limit: record/variant names share one flat WIT
namespace, so type names must be unique across the interface.
Fallible methods¶
A fidius method returning Result<T, PluginError> maps to a WIT
func(..) -> result<T, plugin-error> where:
The host maps result::err(plugin-error) → CallError::Plugin(PluginError{..})
— behaviour-identical to the cdylib STATUS_PLUGIN_ERROR path and the Python
exception path. A wasmtime trap (panic/unreachable/OOB) maps to
CallError::Backend { runtime: "wasm", message } (the variant added in
FIDIUS-T-0095).
Interface-hash validation (integrity, not security)¶
cdylib bakes an interface_hash (FNV-1a over sorted method signatures) into its
descriptor; Python exports __interface_hash__. The WASM component does the
equivalent by exporting:
At load the host calls it and rejects a mismatch against the expected hash
(LoadError-level rejection, same guarantee as the other two backends). This is
an integrity check (catch wrong/incompatible interface), not a security
control — Ed25519 signing remains the security boundary and is artifact-agnostic
(.wasm is signed exactly like a cdylib/.fid).
Reference WIT¶
tests/wasm-fixtures/greeter/wit/world.wit is the reference contract used by the
Phase-2 test components (T-0102 Rust guest, T-0105 non-Rust guest). It exercises
a typed method, a #[wire(raw)] method, a fallible method, and the hash carrier.