Phase 6 — Wasmtime as the process backend
Goal: the pivot the whole design was built for — swap a process body from a native Rust closure to a sandboxed Wasm instance, behind the samerusm-otp API. Task-level isolation becomes true memory isolation. Graduates: the fairness scenario to live Wasm.
Why this is the keystone
Phases 1–5 made the actor model real and measurable on native bodies. Phase 6 proves the bet: because the OTP layer was designed Wasm-ready, adding Wasmtime is additive, not a rewrite. A process is still a Tokio task and a table entry — now its body is a guest instance that can crash, loop, or misbehave without touching anything else.
The hard boundary
All Wasmtime lives in rusm-wasm. The core (rusm-otp) still has zerowasmtime dependency — the dependency graph enforces it. rusm-wasm drives the core through its public API; Wasm never bleeds into Wasm-irrelevant code.
What we built (TDD throughout)
WasmRuntimeover a sharedrusm-otpRuntime— owns the WasmtimeEngine, aLinker<Host>, and sharedCounters.- Instance-per-process —
compile(wat) -> Module,prepare(module) -> InstancePre<Host>,spawn(prepared, entry) -> ProcessHandle. Each spawn instantiates a fresh, isolated instance as a rusm-otp process. - Fast spawns (instance-per-process, far cheaper than a naive on-demand allocator; the optimized component path reaches ~440k spawns/sec — see Phase 7) via three levers on one
Engine:- pooling allocator — instances/memories/tables recycled from a pool,
memory_init_cow— copy-on-write memory images, so a fresh instance doesn't zero/copy its whole linear memory,- per-module
InstancePre— type-checking and host-import resolution done once atprepare, not per spawn.
- Epoch-interruption preemption — even a guest in
loop { }is forced to yield and stays killable. The epoch is bumped on a dedicated OS thread, not a Tokio task — critical: as a task it could be starved by the very guests it must preempt, deadlocking. The store yields async on each epoch tick. - Host ABI via
Caller::data—rusm::self_pid(the guest's own pid) andrusm::notify(bumps a shared counter), the seed of the host ABI. - Trap →
ExitReason::Crashed— a guest trap is reported through the same exit machinery as a native crash from Phase 3. - Fairness engine (
rusm-bench) — Wasm spinners saturate every core while Wasm bystanders keep callingnotify; a nonzero bystander rate (~50M+ ops/sec under load, past 400M on free cores) is the proof that preemption is yielding the spinners.
Design notes — efficiency & honesty
- One
Engine, shared levers. Pooling + CoW +InstancePreall hang off the same engine, so the cost moves from per-spawn to one-time per-module. - Dedicated epoch thread. The single most important correctness fix in this phase — preemption that can itself be preempted isn't preemption.
- The spawn bench counts honestly. It asserts
notifications == n(every guest actually ran its body), so crashed instances can't inflate the rate.
Concepts introduced
Play with it
cargo run -p rusm-bench -- run fairness 5 # spinners saturate cores; bystanders still run
cargo test -p rusm-wasm # instance-per-process, traps, preemptionVerification
cargo test -p rusm-wasm green (add, host-import call, pid reporting, trap → crash, spinner preemption); fairness live in the dashboard; the Wasm-free invariant holds (no wasmtime anywhere under rusm-otp).
Next
Phase 7: component hosting — run real WASM components (the component model + WASI p2/p3) as RUSM processes, with a rusm:runtime actor ABI and default-deny per-process capabilities.