erlang_wasm

View Source

A WebAssembly runtime implemented in Erlang/OTP.

Decoding, validation, instantiation, execution, linear memory and WASI are all implemented here. This is not a binding to Wasmtime, Wasmer, WAMR or wasm3. The only native code is an optional 200-line file NIF that closes one specific security window, and the runtime works without it.

{ok, Mod}  = wasm:load_file("plugin.wasm"),
{ok, Inst} = wasm:instantiate(Mod, #{
    {~"env", ~"log"} =>
        fun(Ctx, [Ptr, Len]) ->
            {ok, Bin} = wasm:read_memory(Ctx, Ptr, Len),
            logger:info("~ts", [Bin]),
            {ok, []}
        end}),
{ok, [Result]} = wasm:call(Inst, ~"run", [42]).

Nothing raises. A malformed binary, an ill-typed module, a trap and a resource limit all come back as {error, Error} carrying a class, a machine-readable kind, the specification's message text, and context.

Status

Runs unmodified real-toolchain output: a Rust wasm32-wasip1 std binary through _start, and clang -O2 freestanding modules.

All 64,774 core specification assertions pass, across 215 suites, with an empty baseline. Not implemented is refused explicitly rather than approximated.

Implemented: WebAssembly 1.0 core, bulk memory, reference types, multi-value, multiple memories, memory64, SIMD, tail calls, typed function references, exception handling, garbage collection, relaxed SIMD, threads and shared memories, sign extension, saturating float-to-int, and 44 WASI preview 1 syscalls with capability-based filesystem and network access.

It reads both formats: the binary one and the text format, .wat modules and .wast scripts alike.

Sockets are granted the way directories are: by naming what may be reached, with nothing reachable by default. See docs/wasi.md and docs/security.md.

Two ways to use it

%% compiled: logic fixed at build time, one level of interpretation
{ok, W} = plugin_worker:start_link("plugin.wasm"),
{ok, ~"user@example.com"} = plugin_worker:normalise(W, ~"  User@Example.COM  ").

%% interpreted: logic arrives as text, two levels
{ok, S} = script_worker:start_link("qjs.wasm"),
{ok, ~"3\n"} = script_worker:eval(S, ~"print(1 + 2);").

Both are in examples/, both run untrusted code under a timeout with nothing surviving a request. docs/guests.md has the commands to build the guests, run both from rebar3 shell, and decide which shape you want.

Why it is built this way

The design follows from measurements on the BEAM rather than from how C runtimes are built. Five results shaped it:

A cons-list walk beats a flattened instruction stream. Same program, 2.4M instructions: walking a nested AST measured 3.7 ns per instruction, while the bytecode-plus-program-counter shape every C interpreter uses measured 5.6 ns. element/2 with a runtime index is bounds-checked; matching a list head is a dereference the compiler turns into a jump table. WebAssembly control flow is structured, so blocks nest and there is no program counter at all.

Erlang cannot represent NaN or Infinity as a float. 0.0/0.0 raises badarith, and <<F:64/float>> does not even match those bit patterns. Floats use a hybrid representation. A C NIF was measured as an alternative and was slower: a NaN-capable NIF must return raw bit patterns, and every such pattern is a heap bignum.

A v128 is a binary, not a 128-bit integer. Bit syntax truncates each field to its declared width, so lane wrapping is free, and the vector is built in one allocation. i32x4.add measured 11.8 ns against 126 ns for the integer form; i8x16.add 14.6 ns against 610 ns.

Garbage-collected objects cannot ride on BEAM garbage collection. They are mutable and cyclic, and the BEAM's only traced mutable container holds integers. So they live in a store this runtime owns and this runtime collects, which is affordable only because the interpreter owns all execution state explicitly: between calls the operand stack is empty, so the roots are enumerable without stack maps or safe points.

The collector is generational, and the generation is free. Object ids come from a counter and are never reused, so the objects allocated since the last collection are exactly an id range: the nursery needs no bookkeeping. A minor collection after a thousand allocations costs 0.083 ms whether the live set is a thousand objects or a hundred thousand, where tracing all of the latter costs 14.9 ms.

atomics makes linear memory viable without native code. A store costs 6 ns against 1201 ns for rebuilding an immutable binary. Memory is chunked atomics arrays sized to the memory, so growth appends instead of copying.

The full reasoning, including the trade-offs rejected and the benchmark that lied, is in the module documentation and docs/features.md.

Sandboxing

WASI capabilities are explicit, and an absent capability is refused rather than silently granted:

{ok, Exit, Stdout, _} =
    wasi:run(Mod, #{args => [~"prog"],
                    dirs => [{~"/data", "/srv/data", read}]}).

No dirs means no filesystem at all, not one rooted at the working directory. Every filesystem escape technique has its own test case, verified against Rust's own std::fs. docs/security.md states what the sandbox does not cover, which is the more useful half.

For untrusted code, put the instance in a process so a runaway module can be killed on a timeout. The runtime ships no process wrapper: process architecture belongs to your application, and docs/worker.md documents the pattern with a tested example in examples/wasm_worker.erl.

Documentation

For changing the runtime rather than using it:

Building

rebar3 compile
rebar3 ct         # tests, including the specification suite
rebar3 dialyzer
rebar3 bench      # benchmarks

A C compiler is optional. Without one, the WASI path resolver falls back to pure Erlang and the residual race is documented.

The conformance suite runs from the upstream sources, read directly. Clone it beside the project and rebar3 ct picks it up:

git clone --depth 1 https://github.com/WebAssembly/testsuite.git

Nothing is generated and no other tool is needed. Without the checkout the conformance suite skips with that message and everything else still runs.

Licence

Apache-2.0.