Wado

WEP: Generic AsyncCall<T> for CM async imports

Context

WASI P3 defines async func imports (e.g. wasi:http/client#send) that are lowered via canon lower async. The canonical ABI for this form:

  1. Allocates a result buffer in caller memory (outptr).
  2. Calls the lowered import with the params and outptr.
  3. The import returns immediately with (subtask_handle << 4) | statusStatus::Returned means the host completed synchronously, Status::Started means the host spawned a subtask that will write the result later.
  4. The caller later calls waitable-set.wait on the subtask to wait for completion and then lifts the result from outptr.

Wado's current CM binding synthesis (wado-compiler/src/synthesis/cm_binding.rs, needs_async_lower branch around line 3205) combines all four steps into a single adapter function that blocks the caller until the subtask returns.

Problem

For async fn imports whose request contains a stream parameter (e.g. Client::send with a Request carrying a body Stream<u8>), the eager wait creates a deadlock:

The CM spec supports interleaving via the Status::Started fast path — the caller receives the subtask handle synchronously and can perform concurrent operations before awaiting the subtask. The current Wado synthesis hides this behind the eager wait, so user code never sees the handle.

Reference: wasmtime's Rust test

vendor/wasmtime/crates/test-programs/src/bin/p3_http_outbound_request_content_length.rs shows the canonical pattern with futures::join!:

let (handle, transmit, ()) = join!(
    async { client::send(request).await },         // async send
    async { transmit.await },                      // transmit future
    async {
        contents_tx.write_all(body).await;          // body writer
        trailers_tx.write(Ok(None)).await;
        drop(contents_tx);
    },
);

Three concurrent futures drive each other to completion. In CM terms: the send subtask, the transmit future, and the body stream rendezvous progress on a single task via waitable-set.wait.

Decision

Expose the CM subtask primitive to Wado user code as a type, AsyncCall<T>, and change the synthesized signature of CM async func imports to return AsyncCall<T> instead of T directly. The user explicitly .wait()s when they want the result.

Naming note: this WEP was originally titled Subtask<T>. The type shipped as AsyncCall<T> — "subtask" is the CM primitive it wraps, but the Wado type is the whole in-flight call (handle plus result buffer plus lift function), so AsyncCall reads better at call sites. The non-generic Subtask resource (a thin wrapper over the raw CM subtask handle) was retained, not replaced; see "Breaking changes". This document uses AsyncCall<T> throughout.

Wado type

// core/prelude/types.wado
/// A handle to an in-flight async CM import call, parameterised by the result
/// type `T`. Owns the CM subtask handle and the linear-memory result buffer;
/// `wait` blocks until the subtask returns, lifts the result, and frees the
/// buffer.
pub resource AsyncCall<T> {
    /// Wait for the subtask to return and take its value. Consumes `self`
    /// (drops the subtask handle and frees the result buffer).
    fn wait(self) -> T;

    /// Cancel the in-flight subtask and free the buffer. Consumes `self`.
    fn cancel(self);

    /// Join this subtask to a waitable set for manual polling (used when the
    /// caller wants to wait on multiple subtasks or streams simultaneously).
    /// Borrows `self`; the caller still owns the `AsyncCall<T>` and remains
    /// responsible for the eventual `wait` / `cancel`.
    fn join(&self, set: &WaitableSet) -> Waitable;
}

AsyncCall<T> is a resource in the ownership sense of WEP 2026-05-21: an affine (move-only) type with a destructor. It is not a CM-imported resource and is not exported across any CM boundary — it is a guest-internal resource. Its representation is a guest-internal aggregate: the packed subtask handle, the result buffer pointer, the buffer size and alignment (for realloc-based free), and a per-T lift function. Size and alignment are baked in at monomorphization time based on T.

wait and cancel take self by value because they genuinely consume the in-flight call — after either, the subtask handle is dropped and the buffer freed — and because consuming self receivers are restricted to resource types (see WEP 2026-05-21). join only registers the subtask with a waitable set, so it borrows.

Implementation status and divergence

This WEP is implemented, but the shipped form diverges from the design above in two ways that the affine ownership model (WEP 2026-05-21) is meant to correct:

Both are exactly the unsoundness the affine model removes: as a resource, AsyncCall<T> is non-copyable, and consuming wait(self) / cancel(self) turn the documented use-after-free into a compile error. Closing this gap is tracked by WEP 2026-05-21's roadmap; until then the &self form remains, with the contract enforced only by documentation.

Synthesis of async imports

For each WIT async func import, the compiler synthesizes a Wado function whose return type is AsyncCall<T> where T is the CM return type lifted to Wado. The body:

  1. Allocates outptr via realloc with size/align computed from T.
  2. Calls the import via canon lower async, receiving the packed subtask handle / status.
  3. Wraps the packed handle, the result buffer pointer, its size and alignment, and a per-T lift function in an AsyncCall<T> value and returns it.

The eager wait + lift + free pipeline lives in AsyncCall<T>::wait, monomorphized per T:

  1. If the packed handle is zero (Status::Returned synchronously), skip the wait.
  2. Otherwise, create a WaitableSet, join the subtask, loop on wait() until Status::Returned, drop both.
  3. Lift T from outptr using the per-import lift function.
  4. realloc(outptr, size, align, 0) to free.

wado-from-idl automation

WIT async func foo(...) -> T ⇒ Wado fn foo(...) -> AsyncCall<T> (no async keyword, matching the spec's rule that "effect declarations never use the async keyword"). wado-from-idl tracks is_async in its IR and emits AsyncCall<ReturnType> rather than adding the async keyword.

World exports (entry points like run, handle) continue to use async fn since they represent the CM lifting boundary, not a CM import adapter.

User code patterns

GET or body-less request (no stream parameter):

let resp = Client::send(req).wait();

POST with body stream (mirror of example/http_bin.wado's task return pattern, but for the outbound direction):

let [body_rx, body_tx] = Stream::<u8>::new();
let [trailers_future, trailers_tx] = Future::<...>::new();
let [req, _transmit] = Request::new(headers, Option::Some(body_rx), trailers_future, null);
req.set_method(Method::Post);
// ... configure req ...

let task = Client::send(req);    // canon lower async, host subtask starts
body_tx.write(body);             // rendezvous with subtask reading body_rx
body_tx.drop();
let resp = task.wait();          // wait + lift + free; consumes `task`
trailers_tx.write(Result::Ok(null));

Consequences

Breaking changes

Out of scope

Implementation status

References