feat: wasm actor crate — run WebAssembly guests inside swactor actors
Adds `crates/wasm-actor/` (swactor-wasm-actor), which embeds wasmtime-sandboxed Wasm instances inside regular swactor actors. Messages flow as raw bytes through the guest ↔ host contract (alloc/handle exports, swactor.send import). The host drains an outbox after each handle call and routes messages via ctx.send(). Includes 3 no_std guest modules (echo, double, silent) and 7 integration tests covering roundtrip delivery, binary fidelity, multi-send, error cases, engine sharing, and native↔wasm interop. Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
parent
f4f26c9f41
commit
60ee2d1a2c
20 changed files with 2079 additions and 110 deletions
1065
Cargo.lock
generated
1065
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "crates/python", "crates/wasm", "crates/simulation", "crates/runtime-dashboard", "crates/distribution", "crates/simulation-dashboard", "crates/std"]
|
members = [".", "crates/python", "crates/wasm", "crates/wasm-actor", "crates/simulation", "crates/runtime-dashboard", "crates/distribution", "crates/simulation-dashboard", "crates/std"]
|
||||||
exclude = ["tools/depgraph"]
|
exclude = ["tools/depgraph"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
|
|
|
||||||
12
crates/wasm-actor/Cargo.toml
Normal file
12
crates/wasm-actor/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "swactor-wasm-actor"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
swactor = { path = "../.." }
|
||||||
|
wasmtime = "29"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
swactor = { path = "../..", features = ["getrandom"] }
|
||||||
|
wat = "1"
|
||||||
59
crates/wasm-actor/src/actor.rs
Normal file
59
crates/wasm-actor/src/actor.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
use swactor::actor::{ActorInterface, Ctx};
|
||||||
|
use wasmtime::{Memory, Store, TypedFunc};
|
||||||
|
|
||||||
|
use crate::ByteMessage;
|
||||||
|
|
||||||
|
/// State accessible to host functions during guest execution.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct HostState {
|
||||||
|
pub outbox: Vec<(swactor::actor::ActorAddress, Vec<u8>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An actor whose logic is defined by a WebAssembly guest module.
|
||||||
|
///
|
||||||
|
/// Messages arrive as [`ByteMessage`], are copied into Wasm linear memory,
|
||||||
|
/// and processed by the guest's `handle` export. The guest can send messages
|
||||||
|
/// back via the `swactor.send` host import.
|
||||||
|
pub struct WasmActor {
|
||||||
|
pub(crate) store: Store<HostState>,
|
||||||
|
pub(crate) memory: Memory,
|
||||||
|
pub(crate) alloc: TypedFunc<i32, i32>,
|
||||||
|
pub(crate) handle: TypedFunc<(i32, i32), ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for WasmActor {
|
||||||
|
type Incoming = ByteMessage;
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: ByteMessage) {
|
||||||
|
let bytes = &msg.0;
|
||||||
|
let len: i32 = match i32::try_from(bytes.len()) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => return, // message too large for i32 ABI
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Allocate space in guest memory
|
||||||
|
let ptr = match self.alloc.call(&mut self.store, len) {
|
||||||
|
Ok(ptr) if ptr < 0 => return, // invalid pointer
|
||||||
|
Ok(0) if len > 0 => return, // OOM — drop message
|
||||||
|
Ok(ptr) => ptr,
|
||||||
|
Err(_) => return, // alloc trapped — drop message
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Write message bytes into guest memory
|
||||||
|
self.memory.data_mut(&mut self.store)
|
||||||
|
[ptr as usize..(ptr as usize + bytes.len())]
|
||||||
|
.copy_from_slice(bytes);
|
||||||
|
|
||||||
|
// 3. Call guest handle
|
||||||
|
if self.handle.call(&mut self.store, (ptr, len)).is_err() {
|
||||||
|
return; // handle trapped — drop message, keep actor alive
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Drain outbox → send via ctx
|
||||||
|
let outbox: Vec<_> = self.store.data_mut().outbox.drain(..).collect();
|
||||||
|
for (dest, payload) in outbox {
|
||||||
|
let _ = ctx.send(dest, ByteMessage(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
crates/wasm-actor/src/builder.rs
Normal file
109
crates/wasm-actor/src/builder.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use wasmtime::{Linker, Module, Store, TypedFunc};
|
||||||
|
|
||||||
|
use crate::actor::{HostState, WasmActor};
|
||||||
|
use crate::engine::SharedEngine;
|
||||||
|
use crate::error::WasmActorError;
|
||||||
|
|
||||||
|
/// Compiles a Wasm module and produces a ready-to-use [`WasmActor`].
|
||||||
|
pub struct WasmActorBuilder {
|
||||||
|
engine: SharedEngine,
|
||||||
|
wasm_bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WasmActorBuilder {
|
||||||
|
pub fn new(engine: SharedEngine, wasm_bytes: impl Into<Vec<u8>>) -> Self {
|
||||||
|
Self {
|
||||||
|
engine,
|
||||||
|
wasm_bytes: wasm_bytes.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile the module, link host functions, and instantiate.
|
||||||
|
pub fn build(self) -> Result<WasmActor, WasmActorError> {
|
||||||
|
let engine = self.engine.inner();
|
||||||
|
let module = Module::new(engine, &self.wasm_bytes)?;
|
||||||
|
|
||||||
|
let mut linker: Linker<HostState> = Linker::new(engine);
|
||||||
|
Self::link_send(&mut linker)?;
|
||||||
|
|
||||||
|
let mut store = Store::new(engine, HostState::default());
|
||||||
|
let instance = linker.instantiate(&mut store, &module)?;
|
||||||
|
|
||||||
|
// Extract required exports
|
||||||
|
let memory = instance
|
||||||
|
.get_memory(&mut store, "memory")
|
||||||
|
.ok_or(WasmActorError::MissingExport("memory"))?;
|
||||||
|
|
||||||
|
let alloc: TypedFunc<i32, i32> = instance
|
||||||
|
.get_typed_func(&mut store, "alloc")
|
||||||
|
.map_err(|_| WasmActorError::MissingExport("alloc"))?;
|
||||||
|
|
||||||
|
let handle: TypedFunc<(i32, i32), ()> = instance
|
||||||
|
.get_typed_func(&mut store, "handle")
|
||||||
|
.map_err(|_| WasmActorError::MissingExport("handle"))?;
|
||||||
|
|
||||||
|
Ok(WasmActor {
|
||||||
|
store,
|
||||||
|
memory,
|
||||||
|
alloc,
|
||||||
|
handle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Link the `swactor.send` host import.
|
||||||
|
fn link_send(linker: &mut Linker<HostState>) -> Result<(), WasmActorError> {
|
||||||
|
linker.func_wrap(
|
||||||
|
"swactor",
|
||||||
|
"send",
|
||||||
|
|mut caller: wasmtime::Caller<'_, HostState>,
|
||||||
|
dest_ptr: i32,
|
||||||
|
payload_ptr: i32,
|
||||||
|
payload_len: i32|
|
||||||
|
-> Result<(), wasmtime::Error> {
|
||||||
|
let mem = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(|e| e.into_memory())
|
||||||
|
.ok_or_else(|| wasmtime::Error::msg("guest must export memory"))?;
|
||||||
|
let data = mem.data(&caller);
|
||||||
|
let mem_len = data.len();
|
||||||
|
|
||||||
|
// Validate non-negative arguments
|
||||||
|
if dest_ptr < 0 || payload_ptr < 0 || payload_len < 0 {
|
||||||
|
return Err(wasmtime::Error::msg(
|
||||||
|
"negative argument in swactor.send",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest_ptr = dest_ptr as usize;
|
||||||
|
let payload_ptr = payload_ptr as usize;
|
||||||
|
let payload_len = payload_len as usize;
|
||||||
|
|
||||||
|
// Bounds-check with overflow protection
|
||||||
|
let dest_end = dest_ptr
|
||||||
|
.checked_add(32)
|
||||||
|
.ok_or_else(|| wasmtime::Error::msg("dest_ptr overflow"))?;
|
||||||
|
let payload_end = payload_ptr
|
||||||
|
.checked_add(payload_len)
|
||||||
|
.ok_or_else(|| wasmtime::Error::msg("payload range overflow"))?;
|
||||||
|
if dest_end > mem_len || payload_end > mem_len {
|
||||||
|
return Err(wasmtime::Error::msg(
|
||||||
|
"out-of-bounds memory access in swactor.send",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read 32-byte destination address
|
||||||
|
let mut addr_bytes = [0u8; 32];
|
||||||
|
addr_bytes.copy_from_slice(&data[dest_ptr..dest_end]);
|
||||||
|
let dest = ActorAddress(addr_bytes);
|
||||||
|
|
||||||
|
// Read payload
|
||||||
|
let payload = data[payload_ptr..payload_end].to_vec();
|
||||||
|
|
||||||
|
caller.data_mut().outbox.push((dest, payload));
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
35
crates/wasm-actor/src/engine.rs
Normal file
35
crates/wasm-actor/src/engine.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use wasmtime::Engine;
|
||||||
|
|
||||||
|
/// A shared, cheaply-cloneable Wasm engine.
|
||||||
|
///
|
||||||
|
/// Created once and reused across multiple [`WasmActor`](crate::WasmActor) instances.
|
||||||
|
/// Configured with maximum sandboxing — no threads, no SIMD, no reference types.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SharedEngine(Arc<Engine>);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SharedEngine {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_tuple("SharedEngine").field(&"<Engine>").finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedEngine {
|
||||||
|
/// Create a new engine with sandboxed defaults.
|
||||||
|
pub fn new() -> Result<Self, wasmtime::Error> {
|
||||||
|
let mut config = wasmtime::Config::new();
|
||||||
|
config.wasm_threads(false);
|
||||||
|
config.wasm_simd(false);
|
||||||
|
config.wasm_relaxed_simd(false);
|
||||||
|
config.wasm_reference_types(false);
|
||||||
|
config.wasm_multi_value(false);
|
||||||
|
config.wasm_bulk_memory(true);
|
||||||
|
let engine = Engine::new(&config)?;
|
||||||
|
Ok(Self(Arc::new(engine)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn inner(&self) -> &Engine {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
27
crates/wasm-actor/src/error.rs
Normal file
27
crates/wasm-actor/src/error.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Errors that can occur when building or running a WasmActor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum WasmActorError {
|
||||||
|
/// A required export is missing from the Wasm module.
|
||||||
|
MissingExport(&'static str),
|
||||||
|
/// The Wasm module failed to compile or instantiate.
|
||||||
|
Wasmtime(wasmtime::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for WasmActorError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingExport(name) => write!(f, "missing required export: `{name}`"),
|
||||||
|
Self::Wasmtime(e) => write!(f, "wasmtime error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WasmActorError {}
|
||||||
|
|
||||||
|
impl From<wasmtime::Error> for WasmActorError {
|
||||||
|
fn from(e: wasmtime::Error) -> Self {
|
||||||
|
Self::Wasmtime(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/wasm-actor/src/lib.rs
Normal file
13
crates/wasm-actor/src/lib.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
mod actor;
|
||||||
|
mod builder;
|
||||||
|
mod engine;
|
||||||
|
mod error;
|
||||||
|
|
||||||
|
pub use actor::WasmActor;
|
||||||
|
pub use builder::WasmActorBuilder;
|
||||||
|
pub use engine::SharedEngine;
|
||||||
|
pub use error::WasmActorError;
|
||||||
|
|
||||||
|
/// A message carrying raw bytes, suitable for passing to/from Wasm guests.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ByteMessage(pub Vec<u8>);
|
||||||
7
crates/wasm-actor/tests/guests/double/Cargo.lock
generated
Normal file
7
crates/wasm-actor/tests/guests/double/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "double-guest"
|
||||||
|
version = "0.1.0"
|
||||||
13
crates/wasm-actor/tests/guests/double/Cargo.toml
Normal file
13
crates/wasm-actor/tests/guests/double/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "double-guest"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
63
crates/wasm-actor/tests/guests/double/src/lib.rs
Normal file
63
crates/wasm-actor/tests/guests/double/src/lib.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
use core::cell::UnsafeCell;
|
||||||
|
use core::panic::PanicInfo;
|
||||||
|
|
||||||
|
// --- bump allocator ---
|
||||||
|
const HEAP_SIZE: usize = 65536;
|
||||||
|
|
||||||
|
struct BumpAlloc {
|
||||||
|
heap: UnsafeCell<[u8; HEAP_SIZE]>,
|
||||||
|
offset: UnsafeCell<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Sync for BumpAlloc {}
|
||||||
|
|
||||||
|
static ALLOC: BumpAlloc = BumpAlloc {
|
||||||
|
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
|
||||||
|
offset: UnsafeCell::new(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn alloc(size: i32) -> i32 {
|
||||||
|
unsafe {
|
||||||
|
let offset = &mut *ALLOC.offset.get();
|
||||||
|
let heap = &mut *ALLOC.heap.get();
|
||||||
|
let align = 8;
|
||||||
|
let start = (*offset + align - 1) & !(align - 1);
|
||||||
|
let end = start + size as usize;
|
||||||
|
if end > heap.len() {
|
||||||
|
return 0; // OOM
|
||||||
|
}
|
||||||
|
*offset = end;
|
||||||
|
heap.as_ptr().add(start) as i32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- host import ---
|
||||||
|
#[link(wasm_import_module = "swactor")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
#[link_name = "send"]
|
||||||
|
fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message format: first 32 bytes = destination address, rest = payload.
|
||||||
|
/// Sends the payload back twice to demonstrate multi-send.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn handle(ptr: i32, len: i32) {
|
||||||
|
if len < 32 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dest_ptr = ptr;
|
||||||
|
let payload_ptr = ptr + 32;
|
||||||
|
let payload_len = len - 32;
|
||||||
|
unsafe {
|
||||||
|
host_send(dest_ptr, payload_ptr, payload_len);
|
||||||
|
host_send(dest_ptr, payload_ptr, payload_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[panic_handler]
|
||||||
|
fn panic(_info: &PanicInfo) -> ! {
|
||||||
|
loop {}
|
||||||
|
}
|
||||||
7
crates/wasm-actor/tests/guests/echo/Cargo.lock
generated
Normal file
7
crates/wasm-actor/tests/guests/echo/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "echo-guest"
|
||||||
|
version = "0.1.0"
|
||||||
13
crates/wasm-actor/tests/guests/echo/Cargo.toml
Normal file
13
crates/wasm-actor/tests/guests/echo/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "echo-guest"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
62
crates/wasm-actor/tests/guests/echo/src/lib.rs
Normal file
62
crates/wasm-actor/tests/guests/echo/src/lib.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
use core::cell::UnsafeCell;
|
||||||
|
use core::panic::PanicInfo;
|
||||||
|
|
||||||
|
// --- bump allocator ---
|
||||||
|
const HEAP_SIZE: usize = 65536;
|
||||||
|
|
||||||
|
struct BumpAlloc {
|
||||||
|
heap: UnsafeCell<[u8; HEAP_SIZE]>,
|
||||||
|
offset: UnsafeCell<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Sync for BumpAlloc {}
|
||||||
|
|
||||||
|
static ALLOC: BumpAlloc = BumpAlloc {
|
||||||
|
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
|
||||||
|
offset: UnsafeCell::new(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn alloc(size: i32) -> i32 {
|
||||||
|
unsafe {
|
||||||
|
let offset = &mut *ALLOC.offset.get();
|
||||||
|
let heap = &mut *ALLOC.heap.get();
|
||||||
|
let align = 8;
|
||||||
|
let start = (*offset + align - 1) & !(align - 1);
|
||||||
|
let end = start + size as usize;
|
||||||
|
if end > heap.len() {
|
||||||
|
return 0; // OOM
|
||||||
|
}
|
||||||
|
*offset = end;
|
||||||
|
heap.as_ptr().add(start) as i32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- host import ---
|
||||||
|
#[link(wasm_import_module = "swactor")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
#[link_name = "send"]
|
||||||
|
fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message format: first 32 bytes = destination address, rest = payload.
|
||||||
|
/// Echo sends the payload portion back to the specified destination.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn handle(ptr: i32, len: i32) {
|
||||||
|
if len < 32 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dest_ptr = ptr;
|
||||||
|
let payload_ptr = ptr + 32;
|
||||||
|
let payload_len = len - 32;
|
||||||
|
unsafe {
|
||||||
|
host_send(dest_ptr, payload_ptr, payload_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[panic_handler]
|
||||||
|
fn panic(_info: &PanicInfo) -> ! {
|
||||||
|
loop {}
|
||||||
|
}
|
||||||
7
crates/wasm-actor/tests/guests/silent/Cargo.lock
generated
Normal file
7
crates/wasm-actor/tests/guests/silent/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "silent-guest"
|
||||||
|
version = "0.1.0"
|
||||||
13
crates/wasm-actor/tests/guests/silent/Cargo.toml
Normal file
13
crates/wasm-actor/tests/guests/silent/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "silent-guest"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
45
crates/wasm-actor/tests/guests/silent/src/lib.rs
Normal file
45
crates/wasm-actor/tests/guests/silent/src/lib.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
use core::cell::UnsafeCell;
|
||||||
|
use core::panic::PanicInfo;
|
||||||
|
|
||||||
|
// --- bump allocator ---
|
||||||
|
const HEAP_SIZE: usize = 65536;
|
||||||
|
|
||||||
|
struct BumpAlloc {
|
||||||
|
heap: UnsafeCell<[u8; HEAP_SIZE]>,
|
||||||
|
offset: UnsafeCell<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Sync for BumpAlloc {}
|
||||||
|
|
||||||
|
static ALLOC: BumpAlloc = BumpAlloc {
|
||||||
|
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
|
||||||
|
offset: UnsafeCell::new(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn alloc(size: i32) -> i32 {
|
||||||
|
unsafe {
|
||||||
|
let offset = &mut *ALLOC.offset.get();
|
||||||
|
let heap = &mut *ALLOC.heap.get();
|
||||||
|
let align = 8;
|
||||||
|
let start = (*offset + align - 1) & !(align - 1);
|
||||||
|
let end = start + size as usize;
|
||||||
|
if end > heap.len() {
|
||||||
|
return 0; // OOM
|
||||||
|
}
|
||||||
|
*offset = end;
|
||||||
|
heap.as_ptr().add(start) as i32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn handle(_ptr: i32, _len: i32) {
|
||||||
|
// Silent: receive bytes, do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
#[panic_handler]
|
||||||
|
fn panic(_info: &PanicInfo) -> ! {
|
||||||
|
loop {}
|
||||||
|
}
|
||||||
331
crates/wasm-actor/tests/wasm_actor.rs
Normal file
331
crates/wasm-actor/tests/wasm_actor.rs
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
use swactor::actor::{ActorAddress, ActorInterface};
|
||||||
|
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
||||||
|
use swactor_wasm_actor::{ByteMessage, SharedEngine, WasmActorBuilder, WasmActorError};
|
||||||
|
|
||||||
|
fn guest_wasm(name: &str) -> Vec<u8> {
|
||||||
|
let path = format!(
|
||||||
|
"{}/tests/guests/{name}/target/wasm32-unknown-unknown/release/{name}_guest.wasm",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
);
|
||||||
|
std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a message with an inbox address prepended (the guest contract).
|
||||||
|
fn framed_msg(dest: &ActorAddress, payload: &[u8]) -> ByteMessage {
|
||||||
|
let mut buf = Vec::with_capacity(32 + payload.len());
|
||||||
|
buf.extend_from_slice(&dest.0);
|
||||||
|
buf.extend_from_slice(payload);
|
||||||
|
ByteMessage(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Echo: send bytes in, same bytes come back ────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn echo_returns_same_payload() {
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
|
||||||
|
let payload = b"hello wasm";
|
||||||
|
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
let received = inbox.try_recv().expect("inbox should have a message");
|
||||||
|
assert_eq!(received.0, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn echo_preserves_binary_payload() {
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
|
||||||
|
let payload: Vec<u8> = (0..=255).collect();
|
||||||
|
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
let received = inbox.try_recv().expect("inbox should have a message");
|
||||||
|
assert_eq!(received.0, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Silent: processes messages without sending anything ───────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silent_produces_no_output() {
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, guest_wasm("silent"))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
|
||||||
|
rt.send_to(addr, ByteMessage(b"ignored".to_vec())).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
assert!(inbox.try_recv().is_none(), "silent guest should not send anything");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Double: one message in, two messages out ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_sends_two_copies() {
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, guest_wasm("double"))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
|
||||||
|
let payload = b"dup me";
|
||||||
|
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
let first = inbox.try_recv().expect("should receive first copy");
|
||||||
|
let second = inbox.try_recv().expect("should receive second copy");
|
||||||
|
assert_eq!(first.0, payload);
|
||||||
|
assert_eq!(second.0, payload);
|
||||||
|
assert!(inbox.try_recv().is_none(), "exactly two messages expected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Missing export → WasmActorError::MissingExport ───────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_alloc_export_returns_error() {
|
||||||
|
// Minimal valid Wasm module: (module) — no exports at all
|
||||||
|
let minimal_wasm = wat::parse_str("(module)").unwrap();
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let result = WasmActorBuilder::new(engine, minimal_wasm).build();
|
||||||
|
match result {
|
||||||
|
Err(WasmActorError::MissingExport(name)) => {
|
||||||
|
assert!(
|
||||||
|
name == "memory" || name == "alloc",
|
||||||
|
"expected missing memory or alloc, got: {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(other) => panic!("expected MissingExport, got: {other}"),
|
||||||
|
Ok(_) => panic!("expected error for module with no exports"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Engine sharing: two actors from the same engine ──────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_engine_serves_multiple_actors() {
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
|
||||||
|
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo"))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let silent = WasmActorBuilder::new(engine, guest_wasm("silent"))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
|
||||||
|
|
||||||
|
let echo_addr = rt.spawn(echo).unwrap();
|
||||||
|
let _silent_addr = rt.spawn(silent).unwrap();
|
||||||
|
|
||||||
|
let payload = b"shared engine test";
|
||||||
|
rt.send_to(echo_addr, framed_msg(inbox.addr(), payload)).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
let received = inbox.try_recv().expect("echo actor should still work");
|
||||||
|
assert_eq!(received.0, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Safety: edge cases that previously caused panics or corruption ────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oob_send_traps_cleanly_and_actor_survives() {
|
||||||
|
// Guest calls swactor.send with dest_ptr pointing past the end of memory.
|
||||||
|
// The host should trap the call; the actor should survive for future messages.
|
||||||
|
let wat = r#"
|
||||||
|
(module
|
||||||
|
(import "swactor" "send" (func $send (param i32 i32 i32)))
|
||||||
|
(memory (export "memory") 1)
|
||||||
|
(func (export "alloc") (param i32) (result i32)
|
||||||
|
i32.const 0 ;; return start of memory (simplistic)
|
||||||
|
)
|
||||||
|
(func (export "handle") (param i32 i32)
|
||||||
|
;; Call send with dest_ptr = 65536 (1 page = end of memory, OOB for 32 bytes)
|
||||||
|
i32.const 65536
|
||||||
|
i32.const 0
|
||||||
|
i32.const 0
|
||||||
|
call $send
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"#;
|
||||||
|
let wasm = wat::parse_str(wat).unwrap();
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
|
||||||
|
// Send a message — handle will try OOB send, which traps
|
||||||
|
rt.send_to(addr, ByteMessage(vec![42])).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// No message should arrive (the send was invalid)
|
||||||
|
assert!(inbox.try_recv().is_none(), "OOB send should not produce a message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alloc_oom_drops_message_actor_stays_alive() {
|
||||||
|
// Guest alloc always returns 0 (OOM). Message should be dropped,
|
||||||
|
// actor should remain alive for subsequent messages.
|
||||||
|
let wat = r#"
|
||||||
|
(module
|
||||||
|
(import "swactor" "send" (func $send (param i32 i32 i32)))
|
||||||
|
(memory (export "memory") 1)
|
||||||
|
(func (export "alloc") (param i32) (result i32)
|
||||||
|
i32.const 0 ;; always OOM
|
||||||
|
)
|
||||||
|
(func (export "handle") (param i32 i32)
|
||||||
|
;; Should never be called if alloc returned 0 for non-zero len
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"#;
|
||||||
|
let wasm = wat::parse_str(wat).unwrap();
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
|
||||||
|
// Send a non-empty message — alloc returns 0, message should be dropped
|
||||||
|
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
// Actor is still alive — send another message, tick again (no panic)
|
||||||
|
rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_alloc_ptr_drops_message() {
|
||||||
|
// Guest alloc returns -1. Host should detect the negative pointer and drop.
|
||||||
|
let wat = r#"
|
||||||
|
(module
|
||||||
|
(import "swactor" "send" (func $send (param i32 i32 i32)))
|
||||||
|
(memory (export "memory") 1)
|
||||||
|
(func (export "alloc") (param i32) (result i32)
|
||||||
|
i32.const -1 ;; invalid negative pointer
|
||||||
|
)
|
||||||
|
(func (export "handle") (param i32 i32))
|
||||||
|
)
|
||||||
|
"#;
|
||||||
|
let wasm = wat::parse_str(wat).unwrap();
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
|
||||||
|
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
|
||||||
|
rt.tick(); // should not panic
|
||||||
|
|
||||||
|
// Actor survives
|
||||||
|
rt.send_to(addr, ByteMessage(vec![2])).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_trap_drops_message_actor_survives() {
|
||||||
|
// Guest handle executes `unreachable`, causing a Wasm trap.
|
||||||
|
// Message should be dropped, actor should stay alive.
|
||||||
|
let wat = r#"
|
||||||
|
(module
|
||||||
|
(import "swactor" "send" (func $send (param i32 i32 i32)))
|
||||||
|
(memory (export "memory") 1)
|
||||||
|
(func (export "alloc") (param i32) (result i32)
|
||||||
|
i32.const 256 ;; valid allocation
|
||||||
|
)
|
||||||
|
(func (export "handle") (param i32 i32)
|
||||||
|
unreachable ;; trap!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"#;
|
||||||
|
let wasm = wat::parse_str(wat).unwrap();
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
|
||||||
|
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
|
||||||
|
rt.tick(); // handle traps, but actor should survive
|
||||||
|
|
||||||
|
// Actor is still alive
|
||||||
|
rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Integration: WasmActor alongside a native Rust actor ─────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ForwardToWasm {
|
||||||
|
wasm_addr: ActorAddress,
|
||||||
|
inbox_addr: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Forwarder;
|
||||||
|
|
||||||
|
impl ActorInterface for Forwarder {
|
||||||
|
type Incoming = ForwardToWasm;
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: ForwardToWasm) {
|
||||||
|
// Build the framed message and forward to the wasm actor
|
||||||
|
let payload = b"from native";
|
||||||
|
let framed = framed_msg(&msg.inbox_addr, payload);
|
||||||
|
let _ = ctx.send(msg.wasm_addr, framed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_actor_communicates_with_wasm_actor() {
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
let wasm = WasmActorBuilder::new(engine, guest_wasm("echo"))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
|
||||||
|
|
||||||
|
let wasm_addr = rt.spawn(wasm).unwrap();
|
||||||
|
let forwarder_addr = rt.spawn(Forwarder).unwrap();
|
||||||
|
|
||||||
|
rt.send_to(
|
||||||
|
forwarder_addr,
|
||||||
|
ForwardToWasm {
|
||||||
|
wasm_addr,
|
||||||
|
inbox_addr: *inbox.addr(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Tick 1: Forwarder receives message and sends to WasmActor
|
||||||
|
rt.tick();
|
||||||
|
// Tick 2: WasmActor receives the forwarded message and echoes to inbox
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
let received = inbox.try_recv().expect("wasm actor should have echoed");
|
||||||
|
assert_eq!(received.0, b"from native");
|
||||||
|
}
|
||||||
191
docs/development_history/WASM_ACTOR.md
Normal file
191
docs/development_history/WASM_ACTOR.md
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
# Wasm Actor Crate — Development History
|
||||||
|
|
||||||
|
> Adds a new crate (`crates/wasm-actor/`) that runs WebAssembly guest code
|
||||||
|
> **inside** a swactor actor. The Wasm instance lives in the actor — not as a
|
||||||
|
> separate OS process. Messages arrive as bytes, get written into Wasm linear
|
||||||
|
> memory, and the guest's `handle` export is called.
|
||||||
|
>
|
||||||
|
> ~350 lines of Rust (host) · 3 guest modules · 7 tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview & Motivation](#1-overview--motivation)
|
||||||
|
2. [What Was Built](#2-what-was-built)
|
||||||
|
3. [Guest ↔ Host Contract](#3-guest--host-contract)
|
||||||
|
4. [Handle Cycle (Hot Path)](#4-handle-cycle-hot-path)
|
||||||
|
5. [Guest Modules](#5-guest-modules)
|
||||||
|
6. [Design Decisions & Tradeoffs](#6-design-decisions--tradeoffs)
|
||||||
|
7. [Known Gaps & Future Improvements](#7-known-gaps--future-improvements)
|
||||||
|
8. [Test Coverage Summary](#8-test-coverage-summary)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview & Motivation
|
||||||
|
|
||||||
|
Swactor already supported running *inside* a browser via `crates/wasm/`
|
||||||
|
(wasm-bindgen). This crate flips the direction: run untrusted Wasm code
|
||||||
|
*inside* an actor, sandboxed by wasmtime. Use cases include user-defined
|
||||||
|
plugins, multi-language actors, and capability-restricted compute.
|
||||||
|
|
||||||
|
The main swactor crate has no wasmtime dependency — all Wasm machinery is
|
||||||
|
isolated in `crates/wasm-actor/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What Was Built
|
||||||
|
|
||||||
|
| Component | Location | Purpose |
|
||||||
|
|-----------|----------|---------|
|
||||||
|
| `swactor-wasm-actor` crate | `crates/wasm-actor/` | Host-side: engine, builder, actor impl |
|
||||||
|
| 3 guest crates | `crates/wasm-actor/tests/guests/{echo,double,silent}/` | `#![no_std]` Wasm modules for testing |
|
||||||
|
| Integration tests | `crates/wasm-actor/tests/wasm_actor.rs` | 7 behavioral tests |
|
||||||
|
|
||||||
|
### Crate modules
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/wasm-actor/src/
|
||||||
|
lib.rs — ByteMessage, re-exports
|
||||||
|
engine.rs — SharedEngine (Arc<wasmtime::Engine>)
|
||||||
|
builder.rs — WasmActorBuilder (compile + link + instantiate)
|
||||||
|
actor.rs — WasmActor implementing ActorInterface
|
||||||
|
error.rs — WasmActorError enum
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public types
|
||||||
|
|
||||||
|
- **`ByteMessage(pub Vec<u8>)`** — message type for Wasm actors. Satisfies
|
||||||
|
`Message` bounds trivially.
|
||||||
|
- **`SharedEngine`** — wraps `Arc<wasmtime::Engine>`. Created once, cloned
|
||||||
|
cheaply across actors. Sandboxed config: no threads, no SIMD, no reference
|
||||||
|
types.
|
||||||
|
- **`WasmActorBuilder`** — takes an engine + raw `.wasm` bytes, compiles the
|
||||||
|
module, links the `swactor.send` host import, extracts typed function handles,
|
||||||
|
returns a `WasmActor`.
|
||||||
|
- **`WasmActor`** — implements `ActorInterface<Incoming = ByteMessage, Response = ()>`.
|
||||||
|
- **`WasmActorError`** — `MissingExport(&'static str)` or `Wasmtime(wasmtime::Error)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Guest ↔ Host Contract
|
||||||
|
|
||||||
|
**Guest must export:**
|
||||||
|
|
||||||
|
| Export | Signature | Purpose |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| `memory` | WebAssembly linear memory | Host reads/writes message bytes here |
|
||||||
|
| `alloc` | `(size: i32) -> i32` | Allocate `size` bytes, return pointer |
|
||||||
|
| `handle` | `(ptr: i32, len: i32)` | Process message at `(ptr, len)` |
|
||||||
|
|
||||||
|
**Guest may import:**
|
||||||
|
|
||||||
|
| Import | Module | Signature | Purpose |
|
||||||
|
|--------|--------|-----------|---------|
|
||||||
|
| `send` | `swactor` | `(dest_ptr: i32, payload_ptr: i32, payload_len: i32)` | Send a message to another actor |
|
||||||
|
|
||||||
|
`dest_ptr` points to 32 bytes of `ActorAddress` in guest linear memory.
|
||||||
|
`payload_ptr` + `payload_len` describe the message bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Handle Cycle (Hot Path)
|
||||||
|
|
||||||
|
```
|
||||||
|
ByteMessage arrives
|
||||||
|
│
|
||||||
|
v
|
||||||
|
1. host calls guest alloc(msg.len) → ptr
|
||||||
|
│
|
||||||
|
v
|
||||||
|
2. host writes msg bytes into guest memory at ptr
|
||||||
|
│
|
||||||
|
v
|
||||||
|
3. host calls guest handle(ptr, len)
|
||||||
|
│
|
||||||
|
├── guest may call swactor.send() N times
|
||||||
|
│ └── each appends (ActorAddress, Vec<u8>) to HostState.outbox
|
||||||
|
│
|
||||||
|
v
|
||||||
|
4. host drains outbox → ctx.send(dest, ByteMessage(payload)) for each
|
||||||
|
```
|
||||||
|
|
||||||
|
Traps during `alloc` or `handle` will panic. Swactor's existing
|
||||||
|
`catch_unwind` in `tick_all` poisons the actor — consistent with the
|
||||||
|
panic-safety model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Guest Modules
|
||||||
|
|
||||||
|
Three `#![no_std]` Rust crates compiled to `wasm32-unknown-unknown`:
|
||||||
|
|
||||||
|
| Guest | Behavior | Tests it supports |
|
||||||
|
|-------|----------|-------------------|
|
||||||
|
| `echo` | Reads 32-byte dest + payload from message; sends payload back to dest | Echo roundtrip, binary preservation |
|
||||||
|
| `double` | Same framing; sends payload back **twice** | Multi-send verification |
|
||||||
|
| `silent` | Receives bytes; does nothing | No-output / no-error baseline |
|
||||||
|
|
||||||
|
Each guest uses a simple inline bump allocator (64 KiB heap, 8-byte aligned)
|
||||||
|
and a `#[panic_handler]` that loops. No external dependencies.
|
||||||
|
|
||||||
|
Message framing convention: the first 32 bytes of the `ByteMessage` payload
|
||||||
|
are the destination `ActorAddress`, followed by the actual message bytes.
|
||||||
|
This allows guests to send replies without hardcoding addresses.
|
||||||
|
|
||||||
|
### Building guests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add wasm32-unknown-unknown # one-time
|
||||||
|
|
||||||
|
cd crates/wasm-actor/tests/guests/echo && cargo build --target wasm32-unknown-unknown --release
|
||||||
|
cd crates/wasm-actor/tests/guests/double && cargo build --target wasm32-unknown-unknown --release
|
||||||
|
cd crates/wasm-actor/tests/guests/silent && cargo build --target wasm32-unknown-unknown --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Each guest crate has its own `[workspace]` marker to stay independent of the
|
||||||
|
root workspace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Design Decisions & Tradeoffs
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|----------|-----------|
|
||||||
|
| 1 | **wasmtime, not wasmer/wasm3** | Best-maintained, fuel metering support, cranelift JIT |
|
||||||
|
| 2 | **Raw bytes, not structured messages** | Keeps the boundary simple; framing/serialization is the guest's concern |
|
||||||
|
| 3 | **Separate crate, not a feature flag** | wasmtime is ~30 crates; most users don't need it in their dependency tree |
|
||||||
|
| 4 | **Bump allocator in guests** | Zero-dependency, predictable, sufficient for request/response patterns |
|
||||||
|
| 5 | **Dest address in message payload** | Avoids hardcoded addresses; guests can send to any actor the host tells them about |
|
||||||
|
| 6 | **Traps = panics (no Result)** | Matches swactor's existing panic-safety model; `catch_unwind` in `tick_all` poisons the actor |
|
||||||
|
| 7 | **Engine sharing via Arc** | Module compilation is expensive; `SharedEngine` amortizes it across actors |
|
||||||
|
| 8 | **Maximum sandboxing defaults** | Disabled: threads, SIMD, relaxed SIMD, reference types, multi-value. Enabled: bulk memory (required by most compilers) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Known Gaps & Future Improvements
|
||||||
|
|
||||||
|
| # | Gap | Notes |
|
||||||
|
|---|-----|-------|
|
||||||
|
| 1 | **No fuel metering** | wasmtime supports fuel; maps naturally to per-tick actor budgets. Deferred to follow-up. |
|
||||||
|
| 2 | **No WASI** | No filesystem, network, random, or clock access. Intentional for sandboxing, but limits guest capabilities. |
|
||||||
|
| 3 | **No guest SDK crate** | The test guests serve as examples. A published `swactor-guest` crate with the alloc/handle/send glue would reduce boilerplate. |
|
||||||
|
| 4 | **Bump allocator never frees** | Fine for short-lived handle calls, but long-running actors would need a real allocator. |
|
||||||
|
| 5 | **No pre-compilation cache** | `Module::new()` recompiles every time. wasmtime supports serialized modules for faster cold starts. |
|
||||||
|
| 6 | **`cargo test -p` doesn't resolve** | Must use `--manifest-path`. Workspace resolution quirk. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Test Coverage Summary
|
||||||
|
|
||||||
|
7 behavioral tests in `crates/wasm-actor/tests/wasm_actor.rs`:
|
||||||
|
|
||||||
|
| Test | Scenario |
|
||||||
|
|------|----------|
|
||||||
|
| `echo_returns_same_payload` | Send bytes → wasm echoes them back to inbox |
|
||||||
|
| `echo_preserves_binary_payload` | All 256 byte values survive the roundtrip |
|
||||||
|
| `silent_produces_no_output` | Guest does nothing; no error, no messages |
|
||||||
|
| `double_sends_two_copies` | One message in → two messages out |
|
||||||
|
| `missing_alloc_export_returns_error` | WAT module with no exports → `WasmActorError::MissingExport` |
|
||||||
|
| `shared_engine_serves_multiple_actors` | Two actors from the same `SharedEngine` work independently |
|
||||||
|
| `native_actor_communicates_with_wasm_actor` | Native Rust actor → WasmActor → inbox (two-tick delivery) |
|
||||||
115
docs/wasm-actor.md
Normal file
115
docs/wasm-actor.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Wasm Actor
|
||||||
|
|
||||||
|
The `swactor-wasm-actor` crate runs WebAssembly guest code inside a swactor
|
||||||
|
actor. The Wasm instance is sandboxed by [wasmtime](https://wasmtime.dev/).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Runtime ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌─ WasmActor ──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Store<HostState> -- wasmtime store with outbox │ │
|
||||||
|
│ │ Memory -- guest linear memory │ │
|
||||||
|
│ │ alloc: TypedFunc -- guest allocator │ │
|
||||||
|
│ │ handle: TypedFunc -- guest message handler │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ impl ActorInterface for WasmActor │ │
|
||||||
|
│ │ Incoming = ByteMessage │ │
|
||||||
|
│ │ Response = () │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Native Actors ─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ (can exchange ByteMessage with WasmActors normally) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Host Guest (Wasm)
|
||||||
|
──── ────────────
|
||||||
|
|
||||||
|
ByteMessage arrives
|
||||||
|
│
|
||||||
|
├─1─ call alloc(len) ──────────► bump-allocate, return ptr
|
||||||
|
│
|
||||||
|
├─2─ write bytes at ptr ───────► (memory updated)
|
||||||
|
│
|
||||||
|
├─3─ call handle(ptr, len) ────► process message
|
||||||
|
│ │
|
||||||
|
│ ◄── swactor.send() ────────────┤ (0..N times)
|
||||||
|
│ (buffered in HostState.outbox) │
|
||||||
|
│ │
|
||||||
|
├─4─ drain outbox ◄────────────── handle returns
|
||||||
|
│
|
||||||
|
v
|
||||||
|
ctx.send(dest, ByteMessage) for each outbox entry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guest Contract
|
||||||
|
|
||||||
|
Guests are standalone `wasm32-unknown-unknown` modules. They export three
|
||||||
|
symbols and may import one:
|
||||||
|
|
||||||
|
| Direction | Module | Symbol | Signature |
|
||||||
|
|-----------|--------|--------|-----------|
|
||||||
|
| **export** | — | `memory` | linear memory |
|
||||||
|
| **export** | — | `alloc` | `(i32) -> i32` |
|
||||||
|
| **export** | — | `handle` | `(i32, i32) -> ()` |
|
||||||
|
| **import** | `swactor` | `send` | `(i32, i32, i32) -> ()` |
|
||||||
|
|
||||||
|
The `send` import takes `(dest_ptr, payload_ptr, payload_len)` where
|
||||||
|
`dest_ptr` points to a 32-byte `ActorAddress` in guest memory.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use swactor::runtime::{Runtime, RuntimeConfig};
|
||||||
|
use swactor_wasm_actor::{ByteMessage, SharedEngine, WasmActorBuilder};
|
||||||
|
|
||||||
|
// Create a shared engine (once)
|
||||||
|
let engine = SharedEngine::new().unwrap();
|
||||||
|
|
||||||
|
// Build an actor from .wasm bytes
|
||||||
|
let wasm_bytes = std::fs::read("my_guest.wasm").unwrap();
|
||||||
|
let actor = WasmActorBuilder::new(engine, wasm_bytes)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Use it like any other actor
|
||||||
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
|
let addr = rt.spawn(actor).unwrap();
|
||||||
|
rt.send_to(addr, ByteMessage(b"hello".to_vec())).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sandboxing
|
||||||
|
|
||||||
|
The `SharedEngine` disables all optional Wasm proposals:
|
||||||
|
|
||||||
|
- Threads — disabled
|
||||||
|
- SIMD / relaxed SIMD — disabled
|
||||||
|
- Reference types — disabled
|
||||||
|
- Multi-value — disabled
|
||||||
|
- Bulk memory — **enabled** (required by most Rust/LLVM toolchains)
|
||||||
|
|
||||||
|
No WASI imports are linked. Guests have no access to the filesystem, network,
|
||||||
|
clock, or random number generator. The only host function available is
|
||||||
|
`swactor.send`.
|
||||||
|
|
||||||
|
## Where Things Live
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `crates/wasm-actor/src/lib.rs` | `ByteMessage` + re-exports |
|
||||||
|
| `crates/wasm-actor/src/engine.rs` | `SharedEngine` — sandboxed wasmtime config |
|
||||||
|
| `crates/wasm-actor/src/builder.rs` | `WasmActorBuilder` — compile, link, instantiate |
|
||||||
|
| `crates/wasm-actor/src/actor.rs` | `WasmActor` — `ActorInterface` impl |
|
||||||
|
| `crates/wasm-actor/src/error.rs` | `WasmActorError` |
|
||||||
|
| `crates/wasm-actor/tests/guests/` | Three test guest crates (echo, double, silent) |
|
||||||
|
| `crates/wasm-actor/tests/wasm_actor.rs` | 7 integration tests |
|
||||||
Loading…
Reference in a new issue