wasm_keeper (wasm v0.1.0)

View Source

The 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.

tokenheld byremoved by
{instance, Id}an instance that created or imported the memory, table or globalwasm:destroy/1, or its builder exiting
{process, Pid}a standalone resourcewasm_memory:free/1, or Pid exiting
manuala standalone thread-shared memorywasm_memory:free/1 only
{build, Ref}an instantiation still in progresstransfer/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

meta()

-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.

resource()

-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.

token()

-type token() :: {instance, reference()} | {process, pid()} | {build, reference()} | manual.

A holder of a resource. Removing one that is absent is a no-op.

Functions

acquire(Resource, Token, Owner)

-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.

charge_of(Resource)

-spec charge_of(resource()) -> non_neg_integer().

Pages currently reserved for a resource, or 0 if it is gone.

discard(Token)

-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.

ensure_table()

-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.

grow_abort(Resource, GrowRef)

-spec grow_abort(resource(), reference()) -> ok.

Give back a growth's reservation without publishing anything.

grow_begin(Resource, Delta, Ceiling)

-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.

grow_commit(Resource, GrowRef, Chunks, NewPages)

-spec grow_commit(resource(), reference(), tuple(), non_neg_integer()) -> ok.

Publish the chunk tuple and the new size together, ending the growth.

handle_call/3

handle_cast(Msg, State)

handle_info/2

holders_of(Resource)

-spec holders_of(resource()) -> [token()].

The holders of a resource, for tests and diagnostics.

init/1

release(Resource, Token)

-spec release(resource(), token()) -> ok.

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.

reserve(Pages, Meta, Token, Owner)

-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.

resources()

-spec resources() -> non_neg_integer().

How many resources are registered. Diagnostics.

set_limit/2

-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_link()

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.

total_of(Token)

-spec total_of(token()) -> non_neg_integer().

Pages a holder holds across every memory it can reach. Diagnostics.

transfer(From, To, Owner)

-spec transfer(token(), token(), pid() | none) -> ok.

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.