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
}

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.

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.

Deferral is therefore provisional: lowering defers anything that is not syntactically constant, and a single classifier later promotes back everything the optimizer reduced to a constant expression. It runs once the value is lowered to its Wasm shape, because that is 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.

The declared initializer is never replaced

A global's recorded initializer is always the one the program declared. A deferred global carries its placeholder alongside, never instead.

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