Changelog
View Source0.3.0
This release is about running other people's code: safely, and fast enough to be worth doing.
Three parts, meant to be used together.
- A worker runs one untrusted request at a time, in its own process, with its own deadline and its own limits.
- Snapshots let a language like Python start once, when the worker starts, instead of starting again on every request.
- The compiled tier now works for a worker like that. It did not before.
Together they take a CPython request from about a minute to 35 ms.
Run untrusted code, one request at a time
script_worker is the worker. It knows about modules, imports, deadlines and
output limits. It knows nothing about WASI, or JSON, or what your guest calls
its entry point. That part is an adapter: one module per language.
{ok, _} = worker_reaper:start_link(#{scratch => "/var/tmp/w"}),
{ok, W} = script_worker:start_link(my_adapter, #{root => scratch}),
{ok, R} = script_worker:run(W, Request).Three languages come with adapters already. js_worker and python_worker
take a function written by whoever is sending the request. Lua is
lua_reactor_adapter.
Every language needs its own limits, and an adapter will never raise one for you. Python will not even start until you raise several of them. The Python guide lists them.
Read next: workers to run one, the adapter contract to write one, and JavaScript, Python or Lua for a language.
Smaller things: max_output_bytes now accepts separate bounds for stdout and
stderr. wasm:extern/0 names the type extern/2 returns.
Start an interpreter once, not once per request
Starting CPython takes about a minute and a half. Doing that per request is not an option, and keeping one interpreter alive across requests leaks one caller's state into the next.
So capture it once, and give every request a fresh copy:
{ok, Image} = wasm:snapshot(Init),
{ok, Fresh} = wasm:restore(Image, FreshImports, #{}).The copy is genuinely fresh. Globals, memory and tables come from the image, but the imports are the ones you pass in now, so one request cannot reach another's files or sockets.
The instance you capture has to be created with snapshotable => true, and a
restore refuses an image that does not match the module it is handed.
wasm:save_snapshot/2 and load_snapshot/2 put an image on disk.
max_snapshot_bytes caps what one node keeps in memory.
Read next: snapshots.
Compiling hot code, and why it helps now
Turn it on with compile => true and fuel => infinity. Those two go
together: leaving a fuel limit in place quietly keeps you on the interpreter.
Point code_cache_dir at a directory you own, and a restart reuses what was
compiled last time. That is minutes of work turned into seconds.
What changed:
- A fresh instance uses compiled code immediately. It used to wait for a function to be called 32 times. A worker that builds a new instance per request almost never got there, so 31 requests in 32 ran interpreted next to compiled code that was sitting right there.
- Restoring a snapshot is three times faster. It used to write out the module's initial data and then blank it again, even though the image was about to overwrite all of it. A CPython request went from 64 ms to 35 ms.
- A compile can be given a memory cap, and can be interrupted.
compile_max_heap_wordscaps a single compile.compile_budget_heap_wordscaps the whole machine: divide it by the cap and that is how many compiles run at once. A guest that does not get a slot keeps interpreting and tries again later. Both are off unless you turn them on. - The compiled-code cache is checked, not trusted. It verifies the directory and every file it reads, and quietly recompiles if anything looks wrong. It will not read through a symlink or out of a world-writable directory.
Read next: the compiled tier.
If requests are slower than you expect, set a heap floor
A restored instance holds almost nothing on the Erlang heap, so the runtime gives its process a tiny one and then collects garbage hundreds of times during a single call.
runner_min_heap_words fixes it. The right value depends on the guest:
200,000 for QuickJS and Lua, 1,000,000 for CPython. Going higher than that
makes things worse, not better. capture_min_heap_words does the same for the
snapshot.
Read next: tuning.
Breaking
script_worker used to be the QuickJS worker. It is called qjs_worker now
and behaves exactly as it did. The old name now belongs to the
language-neutral worker described above.
0.2.2
Documentation only. No code changed.
Documentation
A guide for talking to a guest while it runs, Streams. You need it when the module you are running is a server rather than a function: a script with its own read loop, a language runtime answering one request at a time, or a program whose output you want as it is produced.
There is no new API for this, which is the point of the page. A stdin
capability may be a fun and a fun is allowed to block, so fd_read waits until
you answer; a stdout capability may be a pid, which receives
{wasi_output, RunnerPid, Bytes} per write. Both were one table cell each in
the WASI guide, so the recipe was not findable. The WASI and Workers guides now
point at it, and it states the two things that bite: the fun blocks the process
running the call, so the process feeding the guest has to be a different one,
and it needs an after or a guest parked on a read holds a worker for ever.
The README says that the project is developed with strong AI assistance, and what that process is: humans lead the architecture, semantics, testing and benchmarking, generated code is a proposal rather than evidence, and changes are validated against the specification suite, real toolchain output and repeatable benchmarks.
0.2.1
Changed
array.copy and array.fill do less work per element. Over ten thousand
elements, a copied element costs 7.0 reductions where it cost 19.9, and a
filled one 6.0 where it cost 7.8.
array.copy built three lists per copy and read the array's length from the
object table once per element, re-answering what the range check had already
answered. The loop lives in wasm_heap now, beside the accounting it has to go
through: it reads the source array's default once rather than per element, and
decides an overlapping copy by direction instead of taking a snapshot of the
source. A partial array.fill counts down rather than walking a list of the
indices it is about to use.
Behaviour does not change, including the rule that an overlapping array.copy
behaves as though an intermediate copy were taken.
0.2.0
Changed
The supervision tree is one supervisor per subsystem. Five servers under one
intensity => 5, period => 10 shared a budget, so losing the module cache
repeatedly could take the engine, the keeper and the code slots with it, and
the tables went too. Each subsystem now has its own supervisor and its own
10 in 60, and wasm_store owns the long-lived tables.
A wrong-typed call argument answers {link, argument_type} where it used to
answer {malformed, internal}. malformed is the decode class and an argument
is not a decode concern; the kind is new, the class has changed, and anything
matching on the old pair needs updating.
max_memory_pagesnow covers garbage-collected objects as well as linear memory. A workload under a tight ceiling that allocates structs or arrays can be refused where it was not. The node page budget widens the same way, somemory.growcan return -1 because a guest filled the object store. Both were unbounded before: a guest filling a twenty-million element array took 1.8 GB withmax_heap_wordsset,process_flag(max_heap_size, ...)set on the process running it, andpages_in_usereading zero throughout, because a struct or an array is a row in ETS and ETS is not process heap.max_heap_wordsis documented as what it always was: a ceiling on terms on the caller's own heap, applied by the caller. It never covered guest memory of either kind, and no longer reads as though it might.
Fixed
A store of few large objects was never collected. Every rule in
wasm_heapcounted objects:major_due/1compared a row count against a floor of 4096, so a workload replacing one large array per call never got a major collection, and a minor one leaves the old generation alone by design. Four rounds of a fifty thousand element array left all four, sixteen megabytes, with one reachable. Bothmajor_due/1andshould_collect/1now read bytes as well as rows, with agc_min_major_pagesfloor. A workload that allocates heavily and keeps nothing does about 14% more work and stops leaking; one with a stable live set is unaffected.atomic.fencewas rejected as invalid. The decoder and the interpreter both knew it and the validator had no clause, so every module carrying a fence failed to load. The specification suite does not exercise it.A tree death leaked the node page budget permanently. The counter lives in
persistent_termand outlives the supervision tree; the registry that says who holds those pages does not. Pages charged when the tree died could never be released, and it accumulated across application restarts.wasm_keepernow reconciles the two when it starts.A limits map that could not mean what it said was ignored.
#{max_depth => lots}failed open, because every integer sorts before every atom, so a guest could recurse a million frames under a ceiling the embedder believed it had set.wasm_limits:validate/1existed and nothing called it.The compiled tier computed on ill-typed arguments.
wasm_execchecks arity and the tier never reaches it, so the same call answered differently depending on whether the function was hot; and a float passed for ani32was rejected by the interpreter and hadi32.addrun on it by the tier. Both are checked once now, before either engine is chosen.
0.1.1
wasm:compile/1 takes the text format:
{ok, M} = wasm:compile({wat, ~"(module (func (export \"f\") (result i32) i32.const 7))"}).load/1 still takes the binary format only: the cache is keyed on a content
hash, and a module built from text takes a fresh identity every time.
Fixed
Seven lifecycle defects found by an audit of the previous release.
- A reader killed inside
wasm_heap:lease/1orunlease/2stranded a count nothing could give back, and the object store never collected again. - A keeper restart dropped every per-instance memory ceiling, so an instance
created with
max_memory_pagesgrew past it. - A process calling instances it does not destroy kept one table array and one compiled entry per instance, without bound.
sock_send_toleaked a socket when the send failed, and another when the guest's output pointer was out of bounds.atomic.waitreported a wakeup that never happened when the notifier died between claiming a waiter and sending to it.wasm_engine's per-instance limits table had no callers and is gone.
0.1.0
First public release. The versions before it were developed in a private repository and are not published; this is the whole runtime as one release.
The Hex package is erlang_wasm; the OTP application inside it is wasm.
{deps, [{wasm, {pkg, erlang_wasm}}]}.What it does
A WebAssembly runtime written in Erlang/OTP. Decoding, validation, instantiation, execution, linear memory and WASI preview 1 are implemented in Erlang. The only native code is an optional file NIF that closes a time-of-check-to-time-of-use window in WASI path resolution, and the runtime falls back to a pure Erlang resolver when it is absent.
Proposals
WebAssembly 1.0 core, bulk memory, reference types, multi-value, multiple memories, memory64, SIMD, relaxed SIMD, tail calls, typed function references, exception handling, garbage collection, threads and shared memories, sign extension, and saturating float-to-int conversion.
Both formats are read: the binary format, and the text format as .wat modules
and .wast scripts.
WASI
Forty-four preview 1 syscalls. Directories and sockets are granted by naming
what may be reached, with nothing reachable by default. See docs/wasi.md and
docs/security.md.
The compiled tier
Hot functions are lowered to Core Erlang, compiled and loaded into a fixed pool
of sixteen pre-interned module names, so no atom is ever derived from a guest's
bytes. Off by default; see docs/compiled-tier.md for when it pays and when it
does not.
Errors and limits
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. Memory pages, tables and
globals are held by holder tokens whose owning process's death releases them,
so a killed worker cannot leak a page.
Conformance
64,774 core specification assertions across 215 suites, with an empty skip
baseline, and 65,481 of them replayed through generated code. Seventy-two of
the 72 wasi-testsuite cases pass with the NIF, 68 without it. Neither suite is
vendored; docs/features.md says how to clone them.