Wado

Research: Callbacks across the Component Model Boundary

How a Wado component can parameterize a dependency component's behavior with its own code — the callback / dependency-injection pattern — given that the Component Model has no first-class function values. Motivated by making a published library (e.g. wado-lang:marl) extensible: the library performs a guest-defined effect (Highlight), and the consumer supplies the implementation from outside the library's OCI artifact.

This note is the guest↔guest direction: neither end is a host, so neither can call the other's exports. The host↔guest direction is Web § Callbacks.

The engine rules below and the endpoint (first-class function values) carry across; the host-pump rejection does not — it is about a published artifact's portability, which a browser glue we generate for our own bindings does not owe.

Companion WEPs:

The problem

A guest effect declared in a library (interface Highlight used with with Highlight, no handler installed) lowers to a CM import of a synthesized interface when the library is compiled with --lib (implemented; see Status below). The question is how the consumer provides the implementation. The obvious wish — the consumer's with h do { ... } handler receiving calls from inside the dependency — requires a call path dependency → consumer while the consumer is itself mid-call into the dependency.

Component instantiation is a DAG (imports are satisfied at instantiation, no cycles), and component functions are not values, so a callback cannot be passed at call time. The spec anticipated this exact need.

What the spec says

The use case is a design goal

UseCases.md #8, verbatim:

A component developer creates a fresh private instance of a dependency, supplying the component's own functions as imports to the dependency. The component does this to parameterize the dependency's behavior with the component's own logic or implementation choices (achieving the goals usually accomplished using callback registration or [dependency injection]).

The sanctioned mechanism: donut wrapping

Linking.md §"Higher-order Shared-Nothing Linking (aka donut wrapping)": a parent component nests the child and satisfies the child's imports with canon lifts of the parent's own core functions; the child's exports are canon lowered back into the parent's core code (M1 --lift--> C --lower--> M2, with funcref-table plumbing so one core module both calls the child and receives calls from it). The instance graph stays acyclic — the callback edge is core-level, inside one component instance.

Reentrance is legal, recursive reentrance included. WebAssembly/component-model#705 removed the may_enter trap from the Canonical ABI, and Concurrency.md, "Reentrance", leaves its hazards to the component's documented API.

Function values are explicitly future work

Concurrency.md future extensions: "allow function closures to be passed as first-class values, supporting the 'callback' pattern in many pre-existing APIs". Until then, callbacks are encoded via linking (above) or via stream/future values.

What engines implement

wasmtime executes donut adapters since 49

Through wasmtime 48, the fused-adapter compiler (FACT) compiled any adapter whose lift and lower sides were the same instance or in an ancestor relation into an unconditional trap. That over-approximated the spec's guard, which traps only recursive reentry, so every donut call trapped.

wasmtime 49 runs them. The hand-written donut component in wado-compiler/tests/integration/cm_donut_canary.rs instantiates and its parent→child call returns the expected value.

Sibling adapters (the shape wasm-compose produces, and what the component -import pipeline uses today) are unaffected.

Host reentry

wasmtime 49 refuses entry only to an instance that has trapped. A host import handler may call back into its caller's component while the import call is in progress, and a detached task scheduled from the host event loop may enter it concurrently: cm_callback_export.rs enters a synchronously lifted export while run waits on a timer. jco 1.30.0 mishandles the first case; see Research: CM Pain Points.

Options

Satisfy the dependency's guest-effect import with a sibling provider component at composition time. The consumer names a provider on the import:

use { Marl } from "wado-lang:marl" with { provider: "./highlight_provider.wado" };

The compiler compiles highlight_provider.wado on-demand into a component that exports the dependency's imported interface (its export fns lowered into that interface), connects provider.export → dependency.import in the existing wasm-compose graph, and discharges the reconstructed effect at effect-check — so the consumer calls Marl with no handler installed. The provider file is plain (export fn highlight(...) { ... }); it binds by operation name to the dependency's imported interface. Sibling adapters are engine-clean and already exercised by the component-import pipeline.

This is UseCases #8 exactly, and it completes the reconstruction WEP's own rule — "if [an interface] is satisfied by a fused sibling component, it is a transparent namespace": a declared provider makes the reconstructed effect disappear from the consumer's obligations; with no provider, the effect remains required (reachable from the host or an outer component that ultimately satisfies it).

Trade-off: binding is static. No with h do dynamic extent across the boundary; the provider cannot close over the consuming program's state (it is its own component). For self-contained parameterizations — a syntax highlighter, a codec, a policy function — this is a fit, not a limitation.

Effect channels — stream-encoded callbacks (the dynamic direction)

Encode the effect protocol in values: the library's export takes/returns stream<request> / stream<response> handles; the consumer runs a pump — loop { read request; handle; write response } — as a task concurrent with the call. Pure guest-to-guest, portable to any P3 host, and real dynamic extent (the handler closes over the consuming program's state). This is the principled way to reach the dynamic quadrant provider composition and the host pump cannot both cover; it is the reification of an effect as an async channel, and an effect signature is a (degenerate) session type over it.

Buffering is not a problem for the base case. CM streams are rendezvous (buffer 0): a write with no pending read parks and completes when a reader arrives — it is not an instant deadlock, and a reader never arriving is the only failure. A synchronous effect op — call, block for the result — is itself a rendezvous, so buffer 0 is the natural fit: write req; read res on the library side interlocks with read req; handle; write res on the pump, one-in-one-out, no buffering or reordering.

The real requirement is liveness: the pump must run concurrently with the call. That is the compiler-generated driver's responsibility (spawn the pump, then call; close the request stream on return so the pump terminates) — a footgun only if hand-written. Buffer 0 bites just once: a library firing several effect ops without awaiting each (concurrent / multi-shot), where send-send with no ready reader needs a small buffer or a correlation-id protocol with an eager multi-read pump. The synchronous, one-op-at-a-time base model needs neither.

Cost: the library's published WIT becomes a channel protocol rather than a clean interface import; both sides are async; and the compiler must lower effect ops to stream operations (or the library hand-writes the protocol) — WEP-scale. Worth doing on its own motivation — interactive / streaming effects across the boundary (a logger/tracer subscriber, a mid-computation policy or permission ask, progress / cancellation), not as a highlighting mechanism.

Rejected: host effect pump

Leave the import unsatisfied and have the wado runtime harness provide it as a host function that pumps calls to the consumer's handler (a detached task against an async-lifted shim export — legal, see Host reentry above). It offers dynamic extent, but the artifact is no longer self-satisfying: it runs only on a wado-aware host, forfeiting the portability that is the point of targeting Wasm. Rejected on that ground; effect channels reach the same dynamic quadrant portably.

Available since wasmtime 49: donut wrapping

The spec-sanctioned mechanism (above). wasmtime 49 executes it, and cm_donut_canary.rs fails if that stops. Not yet evaluated against provider composition and effect channels.

Future spec: first-class function values

The eventual loosening. When function closures become passable values, a guest effect could be satisfied per-call with an actual closure. Tracked in Concurrency.md's future extensions; nothing to build against today.

Decision

Adopt provider composition now for static parameterization. Keep the library-side contract (guest effect → synthesized CM import) unchanged so effect channels can be added later for dynamic, portable cross-boundary effects, and so a future donut / first-class-function world needs no contract change. The host effect pump is rejected (non-portable).

Status