Memory Model
How memory is organized in q64. Regions as the unifying abstraction;
the dual linear / managed heap; cross-region transfers; the multi-
memory layout; how @send is derived; and how shared state lives in
SAB-backed regions.
Status: draft (v0). Ports the design discussion in
q64-lang/design’smemory.mdinto spec form, then pins down three open calls (shared-region declaration via@shared, managed via@managed, cross-region transfer via a generictransfer(…)) that the narrative doc left as block-style sketches.
Design goals
- Two heaps, both first-class. Linear memory (allocator-managed, pointer-based) and WasmGC references (engine-collected) coexist. Neither is “the safe one” and neither is “the unsafe one.”
- Regions are values with lifetimes. Every allocation belongs to a region; the region owns the bytes; the region’s lifetime bounds the value’s lifetime.
- No hidden cross-heap moves. Linear → managed and managed → linear conversions are explicit, named, and always copy.
- The platform layout is queryable, not surprising. Region
kinds map to distinct Wasm linear memories (
mem.stack,mem.arena, …) so failure modes and diagnostics localize to the right backing memory. - AI-agent friendly. Region kind, sharedness, and managed-vs- linear status are all grep-able at declarations.
Vocabulary
| Word | Meaning |
|---|---|
| region | An allocator with a lifetime and a policy. Values “live in” a region. |
| region kind | Arena / Pool / Stack / Free-list / Managed — the strategy backing a region. |
| linear memory | Pointer-based Wasm memory (allocator-managed; freed by region exit). |
| managed memory | WasmGC references (engine-collected; per-Wasm-instance). |
@send | Derived predicate: this type’s ownership may cross a thread boundary. |
| shared region | A region whose backing memory is SAB-shared across threads. |
transfer | The single generic cross-region/cross-heap copy primitive (see §Transfers). |
The platform: Wasm 3.0, dual address space (wasm32 / wasm64)
q64 targets core Wasm 3.0. Two of its features — Memory64 and Table64 — define the address space, and that is a per-build choice, not a default. Every build is compiled for exactly one address space:
| Address space | Pointers | Memory64 / Table64 | Runs on Apple WebKit (Safari + every iPad/iOS browser) |
|---|---|---|---|
wasm32 | i32 | off | yes — the universal baseline (linear memory ≤ 4 GiB) |
wasm64 | i64 | on | no (as of 2026) — unlocks > 4 GiB on capable hosts |
There is no default address space. A build must name one
explicitly — via the target’s addressSpace, or the --addr flag (see
qube.json5.md §Targets and
q64-cli.md / qube-cli.md); q64
errors if neither resolves one. The rationale is compatibility:
Memory64 is not implemented in Apple’s WebKit, so a silent 64-bit
default would emit qubes that cannot run on iPad/iOS — the very floor
the ecosystem targets. Forcing the choice makes every qube declare
where it can run, rather than discovering it at runtime.
The remaining Wasm 3.0 features q64 builds on are independent of the address-space choice and present in both modes:
- Multiple memories — a core module declares several linear memory instances; q64 uses this to segregate region kinds.
- WasmGC —
struct.new,array.new, engine-managed references. - Threads + atomics — SAB-backed shared linear memory (browser: gated on COOP/COEP cross-origin isolation — an opt-in upgrade, not the floor; see §“Concurrency platform audit”).
- SIMD — 128-bit vector ops.
Not on the floor: stack-switching. Earlier drafts assumed lightweight coroutine stacks (the typed-continuations proposal). The concurrency platform audit found no engine q64 targets exposes it (wasmtime 45 has no C-API toggle; no browser ships it to stable as of 2026-06). The v0 concurrency floor is a single-threaded cooperative scheduler instead.
These choices compound. The dual-heap model is what the platform gives us, not a compromise. The address-space split is the one place the platform is not uniform — so q64 surfaces it rather than hiding it: it shows up in the target, the build-output path, the synthesized manifest, and (downstream) in how a deploy host such as qubepods stores and serves the matching artifact.
Address-space negotiation
A qube that publishes both a wasm32 and a wasm64 build is served
by capability, not by guesswork. The contract:
- The glue code probes the engine. The loader — the browser host
adapter (
runtime/browser/host.js), or the gate/runtime in front of a deployed qube — tests for Memory64 support before fetching a module. The cheap, side-effect-free probe is to validate a tiny module that declares a 64-bit memory, e.g.WebAssembly.validate(<(memory i64 1) module>); atrueresult means the engine can runwasm64. (Apple WebKit returnsfalseas of 2026.) - The glue code requests the right build. It asks the store for
wasm64only when the probe passed; otherwise it asks forwasm32. - The store/registry falls back to
wasm32. If the requested build is missing — or the client sent no capability hint, or the hint is unknown — the serving layer returns thewasm32artifact.wasm32is the universal safe fallback: every engine can run it, so no client is ever left without a runnable build. The only unrunnable case is a qube that publishedwasm64only and a WebKit client — which is why the toolchain and manifest flag a wasm64-only qube as not-runnable-on-WebKit.
This keeps the negotiation simple: clients never parse modules to discover
the address space, the store never has to guess, and wasm32 is always the
answer when anything is uncertain. The compiled artifacts themselves are not
stored in the Continuum (it holds source archives — see
continuum-api.md); the dual-build store + per-request
selection live in the deploy host (on qubepods, the artifact store keyed by
the QubePod manifest’s component.variants).
Concurrency platform audit (the stack-switching reckoning)
The address-space split above came from probing one feature (Memory64). The same discipline applies to the features the concurrency and stream layers lean on. The headline finding: q64’s concurrency design was written assuming stack-switching (lightweight coroutine stacks / the typed-continuations proposal), and no production engine exposes it on the floor q64 targets. The audit below is the basis for the v0 concurrency floor.
Support matrix — ✓ available, ✗ absent, ⚠ available with a
caveat. The wasmtime column is probed against the vendored engine
(vendor/wasmtime, 45.0.0, via the C-API wasmtime_config_wasm_*
toggles it exposes); the browser and wasmer columns are from engine
release notes / known status as of 2026-06 and should be re-probed
with the live WebAssembly.validate script (below) before any host is
declared supported.
| Feature (proposal) | WebKit — Safari/iPad | Chrome / V8 | Firefox | wasmtime 45 | wasmer |
|---|---|---|---|---|---|
| Tail calls | ✓ | ✓ | ✓ | ✓ | ✓ |
| Exception handling | ✓ | ✓ | ✓ | ✓ | ✓ |
| WasmGC | ✓ (Safari 18.2+) | ✓ | ✓ | ✓ | ⚠ |
| Threads + atomics + SAB | ⚠ (COOP/COEP) | ⚠ (COOP/COEP) | ⚠ (COOP/COEP) | ✓ | ✓ |
| Stack-switching | ✗ | ✗ | ✗ | ✗ (no C-API toggle) | ✗ |
Notes:
- Stack-switching is the blocker. wasmtime 45 ships config toggles for
tail-calls, exceptions, GC, function-references, and threads, but no
wasm_stack_switchingtoggle — its only stack mechanism is host-side fibers (async/component_model_async_stackful), which is not the guest-visiblecont/resume/suspendinstruction set. No browser ships the typed-continuations proposal to stable (Chrome’s JSPI is a different, JS-Promise–shaped feature, not general guest stack-switching). A concurrency runtime that needs guest stacks to suspend mid-function therefore cannot run anywhere q64 ships today. - Threads need cross-origin isolation in the browser.
SharedArrayBuffer(hence wasm threads/atomics) is gated on COOP/COEP headers. qubepods can serve them, but it is a deployment constraint, and iPad Safari is the weakest link — so threads are an opt-in upgrade, never the floor. - Everything else q64 needs (tail-calls, EH for the
paniclowering, GC forManaged) is broadly available;wasmer’s GC support is the laggard.
Re-runnable probe. Mirror the Memory64 probe — WebAssembly.validate
a tiny module per feature, log engine + version + date, in each browser
and via each native host. (Script lives with the runtime adapters; this
spec records the decision, not the harness.)
The v0 concurrency floor (normative)
Because stack-switching is unavailable on the floor — and WebKit/iPad is the declared compatibility floor — q64 v0 does not use guest stack-switching. The v0 floor is a single-threaded cooperative scheduler:
- One scheduler, one thread. Tasks (
spawn, graph stages, actor message turns) run on a single wasm instance with no shared-memory parallelism. This preserves the one-scheduler invariant ofconcurrency-model.mdon every host. - Suspension only at statically-known boundaries. A task may yield
only where the compiler already sees a suspension point — an
await, a channelsend/recv, a host-call that returns a future. The front end knows these from the effect system, so the lowering is a CPS / state-machine transform of the suspendable functions (only functions whose effect signature can suspend are split), not a whole-program asyncify pass. - No mid-function preemption. A CPU-bound loop with no suspension point runs to completion before the scheduler regains control — the same contract as a JS event loop or a single-threaded async runtime. Acceptable for v0; cooperative by construction.
Why this floor over the alternatives the audit weighed:
- (rejected) “require a capable host.” Would exclude iPad/WebKit — violates the compatibility floor.
- (rejected) asyncify the whole program. Binaryen can, but it taxes every function with code-size and call-overhead even where no suspension happens; the effect system lets us transform only the functions that can actually suspend.
- (chosen) cooperative scheduler, selective CPS lowering. Runs everywhere with no stack-switching and no SAB/COOP-COEP requirement, and the suspension points are exactly the ones the type/effect system already identifies.
Upgrade path, not a redesign. True parallelism (threads + atomics,
where COOP/COEP and host support allow) and guest stack-switching (if/when
it ships broadly) are opt-in upgrades behind the same scheduler
abstraction — a capable target may lower spawn to a real worker thread.
The surface language and the one-scheduler model do not change; only the
task-resumption mechanism does. This is the concurrency analogue of the
wasm32/wasm64 per-build choice: the floor is universal, capability
unlocks more.
Region kinds
| Kind | Allocator | Free strategy | Concurrency | Typical use |
|---|---|---|---|---|
Arena<N> | bump pointer | all at region exit | single-task only | per-request, per-frame, per-tick |
Pool<T, N> | fixed slot index | individual or at exit | lock-free with reservation | message queues, frame pools |
Stack | LIFO | implicit on return | strictly per-task | local temporary buffers |
FreeList | malloc-style | individual | single-task or locked | general-purpose dynamic memory |
Managed | engine (WasmGC) | engine, async | per-Wasm-instance | long-lived graphs, cyclic structures |
Each kind is a concrete type fitting the auto-prelude face
Region. Generic code over “any region” uses the R: Region
parameter (per generics.md §“The four parameter kinds”).
The Region face is the compiler-blessed marker for “this
type is a region kind.” It carries no user-visible methods in
v0; user code never writes fit MyKind : Region, because the
six blessed kinds (Arena, Pool, Stack, FreeList,
Managed, plus the Interned<R> target marker) are the only
inhabitants. The face exists so R: Region generic bounds and
the region <name> : <kind> { … } declaration form share a
single name in the type system. Adding new region kinds is a
language-level change (tracked under “Open items deferred”).
The word “Region” therefore has three roles, easy to confuse:
| Use | Meaning |
|---|---|
Region (face) | The auto-prelude face every region kind fits. Used in bounds (R: Region). |
| region kind | One of Arena<N>, Pool<T, N>, Stack, FreeList, Managed. A concrete type. |
| region binding | The named value introduced by region <name> : <kind> { … }. Lives in scope. |
The rest of this spec uses “the Region face,” “a region kind,”
or “a region binding” — never bare “region” — when the
distinction matters.
Frame reclamation (v0): Stack discipline on the implicit arena
The v0 compiler allocates from one implicit scope arena — a bump
pointer (sp) in linear memory, starting past the static data. Named
region bindings are future surface; until they land, reclamation comes
from giving call frames the Stack kind’s semantics (LIFO,
implicit free on return) layered on that arena, as a caller-side
calling convention:
- Watermark. Every call site saves
spinto a local before evaluating the call’s arguments. Everything allocated from that point — argument temporaries, the callee’s entire frame, the frames of everything it calls — sits above the watermark. - The result slides down. After the call returns: a scalar result
needs no memory, so
spis simply restored to the watermark. An aggregate result (astr’s bytes, a record, a boxed enum) is copied down to the watermark (memory.copy— memmove semantics, so the overlapping downward slide is safe; record results align the watermark up to the value’s alignment first), the result pointer is rebased onto it, andspbecomeswatermark + size. The frame dies; exactly its result survives, now inside the caller’s live region. - Host statements reset. A host-call statement (
env.out(…),qview.*) consumes its bytes before it completes, so the entry’s statement wrapper restoresspto its own watermark afterwards — a formatted-print loop runs in constant memory.
The convention is correct without escape analysis: results are slid
unconditionally (re-copying a constant or a passthrough parameter is
wasteful, never wrong — str and record results are immutable values
at the seam). Compiler analyses may skip provably-redundant slides;
they can never be required for correctness.
Pinned boundary — interior pointers. The flat slide is a complete
deep copy only while aggregates contain no pointers, which is true of
the v0 floor (scalar fields; enum payload slots are scalars; str
values are (ptr, len) pairs held in locals, never stored inside
aggregates). The day a pointer-bearing field lands (a str struct
field, a nested-struct reference), its type’s returns must either
slide recursively (the compiler knows the layout) or allocate into the
caller’s frame from the start. That decision rides with the feature
that introduces such a field; until then a pointer-bearing aggregate
return is a compile error, not a silent shallow copy.
Known leaks at this floor (reclaimed only by an enclosing frame’s
exit, or never in main): a binding re-assigned each loop iteration
abandons its previous bytes; concat temporaries feeding a let (not a
host statement) in main. Per-iteration resets via the existing
escape scan, and explicit scope { } resets, are the recorded next
rungs.
Region literal syntax
A region declaration introduces a named region whose lifetime is
the enclosing block:
region rt: Arena<1.MB> { let buf = Vec<u8>.new(rt) process(buf)} // rt and everything in it freed at the closing braceForm: region <name> : <RegionKind> { <body> }. The body sees
<name> as a value of type <RegionKind>. At the closing brace:
- Arena / Pool / Stack regions: bytes returned to the parent memory (or the OS, for top-level regions).
- FreeList regions: any still-live allocations cause
REG040(“region exited with live allocations”). - Managed regions: the GC handle is dropped; the engine collects on its own schedule.
Nested regions inherit cancellation and panic-unwind behavior from
their enclosing scope (per concurrency.md).
Scope’s implicit arena
Every scope { … } block (per concurrency.md)
carries a default Arena-kind region binding named scope. Code
that does not declare its own region uses this implicit one:
scope { let buf = Vec<u8>.new() // allocates in scope's arena process(buf)} // arena freed hereFunctions that don’t take an explicit R: Region parameter allocate
into scope by default.
Terminology. “The scope arena” (used in errors.md,
effects.md, concurrency.md, streams.md) is the value of
this implicit binding at the nearest enclosing scope { … }. It
is always an Arena<N> kind. Panic payloads allocate here (per
errors.md §panic and trap); stage bodies
allocate here unless they name a different region. The binding
shadows lexically: a nested scope { … } introduces a fresh
scope binding whose lifetime is the nested block.
The two heaps
Linear memory:
- Carved up by allocators (Arena / Pool / Stack / FreeList).
- Pointer-based; pointers are
i32(wasm32) ori64(wasm64), per the build’s address space (see §“The platform”). - Explicit lifetime; the owning region frees the bytes.
- Lowers to Wasm linear memory.
- Can be SAB-shared (see §Shared regions).
Managed memory (Managed kind):
- WasmGC references, opaque to user code.
- Engine-collected.
- Lowers to Wasm GC types (
struct.new,array.new,reftypes). - Per-Wasm-instance (per-Worker in the browser). Cannot leave its
thread without a
transfer.
The heaps are disjoint at the platform level. Wasm 3.0 provides separate instructions and separate memory areas. q64 surfaces the boundary; there is no implicit pointer-to-reference conversion.
Linear struct layout
How a struct value is laid out when it lives in linear memory (any
non-Managed region). Managed structs lower to WasmGC struct.new
and have no byte layout. The rules are deliberately C-like and
stable: layout is part of the platform contract (q64 show layout <type> prints it), never a surprise.
-
Declaration order is layout order. Fields are placed in source order; the compiler never reorders. (Predictability over packing — a developer who wants a tighter struct reorders the declaration.)
-
Natural size and alignment per field type:
Field type Size (bytes) Alignment bool,i8,u81 1 i16,u16,f162 2 i32,u32,f324 4 i64,u64,f648 8 ref T, pointer componentsaddress-space width (4 on wasm32, 8 onwasm64)same as size nested structits own size its own alignment -
Each field starts at the next multiple of its alignment after the previous field’s end (padding bytes are unspecified, not zeroed). A nested struct field embeds its bytes inline.
-
Struct alignment = max field alignment; struct size is rounded up to a multiple of the struct alignment. An empty struct has size 0, alignment 1.
-
Allocation is aligned. A region allocates a struct at an address that is a multiple of its alignment (the arena bump pointer rounds up before allocating).
Field access compiles to a load/store at base + constant offset.
Only pointer-bearing fields make a layout address-space-dependent;
a struct of scalars has identical layout on wasm32 and wasm64.
Values vs. addresses. A record binding whose value never escapes
(no whole-value use: not passed, returned, nested, or referenced) may
be scalarized by the compiler (SROA) and never materialize in memory.
The layout above governs every value that does materialize: passed or
returned whole, stored in another struct, or referenced via ref.
Status (impl): v0 lowers escaping record values into the scope arena (no reclamation yet), passes and returns them as a single pointer at the build’s address width, and supports
i64,f64,f32, the narrow integer widths (u8/i8/u16/i16/u32/i32— storage-only: width-true loads/stores, in-range literal inits, arithmetic via expliciti64(x)widening), andboolfields; nested structs,str/reffields, andq64 show layoutare the next slices.
Enum representation (v0)
An all-unit enum (every variant payload-free) is immediate: a value is its variant’s declaration-order tag, an integer at the compute floor (i64). No memory is touched.
An enum with any tuple-payload variant is boxed: a value is a linear record in the active region (the scope arena in v0) laid out as
offset 0: tag (i64 — the variant's declaration-order index)offset 8·(i+1): p_i (the i-th payload slot)size = 8 · (1 + max payload arity over all variants)align = 8Every value of the enum — including its unit variants — shares this
one shape, so match always reads the tag at offset 0 and a variant’s
payload slots are at fixed offsets. Slots beyond a variant’s arity are
unwritten (never read: pattern arity is checked against the variant).
Status (impl): v0 boxes payload enums with up to four
i64payload slots (tuple form); record-form payloads, non-i64 payload types, and a niche/packed representation are later slices.
Marking a struct managed
A @managed annotation marks a struct as living in WasmGC memory:
@managedstruct GameState { entities: Vec<Entity, Managed>, quadtree: ManagedBox<Quadtree>,}Effects of @managed:
- The struct may only be allocated via the
Managedregion’s constructorManaged.box(value) -> ManagedBox<T>, which registersvalueas a fresh WasmGC root and returns a handle. There is no separate “shared managed” constructor in v0; cross-thread sharing of managed values requirestransfer(to: <thread-local Managed>)on the receiving side (§“Cross-region transfers”). - Its fields must themselves be managed or be
Copy-style value types (numerics, bool). Arefinto linear memory isTYP060-equivalent in this spec:REG020(“linear pointer in managed struct”). - The struct is not
@send, regardless of its field composition: WasmGC roots are per-Wasm-instance. Seeeffects.md§“Annotation carve-outs”.
This supersedes the managed struct … keyword form sketched in
design.md. The annotation pattern matches @derive and other
declaration-level annotations from
faces.md.
Region parameters in types
Allocating types take a region as a type parameter (per the
generics spec). The default is the enclosing scope’s implicit
arena (§“Scope’s implicit arena”, above), spelled scope:
Vec<T, R: Region = scope> // growable arrayMap<K, V, R: Region = scope> // hash mapSet<T, R: Region = scope> // hash setString<R: Region = scope> // owned stringBox<T, R: Region = scope> // single-value boxBytes<R: Region = scope> // owned byte buffer; alias for Vec<u8, R>The names Vec, Map, Set, Box, Bytes, String,
ManagedBox, Atomic, and Shared are in the language
auto-prelude (per modules.md §“The
auto-prelude”). Their concrete fits live in the stdlib qube
q64.collections (compiled together with the language so the
prelude can re-export them), but no import is required at
the user side.
R is a compile-time identity that names a specific region
value, not a region kind. Vec<i64, arena_a> and
Vec<i64, arena_b> are different types; you cannot move data
between them without a transfer(to: …).
What “default region” means
The default R = scope resolves at each use site to the value
named scope in the enclosing block’s lexical scope: the
implicit arena introduced by the nearest scope { … } (or the
function body’s top-level scope) per §“Scope’s implicit arena”.
fn outer() { let a: Vec<i64> = Vec.new() // a's R = the outer scope's arena inner_a(a) scope { let b: Vec<i64> = Vec.new() // b's R = the inner scope's arena inner_b(b) } // b's arena freed here}a and b are not the same type: their R parameters refer to
different region values. A function that takes v: Vec<i64>
without naming R introduces an anonymous generic
<R: Region> per generics.md §“Implicit face
parameters” — the caller passes a Vec from any
region, the callee is monomorphized per call site.
The Zig-style “thread the allocator through” escape hatch lets
libraries name R explicitly when they need to return a value
allocated in a caller-supplied region:
pub fn build_index<R: Region>(r: R, terms: [Term]) -> Index<R> { let idx = Index<R>.new(r) for t in terms { idx.insert(r, t) } idx}Constructor calls with the default
A constructor like Vec.new() (with no r argument) allocates
into the binding-site’s enclosing scope arena. The
desugaring is uniform: Vec<T>.new() ≡ Vec<T, scope>.new(scope),
where scope on the RHS is the value of the implicit-arena
binding. A constructor called from a function that has named a
region parameter (fn f<R: Region>(r: R)) and supplies it
explicitly — Vec<T, R>.new(r) — bypasses the implicit-arena
sugar.
Methods that grow a collection follow the same rule. The
Collection.push(self: ref Self, x: T) signature takes no r
parameter when the collection’s R is fixed at construction —
each push allocates into the region the Vec was originally
built with. Libraries that want to allocate into a different
region per push must use the explicit form
Collection.push_into(self: ref Self, r: R2, x: T) and accept
the resulting transfer cost.
Cross-region transfers
The generic transfer primitive moves a value from one region to another. Source and target kinds determine the semantics:
// Conceptual signatures, parameterized on the source's static type.// The compiler synthesizes one specialization per source-region kind.fn transfer<R2: Region>(self: Vec<u8, R1>, to: R2) -> Vec<u8, R2>fn transfer<R: Region>(self: str, to: R) -> String<R>| Source kind | Target kind | Effective semantics |
|---|---|---|
| Linear | Linear | Deep copy. Source remains valid in its region. |
| Linear | Managed | Deep copy + register as GC root (formerly “pin”). |
| Static | Linear | Copy bytes into the target region. |
| Static | Managed | Copy + register as GC root. |
| Managed | Linear | Deep copy out of GC into the linear region. |
| Static | Interned | Deduplicate; register canonical instance. |
Interned is a target marker (not a region kind, strictly) that
selects deduplicating semantics: the target region keeps a hashtable
keyed by content hash, and transfer(to: Interned<R>) returns a
handle to the canonical instance — the second transfer of an
equal value re-uses the first allocation. It composes with
Managed: "hello".transfer(to: Interned<Managed>) deduplicates
inside the WasmGC heap. Hashtable lookup after the first allocation
is @pure (no further alloc).
The only verb is transfer(to: …). copy_to, pin_to, and
intern are not in the language; the compiler rejects them as
unknown verbs (REG050).
Examples
region arena1: Arena<1.MB> { let a: Vec<u8, arena1> = capture_buffer()
region arena2: Arena<2.MB> { let b: Vec<u8, arena2> = a.transfer(to: arena2) // deep copy send_to_thread(b.transfer(to: shared_pool)) // to a SAB pool }
let c: ManagedBox<Vec<u8>> = a.transfer(to: Managed) // pin into GC}The cost is visible: every cross-region step is a named call. An
@realtime stage cannot perform a transfer with unbounded size
(per effects.md) — EFF110.
Lifetime tracking
Linear regions have explicit lifetimes; the compiler checks that values do not outlive their region. Rules:
- A region’s lifetime is the
region <name> : … { … }block (or the enclosingscope’s arena for the implicit case). - A value’s lifetime is bounded by its region’s lifetime.
- A function cannot return a value allocated in one of its local
regions. The compiler diagnoses this at the
returnsite asREG010. - A function can return a value whose region was supplied by the caller (the standard Zig pattern):
pub fn parse<R: Region>(r: R, input: str) -> Tree<R> { Tree<R>.new(r, ...) // Tree allocated in caller's region; safe to return}Managed types have no manual lifetime; the GC handles it. They are not constrained by scope boundaries but cannot escape their Wasm instance (their thread).
Multi-memory architecture
Status (impl): not yet implemented — aspirational target. Codegen emits a single linear memory today; the region kinds below are byte-offset regions within it (the bump-arena
spglobal, etc.). Multi-memory segregation is a deferred non-goal for now. Bulk-memory ops are enabled.
Wasm 3.0’s multiple-memory feature lets q64 segregate region kinds into distinct linear memory instances:
| Memory | Backing kinds | Notes |
|---|---|---|
mem.stack | Stack | Task stacks, one growable region per virtual thread. |
mem.arena | Arena | Per-scope bump arenas. |
mem.heap | Pool, FreeList | Per-task pools and free-list regions. |
mem.shared | shared regions | SAB-backed; the only memory that pays SAB cost. |
mem.large | any kind, > 1 MB allocs | Fragmentation isolation. |
mem.rodata | Static / interned data | Read-only; engine enforces. |
Benefits:
- Failure isolation. A stack overflow doesn’t corrupt arenas; a free-list bug doesn’t taint shared data.
- Sharing cost paid where it’s needed. Only
mem.sharedis SAB-backed; thread-local memories don’t pay SAB overhead. - Diagnostics localize. Stack traces, heap dumps, and allocator reports name the originating memory.
User code never names a memory directly. The multi-memory layout
shows up in q64 show memories (per
q64-cli.md) and in diagnostics.
@send derivation
@send is a type-level predicate. The derivation rules and the
q64 show send <type> introspection are normatively specified in
effects.md §“The @send story”; this section
records only the region-level inputs the derivation consumes:
- Linear regions are single-owner — a value allocated in
Arena/Pool/Stack/FreeListis@sendif and only if itsTis@sendand the region’s handle is itself@send(i.e., the region is not borrowed by a task other than the current one). - Shared regions (
@sharedstructs, backed bymem.shared) are sharable — their handle is@sendby construction, soVec<T, R>in a shared region is@sendwheneverTis. - Managed memory (WasmGC) is per-Wasm-instance. Any type
whose composition includes a managed reference (a
ManagedBox<T>, aVec<T, Managed>, a@managedstruct) is not@send. Crossing a thread boundary with such a value requirestransfer(to: <thread-local Managed>)on the receiving side (see §“Cross-region transfers”).
effects.md enumerates the full struct / enum / parameter
composition rules and defines the EFF113 / EFF114
diagnostics; this spec does not duplicate them.
Shared regions
A shared region’s backing memory lives in mem.shared and is
visible to every Wasm thread. Declared with @shared on a struct:
@sharedstruct World { counter: Atomic<i64>, grid: Shared<Grid>,}
scope { let world = World.new() spawn { world.counter.add(1) } spawn { world.counter.add(1) }}Effects of @shared:
- The struct is allocated in
mem.shared(SAB-backed in the browser;pthread_mutex-protected linear memory in Wasmtime / Wasmer). - Every field must be
Atomic<T>,Shared<T, P>, or another@sharedstruct. A plain field isREG030(“non-shareable field in @shared struct”). - The struct is
@send(its handle can cross threads freely). - Field access compiles to atomic loads/stores or to
Shared<T, P>-policy operations (Mutex / RwLock / LockFree / Disjoint).
This supersedes the shared_region world { … } block form sketched
in design.md; the annotation pattern is consistent with
@managed above.
Shared<T, P> policies
Shared<T, Mutex> // exclusive access, blockingShared<T, RwLock> // many readers or one writerShared<T, LockFree> // requires T: Atomic or T: ImmutableSnapshotShared<T, Disjoint<F>> // compile-time-verified disjoint field accessAtomic<T> is the primitive for shared scalars. The blessed
payload types in v0 are the integer tower (Atomic<i32>,
Atomic<i64>, Atomic<u32>, Atomic<u64>) and Atomic<bool>.
Backed by Wasm 3.0 atomic ops. Methods: load() -> T,
store(v: T), add(v: T) -> T, sub(v: T) -> T,
compare_exchange(expected: T, new: T) -> T (returns the prior
value), plus the bitwise variants on integer payloads (or,
and, xor). Atomic pointer-sized handles (e.g. atomic
references into shared regions) are deferred until the shared-
reference design lands.
Memory ordering, v0. Every Atomic<T> operation uses
sequentially-consistent ordering — the strongest Wasm 3.0
ordering. Conservative-by-default; a future revision may add
@relaxed / @acquire / @release annotations on individual
operations for hot paths that have measured the need. v0 picks
correctness over peak throughput.
Most user code uses Shared<T, Mutex> or Atomic<T>. LockFree
and Disjoint are advanced; they enable hot paths the compiler can
prove safe without runtime checks.
Allocator parameters as cultural pattern
Zig’s influence, generalized: anything that allocates takes the region as a parameter when the caller wants control:
pub fn intern<R: Region>(r: R, s: str) -> String<R>pub fn merge_sort<T, R: Region>(r: R, items: [T]) -> Vec<T, R>pub fn collect<T, R: Region>(r: R, stream: Stream<T>) -> Vec<T, R>This appears in libraries where allocation strategy matters and
in stdlib code that wants to be generic. Application code with no
strong preference falls back to the implicit scope arena and
omits R.
Effect interactions
Memory operations carry the effects from
effects.md:
| Operation | Effects |
|---|---|
| Arena / Pool / Stack alloc | Allocates ⇒ violates @no_alloc. |
| FreeList alloc | Allocates ⇒ violates @no_alloc. |
| Managed alloc | Allocates + may suspend (GC) ⇒ @no_suspend violation as well. |
transfer(to: linear) | Allocates in target; unbounded size ⇒ EFF110 if @realtime. |
transfer(to: Managed) | Allocates + may suspend ⇒ both effects. |
transfer(to: Interned<…>) | Allocates first time; hashtable lookup is @pure after. |
Atomic<T>.load/store/cas | @no_alloc + @no_suspend ✓ — safe inside @realtime. |
The @realtime audio path may allocate nothing at run time.
That means: a transfer cannot appear in a @realtime stage; the
audio thread reads from Pool-backed channels and operates on
already-positioned buffers.
Diagnostic codes
All region-and-lifetime diagnostics use the REG prefix. Numbers
are stable, never reused. REG040–REG099 reserved for future
expansion.
| Code | Short message | When |
|---|---|---|
REG010 | value outlives its region | A function returns a value allocated in one of its local regions. |
REG011 | borrowed value escapes its region | A ref to region-local data is stored outside that region. |
REG012 | cross-region pointer without transfer | A function takes Vec<u8, R1> but is called with Vec<u8, R2>. |
REG013 | implicit-scope arena allocation in @no_alloc function | An unannotated allocation appears under @no_alloc. |
REG020 | linear pointer in @managed struct | A @managed struct has a field pointing into linear memory. |
REG021 | managed reference in non-managed struct | A plain struct has a managed-reference field; whole struct must be @managed. |
REG030 | non-shareable field in @shared struct | A field whose type isn’t Atomic<T> / Shared<T, P> / @shared. |
REG031 | @shared struct passed by linear value | A @shared struct can only be referenced through its handle, not copied by value. |
REG032 | shared region escapes its scope | A @shared value crosses into a scope where its backing memory isn’t visible. |
REG040 | region exited with live allocations | A FreeList region exited without explicit frees for some allocations. |
REG041 | nested region outlives parent | A region tries to outlive the region that owns its backing memory. |
REG042 | invalid region kind for operation | E.g. Stack region passed where a growable region is required. |
REG050 | unknown transfer verb | Source uses copy_to / pin_to / intern instead of transfer(to: …). |
All codes are emitted using the standard envelope from
diagnostics.md.
Examples
Per-frame arena in a renderer
fn render_frame(scene: Scene) -> Frame { region frame_arena: Arena<8.MB> { let visible = scene.cull(frame_arena) // Vec<Object, frame_arena> let cmds = build_command_list(frame_arena, visible) let pixels = rasterize(frame_arena, cmds)
// Pixels need to escape; transfer to caller's scope arena. pixels.transfer(to: scope) }}Everything inside the region block is freed at the closing brace
except pixels, which the explicit transfer copies out.
Managed graph with cycles
@managedstruct Node { children: Vec<ManagedBox<Node>>, parent: Option<ManagedBox<Node>>,}
fn build_tree() -> ManagedBox<Node> { let root = Managed.box(Node { children: Vec.new(), parent: None }) let child = Managed.box(Node { children: Vec.new(), parent: Some(root) }) root.children.push(child) root}child points back to root via parent; root points to
child via children. That’s a cycle in the object graph — fine
under WasmGC, which collects the whole subgraph once no managed
reference reaches it. A linear-memory allocator couldn’t free
this without a topological owner; the managed heap is the escape
hatch for graphs the developer doesn’t want to discipline by
hand.
Shared counter across tasks
@sharedstruct Hits { count: Atomic<i64>,}
scope { let hits = Hits.new() for _ in 0..100 { spawn { hits.count.add(1) } } // structured scope joins; at this point hits.count == 100}Real-time audio path — pool-backed channel
fn audio_engine { region pool: Pool<Frame, 64> { // 64 slots, lock-free let (tx, rx) = channel<Frame>(pool, policy: RingBuffer, capacity: 64)
spawn { capture_loop(tx) } // producer; allocates into the pool
scope @realtime { loop { let frame: Frame = rx.recv() // RingBuffer recv takes no ctx play(frame) // @realtime; no allocation } } }}The pool lives outside the @realtime scope; the audio loop only
reads from pre-allocated slots. transfer does not appear in the
hot path.
Open items deferred
- Region parameter inference at call sites. Today the caller
passes
Rexplicitly (or relies onArenadefault). A future pass may inferRfrom the binding-type context. - First-class region polymorphism in faces. A face whose
methods take
r: Rparameter for arbitraryRworks today; faces with associated regions (type R: Region) are deferred. - Borrow checker for cross-region references. v0 forbids
cross-region references statically (
REG011/REG012); a future revision may permit them via explicit lifetime annotations. - Specialization of
transfersemantics. OptimizingLinear → Managedfor small POD types (avoiding the GC root registration) and similar peephole improvements; tracked by the faces.md specialization deferral. - Custom region kinds via user types. Today
Arena,Pool,Stack,FreeList,Managed,Internedare blessed. Letting user libraries declare newR: Regionkinds requires more thought about layout,@send, and thetransfertable. - Drop semantics on region exit. A FreeList region with live
allocations could call user-defined destructors instead of
raising
REG040; deferred until the broader destructor design lands.
Related specs
effects.md—@sendderivation, allocation effects, transfer effects in@realtimepaths.generics.md— theR: Regionparameter kind, default region parameters on collections, region face bounds.concurrency.md— scope’s default arena, structured concurrency interaction with regions,@sharedregions and channels, atomics.faces.md—Regionis an auto-prelude face;Arena,Pool,Stack,FreeList,Managed,Internedare blessed types fitting it.modules.md—Regionand the region kinds are in the auto-prelude; no import required.q64-cli.md—q64 show regions <fn>,q64 show alloc <fn>,q64 show layout <type>,q64 show memoriesintrospection.diagnostics.md— envelope format for theREG*codes.