Wado

WEP: Global Variables

Context

Wado needs module-level state: configuration values, counters, caches, singletons. WebAssembly provides globals for exactly this, and Wado's philosophy is that the Wasm concept should stay visible rather than being wrapped.

Wasm globals

Aspect Local variable Wasm global
Scope Function Module
Lifetime Stack frame Module
Access Stack slot global.get / global.set
Initialization On function entry On module instantiation
Mutability Always mutable Declared

A Wasm global's initializer must be a constant expression — a subset of Wasm evaluable at instantiation without running code.

Keyword choice

global, not let / static / const. let would conflate two concepts with different initialization, lifetime, and access semantics; static implies a memory model that does not apply; const is reserved for compile-time constants. global names the Wasm concept it compiles to, which keeps the initialization restriction and the access cost visible at the declaration site.

Decision

Syntax

global PI: f64 = 3.14159;
global mut counter: i32 = 0;

pub global VERSION: i32 = 1;
pub global mut state: bool = false;

Every type is allowed, including String, List<T>, and structs.

Assignment

Only a global mut may be assigned. Immutability is a Wado-level property and is enforced regardless of how the global is represented in Wasm.

global CONSTANT: i32 = 42;
global mut variable: i32 = 0;

fn example() {
    variable = 10;    // OK
    CONSTANT = 10;    // Error: cannot assign to immutable global
}

An initializer performs no effect

An initializer runs at module instantiation. Nothing is installed for it then, and it runs in dependency order rather than one the program wrote. It declares no with clause, and has nowhere to declare one, so it behaves as a function body that declares no effect: calling a function that declares one is a compile error. A default-value expression carries the same rule, and one checker answers for both positions, naming the position in its diagnostic.

A closure literal's body is read where it is written, as it is inside a function: the fn() with E a global's annotation gives it grants its body nothing.

An initializer may install its own handler. with E => h do answers the operations its body dispatches:

global COUNTED: i32 = hold: {
    let mut tally = Tally { value: 10 };
    with Counter => &mut tally do {
        break hold: Counter::next()
    }
    break hold: 0
};

A dispatch is not an effect the position holds

A user-defined effect's operation is answered by an installed handler, and traps where none is (see WEP: Effect Handler). That is a runtime outcome rather than a demand on the position, and it is the same outcome in an initializer as in a function body, which is why an initializer may write the dispatch:

global COUNTED: i32 = Counter::next();   // compiles; traps at module init

The purity check would otherwise have to hold only for a dispatch written directly in the initializer: a signature carries no record that a function dispatches an operation, so the same dispatch one call away is invisible to it. A rule that holds for Counter::next() and not for indirect() is worse than no rule, and the runtime answer already covers both.

An operation backed by the host, or by a component that reaches the host, is a different matter: it demands a capability the position must already hold, and an initializer holds none, so dispatching one is a compile error. A purely computational component's operation demands nothing, so an initializer may call it.

What a constant expression can hold

Wado targets Wasm 3.0, so the GC and extended-const instructions are available. The constant instructions are:

This is much wider than a literal. A struct of constants is a struct.new; a list or a short string is an array.new_fixed wrapped in the { repr, used } struct.new; a global derived from an earlier one is a global.get plus arithmetic. Nearly every global a program declares is expressible directly.

Direct and deferred initialization

A global is initialized one of two ways:

Deferral is for values that genuinely need to run code: a call the interpreter cannot evaluate, a value read out of mutable state, or a payload too large to inline as array.new_fixed — a long string literal lives in the data section and is materialized at run time, so no constant expression can denote it.

Two steps put a value in the slot, and both finish before the module is emitted: reify classifies the initializer's syntax, at every optimization level, and a later classifier reads the lowered Wasm value and promotes back what the optimizer folded to a constant.

An initializer is a body, and a body is a function

An initializer that needs to run code is a body: statements, locals, a with … do, calls to rewrite. Everything that walks bodies — template expansion, effect-dispatch desugaring, CM import binding, monomorphization — walks the module's functions. A body reachable any other way is a body those passes miss, and each miss is its own bug.

So reify puts it there. A global whose initializer is not a Wasm constant gets $init$<NAME>, a parameterless function returning the declared value, and the global's slot holds the placeholder. A global carries no locals of its own, so it cannot hold a body at all. Lowering splices those functions into the module's initialization function in dependency order and drops them.

What reify calls Direct stays direct. The Wasm slot can hold it at every phase after, because nothing on the way turns a literal into code, and lowering asserts that rather than trusting it. What reify defers is provisional.

The decision is made on the value, not on the syntax

Whether a global is direct is decided from what its initializer evaluates to, after the optimizer has folded it, and against the constant-instruction set above. It is not decided from the shape of the declaration.

This matters because the two differ enormously. global T: List<i32> = [1, 2, 3] is not a literal, but it evaluates to a sequence of constants, which is exactly an array.new_fixed. Deciding syntactically would defer it; deciding on the value does not.

So reify defers anything that is not syntactically constant, and a single classifier on the lowered Wasm value promotes back everything the optimizer reduced to a constant expression. That classifier settles it: it runs where variant representation and non-null field wrapping are settled and the constant-instruction test is exact.

The cost of deciding there is that the normalized IR never learns the answer, so the compile-time interpreter cannot read a constant global's value — see the value-snapshot entry below.

A placeholder never passes for the declared value

A deferred global holds a placeholder, and says so. Asking it for its declared value answers "assigned elsewhere", never the placeholder.

This is the invariant the representation must preserve. Anything asking "what is this global's value" — constant folding, globalization, documentation — must get a truthful answer, and a placeholder standing in for the initializer is a lie that reads as a perfectly good constant. A global A: i32 = 1 + 2 whose recorded initializer has become 0 folds every read of A to 0.

Wasm slot shape is derived, not stored

Whether the Wasm slot is mutable, whether it is nullable, and whether reads need narrowing are all consequences of the two facts above, and are derived when the Wasm module is built:

Neither the typed IR nor the normalized IR stores these. They describe the Wasm representation, which is the Wasm builder's business.

Multi-module initialization

Each module with deferred globals gets a pub fn $initialize_module() assigning them, ordered topologically so a global is assigned after everything it depends on. A cycle is a compile-time error.

The entry module gets a fn $initialize_modules() that calls each linked module's, guarded by a flag so repeated entry — an HTTP handler invoked many times on one instance — initializes once. Every entry point calls it first. Those calls are ordered the same way, by the globals each module's initializers read, so a global crossing a module boundary is assigned before it is read; the entry module goes last.

The initialization functions are ordinary functions in the normalized IR, so the optimizer inlines, folds, and prunes them like any other. That is why they are materialized before optimization rather than when the Wasm module is built.

A global's value must not be folded into a read that happens inside an initialization function: the topological order guarantees a dependency is assigned first, but the interpreter does not model that order, so it declines there rather than reasoning about it.

Consequences

TODO

Future work