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 Stores).
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.
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. (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. &(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 that does not escape (nostores[param], enforced bycheck_stores_semantic), 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 (param instores, 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.
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.
- [ ] Forbid
&mutto a non-local replace-on-assign place (compile error), subsuming the partial primitive-struct-field guard. Shipcompile_errorfixtures: variant / primitive / enum / flags /fnlist element, and variant / enum struct field. - [ ] Carve out the
stores-gated temp + write-back, one call path at a time:- [ ]
Listindex element (&mut xs[i]) — validated by a throwaway prototype on the free-function / static-dispatch path; reuses the existingindex_assigndispatch. - [ ] struct field (
&mut s.f) — write-back is a plain field assign. - [ ] 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 — silent write-back drop.
&mutto a non-local replace-on-assign place compiles but discards the write, for every replace type. Verified by probe (HEAD):place result &mut xs[i]— primitive elementdropped &mut xs[i]— enum elementdropped &mut xs[i]— flags elementdropped &mut xs[i]— variant elementdropped &mut fns[i]—fnelementdropped &mut s.f— enum / variant fielddropped The same operations on a value-type local all work. Resolved by the forbid + carve-out.
-
[ ] D2 — no shared predicate. The boxed set is inlined in
lower/plan/boxing.rs::create_needed_box_typesasis_prim || is_enum(non-variant-case) || is_variant || is_fn. Extract it so forbid / carve-out cannot drift from boxing. -
[ ] 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.) -
[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
storesanalysis 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 escapes (stores), 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).
