wasmCloud
wasmCloud has no actor primitive of its own, so this host is built from wasmCloud's own pieces: a stateless component (wasi:http/incoming-handler, a fresh instance per request, no memory between calls) is the whole host-control HTTP surface, and a persistent service (wasi:cli/run, started once, expected to block forever) is the durable timer loop. Both share a filesystem-backed wasi:keyvalue bucket — confirmed durable across a kill -9, below — for placement, held calls, the route journal and armed timers, since nothing a stateless component touches survives between invocations otherwise. The code is moxzi/adapters/wasmcloud (Rust, wasm32-wasip2, wash 2.5.2), built by moxzi/conformance/hosts/wasmcloud.json and driven by HOST=wasmcloud scripts/host_matrix_gate.sh.
What wasmCloud owns, what Motoko owns#
| Owns | Who | What |
|---|---|---|
| The Wasm execution sandbox | wasmCloud (wash-runtime) | both workloads; wasi:http ingress (binds :8000 — see below) and outgoing-handler (gated by workload.allowedHosts); the in-process wasi:keyvalue host plugin; component/service lifecycle |
| Per-key identity and placement | this adapter (api/) | FNV-1a32 over Type/id, matching the conformance harness's own hash |
| One-at-a-time execution per key | this adapter (api/) | a wasi:keyvalue/atomics.increment ticket lock — wasmCloud gives a component no mailbox to lean on, unlike Dapr's actor turn or Proto.Actor's virtual actor |
| The route journal, held/pending/deliver/cancel | this adapter (api/) | the same orchestration as hostkit.go, ported to Rust, state in the shared KV bucket instead of a Go struct |
| The durable timer | this adapter (timer/, the persistent service) | nothing invokes a stateless component on a schedule, so a service polls timer:* and runs one locked /v2/remind step per hit |
| Motoko execution, the heap, upgrades | moxzi-actor-host (Rust, native, outside wasmCloud) | everything past /v2/* — this adapter calls it over HTTP and never receives a callback |
The per-key lock is a ticket lock, not a mutex: wasi:keyvalue/atomics has one atomic primitive, increment (no compare-and-swap), so acquiring is "increment lock:{key} and check I landed on 1", releasing is deleting the key. A holder that dies mid-turn would wedge that key forever — there is no lease/expiry — so timer/ sweeps every lock:* at boot, before flipping ready on (which gates api/'s /healthz): a fresh process can never still be holding a lock from its own previous life, so anything left over is provably orphaned.
Prerequisites#
Verified on this machine:
wash --version—wash 2.5.2.rustup target list --installedincludeswasm32-wasip2.cargo --version—cargo 1.97.1.moxziandmoxzi-actor-host, built (cargo build --release -p moxzi -p moxzi-actor-hostundermoxzi/, or use the ones already atmoxzi/target/release/).
Walk through it#
Only one wasmCloud walk-through (or gate run) at a time on this machine: wash dev's HTTP ingress binds 0.0.0.0:8000 unconditionally in this version — no flag, env var, or .wash/config.yaml key moves it (checked empirically). /tmp/moxzi-tutorial-wasmcloud/ is the scratch directory for this walk-through, and run.sh refuses to start if :8000 is already held by something it did not start.
Write and build the two actors#
Same two actors as every other host page — Customer awaits Bank on chargeVia; both simplified from moxzi/conformance/fixtures/{customer,bank}.mo:
// customer.mo
import Prim "mo:⛔";
persistent actor {
type Order = { id : Nat; total : Nat };
var balance : Nat = 0;
var orders : [Order] = [];
var timerFired : Nat = 0;
public func addOrder(order : Order) : async Nat {
orders := Prim.Array_tabulate<Order>(orders.size() + 1, func (i : Nat) : Order {
if (i < orders.size()) orders[i] else order
});
balance += order.total;
orders.size()
};
public query func getBalance() : async Nat { balance };
public query func orderCount() : async Nat { orders.size() };
public func chargeVia(bank : Text, amount : Nat) : async Nat {
let b : actor { debit : (Nat) -> async Nat } = actor (bank);
let v = await b.debit(amount);
balance += v;
balance
};
public func armTimer(delayNs : Nat64) : async () {
ignore Prim.setTimer<system>(delayNs, false, func () : async () { timerFired += 1 });
};
public query func timerCount() : async Nat { timerFired };
};
// bank.mo
persistent actor {
var debited : Nat = 0;
public func debit(n : Nat) : async Nat { debited += n; n };
public query func total() : async Nat { debited };
};
MOXZI_COMPILER=bin/compiler-canister/compiler.wasm MOXZI_LINKER=bin/linker-canister/linker.wasm \
moxzi/target/release/moxzi build customer.mo -o customer.wasm
# customer.mo -> customer.wasm + customer.did (272261 bytes, 1 file(s), 65 steps, 0.79s)
MOXZI_COMPILER=bin/compiler-canister/compiler.wasm MOXZI_LINKER=bin/linker-canister/linker.wasm \
moxzi/target/release/moxzi build bank.mo -o bank.wasm
Start the wasmCloud host#
run.sh PORT OUT fronts wash dev's fixed :8000 with a dependency-free byte relay bound to PORT, and points .wash/config.yaml's wasi_keyvalue_path at OUT/kv-store — the same launch the conformance harness uses (moxzi/conformance/hosts/wasmcloud.json):
cd moxzi/adapters/wasmcloud
rustup target add wasm32-wasip2 # once
bash run.sh 29700 /tmp/moxzi-tutorial-wasmcloud/out &
# INFO HTTP server listening addr=0.0.0.0:8000 protocol="HTTP"
# portproxy: 127.0.0.1:29700 -> 127.0.0.1:8000
# INFO listening for HTTP requests address=http://127.0.0.1:8000
# moxzi-wasmcloud-timer: ready, polling every 250ms
curl -s http://127.0.0.1:29700/healthz
# ok
Start the moxzi-actor-host services#
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:29721 --state ./state-customer \
--namespace tutorial --deployment crm --actor Customer=./customer.wasm --known Bank/1
# moxzi-actor-host: Bank/1 is 6lkaw-mxxfa-ayudl-lyjba-qiou3-hjvqo-deil4-f3r6l-apodg-jbfpw-uae
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:29722 --state ./state-bank \
--namespace tutorial --deployment crm --actor Bank=./bank.wasm
Point placement at the services, and call it#
curl -X PUT http://127.0.0.1:29700/_moxzi/placement -H 'content-type: application/json' \
-d '{"types":{"Customer":["http://127.0.0.1:29721"],"Bank":["http://127.0.0.1:29722"]}}'
# HTTP 204
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/addOrder \
-H 'content-type: application/json' -d '[{"id":1,"total":30}]'
# [1] HTTP 200
A cross-actor await#
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/chargeVia \
-H 'content-type: application/json' \
-d '["6lkaw-mxxfa-ayudl-lyjba-qiou3-hjvqo-deil4-f3r6l-apodg-jbfpw-uae", 5]'
# [35] HTTP 200
curl -X POST http://127.0.0.1:29700/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [5]
getBalance now reads 30 + 5 = 35: api/ routed the call through its own journal in the shared KV bucket, exactly the "route journal written before routing" pattern every host in the matrix uses.
Arm a timer, and watch it fire#
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/armTimer \
-H 'content-type: application/json' -d '[3000000000]'
sleep 6
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
timer/, the one persistent process in this deployment, polled timer:* and ran one locked /v2/remind step once the due row appeared.
Kill it and bring it back#
Service-level restart — the Motoko heap is a file on disk, independent of wasmCloud:
kill <moxzi-actor-host Customer pid> # 29721
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:29721 --state ./state-customer \
--namespace tutorial --deployment crm --actor Customer=./customer.wasm --known Bank/1
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [35]
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Host-level restart — kill -9 the whole run.sh tree (wash dev and the port relay together), confirming the ports really free up, then bring it back on the same OUT directory so it points at the same KV bucket:
kill -9 <run.sh pid> <wash dev pid> <portproxy.py pid>
sleep 2
nc -z 127.0.0.1 8000 || echo "8000 free"
nc -z 127.0.0.1 29700 || echo "29700 free"
cd moxzi/adapters/wasmcloud
bash run.sh 29700 /tmp/moxzi-tutorial-wasmcloud/out &
curl -s http://127.0.0.1:29700/healthz
# ok
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [35] — no re-PUT of placement: the filesystem-backed wasi:keyvalue bucket survived the kill -9
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Drain, then upgrade#
curl -X POST http://127.0.0.1:29700/actors/Customer/1/admin/drain -d '{}'
# {"state":"Quiescent"}
curl -X POST http://127.0.0.1:29700/actors/Customer/1/admin/upgrade \
-H 'content-type: application/json' \
-d "{\"deploymentId\":\"crm-v2\",\"wasm\":\"$(base64 -i customer.wasm | tr -d '\n')\"}"
# {"state":"Active"}
curl -X POST http://127.0.0.1:29700/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [35]
Operating notes#
These notes are for the service form. In process, the actor's image lives in the same wasi:keyvalue bucket and nothing is written to a service's disk — see In process below.
- Durable state, two places: the Motoko heap lives on each
moxzi-actor-hostservice's own disk under--state. Everything this adapter owns — placement, held keys, the route journal, armed timers, the per-key locks — lives in thewasi:keyvaluebucket, which is only durable with the filesystem backend (wash dev's keyvalue plugin also ships in-memory, NATS JetStream and Redis backends — the in-memory one does not survive a restart).run.shrewrites.wash/config.yaml'swasi_keyvalue_pathto a stable directory before every start, including a restart. - The ingress port is not configurable in
wash2.5.2:wash devbinds0.0.0.0:8000unconditionally — no CLI flag, env var, or config key moves it, andwash host --http-addrneeds a Kubernetesruntime-operatorthis environment does not have to actually serve a workload.run.shfronts it withportproxy.py, a plain TCP byte relay (not an HTTP proxy, so it cannot misinterpret the traffic), which is why only one wasmCloud walk-through or gate run can be up at a time on one machine. - A service's sockets are private to the runtime: no wasmCloud "service" here opens a real, externally-reachable TCP port —
timer/talks tomoxzi-actor-hostpurely outbound (wasi:http/outgoing-handler, gated byworkload.allowedHosts). - Upgrading an actor: drain, then upgrade with the new module as base64 (shown above). An upgrade only proceeds once the actor is
Quiescent; a failed one leaves the previous deployment running. - What to watch:
run.sh's own log (wash dev's output interleaved withapi/'s andtimer/'seprintln!s) and/healthz, whichtimer/'s boot-time lock sweep gates — a request is never routed against a lock that might be a leftover from a killed previous life of the process.
In process#
The same actor also runs inside wasmCloud's own process through moxzi-rts, with wasmCloud keeping its image: see /docs/hosts/in-process/ for the contract.
| In process on wasmCloud | |
|---|---|
| Binding | a wash-runtime embedder with a moxzi plugin |
| The actor's image lives in | the same filesystem-backed wasi:keyvalue bucket as the service form |
| Update latency vs wasmCloud's own durable actor (p50, macOS) | 4.39x |
| Conformance | HOST=wasmcloud-inproc scripts/host_matrix_gate.sh |
The ratio is high because wasmCloud's own durable counter is fast (0.31 ms), not because the Motoko turn is slow.
From moxzi/adapters/wasmcloud/ (the exact commands and environment are in moxzi/conformance/hosts/wasmcloud-inproc.json):
cargo build --workspace --target wasm32-wasip2 --release --features in_process
(cd host && cargo build --release)
host/target/release/moxzi-wasmcloud-inproc-host ./kv-store
The conformance matrix#
wasmCloud's column of the spec §61 matrix, real wash-runtime, real process kills (moxzi/adapters/wasmcloud/README.md, measured 2026-09-23 against compiler b7577666):
| §61 scenario | wasmCloud |
|---|---|
| Persistence | PASS |
| Process Failure | PASS |
| Trap Rollback | PASS |
| Actor Isolation | PASS |
| Actor Parallelism | PASS 1.05–1.06× |
| Duplicate Invocation | PASS |
| Await | PASS |
| Await Across Restart | PASS |
| Upgrade With No Awaits | PASS |
| Upgrade With Outstanding Await | PASS |
| Permanently Unresolved Await | PASS |
| Timer Across Passivation | PASS |
| Timer Across Process Failure | PASS |
| Tiny Heap Mutation (206 B on an 8 MB heap) | PASS |
| ICRC-72 | PASS |
| Upgrade Compatibility | PASS |
| total | 16/16 |
Run twice: both 16/16, no scenario needed a SKIP (Actor Parallelism confirms two placements really run concurrently — wasmCloud schedules concurrent component invocations natively). Both runs left no wash, wash-runtime, moxzi-actor-host, or portproxy.py process behind, and used no Docker container — this adapter needs none.
Reproduce it (builds the Rust workspace for wasm32-wasip2, starts both workloads and two moxzi-actor-host instances per actor type, then drives all sixteen scenarios with real kills and restarts; never touches Docker):
HOST=wasmcloud scripts/host_matrix_gate.sh