Wado

WEP: post-return for Synchronously-Lifted Exports

Gives a sync-lifted --lib export a way to reclaim the linear memory it hands the host. Fixes wado-lang/wado#1683.

Context

A non-async --lib export is lifted synchronously. When its result is memory-backed — string, list, or a composite holding one — the Canonical ABI returns it indirectly: the guest allocates a buffer, lowers the value into it, and hands the host a pointer.

The ABI's only channel for telling the guest the host has finished reading is the post-return option of canon lift:

the optional post-return function … is called, passing the same core wasm results as parameters so that the post-return function can free any associated allocations.

CanonicalABI.md, canon_lift

Wado never emitted it, so nothing freed the payload. The library world defaults to the freelist allocator precisely so that a long-running host reclaims memory, and the return buffer was the one allocation it could not reclaim: an export returning 1 MiB exhausts a 12 MiB memory cap after eight calls, growing by exactly one payload per call.

post-return is illegal alongside async, so async-lifted exports — the WASI worlds and export async fn — cannot use it. They reclaim the same buffers straight after task.return; see D2.

Decision

Emit post-return on a synchronous lift that returns its result indirectly, and synthesize the function it names.

When it is emitted

The gate is the indirect return, not memory ownership. Anything wider than one core value comes back through a guest-allocated area, and that area leaks without post-return even when nothing hangs off it — a record of two u32 owns no memory and still loses eight bytes per call. A result that fits in a core value allocates nothing and needs no option.

What gets freed

post-return receives only the outer pointer, so reclamation is a recursive, type-driven walk of the value in linear memory: a list<string> owns its element array and one payload per element. The walk covers strings, lists, records, tuples, variants, options and results, and ends by releasing the return area itself.

Handles are never touched. Lifting transfers an own<r> to the host, so dropping one would be a double-drop. Handles count as owning nothing — the distinction is "owns no memory", not "is not four bytes wide".

A part owning no memory produces no code, so a result of scalars reduces to the single free of its return area.

Staying in step with lowering

The freeing side and the lowering side must agree on where every buffer sits. They share one source of layout truth — the Canonical ABI layout helpers and the type registry — rather than each deriving offsets independently.

They are separate walks even so, because lowering needs naming and type identity that reclamation does not, and folding those in would only make the ownership model worse at its one job. Divergence is instead made loud: a type that reaches the CM boundary with no ownership rule fails on its first round-trip rather than leaking silently. Silence was the old behavior, and it is what let this bug survive.

Verification

The two reclaiming allocators fail loudly in opposite directions, so between them they pin both halves of correctness:

Where a leak can be pinned by inspecting the emitted component, that is preferred to exhausting a memory cap: the inspection is exact and cheap, while an exhaustion test has to guess allocator block arithmetic to stay meaningful and costs real time on a loaded machine.

Consequences

An export whose result fits in a core value is unaffected: its component is unchanged, and no code size is spent where nothing was allocated. An export returning indirectly pays one extra core function and one call per invocation.

Under bump the emitted frees are no-ops, since that allocator never reclaims, so a --lib --allocator bump build pays the call cost for no benefit. Making the option conditional on the allocator was rejected: canon lift states the component's contract with its host, and that contract should not depend on an allocation strategy.

The lift path keeps its own, different discipline — each lift site frees what it just read. Reclamation here is standalone, freeing a value nothing has consumed, so the two cannot simply be merged without double-freeing. Unifying them is a follow-up, and the freelist double-free trap is what would make it safe.

Adjacent leaks

Two leaks of the same family surfaced while auditing the boundary. Neither is reachable by post-return; both are fixed here, off the same ownership model.

D1 — a top-level string parameter was never freed. The caller lowers it into guest memory using the guest's own allocator, so the buffer belongs to the guest once it has been copied onto the GC heap. Nested strings were already released by the lift sites that read them; only the top-level case had no such site. Fixed with this WEP.

D2 — a memory-backed task.return result was never freed. task.return lifts eagerly, so the guest may release the payload as soon as the call returns. post-return is illegal with async, so freeing right after the call is the only mechanism there is. Fixed with this WEP (wado-lang/wado#1708), by the fold below.

The flat fold

The value sits in the flat slots handed to task.return, not behind one pointer, so reclamation starts from a (ptr, len) pair per string or list rather than from an address. Below that first pointer the two folds are the same walk — a list<string>'s elements are in memory either way — and both read the one ownership model, so neither can drift from the other.

Two things only the flat side faces. A variant's cases share the slots after their discriminant, so the walk reads it first: freeing unconditionally would take an Err payload's slots for the Ok buffer's (ptr, len). And a join may have widened a slot past i32, so the pointer comes back out through the coercion that put it in.

This reaches further than the sync side suggests. wasi:http's handler returns what looks like a handle beside an enum, but error-code is a variant whose cases carry strings, so every error response owned linear memory.

A result wider than MAX_FLAT_PARAMS (16) is passed indirectly instead, through a buffer the memory walk above would reclaim directly. That form is not lowered at all (wado-lang/wado#1712), so no such buffer exists yet.

References