WEP: Reference Representation and Mutation Write-Back
Context
Wado has no raw pointers and no borrow checker. A reference (&T / &mut T) is
always a GC-managed handle (see
Value Semantics and Reference Retention).
How that handle is represented on Wasm GC has never been written down, even
though the choice differs by referent type and is what makes mutation through a
&mut observable at the original place.
This WEP specifies the intended representation and &mut semantics, and
catalogs where the current implementation diverges. The divergences are bugs to
fix so the implementation conforms — not behavior to preserve. The motivating
symptom is a silent miscompile (issue #1333): a write through &mut xs[i] or
&mut s.f of certain element/field types is discarded with no diagnostic.
Design (normative)
The dividing line: in-place mutation vs replace-on-assign
A &mut T must let a write be observed at the original place. The representation
follows from how the value is mutated through the reference:
- In-place interior mutation — a struct field write (
r.f = v) or an array element write (r[i] = v).Tis a GC object with addressable interior; the reference isT's own shared GC handle, and the write lands on the one shared object. No extra cell is needed. - Replace-on-assign —
*r = vswaps the whole value, becauseThas no addressable interior to mutate through the reference. The reference must point at a stable heap cell that owns the current value:Box<T>, a compiler-internal one-field struct{ value: T }.*rreads the cell,*r = vwrites it.
The axis is mutation mode, not "scalar vs heap". A variant is a heap GC struct
yet is replace-on-assign (see below). Mutability (& vs &mut) is a type-level
distinction, not a representational one: a reference to a replace-on-assign type
is Box<T> whether & or &mut.
Since both collapse onto one wrapper, the resolved type no longer says which is
which. The rewrite records the shared-&T origin separately, and anything asking
whether a boxed handle can be written through MUST consult that record, not the
type. A Box<T> parameter is the caller's storage, not a copy of it: reading it
as a by-value struct snapshots an aliased reference and loses the write.
Classification of types
| Category | Types | &T / &mut T |
Mutation through the reference |
|---|---|---|---|
| In-place (reference types) | struct; List<T> (raw GC Array<T>); String; i128 / u128 |
shared GC handle | field / element write on the shared object |
| Replace-on-assign (value/boxed) | primitive (except i128 / u128); enum; flags; variant; fn / fn mut |
Box<T> |
*r = v replaces the box's content |
| Handle | resource (opaque i32; conceptually i32 / externref) |
see divergence D6 | replace-on-assign conceptually |
Notes on the non-obvious entries:
struct,List<T>, andStringare heap GC objects whose interior is mutated in place, so the reference is the shared handle. (Stringcontent is immutable today, so no&mutwrite applies — but a reference to it is still the shared handle, never a box.)variantis a GC struct, yet replace-on-assign: its case subtype hierarchy has no fixed mutable field to deref-assign into, so*e = E::B(99)replaces the whole variant value. Hence it is boxed.fn/fn mutare funcref-backed values;*r = other_fnreplaces, so they are boxed. A closure is an immutable GC object that copies share, so it cannot stand for the place holding it; the box is that place.ref_eq(&f, &g)therefore compares places, and a closure's own identity stays unobservable. (Note: the issue #1333 enumeration omitsfn; the implementation boxes it — see D3.)i128/u128are GC structs (a low/highi64pair), so they are treated asstruct(shared handle), and are deliberately excluded from boxing despite their scalar value semantics.resourceis an opaque handle (i32today). Assignment replaces the handle, so it is conceptually replace-on-assign, but the implementation does not box it — see D6.
Connecting a &mut to a place: the box must be the place's storage
For a replace-on-assign type, a write through &mut is observable at the original
place only when that place's storage is the box cell. The compiler establishes
this for locals via the address-taken-locals boxing pass:
reifymarksTirFunction::address_taken_localson&x/&mut xfor a localx.lower::plan::boxingpromotes that local's slot fromTtoBox<T>; reads of the local auto-unbox, and&mut xhands out the shared box.
let mut c = Color::Red;
let r = &mut c; // c's slot promoted to Box<Color>; r shares that box
*r = Color::Blue; // writes the box
code(c); // reads c (auto-unboxed) -> Blue
A non-local place — a List / array element xs[i] or a struct field s.f — has
no local to promote.
The boxed-as-value set is a single predicate
The set of types boxed by reference (the replace-on-assign category) MUST be
defined by one predicate (e.g. is_boxed_reference_target(T)), and the boxing
pass, the forbid rule, and the carve-out below MUST all consume that predicate.
No component re-lists the member types by hand; that is how fn and flags
already drifted out of sync (see D2–
D4).
&mut to a non-local replace-on-assign place
- Default: a
&mut <place>is a compile error when the place is a non-local location (aList/ index element, or a struct field) whose type is a replace-on-assign type per the predicate. The error names the workaround: assign the whole element / field (xs[i] = …,s.f = …). Covers bothIndexandFieldAccessoperands and generalizes (replaces) the existing partial primitive-struct-field guard. variantis the exception, because it is the one replace type whose interior can still be mutated through the borrow: a payload write lands on the shared payload object. Taking the borrow is therefore allowed, and the refusal moves to where the borrow is put into storage outliving the expression that took it — a variable, an aggregate, areturn— which is exactly where no write-back point exists.&(immutable, read-only) to such a place is permitted: it reads a snapshot copy, and there is no write to lose.- Carve-out: when the
&mut <place>is a call argument to a parameter the callee does not retain, the reference provably cannot outlive the call, so it is desugared to a temp + write-back:
The temp is a real local, so the address-taken boxing promotes it exactly as forf(&mut xs[idx]) ⇒ { let $mr_idx = idx; let mut $mr_t = xs[$mr_idx]; f(&mut $mr_t); // mutates the temp's box xs[$mr_idx] = $mr_t; // write-back to the place }&mut <local>. The forbid stays permanently for the escaping case (a retained param, or a&mutbound to a variable / returned), which has no sound write-back point.
In-place places — &mut <local> of any type, and &mut of a struct / List /
String reference mutated in place — are always allowed and unaffected.
What the two rules above rest on:
Option<T>is a variant, soif let Some(e) = &mut self.fis a detached borrow, and payload mutation through it is how the language is written: every gale-generated parser,normalize_element(&mut lab.element)at 5package-galesites, and thevalue_copy_*fixtures. A borrow-site refusal forvariantdeletes all of it, which is why that refusal fits the other replace types and not this one.- The rule keys on the place being non-local, not on the borrow escaping. A
&mutrooted at a local is that local's box, so a whole-value write through it lands even when the borrow is stored in an aggregate and written much later:ValueSerializer { out: &mut v }incore:value, written through bySerializeSeq::end, is load-bearing acrosscore:value/core:json/wasi:http. Only a non-local root detaches.
Decision
- [x] The representation (in-place shared handle vs
Box<T>) is normative, keyed on the in-place-vs-replace dividing line, not scalar-vs-heap. - [ ] Extract the boxed-as-value classification into one predicate shared by boxing / forbid / carve-out.
- [x] Forbid
&mutto a non-local place ofprimitive/enum/flags/fnat the borrow itself, over bothIndexandFieldAccessoperands, subsuming the partial primitive-struct-field guard. - [x] For
variant, forbid only where the borrow is put into storage outliving the expression that took it — a variable, an aggregate, areturn. The borrow itself stays legal, since payload mutation through it lands. - [ ] Ship the remaining
compile_errorfixtures:primitive/enum/flagslist element. Covered today:fnlist element,primitive/enumstruct field, and thevariantstoring positions. - [ ] Carve out the retention-gated temp + write-back, one call path at a time:
- [ ]
Listindex element (&mut xs[i]) — reuses the existingindex_assigndispatch. - [x] struct field (
&mut s.f) — write-back is a plain field assign. - [x] remaining call paths (method-call / indirect-call arguments).
- [ ]
Each carve-out narrows the forbid for the case it handles; the escaping case is never carved out.
Known implementation divergences
These are gaps between the design above and the current tree. Each is a bug to fix to conform; none should be preserved.
-
[ ] D1 — a whole-value write through a
&mutto a non-local place is dropped behind&mut *pfor every replace type butvariant, and for twovariantshapes is refused rather than written back. Probe (HEAD):place, by referent and by where the borrow goes result primitive / enum / flags / fnfield or element, anywhererefused at the borrow primitive / enum / flags / fnbehind&mut *pdropped variant, into a variable / aggregate / payload /returnrefused variant, reaching one of those through a variablerefused variantfield /&mut *p, call argumentwritten back variant, call argument at a retained positionrefused variantelement / whole capture / branch, at a written positionrefused variantelement as a&mut selfreceiver (xs[i].m())refused The same operations on a value-type local all work, wherever the borrow goes; nothing else is the storage itself, since
&mutof any other place re-boxes what it names — a capture and a&mut *preborrow included.variantis exempt from the borrow-site refusal because mutation through its payload lands. A rewritten call reads its callee and each of its arguments in source order, so a preceding one's write is not read past.Both halves follow the borrow, not its spelling: a variable bound to one carries it to whatever sink it reaches, and a whole-value write names its storage through a
&mut *rreborrow, through a capture, through the&mutbindings that carry a parameter on (let q = p; *q = v), and through whatever holds the reference in between. A reference put into a value comes back out of any reference to that value, so a struct field, a list element and a call result are all routes to the storage it names, and a write through one replaces there. Which storage a call hands back is read off a body this reading does not have, so a result whose type can hold a reference is read as handing on every argument; naming a place the callee does not replace costs a redundant store, never a lost write. A closure numbers its locals in its own namespace, so what it replaces there says nothing about the enclosing slot of the same index.What still drops:
-
&mut *pfor every replace type butvariant. The borrow-site refusal keys on aFieldAccess/Indexoperand, so a reborrow slips past it.variantis covered from the other side by the write-back; the rest are not. -
A
variantelement as a call argument, where the callee neither replaces nor stores.&mut xs[i]lowers to&mut *xs.index_ref(i), so its write-back is anindex_assignto synthesize, and that resolves in the elaborator — trait impl, mangled name, recorded dispatch. A lowering pass reaching it would repeat trait resolution, which is why the carve-out lists index-element and struct-field separately. Refusing the whole shape is not an option either, sincenormalize_element(&mut alt.elements[ei])lands. A&mut selfreceiver takes the same borrow, so it waits on the same write-back:xs[i].m()is refused wheremreplaces the element, and the call the carve-out unblocks is spelled either way. -
A call argument that yields a borrow (
f(if c { &mut b.l } else { &mut b.r })), for the mirrored reason: which place it borrowed is not known until it runs. Both want the write-back to follow the borrow to whichever place it named — the points-to answer neither half has. -
A whole capture, where a store back would land in the closure's environment: closure lowering filled that with a copy of the enclosing slot, and the capture mode is its decision, not this pass's. A projection through a capture is fine — it lands on the object the capture copied.
Where the callee does replace or store, all but the &mut self receiver are
refused rather than dropped: that code was already losing the write.
Two sinks are refused wider than the rule asks, neither gateable on a
whole-value write the way a let is. Rebinding (r = &mut b.item) is refused
even where nothing replaces through r, since the detached-local set is
filled in visit order and an assignment need not dominate its reads; gating it
takes that set as a fixpoint before the walk. A break carrying one is
refused because a labeled block's value is read from its tail alone.
The write-back stores the temp back only when the callee replaced it — the
temp aliases the place, so an unconditional store would also undo a write the
callee made through another route to the same place (self, a sibling &mut
argument), which lands on its own.
A store back is still late for a route the callee takes after replacing.
Until the store back runs, the new value is in the temp alone, so another
route reads the old value and has its own write undone. So a call whose
callee replaces the temp is refused where another argument reaches the same
storage. That argument may be a borrow on the same root, the root itself
where the root is a reference, or any reference once the root is borrowed
anywhere in the body. Two reference
parameters the caller passed the same storage through are not seen:
f(a, &mut b.opt) inside g(a: &mut S, b: &mut S), reached as
g(&mut s, &mut s), still stores back over a's write.
Two costs come with running the refusal after monomorphization, where the
types are concrete enough to see a variant behind a type parameter:
-
Liveness drops unreachable functions first, so a detached borrow in dead code is not reported. It cannot execute, so nothing miscompiles.
-
The refusals reach
wado check, which lowers, but notwado query diagnostics, which does not; and only the first per function is reported. Raising them where the LSP sees them takes what the callee retains at the call site, which onlylower::plancomputes. -
[ ] D2 — the boxed set is
TypeTable::is_boxed_reference_target, which boxing and the implicit&mut selfreceiver borrow both read. The forbid rule still carries its own list, since it excludesvariant; stating it as that predicate minusvariantis what keeps the two from drifting. -
[ ] D3 —
fncoverage. The implementation boxesfn/fn mut, but issue #1333's type list omits them; any forbid written from that list would miss&mut fns[i]. The predicate (D2) is the source of truth. -
[ ] D4 —
flags. There is no explicitflagsarm in the box predicate; it works only becauseflagslowers to itsu32primitive earlier. Make the predicate nameflagsexplicitly. -
[ ] D5 — overloaded
ResolvedType::Enum. A standaloneenumand a variant's payload-less case subset are bothResolvedType::Enum, disambiguated only byname ∉ variant_names. This overloading is fragile and a latent bug source; a distinct representation for variant-case discriminants should be considered (possibly as separate work). -
[ ] D6 —
resource. A resource handle is replace-on-assign but is not in the box predicate, so&mut resourcehas no stable cell. Decide and document: either box resource handles like other replace types, or explicitly reject&mutof a resource. (&mut resourceis currently unverified / effectively unsupported.) A&resourceis the handle itself, soref_eqon two compares handles: exact for an affine resource, whose handle only one place holds, and true for two copies of an unrestricted one. -
[x] D7 — whole-value
*ref = vwrite-back forList<T>and tuples. An in-place&mut Tmakes*r = va field-wise write-back onto the shared handle, lowered bytry_expand_deref_aggregate_assign. That expansion only recognisedResolvedType::Struct(String, and monomorphized generics likeTreeMap<K,V>), soList<T>and tuples ([A, B]) — in-placeGenericInstances that are never monomorphized into their own struct — fell through and the assignment was silently dropped at every opt level (*xs = []was a no-op). Fixed by decomposingList<T>through its canonicalSeqField{repr, used}layout and a tuple through its positional fields, both with concrete element types. -
[x] D8 —
*ref = vdid not deep-copy the RHS. The write-back decomposition intry_expand_deref_aggregate_assignmoved the RHS's fields into the shared handle without a value copy, because deref-expansionLets are synthesized aftervalue_copy::insert's walk andwrap_value_copy_operandfound no registered helper for the referent type at that site. So*r = valiasedv's interior — e.g.*list_ref = other; other[0] = 9also mutated the referent's element. Fixed by seeding a copy helper for the deref-target RHS type in theanalyzewalker and requesting the copy at the expansion site through the fold'sshould_wrap_value_copypredicate, so a live RHS is copied while a fresh / moved one (*xs = []/literal cases) stays free with no copy inserted at all. Note: a separate pre-existing gap remains — a tuple literal does not copy its element variables (a = [inner, 1]; inner[0] = 9mutatesa.0), which is tuple-literal construction, not deref-assign, and is out of scope here.
Consequences
Positive
- The representation is documented and normative, with one stated axis (in-place vs replace) instead of folklore.
- The silent miscompile becomes a compile error, then progressively a working
write-back for the sound cases — across all replace types, including
fn, because forbid/carve-out derive from the shared predicate. - The carve-out reuses the existing retention analysis and local-boxing machinery; no new runtime representation, allocation, or indirection.
- A single classification predicate removes the fn / flags drift class of bug.
Negative
- A value-type
&mutwhose param is retained, or one bound / returned outside a call, stays forbidden. Workaround: assign the whole element / field, or restructure functionally. This is the genuinely-unsound case under the current representation. &mutrepresentation visibly depends on whether the referent is in-place or replace-on-assign; generic code over&mut Tis monomorphized perT(already true today — this WEP only records it).
