wasm_limits (wasm v0.2.1)
View SourceResource limits, and an honest account of what they do not cover.
Pass a limits map to wasm:instantiate/3, or per call. Reach for the presets
when you have not measured anything yet: getting these right matters most for
the people least likely to tune them.
Limits = wasm_limits:untrusted(),
{ok, Inst} = wasm:instantiate(Module, Imports, Limits).What you get
| limit | bounds |
|---|---|
fuel | execution budget, charged at calls and loop back-edges; every unbounded execution passes through one of those |
timeout | wall clock per call. Enforced by whoever owns the instance, not by the library: an inline call runs in your process and cannot be interrupted. See docs/worker.md |
max_depth | WebAssembly call depth |
max_heap_words | Erlang terms on the caller's own heap, applied by the process owning the instance with process_flag(max_heap_size, ...). Not linear memory and not garbage-collected objects: neither is on that heap. See examples/wasm_worker.erl |
max_memory_pages | node memory this instance can reach: every memory it can address, plus its share of the object store, added together |
| node page budget | wasm_engine, across all instances |
max_memory_pages bounds what an instance can reach, not what it created. An
imported memory counts, because a module that imports one can address every
page of it, and it counts once however many import slots name it. A garbage
collected heap counts the same way and for the same reason: linked instances
share one store, so it is charged once and bounded by whichever of them was
promised least. Its objects are ETS rows rather than atomics pages, and are
converted at the page size purely so that one number bounds both. Growing a
memory two instances share therefore needs room under both ceilings: the
stricter one wins, or the more generous instance would grow a memory past what
the other was promised. Refusal is the value -1 from memory.grow, as the
specification requires, and an instance that cannot fit its declared memory at
all fails to instantiate.
What putting it in a process does not give you
This section exists because "each instance is in a process, so it is isolated" is the most tempting wrong claim available about this design. A process is a fault and lifecycle boundary. It is not a security boundary, and these gaps are real:
- Linear memory is invisible to
max_heap_size.atomicsarrays are off-heap. A module can exhaust node memory without its process heap moving at all. This is why page accounting is explicit and node-wide inwasm_enginerather than left to the BEAM. - Per-instance limits do not bound concurrency. No single instance can monopolise a scheduler, because the interpreter is preemptible. But ten thousand instances each behaving perfectly will still saturate every scheduler. To bound that you need a pool or a concurrency cap above this layer; a per-instance limit cannot see it.
- The atom table is node-wide and never reclaimed. One reachable
binary_to_atomon anything a guest controls would be a permanent leak and eventually a node kill, immune to every limit here. That covers the module bytes and the strings a guest passes across the WASI boundary, andwasm_prop_SUITEasserts both: not that the count never moves, since host libraries intern atoms when they lazily load a module, but that it does not grow with the number of distinct strings a guest sends. - Host functions are the real attack surface. Limits constrain WebAssembly,
not the Erlang code you supply as an import. A host function that reads a
file, allocates without bound, or blocks forever is outside all of this.
Capability-based WASI (
wasi_preview1) is how the standard ones get addressed; imports you write yourself are yours to bound. - Timing and microarchitectural side channels are not addressed. Nothing here prevents a co-tenant module from measuring shared cache behaviour. A shared BEAM node is not a boundary against that class of attack; only OS-level or hardware separation is.
fuelbounds work, not time. A host call that blocks consumes no fuel. Wall-clock bounding istimeout, and you need the two together.
Summary
Functions
Defaults for your own code. No fuel ceiling, no timeout.
Defaults for running code you do not control.
Check a limits map, and reject one that cannot mean what it says.
Types
Functions
-spec default() -> limits().
-spec trusted() -> limits().
Defaults for your own code. No fuel ceiling, no timeout.
Faults still come back as values. Nothing here is unsafe, only unbounded.
-spec untrusted() -> limits().
Defaults for running code you do not control.
Deliberately tight. If you need more, raise the specific limit knowingly. That is a better failure mode than discovering a permissive default was load-bearing.
Check a limits map, and reject one that cannot mean what it says.