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.

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