wasm_engine (wasm v0.1.0)

View Source

Node-wide resource accounting.

This is where you cap how much linear memory the whole node may hold, and where you go to see how much is in use:

wasm_engine:set_page_limit(16384),          % 1 GiB
#{pages_in_use := N} = wasm_engine:stats().

Set the cap. Linear memory is backed by atomics arrays, which live outside the process heap. That is what makes them fast (see the benchmark table in the design notes), but it also means max_heap_size cannot see them: a module can exhaust node memory without its owning process's heap ever moving. So page accounting has to be explicit, and it has to be node-wide rather than per-instance, because a thousand small instances are as much of a threat as one large one.

Reading the counter is a lock-free atomics get, which is what keeps it off the cost of an access. Moving it is not: reservation and release happen inside a wasm_keeper transaction, together with the registry row that says whose pages they are. Reserving here and recording the holder afterwards is exactly how the two came apart, and a counter that disagrees with the registry is a counter that eventually refuses every allocation on the node.

Summary

Functions

The shared store, under its general name.

Make sure the shared store exists.

Create the waiter table if it is not there.

Make sure the waiter table exists, owned by something that outlives waiters.

Intern a canonical recursive type group, returning its node-wide identity.

Reserve N pages against the node-wide budget.

Cap the total linear memory pages every instance on this node may hold together.

Start the engine, or adopt the one that is already there.

Read the current page budget and what is in use.

The largest number of entries a table may hold.

Functions

cell_forget(Id)

-spec cell_forget(reference()) -> ok.

cell_get(Id)

-spec cell_get(reference()) -> term().

cell_put(Id, Value)

-spec cell_put(reference(), term()) -> ok.

The shared store, under its general name.

wasm_table was the first user, so the rows are called tables; a shared mutable global is one term in the same store, with the same lifetime rules.

ensure_store()

-spec ensure_store() -> ok.

Make sure the shared store exists.

wasm_sup calls this so the store belongs to the supervisor. It holds every table's contents, every shared global's value and every published chunk tuple, and wasm_keeper's registry names rows in it: an engine restart that took the store with it would leave the registry pointing at nothing.

ensure_waiter_table()

-spec ensure_waiter_table() -> ok.

Create the waiter table if it is not there.

wasm_sup calls this so the table belongs to the supervisor: an engine restart would otherwise strand every parked agent, since a wait is a receive that only a row in this table can be found by.

ensure_waiters()

-spec ensure_waiters() -> ok.

Make sure the waiter table exists, owned by something that outlives waiters.

wasm_wait calls this rather than creating the table itself. An ETS table dies with the process that created it, and a waiter is a guest's process: when one was killed on a worker timeout it took every other agent's registration with it, and the next notify woke nobody.

There is no fallback to creating it locally. That fallback existed so a threaded module could run without the application, and it reintroduced exactly the defect it sits next to: an owner that is a waiter. An unsupervised engine is started on demand instead, so there is one behaviour to reason about and one that gets tested.

handle_call/3

handle_cast(Msg, State)

handle_info(Info, State)

init/1

intern_rec_group(Key)

-spec intern_rec_group(term()) -> non_neg_integer().

Intern a canonical recursive type group, returning its node-wide identity.

Node-wide because type identity has to hold across modules: one module imports a function whose type another declared, and they must agree it is the same type.

Lock-free. A racing pair may both allocate an id, but only one insert_new wins and the loser re-reads the winner's, so an id is never handed to two different groups. Interning happens once per group at compile time, so the retry costs nothing worth avoiding.

limits_of(Pid)

-spec limits_of(pid()) -> map().

page_limit()

-spec page_limit() -> non_neg_integer().

pages_in_use()

-spec pages_in_use() -> non_neg_integer().

register_limits(Pid, Limits)

-spec register_limits(pid(), map()) -> ok.

release_pages/1

-spec release_pages(non_neg_integer()) -> ok.

reserve_pages/1

-spec reserve_pages(non_neg_integer()) -> ok | {error, limit}.

Reserve N pages against the node-wide budget.

Call this from wasm_keeper and nowhere else: a reservation that is not recorded against a holder in the same step belongs to nobody, and nothing will ever give it back. You get {error, limit} rather than an exception, because memory.grow has to turn a refusal into the value -1 that the specification requires, not into a trap.

set_page_limit(N)

-spec set_page_limit(non_neg_integer()) -> ok.

Cap the total linear memory pages every instance on this node may hold together.

Set this once at startup if you run modules you do not control. A page is 64 KiB, so set_page_limit(16384) is 1 GiB. Reserving past the cap makes memory.grow return -1 to the module, as the specification requires, rather than trapping.

start_link()

Start the engine, or adopt the one that is already there.

The waiter table needs an owner that outlives any waiter, and a threaded module can be run before the application is started. So the engine, like wasm_keeper, may already exist by the time the supervisor gets here; adopting it keeps the table it owns instead of taking every parked agent's registration down with it.

stats()

-spec stats() -> #{atom() => term()}.

Read the current page budget and what is in use.

table_forget(Id)

-spec table_forget(reference()) -> ok.

table_get(Id)

-spec table_get(reference()) -> term().

table_grow_limit()

-spec table_grow_limit() -> non_neg_integer().

The largest number of entries a table may hold.

Set it with the table_grow_limit application environment key. Unlike the page budget it bounds each table rather than their node-wide total, because what it is there for is stopping one table.grow from doing unbounded work.

table_put(Id, Array)

-spec table_put(reference(), term()) -> ok.

unregister_limits(Pid)

-spec unregister_limits(pid()) -> ok.