wasm_keeper (wasm v0.1.0)
View SourceThe authority on who still holds a shared resource.
You do not call this. It is what makes wasm:destroy/1, wasm_memory:free/1
and a process exiting agree with one another about when a memory's pages go
back to the node.
Why a holder set and not a count
A count cannot tell two releases by one holder from releases by two. Destroy an instance twice, or destroy one that imported the same memory through two import slots, and a count goes down twice for one holder: the node's page counter fell below zero, wrapped to 2^64-1, and refused every allocation on the node for the rest of its life.
So a resource is keyed by a stable id and holds a set of holder tokens. Removing a token that is not there is a no-op, which is what makes a double release harmless, and the resource is reclaimed when the set empties.
| token | held by | removed by |
|---|---|---|
{instance, Id} | an instance that created or imported the memory, table or global | wasm:destroy/1, or its builder exiting |
{process, Pid} | a standalone resource | wasm_memory:free/1, or Pid exiting |
manual | a standalone thread-shared memory | wasm_memory:free/1 only |
{build, Ref} | an instantiation still in progress | transfer/3 on success, discard/1 on failure, or its builder exiting |
The manual token is what keeps the documented guarantee that a shared memory
outlives the process that made it: nothing about a process exiting removes it.
Why death and not only exceptions
A process killed with exit(Pid, kill) runs no cleanup, and that is the
documented behaviour of a worker timeout. So every token carries the process
whose death releases it, and the keeper monitors that process. Explicit release
stays the fast path; the monitor is what makes the model true when there is no
chance to be explicit.
Why the transaction
The node-wide page counter in wasm_engine is a fast unsynchronised read. It is
mutated only here, inside a call, together with the registry row that says who
the pages belong to. Reserving pages in the caller and registering the holder
afterwards is exactly how the counter and the registry come apart: die in
between and the pages are charged to nobody.
Growth, in two stages
Allocating chunks is not cheap and must not happen inside the serialised
callback, or one large growth would stall every release, every rollback and
every other memory's growth behind it. So the keeper validates and reserves,
the grower allocates, and the keeper commits the chunk tuple and the published
size together. Concurrent growers queue rather than being refused, because
memory.grow returning -1 is observable to the module and must mean the budget
really was exhausted.
The registry, not the caller's possibly stale handle, is the authority for how
many pages a resource has. That is why free/1 releases the size the memory is
now rather than the size the handle was made at.
Summary
Types
What reclaiming this resource means once the last holder is gone.
A resource's stable identity, minted here so it exists before the resource does. Reserving pages under an id the caller cannot yet have computed is what keeps the reservation and the registration in one transaction.
A holder of a resource. Removing one that is absent is a no-op.
Functions
Add a holder to a resource that already exists.
Pages currently reserved for a resource, or 0 if it is gone.
Release everything the calling process holds under Token.
Create the registry table if it is not there.
Give back a growth's reservation without publishing anything.
Claim the right to grow Resource by Delta, up to Ceiling pages.
Publish the chunk tuple and the new size together, ending the growth.
The holders of a resource, for tests and diagnostics.
Remove a holder. The resource goes when the set empties.
Reserve Pages and register Token as the first holder, in one step.
How many resources are registered. Diagnostics.
Cap how many pages one holder may reach in total.
Start the supervised keeper, or adopt the one that is already there.
Pages a holder holds across every memory it can reach. Diagnostics.
Move every token From holds onto To, atomically.
Types
-type meta() :: {memory, undefined | reference(), undefined | atomics:atomics_ref()} | cell.
What reclaiming this resource means once the last holder is gone.
cell is a row in the shared store keyed by the resource's own identity, which
is what a table's contents and a shared global's value are. Minting the
identity here and using it as the row key means there is one name for the
thing, not two that have to be kept in step.
-type resource() :: reference().
A resource's stable identity, minted here so it exists before the resource does. Reserving pages under an id the caller cannot yet have computed is what keeps the reservation and the registration in one transaction.
A holder of a resource. Removing one that is absent is a no-op.
Functions
-spec acquire(resource(), token(), pid() | none) -> ok | {error, gone | instance_limit | keeper_unavailable}.
Add a holder to a resource that already exists.
{error, gone} means the last holder released it before you got here, which an
importer has to treat as a link failure rather than as a memory it may use.
-spec charge_of(resource()) -> non_neg_integer().
Pages currently reserved for a resource, or 0 if it is gone.
-spec discard(token()) -> ok.
Release everything the calling process holds under Token.
What a build transaction is rolled back with. A ledger threaded through the build is lost the moment it throws, because the exception carries the error and not the newest value from the abandoned stack; the keeper holds it instead, so there is something left to roll back.
-spec ensure_table() -> ok.
Create the registry table if it is not there.
wasm_sup calls this so the table belongs to the supervisor rather than to the
keeper: a keeper restart then finds its state where it left it instead of
starting from an empty registry with every resource on the node unaccounted
for. The table is public so the keeper writes to it directly, which keeps the
supervisor off a path anything waits on.
Give back a growth's reservation without publishing anything.
-spec grow_begin(resource(), non_neg_integer(), non_neg_integer()) -> {ok, reference(), non_neg_integer()} | {error, exceeds_max | limit | instance_limit | gone | keeper_unavailable}.
Claim the right to grow Resource by Delta, up to Ceiling pages.
Answers the authoritative current size, which is what the new chunk tuple has to be built against: another holder may have grown this memory since the caller last looked. Concurrent growers queue here rather than being refused.
-spec grow_commit(resource(), reference(), tuple(), non_neg_integer()) -> ok.
Publish the chunk tuple and the new size together, ending the growth.
The holders of a resource, for tests and diagnostics.
Remove a holder. The resource goes when the set empties.
Always ok: releasing a token that is not held, or a resource that is already
gone, is the case this exists to make harmless.
-spec reserve(non_neg_integer(), meta(), token(), pid() | none) -> {ok, resource()} | {error, limit | instance_limit | keeper_unavailable}.
Reserve Pages and register Token as the first holder, in one step.
The Owner is the process whose death releases the token, or none for a
manual token. You get back the resource's identity, which every later call
names it by.
-spec resources() -> non_neg_integer().
How many resources are registered. Diagnostics.
-spec set_limit(token(), non_neg_integer() | infinity) -> ok.
Cap how many pages one holder may reach in total.
max_memory_pages was documented as a per-instance ceiling and enforced
nowhere: a module declaring three hundred pages instantiated under a limit of
two hundred and fifty-six. Checking it where a memory is created would not have
been enough either, because an imported memory is never created by the instance
that imports it.
So the ceiling belongs to the holder, and every way of becoming one goes through this module. A shared memory therefore grows only as far as its strictest holder allows, which is a consequence worth stating rather than a rule of its own: the alternative is one instance growing a memory past a limit another instance was promised.
Start the supervised keeper, or adopt the one that is already there.
A memory can be made before the application is started, so a keeper may already exist by the time the supervisor gets here. Replacing it would throw away the monitors that are the only record of who holds what, and the new keeper would come up believing the node held nothing. So it is adopted instead: linked into the supervision tree, and asked to name the supervisor as its table's heir so a later crash does not take the registry with it.
-spec total_of(token()) -> non_neg_integer().
Pages a holder holds across every memory it can reach. Diagnostics.
Move every token From holds onto To, atomically.
Used when a build succeeds: the entries a builder accumulated become the instance's, without a window in which they belong to neither.