Wado

WEP: Effect Handler

Status: Stable

Implementation Status

The full dispatch protocol — front-end, elaborator/effect-check, synthesis pass, codegen — is shipping. Detailed development history is in the git log; this section records only the current state.

Context

WEP: Effect System Design defines how Wado tracks side effects. This WEP defines how effect handlers provide implementations for effects, enabling dependency injection, testing, and middleware patterns.

Effects are capabilities required by functions. The runtime (wasmtime) provides real implementations for world-imported effects. Effect handlers let user code substitute custom implementations, analogous to how wasmtime's add_to_linker registers host implementations for WIT imports.

Design Goals

Decision

Effect as Trait

An effect declaration defines an interface (like a trait). Any struct that implements the effect's operations can serve as a handler:

interface Stdin {
    fn read_line() -> String;
}

struct MockStdin {
    responses: List<String>,
    mut index: i32,
}

impl Stdin for MockStdin {
    fn read_line(&mut self) -> String {
        let result = self.responses[self.index];
        self.index += 1;
        resume result
    }
}

Handler implementations add &self or &mut self to access the handler's state. The effect declaration itself has no self parameter (effect operations are free functions from the caller's perspective).

Using Handlers

The with Effect => value do { ... } block installs a handler for the scope of the do block. The => arrow reads as a dispatch binding ("calls to Effect go to value"); it mirrors the match-arm arrow and is deliberately not =, since the operation pushes a handler onto a per-effect stack rather than performing assignment.

fn test_input() {
    let mut mock = MockStdin { responses: ["hello", "world"], index: 0 };
    with Stdin => &mut mock do {
        let a = Stdin::read_line();  // "hello"
        let b = Stdin::read_line();  // "world"
    }
    assert mock.index == 2;
}

Multiple handlers:

with Stdin => &mut mock_stdin, Stdout => &mut mock_stdout do {
    // ...
}

Value Form

with ... do { ... } is an expression. Like other Wado blocks (if, match, labeled blocks), it evaluates to its body's trailing value, so it can be bound or fed directly into another expression:

let id = with Random => &mut rng do {
    Uuid::v4()
};

// Two value-form blocks feeding a comparison.
let a = with Random => &mut rng, SystemClock => &early do { Uuid::v7() };
let b = with Random => &mut rng, SystemClock => &late do { Uuid::v7() };
assert a < b;

The body's value is evaluated while the handler is still installed and survives the per-effect dispatch restore. The statement form is just the special case where the body's tail is Unit.

Only the effects actually needed are required on the calling function:

Handler Methods with Effects

Handler methods can have their own effect requirements:

struct LoggingStdin {
    response: String,
}

impl Stdin for LoggingStdin {
    fn read_line(&self) -> String with Stdout {
        println("reading...");
        resume self.response
    }
}

// Caller must have Stdout (handler method's effect), but not Stdin (handled)
fn test_logging() with Stdout {
    let mock = LoggingStdin { response: "mocked" };
    with Stdin => &mock do {
        let line = Stdin::read_line();
    }
}

Handling Granularity: ..forward and ..trap

By default, impl Effect for Type must implement every operation of the effect (like a complete trait impl); a missing operation is a compile error. A trailing rest clause opts the unimplemented operations into one of two behaviours:

forward and trap are contextual keywords, recognised only in this rest position. There is no bare ..: the choice between forwarding and trapping is always explicit, since silently picking either is a footgun (a forwarded mock leaks to the real outer handler; a trapping layer breaks composition).

A layer overrides the operations it cares about and forwards the rest:

struct Filter { min: Level }

impl Log for Filter {
    fn enabled(&self, meta: &Metadata) -> bool { resume (meta.level as i32) >= (self.min as i32) }
    fn event(&self, event: &Event) {
        if (event.meta.level as i32) >= (self.min as i32) { Log::event(event) }
        resume ()
    }
    ..forward  // new_span / enter / exit / record_fields / … → outer Log handler
}

A mock implements only the operations the test expects, trapping on anything unexpected so a stray call cannot silently reach the real outer handler:

struct MinimalTcp;

impl TcpSocket for MinimalTcp {
    fn create(&self, family: IpAddressFamily) -> Result<TcpSocket, ErrorCode> {
        resume Result::Ok(mock_socket())
    }
    fn connect(&self, self_: &TcpSocket, addr: IpSocketAddress) -> Result<(), ErrorCode> {
        resume Result::Ok(())
    }
    ..trap  // bind, listen, send, receive, … — trap if called
}

..forward desugars each missing operation to fn op(args) { resume Effect::op(args) }. A handler body runs in the outer scope (see Effect Forwarding), so the call reaches the outer handler, not itself. With no outer handler installed it traps like any unhandled operation — so ..forward on the outermost handler of an effect behaves like ..trap for the operations it omits; a leaf sink that wants a no-op must implement that operation explicitly.

Resume Keyword

resume is a control flow expression similar to return. It passes a value to the computation and transfers control. The expression resume itself evaluates to ().

impl Stdin for MockStdin {
    fn read_line(&self) -> String {
        resume "value"
    }
}

For post-processing (one-shot continuations):

impl FileSystem for ManagedFs {
    fn open_file(&self, path: String) -> Handle {
        let handle = real_open(path);
        resume handle;
        real_close(handle);  // runs after do block completes
    }
}

Continuation Semantics and Execution Model

One-shot only. Each resume executes at most once. Multi-shot continuations are a future consideration pending Wasm Stack Switching support.

Execution model depends on whether post-resume code exists:

Pattern Example Implementation
No post-resume fn op() { resume value } resume compiles to return
Post-resume fn op() { resume value; cleanup(); } Wasm Stack Switching

Most handlers (test mocks, DI) have no post-resume code and use the return optimization. Post-resume handlers (resource cleanup, generators) require Wasm Stack Switching, which is available on amd64 in wasmtime.

Effect Forwarding

Handlers only handle the effects they declare. All other effects forward to the outer scope. This follows the universal pattern in algebraic effect systems (Koka, Eff, OCaml 5, Effekt).

let mock = MockClient;
with Client => &mock do {
    let headers = Fields::new();    // Fields is not handled → forwards to outer scope
    let req = Request::new(...);    // Request is not handled → forwards to outer scope
    let resp = Client::send(req);   // Client IS handled → goes to MockClient
}

Handler method bodies execute in the outer effect scope. This means:

struct CachingClient {
    cache: &mut TreeMap<String, Response>,
}

impl Client for CachingClient {
    fn send(&mut self, request: Request) -> Result<Response, ErrorCode> {
        let key = request.get_path_with_query();
        if let Some(cached) = self.cache.get(key) {
            resume Result::Ok(cached)
        }
        let resp = Client::send(request);  // Client — outer scope (real impl)
        self.cache[key] = resp;
        resume resp
    }
    ..forward
}

export fn run() with Stdout, Client {
    let mut cache = TreeMap::<String, Response>::new();
    with Client => &mut CachingClient { cache: &mut cache } do {
        app();
    }
}

Handler Nesting

Handlers nest naturally. Inner handlers override specific effects; unhandled effects forward through the chain to the outermost scope:

let mut mock_stdout = MockStdout { captured: [] };
let mock_client = MockClient;
with Stdout => &mut mock_stdout do {
    with Client => &mock_client do {
        println("sending...");   // Stdout → MockStdout (outer handler)
        Client::send(req);       // Client → MockClient (inner handler)
    }
}

World Imports as the Outermost Handler

A world's imports define the outermost handler scope. The runtime (wasmtime) provides the real implementations for all imported effects. A with ... do block creates a nested handler that overrides specific effects within its scope.

Conceptually, compiling for wasi:cli/command:

wasmtime (outermost handler)
  ├─ Stdout     = WASI stdout implementation
  ├─ Stderr     = WASI stderr implementation
  ├─ TcpSocket  = WASI socket implementation
  ├─ Stream     = CM canonical runtime
  ├─ Future     = CM canonical runtime
  └─ ...all world imports...

  do {
      run()   ← user's export fn
  }

For the test world, the runtime provides a minimal set (Stdout, Stderr, CM builtins). Effects not imported by the test world (e.g., Client, Fields, Request from wasi:http) must be provided by user handlers.

CM Streams and Futures Are Unbuffered

CM streams and futures are semantically unidirectional unbuffered channels (see CM Concurrency spec). stream.write blocks until a concurrent reader consumes the data; future.write blocks until a concurrent reader reads the value. The CM runtime does not buffer data between the readable and writable ends.

This means synchronous effect handlers cannot directly use CM streams for data transfer. Consider println:

pub fn println(message: String) with Stdout {
    let [rx, tx] = Stream::<u8>::new();           // 1. stream pair
    let handle = Stdout::write_via_stream(rx);    // 2. handler intercepts here
    write_to_stream(tx, message, true);           // 3. tx.write() — blocks if no reader on rx
    drop_cli_write_future(handle);                // 4. future.drop() — blocks if no writer
}

If the handler at step 2 simply stores rx and resumes, the caller's tx.write() at step 3 blocks waiting for a reader on rx. In a synchronous handler, there is no concurrent reader — deadlock.

The real WASI runtime avoids this because write_via_stream starts an async task that reads rx concurrently. Synchronous mock handlers need a different approach: replace CM's unbuffered streams and futures with buffered in-memory implementations.

Buffered CM Handlers (MockCM)

core:test will provide MockCM — a handler that implements Stream<u8>, StreamWritable<u8>, Future<T>, and FutureWritable<T> with buffered in-memory semantics. Writes append to a buffer without blocking; reads return buffered data immediately.

struct StreamBuffer {
    mut data: List<u8>,
    mut read_pos: i32,
    mut write_closed: bool,
}

struct MockCM {
    mut stream_buffers: List<StreamBuffer>,
    mut future_count: i32,
}

impl Stream<u8> for MockCM {
    fn new(&mut self) -> [Stream<u8>, StreamWritable<u8>] {
        let id = self.stream_buffers.len();
        self.stream_buffers.push(StreamBuffer { data: [], read_pos: 0, write_closed: false });
        resume [id as Stream<u8>, id as StreamWritable<u8>]
    }

    fn read(&mut self, stream: &Stream<u8>, max: i32) -> List<u8> {
        let id = *stream as i32;
        let buf = &mut self.stream_buffers[id];
        let available = buf.data.len() - buf.read_pos;
        if available == 0 { resume [] }
        let count = i32::min(max, available);
        let mut result: List<u8> = [];
        for let mut i = 0; i < count; i += 1 {
            result.push(buf.data[buf.read_pos + i]);
        }
        buf.read_pos += count;
        resume result
    }

    fn drop(&self, stream: &Stream<u8>) { resume () }
    fn cancel_read(&self, stream: &Stream<u8>) { resume () }
}

impl StreamWritable<u8> for MockCM {
    fn write(&mut self, writable: &StreamWritable<u8>, data: List<u8>) {
        let id = *writable as i32;
        self.stream_buffers[id].data.extend(data);
        resume ()  // buffered — never blocks
    }

    fn write_raw(&mut self, writable: &StreamWritable<u8>, data: builtin::array<u8>, len: i32) {
        let id = *writable as i32;
        let buf = &mut self.stream_buffers[id];
        for let mut i = 0; i < len; i += 1 {
            buf.data.push(builtin::array_get_u8(data, i));
        }
        resume ()
    }

    fn drop(&mut self, writable: &StreamWritable<u8>) {
        let id = *writable as i32;
        self.stream_buffers[id].write_closed = true;
        resume ()
    }

    fn cancel_write(&self, writable: &StreamWritable<u8>) { resume () }
}

Future and FutureWritable use &T references for type-erased storage (GC keeps values alive):

struct MockCMFutureSlot {
    mut value: Option<&()>,  // type-erased: &T cast to &()
}

// MockCM also has:
//   mut future_slots: List<MockCMFutureSlot>,

impl<T> Future<T> for MockCM {
    fn new(&mut self) -> [Future<T>, FutureWritable<T>] {
        let id = self.future_slots.len();
        self.future_slots.push(MockCMFutureSlot { value: null });
        resume [id as Future<T>, id as FutureWritable<T>]
    }

    fn read(&self, f: &Future<T>) -> Option<T> {
        let id = *f as i32;
        if let Some(erased) = self.future_slots[id].value {
            let ref = erased as &T;  // cast back to original type
            resume Option::Some(*ref)
        }
        resume null
    }

    fn drop(&self, f: &Future<T>) { resume () }
    fn cancel_read(&self, f: &Future<T>) { resume () }
}

impl<T> FutureWritable<T> for MockCM {
    fn write(&mut self, fw: &FutureWritable<T>, value: T) {
        let id = *fw as i32;
        self.future_slots[id].value = Option::Some(&value as &());  // type-erase via &()
        resume ()
    }

    fn drop(&self, fw: &FutureWritable<T>) { resume () }
    fn cancel_write(&self, fw: &FutureWritable<T>) { resume () }
}

Handler Bundling

When a type implements multiple effects, listing each one in with is verbose. If the effect name is omitted, the with block handles all effects the type implements:

// Explicit: list each effect separately
with Stream<u8> => &mut cm, StreamWritable<u8> => &mut cm,
     Future<T> => &mut cm, FutureWritable<T> => &mut cm do { ... }

// Bundled: handle all effects MockCM implements
with &mut cm do { ... }

Multiple handlers compose naturally:

with &mut cm, Stdout => &mut stdout, Client => &mut client do {
    run();
}

This follows wasmtime's pattern where a single WasiState struct implements multiple *View traits and is registered with one add_to_linker call.

Handlers for Testing

Effect handlers enable testing code that uses WASI effects without a real WASI runtime. Test functions implicitly have all effects, so handlers can provide any effect. core:test::MockCM provides buffered CM canonical handlers as a foundation.

Stdout Handler Example

MockStdout stores stream handles from each write_via_stream call. Because streams go through MockCM (buffered), the caller's tx.write() succeeds without blocking. After the do block, drain() reads buffered data from the stored stream handles:

struct MockStdout {
    mut streams: List<Stream<u8>>,
}

impl Stdout for MockStdout {
    fn write_via_stream(&mut self, data: Stream<u8>) -> Future<Result<(), ErrorCode>> {
        self.streams.push(data);
        let [f, ftx] = Future::<Result<(), ErrorCode>>::new();
        ftx.write(Result::<(), ErrorCode>::Ok(()));
        ftx.drop();
        resume f  // no post-resume → compiles to return
    }
}

impl MockStdout {
    fn drain(&mut self) -> String {
        let mut result = String::with_capacity(256);
        for let stream of self.streams {
            loop {
                let chunk = stream.read(4096);
                if chunk.is_empty() { break; }
                result.push_str(String::from_utf8(chunk));
            }
            stream.drop();
        }
        self.streams = [];
        return result;
    }
}

test "println captures output" {
    let mut cm = MockCM::new();
    let mut stdout = MockStdout { streams: [] };
    with &mut cm, Stdout => &mut stdout do {
        println("hello");
        println("world");
        // drain() must be called inside MockCM scope (fake handles are only valid here)
        let output = stdout.drain();
        assert output == "hello\nworld\n";
    }
}

Execution flow:

println("hello"):
  Stream::<u8>::new()            → MockCM: creates buffer #0, returns fake handles
  Stdout::write_via_stream(rx)  → MockStdout: stores rx, creates fake Future, resumes
  tx.write_raw(bytes, len)      → MockCM: appends to buffer #0 (no block)
  tx.drop()                     → MockCM: marks buffer #0 as write-closed
  future.drop()                 → MockCM: no-op

stdout.drain():
  stream.read(4096)             → MockCM: reads from buffer #0 (immediate)
  stream.drop()                 → MockCM: no-op

HTTP Client Handler Example

Testing code that calls Client::send (e.g., example/http_get.wado). The mock constructs a Response with body data pre-written to a buffered stream — this is safe because MockCM streams are buffered, so body_tx.write() succeeds immediately without a concurrent reader:

struct MockClient {
    mut requests: List<String>,
    response_body: String,
    status: StatusCode,
}

impl Client for MockClient {
    fn send(&mut self, request: Request) -> Result<Response, ErrorCode> {
        if let Some(path) = request.get_path_with_query() {
            self.requests.push(path);
        }

        let headers = Fields::new();  // forwards to outer scope
        let [trailers_rx, trailers_tx] = Future::<Result<Option<Trailers>, ErrorCode>>::new();
        let [body_rx, body_tx] = Stream::<u8>::new();  // → MockCM (buffered)

        body_tx.write(self.response_body.bytes().collect());  // buffered — no block
        body_tx.drop();
        trailers_tx.write(Result::<Option<Trailers>, ErrorCode>::Ok(null));
        trailers_tx.drop();

        let [resp, _] = Response::new(headers, Option::Some(body_rx), trailers_rx);
        resp.set_status_code(self.status);

        resume Result::<Response, ErrorCode>::Ok(resp)  // no post-resume
    }
    ..trap
}

test "http-get fetches and prints" {
    let mut cm = MockCM::new();
    let mut stdout = MockStdout { streams: [] };
    let mut client = MockClient {
        requests: [],
        response_body: `{"origin": "127.0.0.1"}`,
        status: 200,
    };
    with &mut cm, Stdout => &mut stdout, Client => &mut client do {
        run();  // example/http_get.wado's export fn run()
        assert client.requests[0] == "/get";
        let output = stdout.drain();
        assert output contains "Status: 200";
    }
}

Note: Fields::new(), Response::new() etc. are HTTP resource operations that forward to the outer scope. This test requires a world that imports wasi:http types (e.g., wasi:http/service), or additional handlers for those resources.

HTTP Server Middleware Example (Post-Resume)

A timing middleware uses post-resume to measure request processing time. The handler delegates to the outer Handler implementation via effect forwarding, resumes the response to the caller, then records metrics:

struct TimingMiddleware {
    mut log: List<[String, u64]>,
}

impl Handler for TimingMiddleware {
    fn handle(&mut self, request: Request) -> Result<Response, ErrorCode> {
        let path = request.get_path_with_query().unwrap_or("?");
        let start = MonotonicClock::now();
        let resp = Handler::handle(request);  // delegates to outer scope
        resume resp;
        // Post-resume (Stack Switching): runs after do block completes
        let elapsed = MonotonicClock::now() - start;
        self.log.push([path, elapsed]);
    }
    ..forward
}

Testing with MockHandler as the downstream:

struct MockHandler {
    status: StatusCode,
    body: String,
}

impl Handler for MockHandler {
    fn handle(&self, request: Request) -> Result<Response, ErrorCode> {
        let headers = Fields::new();
        let [trailers_rx, trailers_tx] = Future::<Result<Option<Trailers>, ErrorCode>>::new();
        let [body_rx, body_tx] = Stream::<u8>::new();
        body_tx.write(self.body.bytes().collect());
        body_tx.drop();
        trailers_tx.write(Result::<Option<Trailers>, ErrorCode>::Ok(null));
        trailers_tx.drop();
        let [resp, _] = Response::new(headers, Option::Some(body_rx), trailers_rx);
        resp.set_status_code(self.status);
        resume Result::<Response, ErrorCode>::Ok(resp)
    }
    ..trap
}

test "timing middleware records elapsed time" {
    let mut cm = MockCM::new();
    let downstream = MockHandler { status: 200, body: "ok" };
    let mut timing = TimingMiddleware { log: [] };
    with &mut cm do {
        with Handler => &downstream do {
            with Handler => &mut timing do {
                let req = create_test_request("/api");
                let resp = Handler::handle(req);
                assert resp matches { Ok(_) };
            }
        }
    }
    assert timing.log.len() == 1;
    assert timing.log[0].0 == "/api";
}

Handler nesting: inner TimingMiddleware intercepts Handler::handle, delegates to the outer MockHandler via effect forwarding, and records timing in post-resume.

Implementation Notes

Front-End Grammar Notes

do and resume are contextual keywords

The lexer never emits dedicated Do or Resume token kinds; both words are returned as ordinary identifiers and the parser only treats them as keywords in unambiguous positions:

This keeps both words available as variable names and avoids breaking generated Wado source (e.g. ANTLR4 driver output that uses let do = … for a TypeScript token of that name).

Handler expressions are restricted to unary expressions

Inside with E1 => handler do { ... }, the handler slot is parsed with parse_unary_expr, which covers references (&h, &mut h), prefix-* deref, !/~/-, identifiers, calls, method calls, and field/index access. It deliberately stops short of:

This keeps the grammar unambiguous: stopping at unary level prevents the handler expression from greedily eating the trailing , or do token that closes the binding list. Cases that need the excluded forms must wrap the handler in parentheses, e.g. with E => (h as &mut MockE) do { ... }.

Dispatch Mechanism: funcref vtable + Wasm Global

Each effect gets a Wasm global holding a nullable reference to a dispatch record. The dispatch record is a Wasm GC struct containing a funcref per operation, a reference to the handler instance, and a reference to the outer (previous) dispatch record.

;; One dispatch record type per effect
(type $Dispatch_Stdout (struct
  (field $outer   (ref null $Dispatch_Stdout))   ;; previous handler
  (field $handler (ref any))                      ;; handler instance
  (field $op_write_via_stream (ref $sig_wvs))     ;; funcref per operation
))

;; One global per effect
(global $__effect_Stdout (mut (ref null $Dispatch_Stdout)) (ref.null $Dispatch_Stdout))

When no handler is installed (the common production path), the global is null and effect operations call the CM adapter directly. The null check is branch-predictor-friendly and adds negligible overhead relative to the CM boundary crossing.

Dispatch Function

One dispatch function is generated per effect operation. It checks the global, and either calls the CM adapter (default) or calls through the dispatch record's funcref:

;; __dispatch_Stdout_write_via_stream
(func $dispatch_stdout_wvs (param ...) (result ...)
  (local $dispatch (ref null $Dispatch_Stdout))
  (local.set $dispatch (global.get $__effect_Stdout))
  (if (ref.is_null (local.get $dispatch))
    (then
      ;; Default: call existing CM adapter
      (return (call $__cm_adapter_stdout_wvs ...)))
    (else
      ;; Handler: restore outer scope, call handler, re-install
      (global.set $__effect_Stdout
        (struct.get $Dispatch_Stdout $outer (local.get $dispatch)))
      (local.set $result
        (call_ref $sig_wvs
          (struct.get $Dispatch_Stdout $handler (local.get $dispatch))
          ...
          (struct.get $Dispatch_Stdout $op_write_via_stream (local.get $dispatch))))
      (global.set $__effect_Stdout (local.get $dispatch))
      (return (local.get $result)))))

The outer-scope restoration before call_ref ensures that handler method bodies execute in the outer effect scope. This makes effect forwarding and self-delegation (e.g., CachingClient calling Client::send to reach the real implementation) work correctly without infinite recursion.

Compilation of with ... do

with Stdout => &mut mock do { body }

Compiles to:

  1. Construct a dispatch record: struct.new $Dispatch_Stdout (global.get $__effect_Stdout, mock_ref, funcref_for_each_op)
  2. global.set $__effect_Stdout with the new dispatch record
  3. Execute body — every control-flow exit from the body (return, break L/continue to a target outside the body) gets the restore step (4) spliced in front of it; value-carrying jumps bind the value to a temp local first so it evaluates under the still-installed handler
  4. global.set $__effect_Stdout with the dispatch record's outer field (restore)

Nesting composes naturally — each with block links to the previous dispatch record via outer.

Compilation of resume

resume value compiles to return value. The handler method is a normal function; the dispatch function receives and propagates the return value. Post-resume code (e.g., cleanup after the do block) requires Wasm Stack Switching and is deferred.

Rest clause: ..trap / ..forward

Each unimplemented operation gets a stub funcref in the dispatch record. ..trap installs a trap stub: (func $trap (...) (unreachable)). ..forward installs a forward stub that calls the operation's wrapper with outer already restored, reaching the outer handler — the codegen of fn op(args) { resume Effect::op(args) }. With no outer handler the wrapper falls through to the default / CM adapter, or traps if the effect has none.

Binary Size

Element Cost
Per effect 1 global + 1 GC struct type
Per operation 1 dispatch function (10-20 instr)
Per with block 1 struct.new + global save/restore
Per handler impl 1 wrapper per implemented op

Growth is O(operations), independent of the number of call sites or handler types. Function signatures are unchanged — no hidden parameters.

Design Alternatives Considered

Consequences