swactor/crates/bin-runner/tests/wasm_actor.rs

1367 lines
55 KiB
Rust
Raw Normal View History

use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
use swactor_bin_runner::{ByteMessage, SharedEngine, WasmActor, WasmActorBuilder, WasmActorError};
use swactor_std::CtxWatching;
use proptest::prelude::*;
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)
}
/// Build a WAT module where alloc returns a constant value.
fn alloc_returns_wat(alloc_val: i32) -> Vec<u8> {
let wat = format!(
r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const {alloc_val})
(func (export "handle") (param i32 i32))
)"#
);
wat::parse_str(&wat).unwrap()
}
/// Build a WAT module where handle calls send with specific arguments.
fn send_args_wat(dest_ptr: i32, payload_ptr: i32, payload_len: i32) -> Vec<u8> {
let wat = format!(
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)
(func (export "handle") (param i32 i32)
i32.const {dest_ptr}
i32.const {payload_ptr}
i32.const {payload_len}
call $send
)
)"#
);
wat::parse_str(&wat).unwrap()
}
// ═══════════════════════════════════════════════════════════════════════════════
// Group 1: Builder contract
// ═══════════════════════════════════════════════════════════════════════════════
#[test]
fn build_succeeds_for_valid_modules() {
let engine = SharedEngine::new().unwrap();
// Pre-compiled guests
WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
WasmActorBuilder::new(engine.clone(), guest_wasm("double")).build().unwrap();
WasmActorBuilder::new(engine.clone(), guest_wasm("silent")).build().unwrap();
// Custom WAT with extra exports, data segments, funcref table
let extras = wat::parse_str(r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 0) "hello")
(table 1 funcref)
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param i32 i32))
(func (export "custom_fn") (result i32) i32.const 42)
)"#).unwrap();
WasmActorBuilder::new(engine.clone(), extras).build().unwrap();
// Module without send import (doesn't import swactor.send)
let no_send = wat::parse_str(r#"(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param i32 i32))
)"#).unwrap();
WasmActorBuilder::new(engine, no_send).build().unwrap();
}
#[test]
fn build_rejects_missing_exports() {
let engine = SharedEngine::new().unwrap();
// Empty module — missing everything
let result = WasmActorBuilder::new(engine.clone(), wat::parse_str("(module)").unwrap()).build();
let err = result.err().expect("should fail");
let msg = format!("{err}");
assert!(msg.contains("memory") || msg.contains("alloc"), "got: {msg}");
// Memory only — missing alloc
let mem_only = wat::parse_str("(module (memory (export \"memory\") 1))").unwrap();
let err = WasmActorBuilder::new(engine.clone(), mem_only).build().err().expect("should fail");
assert!(format!("{err}").contains("alloc"));
// Memory + alloc — missing handle
let no_handle = wat::parse_str(r#"(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
)"#).unwrap();
let err = WasmActorBuilder::new(engine.clone(), no_handle).build().err().expect("should fail");
assert!(format!("{err}").contains("handle"));
// Wrong memory export name
let wrong_mem = wat::parse_str(r#"(module
(memory (export "mem") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
)"#).unwrap();
assert!(WasmActorBuilder::new(engine.clone(), wrong_mem).build().is_err());
// Wrong alloc signature (two params)
let wrong_alloc = wat::parse_str(r#"(module
(memory (export "memory") 1)
(func (export "alloc") (param i32 i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
)"#).unwrap();
assert!(WasmActorBuilder::new(engine.clone(), wrong_alloc).build().is_err());
// Wrong handle signature (returns i32)
let wrong_handle = wat::parse_str(r#"(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32) (result i32) i32.const 0)
)"#).unwrap();
assert!(WasmActorBuilder::new(engine, wrong_handle).build().is_err());
}
#[test]
fn build_rejects_invalid_wasm_and_disabled_features() {
let engine = SharedEngine::new().unwrap();
// Invalid bytes
assert!(WasmActorBuilder::new(engine.clone(), vec![0xDE, 0xAD]).build().is_err());
// Empty bytes
assert!(WasmActorBuilder::new(engine.clone(), vec![]).build().is_err());
// Truncated valid wasm
let valid = guest_wasm("echo");
let truncated = valid[..valid.len() / 2].to_vec();
assert!(WasmActorBuilder::new(engine.clone(), truncated).build().is_err());
// SIMD (disabled in engine config)
let simd = wat::parse_str(r#"(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32)
v128.const i32x4 0 0 0 0
drop
)
)"#);
assert!(simd.is_err() || WasmActorBuilder::new(engine.clone(), simd.unwrap()).build().is_err());
// Multi-value (disabled)
let mv = wat::parse_str(r#"(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32)
(block (result i32 i32)
i32.const 1
i32.const 2
)
drop drop
)
)"#);
assert!(mv.is_err() || WasmActorBuilder::new(engine, mv.unwrap()).build().is_err());
}
#[test]
fn error_display_and_from_impls() {
// MissingExport
let missing = WasmActorError::MissingExport("memory");
let s = format!("{missing}");
assert!(s.contains("memory"), "MissingExport display: {s}");
// Wasmtime error via From
let wt_err = wasmtime::Error::msg("test error");
let converted: WasmActorError = wt_err.into();
let s = format!("{converted}");
assert!(s.contains("test error"), "Wasmtime display: {s}");
// Distinct display
let missing_s = format!("{}", WasmActorError::MissingExport("alloc"));
let wt_s = format!("{}", WasmActorError::Wasmtime(wasmtime::Error::msg("x")));
assert_ne!(missing_s, wt_s);
// Send + Sync
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<WasmActorError>();
}
proptest! {
#[test]
fn prop_builder_never_panics(
pages in 0u32..10,
alloc_return in prop_oneof![-100i32..0, 0i32..70000, Just(i32::MIN), Just(i32::MAX)],
) {
let wat = format!(
r#"(module
(memory (export "memory") {pages})
(func (export "alloc") (param i32) (result i32) i32.const {alloc_return})
(func (export "handle") (param i32 i32))
)"#
);
if let Ok(wasm) = wat::parse_str(&wat) {
let engine = SharedEngine::new().unwrap();
// Should succeed or return clean error — never panic
let _ = WasmActorBuilder::new(engine, wasm).build();
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// Group 2: Echo round-trip
// ═══════════════════════════════════════════════════════════════════════════════
#[test]
fn echo_round_trip_story() {
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();
// Text payload
rt.send_to(addr, framed_msg(inbox.addr(), b"hello wasm")).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"hello wasm");
// Binary payload (all 256 byte values)
let binary: Vec<u8> = (0..=255).collect();
rt.send_to(addr, framed_msg(inbox.addr(), &binary)).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, binary);
// Empty message — echo needs >= 32 bytes, so no reply
rt.send_to(addr, ByteMessage(vec![])).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none());
// Address-only message (32 bytes, 0 payload) — echo sends empty payload
rt.send_to(addr, framed_msg(inbox.addr(), b"")).unwrap();
rt.tick();
let resp = inbox.try_recv().unwrap();
assert!(resp.0.is_empty(), "address-only should echo empty payload");
// Single byte payload
rt.send_to(addr, framed_msg(inbox.addr(), &[0x42])).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, vec![0x42]);
// Actor still alive after all those messages
rt.send_to(addr, framed_msg(inbox.addr(), b"fin")).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"fin");
}
proptest! {
#[test]
fn prop_echo_preserves_arbitrary_payload(payload in proptest::collection::vec(any::<u8>(), 0..500)) {
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();
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
if payload.is_empty() {
// echo guest: if len < 32, returns empty; if len == 32 (addr only), returns empty payload
let resp = inbox.try_recv();
// With 32-byte address + 0-byte payload, echo sends back empty
if let Some(msg) = resp {
prop_assert!(msg.0.is_empty());
}
} else {
let received = inbox.try_recv().expect("echo should reply for non-empty payload");
prop_assert_eq!(received.0, payload);
}
}
}
#[test]
fn double_and_silent_contracts() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
// Double: 1 message in → exactly 2 out
let double = WasmActorBuilder::new(engine.clone(), guest_wasm("double")).build().unwrap();
let daddr = rt.spawn(double).unwrap();
rt.send_to(daddr, framed_msg(inbox.addr(), b"dup")).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"dup");
assert_eq!(inbox.try_recv().unwrap().0, b"dup");
assert!(inbox.try_recv().is_none(), "exactly 2");
// Silent: messages in → 0 out
let silent = WasmActorBuilder::new(engine, guest_wasm("silent")).build().unwrap();
let saddr = rt.spawn(silent).unwrap();
for _ in 0..10 {
rt.send_to(saddr, framed_msg(inbox.addr(), b"ignored")).unwrap();
}
rt.tick();
assert!(inbox.try_recv().is_none(), "silent never sends");
}
// ═══════════════════════════════════════════════════════════════════════════════
// Group 3: Alloc failure resilience
// ═══════════════════════════════════════════════════════════════════════════════
proptest! {
#[test]
fn prop_any_alloc_return_never_kills_actor(
alloc_val in prop_oneof![
-100i32..0,
0i32..70000,
Just(i32::MIN),
Just(i32::MAX),
Just(0i32),
Just(65500i32),
Just(65536i32),
],
) {
let wasm = alloc_returns_wat(alloc_val);
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 two messages — actor must survive both regardless of alloc return
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick();
rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap();
rt.tick();
}
}
#[test]
fn alloc_trap_recovery() {
// Alloc traps on first call (counter == 0), succeeds thereafter
let wat = r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $calls (mut i32) (i32.const 0))
(func (export "alloc") (param $len i32) (result i32)
(global.set $calls (i32.add (global.get $calls) (i32.const 1)))
(if (result i32) (i32.eq (global.get $calls) (i32.const 1))
(then unreachable)
(else i32.const 4096)
)
)
(func (export "handle") (param $ptr i32) (param $len i32)
(if (i32.ge_u (local.get $len) (i32.const 33))
(then
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
)
)"#;
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wat::parse_str(wat).unwrap()).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// First message — alloc traps, dropped
rt.send_to(addr, framed_msg(inbox.addr(), b"first")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "first msg dropped due to alloc trap");
// Second message — alloc succeeds, echoed
rt.send_to(addr, framed_msg(inbox.addr(), b"second")).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"second");
}
#[test]
fn alloc_alternates_and_exhaustion() {
// Part A: alternating alloc (even calls succeed, odd calls return 0)
let wat = r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $calls (mut i32) (i32.const 0))
(func (export "alloc") (param $len i32) (result i32)
(global.set $calls (i32.add (global.get $calls) (i32.const 1)))
(if (result i32) (i32.rem_u (global.get $calls) (i32.const 2))
(then i32.const 4096)
(else i32.const 0)
)
)
(func (export "handle") (param $ptr i32) (param $len i32)
(if (i32.ge_u (local.get $len) (i32.const 33))
(then
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
)
)"#;
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine.clone(), wat::parse_str(wat).unwrap()).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let mut echoed = 0;
for i in 0u8..6 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
rt.tick();
if inbox.try_recv().is_some() { echoed += 1; }
}
assert_eq!(echoed, 3, "should echo on odd-numbered alloc calls only");
// Part B: echo guest under sustained load — bump allocator exhaustion
let echo = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let rt2 = Runtime::new(RuntimeConfig::default());
let inbox2 = rt2.new_inbox::<ByteMessage>().unwrap();
let addr2 = rt2.spawn(echo).unwrap();
let mut total_echoed = 0;
for _ in 0..2000 {
rt2.send_to(addr2, framed_msg(inbox2.addr(), b"ping")).unwrap();
rt2.tick();
if inbox2.try_recv().is_some() { total_echoed += 1; }
}
// Some succeed (before OOM), some fail (after OOM). Actor survives throughout.
assert!(total_echoed > 0, "at least some messages should echo");
assert!(total_echoed < 2000, "bump allocator should eventually exhaust");
}
// ═══════════════════════════════════════════════════════════════════════════════
// Group 4: Handle trap & outbox semantics
// ═══════════════════════════════════════════════════════════════════════════════
#[test]
fn handle_trap_actor_survives() {
let engine = SharedEngine::new().unwrap();
// unreachable trap
let unreachable_wat = wat::parse_str(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)
(func (export "handle") (param i32 i32) unreachable)
)"#).unwrap();
let actor = WasmActorBuilder::new(engine.clone(), unreachable_wat).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Send 3 messages — all trap, all dropped
for _ in 0..3 {
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick();
}
// Division by zero trap
let divzero_wat = wat::parse_str(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)
(func (export "handle") (param $ptr i32) (param $len i32)
(drop (i32.div_u (local.get $len) (i32.const 0)))
)
)"#).unwrap();
let actor2 = WasmActorBuilder::new(engine, divzero_wat).build().unwrap();
let addr2 = rt.spawn(actor2).unwrap();
rt.send_to(addr2, ByteMessage(vec![1])).unwrap();
rt.tick(); // no panic
}
#[test]
fn outbox_cleared_on_trap() {
// Guest sends once (valid), then traps. Outbox should be cleared.
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)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Send a valid message
(call $send (local.get $ptr) (local.get $ptr) (i32.const 1))
;; Then trap
unreachable
)
)"#;
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wat::parse_str(wat).unwrap()).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, framed_msg(inbox.addr(), b"data")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "outbox cleared on trap — no messages delivered");
// Actor survives
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick();
}
proptest! {
#[test]
fn prop_any_send_args_never_crash_host(
dest_ptr in any::<i32>(),
payload_ptr in any::<i32>(),
payload_len in any::<i32>(),
) {
let wasm = send_args_wat(dest_ptr, payload_ptr, payload_len);
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![0u8; 64])).unwrap();
rt.tick(); // never panic
}
}
#[test]
fn send_boundary_conditions() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
// Zero-length payload sends empty ByteMessage
let zero_len = wat::parse_str(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)
(func (export "handle") (param $ptr i32) (param $len i32)
(call $send (local.get $ptr) (i32.const 0) (i32.const 0))
)
)"#).unwrap();
let actor = WasmActorBuilder::new(engine.clone(), zero_len).build().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("zero-len payload should deliver");
assert!(msg.0.is_empty());
// Dest at exact memory boundary: ptr=65504, needs 32 bytes → end=65536 = memory size. Works.
let exact_end = send_args_wat(65504, 0, 1);
let actor2 = WasmActorBuilder::new(engine.clone(), exact_end).build().unwrap();
let addr2 = rt.spawn(actor2).unwrap();
rt.send_to(addr2, ByteMessage(vec![0u8; 64])).unwrap();
rt.tick(); // should not trap (exact fit)
// Dest one past boundary: ptr=65505 → end=65537 > 65536. Traps.
let one_past = send_args_wat(65505, 0, 1);
let actor3 = WasmActorBuilder::new(engine.clone(), one_past).build().unwrap();
let addr3 = rt.spawn(actor3).unwrap();
rt.send_to(addr3, ByteMessage(vec![0u8; 64])).unwrap();
rt.tick(); // traps but actor survives
// Send to garbage address — silently dropped (no matching inbox)
let garbage = wat::parse_str(r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 500) "\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff\ff")
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param $ptr i32) (param $len i32)
(call $send (i32.const 500) (i32.const 0) (i32.const 1))
)
)"#).unwrap();
let actor4 = WasmActorBuilder::new(engine, garbage).build().unwrap();
let addr4 = rt.spawn(actor4).unwrap();
rt.send_to(addr4, ByteMessage(vec![1])).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "garbage addr → no delivery to our inbox");
}
// ═══════════════════════════════════════════════════════════════════════════════
// Group 5: Lifecycle & integration
// ═══════════════════════════════════════════════════════════════════════════════
#[test]
fn full_lifecycle_story() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
// Spawn echo, use it
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr1 = rt.spawn(echo).unwrap();
rt.send_to(addr1, framed_msg(inbox.addr(), b"alive")).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"alive");
// Stop it
let _ = rt.stop_actor(addr1);
rt.tick();
rt.tick();
// Send to dead actor — silently dropped
let _ = rt.send_to(addr1, framed_msg(inbox.addr(), b"dead"));
rt.tick();
assert!(inbox.try_recv().is_none());
// Respawn — different address, still works
let echo2 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr2 = rt.spawn(echo2).unwrap();
assert_ne!(addr1, addr2, "respawned actor gets different address");
rt.send_to(addr2, framed_msg(inbox.addr(), b"new")).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"new");
// Double-stop is fine
let _ = rt.stop_actor(addr2);
let _ = rt.stop_actor(addr2);
rt.tick();
}
struct Forwarder {
engine: SharedEngine,
wasm_bytes: Vec<u8>,
inbox_addr: ActorAddress,
}
#[derive(Clone)]
struct ForwardMsg(Vec<u8>);
impl ActorInterface for Forwarder {
type Incoming = ForwardMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: ForwardMsg) {
let wasm = WasmActorBuilder::new(self.engine.clone(), self.wasm_bytes.clone())
.build()
.unwrap();
let wasm_addr = ctx.spawn(wasm).unwrap();
let _ = ctx.send(wasm_addr, framed_msg(&self.inbox_addr, &msg.0));
}
}
#[test]
fn wasm_native_interop() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
// Native forwarder spawns a WASM echo actor and forwards a message to it
let forwarder = Forwarder {
engine: engine.clone(),
wasm_bytes: guest_wasm("echo"),
inbox_addr: inbox.addr().clone(),
};
let fwd_addr = rt.spawn(forwarder).unwrap();
rt.send_to(fwd_addr, ForwardMsg(b"via-native".to_vec())).unwrap();
for _ in 0..5 { rt.tick(); }
assert_eq!(inbox.try_recv().unwrap().0, b"via-native");
}
#[test]
fn wasm_relay_chain() {
// A→B→C→inbox: each echo actor strips 32 bytes of address and sends payload to that address
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let a = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let b = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let c = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let addr_a = rt.spawn(a).unwrap();
let addr_b = rt.spawn(b).unwrap();
let addr_c = rt.spawn(c).unwrap();
// Nested framed: [addr_b | addr_c | addr_inbox | "end"]
let mut payload = Vec::new();
payload.extend_from_slice(&addr_b.0);
payload.extend_from_slice(&addr_c.0);
payload.extend_from_slice(&inbox.addr().0);
payload.extend_from_slice(b"end");
rt.send_to(addr_a, ByteMessage(payload)).unwrap();
for _ in 0..3 { rt.tick(); }
// After 3 hops, inbox should have the final payload "end"
// A sends [addr_c | addr_inbox | "end"] to B
// B sends [addr_inbox | "end"] to C
// C sends "end" to inbox
let msg = inbox.try_recv().expect("relay chain should deliver");
assert_eq!(msg.0, b"end");
}
struct DeathCounter {
count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
#[derive(Clone)]
struct WatchAddr(ActorAddress);
impl ActorInterface for DeathCounter {
type Incoming = WatchAddr;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: WatchAddr) {
ctx.watch(msg.0);
}
fn on_actor_exit(&mut self, _ctx: &Ctx, _exited: swactor::actor::ActorExited) {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
#[test]
fn watch_notification() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let target = WasmActorBuilder::new(engine, guest_wasm("silent")).build().unwrap();
let target_addr = rt.spawn(target).unwrap();
let watcher_addr = rt.spawn(DeathCounter { count: count.clone() }).unwrap();
rt.send_to(watcher_addr, WatchAddr(target_addr)).unwrap();
for _ in 0..3 { rt.tick(); }
let _ = rt.stop_actor(target_addr);
for _ in 0..5 { rt.tick(); }
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
}
proptest! {
#[test]
fn prop_lifecycle_fuzz(
guest_idx in 0usize..3,
n_msgs in 0u8..20,
ticks_before in 1usize..5,
ticks_after in 1usize..5,
) {
let names = ["echo", "double", "silent"];
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm(names[guest_idx])).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
for _ in 0..n_msgs {
let _ = rt.send_to(addr, framed_msg(inbox.addr(), b"x"));
}
for _ in 0..ticks_before { rt.tick(); }
let _ = rt.stop_actor(addr);
for _ in 0..ticks_after { rt.tick(); }
while inbox.try_recv().is_some() {}
}
}
proptest! {
#[test]
fn prop_mixed_guest_response_counts(
echo_n in 0usize..5,
double_n in 0usize..5,
silent_n in 0usize..5,
) {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
for _ in 0..echo_n {
let a = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr = rt.spawn(a).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"e")).unwrap();
}
for _ in 0..double_n {
let a = WasmActorBuilder::new(engine.clone(), guest_wasm("double")).build().unwrap();
let addr = rt.spawn(a).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"d")).unwrap();
}
for _ in 0..silent_n {
let a = WasmActorBuilder::new(engine.clone(), guest_wasm("silent")).build().unwrap();
let addr = rt.spawn(a).unwrap();
rt.send_to(addr, ByteMessage(b"s".to_vec())).unwrap();
}
rt.tick();
let total: usize = std::iter::from_fn(|| inbox.try_recv()).count();
prop_assert_eq!(total, echo_n + 2 * double_n);
}
}
#[test]
fn non_byte_message_silently_ignored() {
// Send a String (wrong type) to a WASM actor — should be silently dropped
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();
// send_to with wrong type — shouldn't panic
let _ = rt.send_to(addr, "wrong type".to_string());
rt.tick();
assert!(inbox.try_recv().is_none());
// Actor still alive
rt.send_to(addr, framed_msg(inbox.addr(), b"ok")).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"ok");
}
// ═══════════════════════════════════════════════════════════════════════════════
// Group 6: Engine & runtime config
// ═══════════════════════════════════════════════════════════════════════════════
#[test]
fn shared_engine_traits_and_clone() {
fn assert_send_sync<T: Send + Sync>() {}
fn assert_send<T: Send>() {}
assert_send_sync::<SharedEngine>();
assert_send::<WasmActor>();
let engine = SharedEngine::new().unwrap();
let _ = format!("{:?}", engine); // Debug doesn't panic
// Clone produces working independent engine
let clone = engine.clone();
let a1 = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let a2 = WasmActorBuilder::new(clone, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr1 = rt.spawn(a1).unwrap();
let addr2 = rt.spawn(a2).unwrap();
rt.send_to(addr1, framed_msg(inbox.addr(), b"c1")).unwrap();
rt.send_to(addr2, framed_msg(inbox.addr(), b"c2")).unwrap();
rt.tick();
let mut msgs: Vec<Vec<u8>> = std::iter::from_fn(|| inbox.try_recv().map(|m| m.0)).collect();
msgs.sort();
assert_eq!(msgs, vec![b"c1".to_vec(), b"c2".to_vec()]);
// ByteMessage traits
let bm = ByteMessage(vec![1, 2, 3]);
let bm2 = bm.clone();
assert_eq!(bm, bm2);
let _ = format!("{:?}", bm);
}
#[test]
fn multi_thread_runtime() {
let engine = SharedEngine::new().unwrap();
let config = RuntimeConfig { num_threads: 4, ..RuntimeConfig::default() };
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let mut addrs = Vec::new();
for _ in 0..10 {
let actor = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
addrs.push(rt.spawn(actor).unwrap());
}
for (i, addr) in addrs.iter().enumerate() {
rt.send_to(*addr, framed_msg(inbox.addr(), &[i as u8])).unwrap();
}
let handle = rt.run().unwrap();
let mut received = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(25));
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
if received.len() == 10 { break; }
}
handle.shutdown();
received.sort();
assert_eq!(received, (0..10u8).collect::<Vec<_>>());
}
#[test]
fn budget_and_mailbox() {
let engine = SharedEngine::new().unwrap();
// Budget=1: one message processed per tick
let mut cfg = RuntimeConfig::default();
cfg.actor_message_budget = 1;
let rt = Runtime::new(cfg);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr = rt.spawn(echo).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"a")).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"b")).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"c")).unwrap();
rt.tick();
let first_tick: usize = std::iter::from_fn(|| inbox.try_recv()).count();
assert_eq!(first_tick, 1, "budget=1 processes exactly 1 per tick");
rt.tick();
rt.tick();
let rest: usize = std::iter::from_fn(|| inbox.try_recv()).count();
assert_eq!(rest, 2, "remaining 2 processed over next 2 ticks");
// Self-send loop bounded by budget — no explosion
let mut cfg2 = RuntimeConfig::default();
cfg2.actor_message_budget = 2;
let rt2 = Runtime::new(cfg2);
let echo2 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let self_addr = rt2.spawn(echo2).unwrap();
rt2.send_to(self_addr, framed_msg(&self_addr, b"loop")).unwrap();
for _ in 0..5 { rt2.tick(); } // no panic, bounded
// DropOldest mailbox
use swactor::runtime::MailboxOverflow;
let mut cfg3 = RuntimeConfig::default();
cfg3.default_mailbox_capacity = 3;
cfg3.mailbox_overflow = MailboxOverflow::DropOldest;
let rt3 = Runtime::new(cfg3);
let inbox3 = rt3.new_inbox::<ByteMessage>().unwrap();
let echo3 = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let addr3 = rt3.spawn(echo3).unwrap();
for i in 0..10u8 {
rt3.send_to(addr3, framed_msg(inbox3.addr(), &[i])).unwrap();
}
rt3.tick();
let responses: Vec<_> = std::iter::from_fn(|| inbox3.try_recv()).collect();
assert!(responses.len() <= 3, "mailbox capacity limits processing: got {}", responses.len());
}
#[test]
fn multiple_runtimes_and_scale() {
let engine = SharedEngine::new().unwrap();
// Two runtimes share engine
let rt1 = Runtime::new(RuntimeConfig::default());
let rt2 = Runtime::new(RuntimeConfig::default());
let inbox1 = rt1.new_inbox::<ByteMessage>().unwrap();
let inbox2 = rt2.new_inbox::<ByteMessage>().unwrap();
let a1 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let a2 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr1 = rt1.spawn(a1).unwrap();
let addr2 = rt2.spawn(a2).unwrap();
rt1.send_to(addr1, framed_msg(inbox1.addr(), b"rt1")).unwrap();
rt2.send_to(addr2, framed_msg(inbox2.addr(), b"rt2")).unwrap();
rt1.tick();
rt2.tick();
assert_eq!(inbox1.try_recv().unwrap().0, b"rt1");
assert_eq!(inbox2.try_recv().unwrap().0, b"rt2");
// 50 actors from same engine
let rt3 = Runtime::new(RuntimeConfig::default());
let inbox3 = rt3.new_inbox::<ByteMessage>().unwrap();
for i in 0..50u8 {
let a = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr = rt3.spawn(a).unwrap();
rt3.send_to(addr, framed_msg(inbox3.addr(), &[i])).unwrap();
}
rt3.tick();
let count: usize = std::iter::from_fn(|| inbox3.try_recv()).count();
assert_eq!(count, 50);
// Build+drop 100 actors without spawning — no leak
for _ in 0..100 {
let _ = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
}
}
#[test]
fn ordering_and_determinism() {
let engine = SharedEngine::new().unwrap();
// FIFO ordering
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr = rt.spawn(echo).unwrap();
for i in 0..20u8 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
let msgs: Vec<u8> = std::iter::from_fn(|| inbox.try_recv().map(|m| m.0[0])).collect();
assert_eq!(msgs, (0..20u8).collect::<Vec<_>>(), "FIFO preserved");
// Determinism: same input → same output across runs
let mut results = Vec::new();
for _ in 0..5 {
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(echo).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"deterministic")).unwrap();
rt.tick();
results.push(inbox.try_recv().unwrap().0);
}
assert!(results.windows(2).all(|w| w[0] == w[1]), "deterministic across runs");
}
// ═══════════════════════════════════════════════════════════════════════════════
// Group 7: WASM feature coverage & guest computation
// ═══════════════════════════════════════════════════════════════════════════════
#[test]
fn kitchen_sink_wat_module() {
// Single WAT module exercising as many supported WASM features as possible.
// If this builds and doesn't trap, the engine config is correct.
let wat = r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 2)
;; Data segments
(data (i32.const 0) "kitchen-sink")
;; Globals: mutable and immutable
(global $counter (mut i32) (i32.const 0))
(global $MAGIC i32 (i32.const 42))
;; Table for call_indirect
(table 2 funcref)
(elem (i32.const 0) $helper_add $helper_sub)
;; Internal helper functions
(func $helper_add (param i32 i32) (result i32)
(i32.add (local.get 0) (local.get 1)))
(func $helper_sub (param i32 i32) (result i32)
(i32.sub (local.get 0) (local.get 1)))
(func $deep_call (param $x i32) (result i32)
(i32.mul (local.get $x) (i32.const 2)))
(func (export "alloc") (param $len i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $a i32)
(local $b i64)
(local $c f32)
(local $d f64)
(local $i i32)
(local $tmp i32)
;; Increment global counter
(global.set $counter (i32.add (global.get $counter) (i32.const 1)))
;; Read immutable global
(local.set $a (global.get $MAGIC))
;; i32 arithmetic
(local.set $a (i32.add (local.get $a) (i32.const 10)))
(local.set $a (i32.sub (local.get $a) (i32.const 5)))
(local.set $a (i32.mul (local.get $a) (i32.const 3)))
(local.set $a (i32.div_u (local.get $a) (i32.const 2)))
(local.set $a (i32.rem_u (local.get $a) (i32.const 100)))
;; Bitwise operations
(local.set $a (i32.and (local.get $a) (i32.const 0xFF)))
(local.set $a (i32.or (local.get $a) (i32.const 0x10)))
(local.set $a (i32.xor (local.get $a) (i32.const 0x01)))
(local.set $a (i32.shl (local.get $a) (i32.const 2)))
(local.set $a (i32.shr_u (local.get $a) (i32.const 1)))
(local.set $a (i32.shr_s (local.get $a) (i32.const 1)))
(local.set $a (i32.rotl (local.get $a) (i32.const 3)))
(local.set $a (i32.rotr (local.get $a) (i32.const 3)))
;; Bit counting
(drop (i32.clz (local.get $a)))
(drop (i32.ctz (local.get $a)))
(drop (i32.popcnt (local.get $a)))
(drop (i32.eqz (local.get $a)))
;; Comparisons
(drop (i32.gt_s (local.get $a) (i32.const 0)))
(drop (i32.lt_s (local.get $a) (i32.const 100)))
(drop (i32.le_u (local.get $a) (i32.const 200)))
(drop (i32.ge_s (local.get $a) (i32.const -1)))
;; i64 operations
(local.set $b (i64.const 9999999999))
(local.set $b (i64.add (local.get $b) (i64.const 1)))
(i64.store (i32.const 800) (local.get $b))
(drop (i64.load (i32.const 800)))
;; i64 ↔ i32 conversions
(drop (i32.wrap_i64 (local.get $b)))
(drop (i64.extend_i32_s (local.get $a)))
;; f32 operations
(local.set $c (f32.const 3.14))
(local.set $c (f32.add (local.get $c) (f32.const 1.0)))
(local.set $c (f32.mul (local.get $c) (f32.const 2.0)))
(f32.store (i32.const 900) (local.get $c))
(drop (f32.load (i32.const 900)))
;; f64 operations
(local.set $d (f64.promote_f32 (local.get $c)))
(local.set $d (f64.mul (local.get $d) (f64.const 0.5)))
(f64.store (i32.const 920) (local.get $d))
;; Sign extension
(drop (i32.extend8_s (i32.const 0x80)))
(drop (i32.extend16_s (i32.const 0x8000)))
;; Memory store/load variants
(i32.store8 (i32.const 700) (i32.const 0xAB))
(i32.store16 (i32.const 702) (i32.const 0xCDEF))
(drop (i32.load8_u (i32.const 700)))
(drop (i32.load16_u (i32.const 702)))
;; local.tee
(local.set $tmp (local.tee $a (i32.const 77)))
;; Nested function calls
(drop (call $deep_call (i32.const 5)))
(drop (call $helper_add (i32.const 10) (i32.const 20)))
;; call_indirect via table
(drop (call_indirect (type 0) (i32.const 7) (i32.const 3) (i32.const 0)))
(drop (call_indirect (type 0) (i32.const 7) (i32.const 3) (i32.const 1)))
;; Control flow: if/else
(if (i32.gt_s (local.get $len) (i32.const 0))
(then nop)
(else nop)
)
;; select
(drop (select (i32.const 10) (i32.const 20) (i32.const 1)))
;; block + br_if
(block $skip
(br_if $skip (i32.eqz (local.get $len)))
nop
)
;; loop with counter
(local.set $i (i32.const 0))
(block $exit
(loop $loop
(br_if $exit (i32.ge_u (local.get $i) (i32.const 5)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
)
)
;; br_table dispatch
(block $b0 (block $b1 (block $b2
(br_table $b0 $b1 $b2 (i32.const 1))
) nop) nop) ;; falls through
;; Bulk memory: fill and copy
(memory.fill (i32.const 600) (i32.const 0xAA) (i32.const 32))
(memory.copy (i32.const 650) (i32.const 600) (i32.const 32))
;; memory.size and memory.grow
(drop (memory.size))
(drop (memory.grow (i32.const 1)))
;; nop
nop
)
;; Type for call_indirect
(type (func (param i32 i32) (result i32)))
)"#;
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wat::parse_str(wat).unwrap()).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Send message, tick — no trap
rt.send_to(addr, ByteMessage(vec![1, 2, 3, 4])).unwrap();
rt.tick();
// Send again — global counter increments, still no trap
rt.send_to(addr, ByteMessage(vec![5, 6, 7, 8])).unwrap();
rt.tick();
}
#[test]
fn guest_mutable_state_persists() {
// Global counter increments on each handle call, sends count back
let wat = r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $count (mut i32) (i32.const 0))
(func (export "alloc") (param $len i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(if (i32.lt_u (local.get $len) (i32.const 32)) (then return))
(global.set $count (i32.add (global.get $count) (i32.const 1)))
(i32.store (i32.const 200) (global.get $count))
(call $send (local.get $ptr) (i32.const 200) (i32.const 4))
)
)"#;
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wat::parse_str(wat).unwrap()).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
for expected in 1..=5u32 {
rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
let resp = inbox.try_recv().expect("should get count back");
let count = u32::from_le_bytes([resp.0[0], resp.0[1], resp.0[2], resp.0[3]]);
assert_eq!(count, expected, "counter should persist across messages");
}
}
#[test]
fn guest_transforms_payload() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
// XOR each byte with 0xFF (bitwise NOT)
let xor_wat = r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param $len i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $i i32)
(if (i32.lt_u (local.get $len) (i32.const 33)) (then return))
(local.set $i (i32.const 32))
(block $exit (loop $loop
(br_if $exit (i32.ge_u (local.get $i) (local.get $len)))
(i32.store8
(i32.add (local.get $ptr) (local.get $i))
(i32.xor (i32.load8_u (i32.add (local.get $ptr) (local.get $i))) (i32.const 0xFF)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
))
(call $send (local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32)))
)
)"#;
let xor = WasmActorBuilder::new(engine.clone(), wat::parse_str(xor_wat).unwrap()).build().unwrap();
let xaddr = rt.spawn(xor).unwrap();
rt.send_to(xaddr, framed_msg(inbox.addr(), &[0x00, 0xFF, 0xAA])).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, vec![0xFF, 0x00, 0x55]);
// Reverse payload
let rev_wat = r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param $len i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $i i32) (local $plen i32) (local $pstart i32)
(if (i32.lt_u (local.get $len) (i32.const 33)) (then return))
(local.set $pstart (i32.add (local.get $ptr) (i32.const 32)))
(local.set $plen (i32.sub (local.get $len) (i32.const 32)))
(local.set $i (i32.const 0))
(block $exit (loop $loop
(br_if $exit (i32.ge_u (local.get $i) (local.get $plen)))
(i32.store8
(i32.add (i32.const 900) (local.get $i))
(i32.load8_u (i32.add (local.get $pstart)
(i32.sub (i32.sub (local.get $plen) (i32.const 1)) (local.get $i)))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
))
(call $send (local.get $ptr) (i32.const 900) (local.get $plen))
)
)"#;
let rev = WasmActorBuilder::new(engine, wat::parse_str(rev_wat).unwrap()).build().unwrap();
let raddr = rt.spawn(rev).unwrap();
rt.send_to(raddr, framed_msg(inbox.addr(), b"abcde")).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"edcba");
}
#[test]
fn guest_multi_send_patterns() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
// Guest sends to 2 different destinations in one handle call
let two_dest_wat = r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param $len i32) (result i32) i32.const 1024)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Layout: [addr1:32][addr2:32][payload:rest]
(if (i32.ge_u (local.get $len) (i32.const 65))
(then
(call $send (local.get $ptr)
(i32.add (local.get $ptr) (i32.const 64))
(i32.sub (local.get $len) (i32.const 64)))
(call $send (i32.add (local.get $ptr) (i32.const 32))
(i32.add (local.get $ptr) (i32.const 64))
(i32.sub (local.get $len) (i32.const 64)))
)
)
)
)"#;
let actor = WasmActorBuilder::new(engine.clone(), wat::parse_str(two_dest_wat).unwrap())
.build().unwrap();
let inbox2 = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let mut msg = Vec::new();
msg.extend_from_slice(&inbox.addr().0); // addr1
msg.extend_from_slice(&inbox2.addr().0); // addr2
msg.extend_from_slice(b"shared"); // payload
rt.send_to(addr, ByteMessage(msg)).unwrap();
rt.tick();
assert_eq!(inbox.try_recv().unwrap().0, b"shared");
assert_eq!(inbox2.try_recv().unwrap().0, b"shared");
// Guest sends 100 messages in one handle call
let many_sends_wat = r#"(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param $len i32) (result i32) i32.const 1024)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $i i32)
(if (i32.lt_u (local.get $len) (i32.const 32)) (then return))
(local.set $i (i32.const 0))
(block $exit (loop $loop
(br_if $exit (i32.ge_u (local.get $i) (i32.const 100)))
(i32.store8 (i32.const 900) (local.get $i))
(call $send (local.get $ptr) (i32.const 900) (i32.const 1))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
))
)
)"#;
let actor2 = WasmActorBuilder::new(engine, wat::parse_str(many_sends_wat).unwrap())
.build().unwrap();
let addr2 = rt.spawn(actor2).unwrap();
rt.send_to(addr2, framed_msg(inbox.addr(), b"go")).unwrap();
rt.tick();
let count: usize = std::iter::from_fn(|| inbox.try_recv()).count();
assert_eq!(count, 100, "all 100 sends should be delivered");
}
// ═══════════════════════════════════════════════════════════════════════════════
// Group 8: Stress
// ═══════════════════════════════════════════════════════════════════════════════
#[test]
fn echo_sustained_load() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
// 500 messages to echo actor
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr = rt.spawn(echo).unwrap();
for i in 0..500u16 {
rt.send_to(addr, framed_msg(inbox.addr(), &i.to_le_bytes())).unwrap();
}
for _ in 0..20 { rt.tick(); }
let count: usize = std::iter::from_fn(|| inbox.try_recv()).count();
assert_eq!(count, 500, "all 500 messages echoed");
// Spawn and stop 100 actors — no panic, no leak
for _ in 0..100 {
let a = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let a_addr = rt.spawn(a).unwrap();
rt.send_to(a_addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
let _ = rt.stop_actor(a_addr);
rt.tick();
}
// Drain inbox
while inbox.try_recv().is_some() {}
}