wasm_code_slots (wasm v0.3.0)

View Source

Who is still using a piece of generated code, and when its slot may be reused.

You get here if you turn a WebAssembly module into a BEAM module. Read this before you load one, because loading generated code is the part of a compiler that goes wrong quietly: the code runs, the numbers look good, and the failure arrives later as a process killed mid-call or a call that resolved to the wrong module.

What it is

A fixed pool of slots. Each slot owns one pre-interned module name, wasm_code_0 through wasm_code_N, written into this module's source and interned when it is compiled. Nothing derived from a user's module ever becomes an atom, which is the property that matters: the atom table is node-wide and never reclaimed, so a generated name taken from a module's own bytes is a permanent leak with a remote attacker holding the tap.

A slot is claimed for a content key -- whatever the caller uses to identify a module, normally its hash -- and is held by leases. There are two kinds and both are load-bearing:

  • an instance lease, taken when an instance that may run this code exists
  • a call lease, taken for the duration of a call into it

An instance can be destroyed while a call into it is still running, so releasing the instance's lease must not let the code be replaced underneath the call. Reuse happens only when the last lease of either kind is gone.

The two are taken by different mechanisms, and that is a measurement rather than an inconsistency. An instance lease is rare and is a gen_server call, about 5.8 us, which buys a monitor so that a dead holder gives its lease back. A call lease is on the call path -- an interpreted call costs 44 ns -- so it is one atomics increment on a per-slot counter, about 20 ns, the same shape wasm_heap uses for execution leases.

When to use it

case wasm_code_slots:claim_loading(Key, {instance, Ref}, self()) of
    {compile, Mod, Token} ->
        case load(Mod, generate()) of
            ok    -> wasm_code_slots:publish(Token);
            error -> wasm_code_slots:abort(Token)
        end;
    {resident, _Mod} -> ok;
    loading          -> interpret;
    {error, no_slot} -> interpret
end,
...
case wasm_code_slots:lease_call(Mod, Key) of
    ok    -> try Mod:F(Args) after wasm_code_slots:release_call(Mod) end;
    stale -> interpret
end,
...
ok = wasm_code_slots:release(Key, {instance, Ref}).

Nothing enters a slot between the reservation and publish/1. The counter stays at its exclusive value for the whole reservation, which is what stops a second caller running the slot's previous occupant while the new binary is still being compiled.

claim_loading/3 answers {error, no_slot} when every slot is held, and loading when somebody else is already filling this key in. Neither is a failure, both are the signal to interpret this module instead. A compiler that cannot fall back has to either kill a caller or grow the atom table, and both are worse than being slower.

What it deliberately does not do

It never calls code:purge/1, which kills processes still running old code. Reuse goes through code:soft_purge/1, and a slot whose old code is still running is left alone and reported as unavailable rather than taken.

code:soft_purge/1 is the authority and the call leases are a hint. It was the other way round once and could not be: a lease is given back in an after, which does not run when a process is killed untrappably, and killing a process is how a runaway invocation is stopped here. Leases leak, so they cannot be what decides whether a slot may be taken.

What makes reuse safe is not here at all. Generated code carries the stamp it was built for and refuses a caller carrying another, which is atomic with the call in a way no lease can be. See wasm_core:module/6. That is why a stuck counter can be repaired from code:soft_purge/1 without a race.

code:soft_purge/1 runs inside this server, so every claim, publish and lease queues behind an operation whose cost grows with the number of live processes on the node. It is a known serialisation point, kept deliberately: moving it out re-opens the window the reservation exists to close, and nothing has measured it as a problem.

It does not survive a process killed outright. exit(Pid, kill) skips the after that would give a call lease back, and that slot's counter stays raised for ever, so it is never reused again. The consequence is that callers interpret instead, which is the fallback this module exists to provide, so this costs speed and never safety. wasm_heap accepts the same exposure on the same reasoning.

Summary

Functions

Give up a reservation. The slot goes back to the pool.

Take Words of the node's compile budget, or say it is not there.

Words currently admitted and not yet given back. For tests.

The verdict for one cache directory, worked out once however many ask at once.

How many calls are inside a slot's code. For tests.

Reserve a slot for Key, held by Lease on behalf of Owner.

Forget them. The caller's sequence is deliberately not reset with them.

Every recorded diagnostic, oldest first. [] when the table is absent.

Create the slot table. Called by the supervisor, before the manager.

Forget every cache-directory verdict. For tests.

Count one call on a module, and say whether it has reached After.

Another lease on a slot already claimed for Key.

Take a call lease on an already-resolved slot.

Take a call lease on a slot, by the module name claim/3 answered with.

Take a call lease and check that the slot still holds the code you meant.

The counter and index a slot's call lease uses, resolved once.

The module Key is loaded into, without taking a lease.

Say what resolving compile_max_heap_words produced, and warn once if it is bad.

As observe_config/1, for a named configuration key.

Finish a reservation, after code:load_binary/3 has succeeded.

Record why one compile did not happen.

Give up one lease. The slot becomes reusable when the last one goes, and not before: an instance lease released while a call is still running leaves the code exactly where it is.

Give back a lease taken with lease_at/2.

Give back whatever this process is holding. Safe when it holds nothing.

Give back a call lease.

{Name, Key, LeaseCount} for every slot that holds something.

The module Key is resident in, as a select rather than a listing.

The module name in one slot, by index.

Every slot name, in order. The complete set of atoms this module makes.

Types

key()

-type key() :: term().

lease()

-type lease() :: {instance, reference()} | {call, reference()} | {manual, term()}.

token()

-type token() :: {module(), non_neg_integer()}.

Functions

abort(Token)

-spec abort(token()) -> ok.

Give up a reservation. The slot goes back to the pool.

acquire/2

-spec acquire(pos_integer(), non_neg_integer()) -> ok | {error, busy}.

Take Words of the node's compile budget, or say it is not there.

A per-process heap ceiling bounds one compiler. Sixteen of them, each under it, is still not a bound on the node, and sixteen is what the slot pool allows. This is the other half: one budget the whole node draws on, in heap words, the same unit as the per-compiler ceiling. A compile reserves the ceiling it will be held to, so the aggregate is a sum of quantities the VM itself enforces at every collection rather than a prediction of any kind.

{error, busy} means interpret and ask again later, exactly as a full slot pool does. It is not an error and nothing is queued: a caller that waited would be holding the unit IR it was admitted to compile while it waited.

The reservation is monitored, so a compiler that is killed gives its words back without anything having to notice. Budget of 0 means no budget and answers ok without taking anything, so an embedder who sets nothing pays one map lookup and no server call.

budget()

-spec budget() -> non_neg_integer().

Words currently admitted and not yet given back. For tests.

cache_verdict(Path, Init)

-spec cache_verdict(file:filename(), fun(() -> term())) -> term().

The verdict for one cache directory, worked out once however many ask at once.

Init is a fun rather than a module and function on purpose: this server must not name wasm_code_cache, because that module calls this one and a static edge back would make a fourth module cycle. wasm_architecture_SUITE asserts there are exactly three.

Serialising matters more than it looks. Creating a cache directory is make_dir, then a chmod, and make_dir respects the umask: a second process validating between the two sees a world-writable directory, refuses it, and records that refusal for the life of the node. Running the whole of create, chmod, check, validate, warn and record inside this call is what stops one process reading another's half-built directory.

Nothing it can do is allowed to take this server down: any exception from Init is caught and becomes a refusal, because a cache is an optimisation and a stat that answered eacces must not restart the JIT subtree.

calls_in(Name)

-spec calls_in(module()) -> non_neg_integer().

How many calls are inside a slot's code. For tests.

claim_loading(Key, Lease, Owner)

-spec claim_loading(key(), lease(), pid()) ->
                       {compile, module(), token()} | {resident, module()} | loading | {error, no_slot}.

Reserve a slot for Key, held by Lease on behalf of Owner.

Four answers, and they are four situations rather than two:

  • {compile, Name, Token} — the slot is yours, empty, and held exclusively until you publish/1 or abort/1. Nothing can enter it meanwhile.
  • {resident, Name} — already loaded. A lease was added; nothing was reloaded.
  • loading — somebody else is filling this key in. Interpret. Waiting on another process's compilation is a latency hazard and interpreting is always correct.
  • {error, no_slot} — every slot is held. Interpret.

Owner is monitored for the duration of the reservation as well as for the lease, so a compiler that dies does not hold the slot for ever.

clear_diagnostics()

-spec clear_diagnostics() -> ok.

Forget them. The caller's sequence is deliberately not reset with them.

diagnostics()

-spec diagnostics() -> [{atom(), term(), term()}].

Every recorded diagnostic, oldest first. [] when the table is absent.

ensure_table()

-spec ensure_table() -> ok.

Create the slot table. Called by the supervisor, before the manager.

forget_cache_verdicts()

-spec forget_cache_verdicts() -> ok.

Forget every cache-directory verdict. For tests.

handle_call/3

handle_cast(Msg, S)

handle_info/2

hot(Identity, After)

-spec hot(key(), pos_integer()) -> boolean().

Count one call on a module, and say whether it has reached After.

Counts per module rather than per instance: a workload of short-lived instances would otherwise never get hot. The row is dropped the moment the threshold is reached, so this costs an ets:update_counter/3 during warmup and nothing at all afterwards, and the table does not grow with every module ever seen.

init/1

lease(Key, Lease, Owner)

-spec lease(key(), lease(), pid()) -> ok | {error, not_resident}.

Another lease on a slot already claimed for Key.

lease_at(Ref, Idx)

-spec lease_at(atomics:atomics_ref(), pos_integer()) -> ok | stale.

Take a call lease on an already-resolved slot.

The same counter lease_call/2 takes, without the row lookup that checks which key the slot holds. That check is for a caller which remembered a slot and was overtaken by a reuse, and it is not the only thing standing in the way: generated code refuses a caller whose stamp is not the one it was built for. A caller whose stamp is the module's own content hash therefore does not need the row, because another module's code cannot match that hash. A caller identified by a reference() has no stamp but the slot generation, which only the row can give, and keeps using lease_call/2.

lease_call(Name)

-spec lease_call(module()) -> ok | stale.

Take a call lease on a slot, by the module name claim/3 answered with.

This is the one on the call path, so it is an atomics increment and not a message: about 20 ns against the 5.8 us a manager round trip costs. It names the slot rather than the key, because "do not replace this code while I am inside it" is a property of the slot.

Answers stale rather than waiting when the slot is held exclusively, which is a reservation in progress. Interpret in that case.

Pair it with release_call/1 under try ... after, so a throw still gives it back. A process killed outright leaves the count raised and that slot is then never reused, which costs speed and never safety: a caller who cannot get a slot interprets instead, which is what this module is built to do.

lease_call(Name, Key)

-spec lease_call(module(), key()) -> {ok, non_neg_integer()} | stale.

Take a call lease and check that the slot still holds the code you meant.

The counter protects the slot; it says nothing about which module is in it. A caller that remembered a name can be overtaken by a reuse between remembering and leasing, and would then run somebody else's code. This takes the lease first, so nothing can change underneath the check, and gives it back if the answer is no.

lease_ref(Name)

-spec lease_ref(module()) -> {atomics:atomics_ref(), pos_integer()}.

The counter and index a slot's call lease uses, resolved once.

lease_call/2 looks both up on every call, and so does release_call/1: a persistent_term:get and a name-to-index dispatch, four times across a call that may be a few hundred nanoseconds. They are fixed for the life of a slot name, so a caller that will enter the same code repeatedly resolves them once and uses lease_at/2 and release_at/2.

lookup(Key)

-spec lookup(key()) -> {ok, module()} | error.

The module Key is loaded into, without taking a lease.

Reading without claiming is the point: wasm_jit:await/2 uses it to tell a module that is still compiling from one that is ready, and claiming there would take the free slot away from the compiler that is filling it.

observe_config(What)

-spec observe_config(ok | {bad, term()}) -> ok.

Say what resolving compile_max_heap_words produced, and warn once if it is bad.

Told about every resolution and not only the failures. Without the good ones, a value that is bad, then corrected, then mistyped the same way again would stay silent the second time, because nothing would have told this server the condition had cleared.

The warning is emitted here, inside the call, rather than by a caller acting on a first reply: a caller can die between the reply and the logger call, and this server would then be holding a value it believes was reported and nobody ever saw. Exactly-once needs the decision and the saying to be the same serialised step.

So: once per uninterrupted occurrence of the same bad value. Bad A three times warns once; bad A, valid, bad A warns twice; bad A then bad B warns twice. A manager restart re-arms it, since the held value lives in #state{}, and that is right: a restarted manager has not complained about anything.

Deliberately not a diagnostics-ring row. The ring's sixty-four entries answer "why did the last compiles that did not happen not happen", and a mistyped environment key is not a compile that did not happen. Spending rows on it would evict real refusals.

observe_config(Key, What)

-spec observe_config(atom(), ok | {bad, term()}) -> ok.

As observe_config/1, for a named configuration key.

There is more than one key now, and they must not clear each other: a valid ceiling observation that reset the budget's held value would make the budget warn again on its next resolution, and the "once per uninterrupted occurrence" contract would hold for neither.

publish(Token)

-spec publish(token()) -> ok | stale.

Finish a reservation, after code:load_binary/3 has succeeded.

Answers stale when the slot has moved on to a later reservation, which is what the generation in the token is for: a compiler slow enough to be overtaken must not publish over whatever took its place.

record_diagnostic(Seq, Outcome, Key, Reason)

-spec record_diagnostic(pos_integer(), atom(), term(), term()) -> ok.

Record why one compile did not happen.

Seq comes from the caller, which holds a node-wide atomics counter, so this is one insert and never a read-modify-write: two compilers recording at the same moment cannot lose each other's row.

Reason must already be normalised. Nothing here bounds it, and the OTP compiler's diagnostics and an exit reason's stacktrace are both unbounded.

A no-op when the table is not there, which is what a node that loaded these modules without restarting the application has. Reporting a failure must never be a failure.

release(Key, Lease)

-spec release(key(), lease()) -> ok.

Give up one lease. The slot becomes reusable when the last one goes, and not before: an instance lease released while a call is still running leaves the code exactly where it is.

release_at(Ref, Idx)

-spec release_at(atomics:atomics_ref(), pos_integer()) -> ok.

Give back a lease taken with lease_at/2.

release_budget()

-spec release_budget() -> ok.

Give back whatever this process is holding. Safe when it holds nothing.

release_call(Name)

-spec release_call(module()) -> ok.

Give back a call lease.

resident()

-spec resident() -> [{module(), key(), non_neg_integer()}].

{Name, Key, LeaseCount} for every slot that holds something.

resident_module(Key)

-spec resident_module(key()) -> {ok, module()} | error.

The module Key is resident in, as a select rather than a listing.

On the path every tier-enabled call takes from an instance that has not yet adopted, so it is a match spec the emulator runs over sixteen rows and not a term built for the caller to filter. Asking the gen_server instead cost 320 microseconds a call on a three-microsecond one.

It used to run only when a module was hot. wasm_jit's maybe_adopt now asks it first and consults the threshold only when the answer is error, because whether code already exists and whether to start making some are two questions and only the second wants pacing.

slot_module(Slot)

-spec slot_module(pos_integer()) -> module().

The module name in one slot, by index.

lists:nth/2 over the name list is fine where a slot is resolved once per invocation and not where it is resolved per call, which is what re-entry from the interpreter made it. A tuple index instead, and the tuple is a literal.

slots()

-spec slots() -> [module()].

Every slot name, in order. The complete set of atoms this module makes.

start_link()