Wado

Wasm Stack Switching — Research Reference

This document describes the Wasm Stack Switching proposal, its relationship to Wado's existing async model, and concrete use cases for the Wado language.

Source: WebAssembly Stack Switching Proposal, vendor/wasmtime/ (wasmtime implementation), vendor/component-model/ (CM concurrency design).


Table of Contents

  1. Stack Switching Primitives
  2. Current Async Architecture in Wado
  3. Relationship to CM Async
  4. Use Cases for Wado
  5. Non-Goals

1. Stack Switching Primitives

The Wasm Stack Switching proposal (part of Wasm 3.0) introduces typed continuations as first-class values. Core instructions:

Instruction Signature Description
cont.new (ref $ft) -> (ref $ct) Create a continuation from a function reference
resume (ref $ct, args...) -> results... Resume a continuation with arguments
suspend (tag, args...) -> results... Suspend the current continuation, yielding to the resumer
switch (ref $ct, tag, args...) -> results... Direct switch to another continuation (symmetric)
cont.bind (ref $ct, args...) -> (ref $ct') Partially apply arguments to a continuation

Key properties:

Tags

Tags are declared at the module level and identify suspension points:

(tag $yield (param i32))           ;; suspend with an i32 value
(tag $await (param (ref $future))) ;; suspend with a future handle

When a continuation calls (suspend $yield 42), control returns to the resumer with $yield as the event and 42 as the payload.

Execution Model

Resumer                          Continuation
   |                                 |
   |-- resume(cont, args) --------->|
   |                                 | (runs user code)
   |<-- suspend($tag, values) ------|
   | (handle event)                  | (suspended)
   |-- resume(cont, results) ------>|
   |                                 | (continues)
   |<-- return(results) ------------|
   | (continuation finished)         |

2. Current Async Architecture in Wado

Wado achieves colorless async today without Wasm Stack Switching instructions. The architecture relies on three layers:

2.1 CM Canonical Built-ins

WASI P3 defines async primitives as canonical ABI operations:

These are the guest-visible async primitives. Wado's compiler synthesizes CM binding adapters that use these operations (see wado-compiler/src/synthesis/cm_binding.rs).

2.2 Wasmtime's Stackful Async

Enabled by config.wasm_component_model_async_stackful(true) in wado-cli/src/runtime.rs.

When a guest calls waitable-set.wait, the host:

  1. Suspends the guest's host-managed fiber (not a Wasm continuation)
  2. Polls the underlying async runtime (tokio) for I/O completion
  3. Resumes the fiber when any waitable becomes ready

This is an opaque host mechanism — the guest sees synchronous blocking semantics. The Wasm module itself contains no continuation or fiber instructions.

2.3 Effect System

Wado's effect system (with Stdout, with FileSystem) tracks which functions perform I/O. This is a compile-time mechanism orthogonal to the runtime async model.

2.4 What Already Works

// File I/O — completely synchronous-looking
export fn run() with (Stdout, Preopens) {
    let file = dir.open_at(flags, "hello.txt", oflags, dflags);
    let [stream, _done] = file.read_via_stream(0 as Filesize);
    let subtask = Stdout::write_via_stream(stream);
    wait_for_subtask(subtask);  // blocks via CM stackful async
}

// HTTP handler — async only at the export boundary
export async fn handle(request: Request) -> Result<Response, ErrorCode> {
    // ...
    task return Ok(response);    // delivers result via CM task.return
    trailers_tx.write(Ok(null)); // post-return execution
}

3. Relationship to CM Async

CM Async Does Not Require Stack Switching

The CM concurrency model is designed to be implementable without Wasm Stack Switching. Wasmtime implements it today using host-side fibers. The canonical operations (stream.read, future.write, waitable-set.wait, etc.) are host-provided built-ins, not Wasm instructions.

CM Async Can Benefit From Stack Switching

When Wasm Stack Switching is available, the CM runtime can use native continuations instead of host fibers. Benefits:

For Wado, this transition would be transparent — the same CM canonical operations, same codegen, just faster runtime behavior.

CM Thread Primitives

The CM defines a thread.* built-in family — thread.index, thread.new-indirect, thread.resume-later, thread.suspend, thread.yield, and the thread.{suspend,yield}-then-{resume,promote} switching pairs. These are CM-level cooperative scheduling primitives, distinct from the Wasm Stack Switching instructions: they operate on CM green threads managed by the runtime, and until shared-everything-threads lands they interleave on a single core.

Wado declares none of them, and should not until the encoding settles. Four opcodes are reassigned in place between the pinned generation and upstream — the rest of the family (0x0c thread.yield, 0x26 thread.index, 0x27 thread.new-indirect, 0x29 thread.suspend) holds, and 0x2d thread.yield-then-promote exists only upstream:

Opcode wasmparser 0.252 (pinned) Upstream
0x28 thread.suspend-to-suspended [i32] -> [i32] thread.resume-later [i32] -> []
0x2a thread.unsuspend [i32] -> [] thread.suspend-then-resume [i32] -> [i32]
0x2b thread.yield-to-suspended [i32] -> [i32] thread.yield-then-resume [i32] -> [i32]
0x2c thread.suspend-to [i32] -> [i32] thread.suspend-then-promote [i32] -> [i32]

0x2b and 0x2c keep their signature across the rename, so a declaration written against one generation validates under the other and means something else. 0x28 and 0x2a differ in result arity and would at least fail validation; 0x28 also changes its immediates, reading a cancellable byte in the pinned generation and none upstream. The set moved too: thread.suspend-to is gone, thread.{suspend,yield}-then-promote are new.

Two further gates. The first is the Component Model threading feature — CM_THREADING in wasmparser, wasm_component_model_threading in wasmtime, distinct from core Wasm THREADS (on by default) and from SHARED_EVERYTHING_THREADS. It is off by default in both, and wasmtime's own docs call its support "very incomplete". The second is that thread.new-indirect passes the new thread a single i32, so a GC language needs a side table to hand a spawned thread anything else — the spec defers loosening this until the Canonical ABI is extended for GC. See Non-Goals.


4. Use Cases for Wado

4.1 Effect Handlers (Primary Use Case)

Specification background: Handlers. The examples below are research material in an early handler syntax, not excerpts of the specification.

Effect handlers require the ability to suspend a computation at an effect operation and resume it from the handler. This maps directly to stack switching:

Generator Pattern

interface Generator<T> {
    fn yield(value: T);
}

fn range(start: i32, end: i32) with Generator<i32> {
    let mut i = start;
    while i < end {
        Generator::yield(i);  // suspend here, pass i to handler
        i += 1;               // resume here when handler calls resume()
    }
}

fn collect_all() -> List<i32> {
    let mut result: List<i32> = [];

    with handler Generator<i32> {
        yield(value) => |resume| {
            result.push(value);
            resume();           // resume the continuation
        },
    } {
        range(0, 5);
    }

    return result;  // [0, 1, 2, 3, 4]
}

Wasm compilation strategy:

;; Tag for the Generator::yield effect operation
(tag $generator_yield (param i32))

;; range() becomes a regular function; yield compiles to suspend
(func $range (param $start i32) (param $end i32)
  (local $i i32)
  (local.set $i (local.get $start))
  (block $break
    (loop $loop
      (br_if $break (i32.ge_s (local.get $i) (local.get $end)))
      (suspend $generator_yield (local.get $i))  ;; yield
      (local.set $i (i32.add (local.get $i) (i32.const 1)))
      (br $loop)
    )
  )
)

;; collect_all() creates a continuation and drives it
(func $collect_all (result (ref $array_i32))
  (local $cont (ref null $range_ct))
  ;; Create continuation from range function
  (local.set $cont (cont.new $range_ct (ref.func $range)))
  ;; Bind arguments: range(0, 5)
  (local.set $cont (cont.bind $range_ct $bound_ct (local.get $cont)
                      (i32.const 0) (i32.const 5)))
  ;; Resume loop
  (block $done
    (loop $drive
      ;; resume returns (tag, payload) when suspended, or finishes
      (block $on_yield (result i32)
        (resume $bound_ct (on $generator_yield $on_yield) (local.get $cont))
        (br $done)  ;; continuation finished normally
      )
      ;; Here: yielded value is on stack
      ;; ... append to result array ...
      (br $drive)  ;; resume again
    )
  )
)

Mock Handler (Testing)

handler MockStdout for Stdout {
    let mut output: List<u8> = [];

    write_via_stream(data) => {
        output.extend(collect_stream(data));
        return Ok(());
    },
}

test "captures output" {
    with Stdout => MockStdout {
        println("hello");  // intercepted by MockStdout handler
    }
    // assert MockStdout.output == ...
}

Without stack switching, the handler cannot intercept println mid-execution and redirect I/O — the CM runtime owns the Stdout implementation. With stack switching:

  1. println calls Stdout::write_via_stream → compiles to (suspend $stdout_write ...)
  2. The handler intercepts the suspension, captures the data, and resumes
  3. No actual WASI call is made — the handler short-circuits it

This enables pure unit testing of effectful code without WASI runtime involvement.

4.2 Structured Concurrency

Specification background: Concurrency Model. The join below is research syntax, not the specified AsyncCall .join(&set) form.

fn load_data() -> Data with Http {
    let [users, posts] = join(
        || fetch_users(),
        || fetch_posts(),
    );
    return Data { users, posts };
}

Two possible implementations:

A. CM-Level (No Stack Switching Required)

For I/O-bound concurrent tasks that call WASI async imports:

  1. Each closure calls an async import → gets a subtask handle
  2. Both subtask handles are joined to a single waitable-set
  3. waitable-set.wait blocks until one completes, then the other

This works today with existing CM primitives. join() can be implemented as a library function using waitable-set.wait internally.

B. Guest-Level (Requires Stack Switching)

For CPU-bound concurrent tasks or mixed compute/I/O:

  1. Each closure becomes a continuation via cont.new
  2. A scheduler resumes each continuation in round-robin
  3. Continuations suspend at yield points (explicit or compiler-inserted)

This requires stack switching because the guest needs to interleave execution of multiple computations within a single Wasm function call.

Recommendation: Start with CM-level join for I/O concurrency (practical today), defer guest-level scheduling to when stack switching is stable.

4.3 Lazy Iteration / Generators

Beyond effect handlers, stack switching enables first-class generators as a language feature:

fn fibonacci() -> impl Iterator<i32> {
    let mut a = 0;
    let mut b = 1;
    loop {
        yield a;
        let next = a + b;
        a = b;
        b = next;
    }
}

for let n of fibonacci() {
    if n > 1000 { break; }
    println(`${n}`);
}

Without stack switching, Wado's iterators require manual state machine encoding (implementing Iterator trait with next() method). This is verbose for complex iteration patterns.

With stack switching, the compiler transforms yield into suspend and wraps the generator in a continuation. The for-of loop drives the continuation with resume.

Note: This is a special case of effect handlers — yield is an effect operation on a Generator<T> effect, and for-of is syntactic sugar for a handler that collects values. The spec already shows this pattern (§ Continuation Control).

4.4 Coroutine-Based Stream Processing

Wado's current stream operations (Stream<T>) use CM canonical built-ins that go through host memory. For guest-internal stream processing (no I/O), stack switching enables zero-copy coroutine pipelines:

fn parse_lines(input: &String) with Generator<String> {
    let mut start = 0;
    for let mut i = 0; i < input.len(); i += 1 {
        if input.char_at(i) == '\n' {
            Generator::yield(input.slice(start, i));
            start = i + 1;
        }
    }
    if start < input.len() {
        Generator::yield(input.slice(start, input.len()));
    }
}

fn count_matching(input: &String, pattern: &String) -> i32 {
    let mut count = 0;
    with handler Generator<String> {
        yield(line) => |resume| {
            if line.contains(pattern) { count += 1; }
            resume();
        },
    } {
        parse_lines(input);
    }
    return count;
}

Each line is produced lazily via suspend and consumed immediately by the handler — no intermediate List<String> allocation.


5. Non-Goals

5.1 Preemptive Multithreading

Stack switching provides cooperative concurrency only. True parallelism requires wasm-threads (shared memory + atomics), which is a separate concern. Wado should not conflate the two.

5.2 Replacing CM Async

Stack switching does not replace CM async primitives (stream, future, waitable-set, task.return). These remain the correct mechanism for cross-component and host I/O operations. Stack switching is for guest-internal control flow.

5.3 Async/Await Syntax

Even with stack switching, Wado should not introduce async/await keywords in function implementations. The colorless model (effects + transparent suspension) is a deliberate design choice. Stack switching reinforces this by making suspension truly invisible at the language level.


Appendix: Wasmtime Status

As of wasmtime v49.0.0 (the pinned version), per wado-cli/src/runtime.rs:

Feature Config Flag Status
CM Async wasm_component_model_async(true) Enabled
CM Async Built-ins wasm_component_model_more_async_builtins(true) Enabled
CM Async Stackful wasm_component_model_async_stackful(true) Enabled (host fibers)
CM Threading (🧵) wasm_component_model_threading(true) Not enabled; wasmtime calls its own support "very incomplete"
Wasm Stack Switching wasm_stack_switching(true) Commented out (platform issues)

The wasm_stack_switching flag is commented out in wado-cli/src/runtime.rs with the note "Not supported on macOS". When enabled, it would activate the cont.new/resume/suspend instructions in cranelift's code generator.