Crates continued to grow in number, but most are still quite small and feature specific. This refactor consolidates them.
1366 lines
55 KiB
Rust
1366 lines
55 KiB
Rust
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() {}
|
|
}
|