os-research #32
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]
|
||||
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"]
|
||||
|
||||
[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