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-returnfunction … is called, passing the same core wasm results as parameters so that thepost-returnfunction 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:
freelisttraps on a double-free, so a walk visiting a buffer twice cannot pass. Running the CM type catalog under it covers every shape the walk takes.debugpoisons freed memory and never reuses it, so a free that runs too early or covers too much corrupts data the test reads back.
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
vendor/component-model/design/mvp/CanonicalABI.md—canon_lift,canon_task_returnvendor/component-model/design/mvp/Explainer.md:1362-1368—post-returnrules- Async Canonical Options for
stream.read/stream.write— the neighbouring canonical-option audit
