os-research #32

Merged
zacheryasc merged 5 commits from os-research into master 2026-02-13 07:42:45 +00:00
20 changed files with 2079 additions and 110 deletions
Showing only changes of commit 60ee2d1a2c - Show all commits

1065
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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]

View 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"

View 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));
}
}
}

View 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(())
}
}

View 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
}
}

View 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)
}
}

View 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>);

View 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"

View 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

View 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 {}
}

View 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"

View 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

View 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 {}
}

View 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"

View 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

View 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 {}
}

View 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");
}

View 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
View 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 |