Wado

WEP: Resource Ownership — Move-Only Resources, Authoritative Cleanup, and Value-Copy Elision

Context

A Wado resource is an opaque handle to a Component Model (CM) resource. CM already enforces an ownership discipline — no double-drop, no use-after-transfer, no leaked borrow — but dynamically, as a trap, and only at the component boundary. This WEP makes resources move-only and builds the static analysis that turns that discipline into compile-time guarantees plus deterministic cleanup.

The analysis answers one question — is this the binding's last use, or is the value still live afterward? — which also decides, for a copyable value type, whether a materialization needs a deep copy or can transfer storage in place. So value-copy elision is a second client; this WEP records its implementation too.

The move and unique keywords once reserved for this work are unnecessary: the analysis needs move-only semantics, not new syntax. They are deferred (last section).

CM handle types

Handle Meaning
own<R> Unique owning handle; dropping the last own runs the dtor.
borrow<R> Non-owning handle lent from an own; must be dropped before the call returns.

A handle is an opaque i32 index into a per-instance table (or an externref in CM-GC mode). lift_own transfers ownership and traps if the handle is lent out; lift_borrow leaves the source in place; a task returning with a live borrow traps.

Wado today

Decision

Two resource kinds

Kind Backed by Ownership Cleanup
Affine resource a CM own/borrow or waitable handle, or a guest one Move-only + a check resource.drop / dtor
Host-object resource a Wasm GC reference to a host object (no dtor) Value semantics Wasm GC

An affine resource holds a one-shot, destructor-bearing thing, so copying it would alias it — a double-drop, a use-after-transfer, or a double free. A host-object resource is a GC reference with no dtor, so aliasing is safe. The i32-vs-externref representation is orthogonal; the kind decides the model. Waitable (from join) has no drop and is a copyable newtype, not affine. The rest of this WEP concerns affine resources.

Move-only resources

An affine resource is move-only, with no keyword:

Transfer is implicit at the consumption site — there is no move operator, no lifetimes, no exclusivity analysis.

The receiver has exactly three spellings and never a type annotation:

Spelling Meaning CM handle
self by-value move — resource-only own<R>
&self shared borrow borrow<R>
&mut self mutable borrow borrow<R>

self: T and self: &T are rejected. A copyable type has no use for a value receiver (&self plus a *self deref-copy covers it), and a single borrow spelling removes a needless choice. Bare self is legal on a resource or an aggregate that carries one (its consuming method hands that resource off); on a value type that owns no resource it is a diagnostic (value types are not move-only; use &self) — this is how the value-semantics friction is mitigated, not with a keyword. The rule licenses fn drop(self) and the consuming AsyncCall<T> methods.

Because the annotated forms are gone, the receiver kind is carried entirely by SelfKind (Value / Ref / MutRef); SelfKind::None means a genuine non-method, and a call consumes its receiver iff the dispatch resolves to SelfKind::Value.

The move check

resource_move_check.rs gives resources their move-only semantics in one diagnostic pass.

Layer — it runs on the Semantics layer (AST + type facts), a sibling of effect_check::check_semantics, so it has source spans, surfaces in both wado compile/check and the editor, and sees every function. Post-mono TIR was rejected: it has neither diagnostic spans nor a call-site origin.

Analysis — a forward walker over each body (free / impl / trait / test / function-local). Each resource binding is live or moved-at-span; a by-value consumption records a move and a later use is reported. Branch joins union the moves and drop a diverging path's; a loop body is walked twice to catch a cross-iteration move; a let / for-of re-bind and a re-assignment clear state.

It is independent of the value-copy client's last-use liveness (lower/plan/value_copy/last_use.rs, post-mono over TIR); unifying the two is future work, not a present fact.

Done: use-after-move of a bare resource local, by-value self consumption (a bare self receiver moves it, via a consumes_self dispatch fact — the semantic-layer twin of resource_cleanup's owned_self), and whole-move of a resource-carrying struct / tuple / Result, and no-move-out-of-borrow (a &self return rooted in the borrow that carries a resource) — see below.

Extraction is a by-value receiver (Rust-aligned)

Pulling a resource out of a Result / Option returns an owned handle, so the source must relinquish ownership. unwrap(&self) cannot — a borrow keeps the receiver owned, so the returned handle would alias the one still inside the container: two owners of one move-only resource, hence a double-free. The fix mirrors Rust: the consuming accessors (unwrap / expect / unwrap_err / expect_err) take self by value, so res.unwrap() consumes the whole Result and moves the interior out with a single owner. For value (copyable) types this is the usual value-semantics copy — elided to a move at last use — so .unwrap() stays ergonomic and no rejection is needed. Inspectors (is_ok, …) keep &self.

By-value self on a value type is otherwise a diagnostic (SelfByValueOnNonResource); the generic Result<T, E> / Option<T> impls are permitted because a generic self-type resolves to a GenericInstance, not a concrete value type.

No move out of a borrow

A hand-written fn f(&self) -> Resource { return self.f; } (or match *self { … => interior }, or a let-bound projection) moves a resource out of a borrowed place: the borrow keeps the source owned, so the returned handle aliases it — a double-free. resource_move_check rejects it. For each function it seeds the borrowed parameters (&self / &mut self / &T), tracks bindings projected from them (through let, field access, deref, and match-arm bindings whose scrutinee is borrowed), and flags a return whose value is rooted in a borrow and whose type carries a resource. A &self method returning a freshly produced resource (dir.open_at()) stays legal (its return roots in a fresh allocation, not the borrow).

The gate is the concrete return type carrying a resource, so a generic body (T abstract) is not flagged at its definition; the stdlib avoids the issue by taking self by value, and a user-written generic &self extractor instantiated with a resource is the remaining call-site case (deferred).

Authoritative cleanup

resource_cleanup.rs drops every owned, untransferred resource on each fall-through path, structurally for aggregates via a synthesized match. A value is transferred when passed by value, returned, placed in an aggregate, or used as the receiver of a by-value (self) method — so extraction (unwrap) is just a by-value receiver transfer, with no aggregate-shape guessing. Two fixes landed:

The is_resource_aggregate heuristic is retired: cleanup now drops "owned and not transferred at scope exit" directly.

Deterministic drop

An owned, un-moved resource is dropped at scope exit on every path; a move suppresses that drop. An imported handle drops via resource.drop; a guest-defined one runs its destructor (WEP 2026-01-12). An explicit fn drop(self) consumes the receiver, so exactly one drop fires; a &self drop would be unsound (the binding outlives its handle and double-frees). An aggregate containing a resource is move-only and gets a synthesized destructor in declaration order. Panic does not unwind, so a drop is not guaranteed on panic.

CM boundary

Wado position CM handle
by-value R parameter / return own<R>
&R parameter, &self receiver borrow<R>
by-value self (consuming method) own<R> — transfers the receiver

Once the move check proves a by-value resource uniquely owned with no live borrow, CM's lift_own preconditions hold statically.

Value-copy elision (the second client)

A copyable type uses the same last-use question to move (last use → no copy), copy (source still live → deep copy), or share (neither side mutates again → alias), replacing the old "copy-everything-then-elide" scheme. As implemented it is caller-side and single-phase (lower::translate emits $value_copy$T only where it cannot prove move / share / fresh; no elision pass):

A recursive type's $value_copy$T is a true deep copy (a mutually-recursive helper), replacing an identity fallback that silently shared storage. optimize::escape and optimize::value_copy_elide are deleted.

Known gap: a borrowed projection behind a variant

is_projection_of_param matches a syntactic deref / field / index / payload / cast chain rooted at the first parameter, so build(&self) -> List { return *self } is self-projecting but ArrayIter<T>::next is not:

let item = builtin::array_get(self.repr, self.index);
return Option::Some(item);

array_get is already a container alias read, and Option<T> over a reference type lowers to a bare nullable ref, so the borrowed element could be returned as it stands. Two things hide it: the projection is behind a let binding, and it is wrapped in a variant construction. So next returns owned, and the fold deep-copies the element into the payload — every for x of list over a List of aggregates pays a copy of each element even when the loop body only reads it. Nothing about the copy is required: the same walk written over &list, or as an index loop in one body, reads the element in place under the read-only share.

Closing it needs both halves, in the same fixpoint: the recognizer must see the projection through the binding and the variant, and the fold must then treat the materialization into that payload as a share rather than a copy. Neither the inliner nor any NIR pass can substitute — the copy is chosen before NIR exists, and #[inline(always)] on next leaves the expanded clone in the caller's loop untouched even with the cloned array provably unread.

Amendments to earlier WEPs

Consequences

Implementation status

Verified against the tree.

Move check

Cleanup

Receiver grammar (self / &self / &mut self)

Consuming drop

Value-copy client (done)

Deferred: the move and unique keywords

Intentionally not implemented. Move-only semantics need no syntax: transfer is implicit, a use-after-move diagnostic replaces a move operator's local visibility, and the consuming receiver is spelled bare self (scoped to resources, so it never contradicts value semantics — see the receiver grammar above). unique as a struct modifier only matters for a move-only type that carries no resource — a resource-bearing aggregate is already move-only by composition, and no use case has appeared. Current state: move is not tokenized; unique is lexed to TokenKind::Unique but never parsed. If revived, unique struct reuses the same move-check machinery, resources being its first client.

References