swactor/crates/wasm-actor/tests/wasm_actor.rs
Claude 6aeb289df0 test: Cycle 47 — page boundary exact/OOB, multiple runtimes, full message mirror
- alloc_at_page_boundary_works: alloc + msg = exactly 65536, in bounds
- alloc_one_past_page_boundary_drops: alloc + msg = 65537, OOB drops gracefully
- multiple_runtimes_with_wasm_actors: two separate runtimes with WASM actors
- guest_mirrors_full_message: sends full message (addr+payload) as payload

All 204 tests pass. No new bugs found.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:08:00 +07:00

7281 lines
No EOL
273 KiB
Rust

use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
use swactor_wasm_actor::{ByteMessage, SharedEngine, WasmActor, WasmActorBuilder, WasmActorError};
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)
}
// ── Echo: send bytes in, same bytes come back ────────────────────────────────
#[test]
fn echo_returns_same_payload() {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = b"hello wasm";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("inbox should have a message");
assert_eq!(received.0, payload);
}
#[test]
fn echo_preserves_binary_payload() {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload: Vec<u8> = (0..=255).collect();
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("inbox should have a message");
assert_eq!(received.0, payload);
}
// ── Silent: processes messages without sending anything ───────────────────────
#[test]
fn silent_produces_no_output() {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("silent"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(b"ignored".to_vec())).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "silent guest should not send anything");
}
// ── Double: one message in, two messages out ─────────────────────────────────
#[test]
fn double_sends_two_copies() {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("double"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = b"dup me";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let first = inbox.try_recv().expect("should receive first copy");
let second = inbox.try_recv().expect("should receive second copy");
assert_eq!(first.0, payload);
assert_eq!(second.0, payload);
assert!(inbox.try_recv().is_none(), "exactly two messages expected");
}
// ── Missing export → WasmActorError::MissingExport ───────────────────────────
#[test]
fn missing_alloc_export_returns_error() {
// Minimal valid Wasm module: (module) — no exports at all
let minimal_wasm = wat::parse_str("(module)").unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, minimal_wasm).build();
match result {
Err(WasmActorError::MissingExport(name)) => {
assert!(
name == "memory" || name == "alloc",
"expected missing memory or alloc, got: {name}"
);
}
Err(other) => panic!("expected MissingExport, got: {other}"),
Ok(_) => panic!("expected error for module with no exports"),
}
}
// ── Engine sharing: two actors from the same engine ──────────────────────────
#[test]
fn shared_engine_serves_multiple_actors() {
let engine = SharedEngine::new().unwrap();
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo"))
.build()
.unwrap();
let silent = WasmActorBuilder::new(engine, guest_wasm("silent"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let echo_addr = rt.spawn(echo).unwrap();
let _silent_addr = rt.spawn(silent).unwrap();
let payload = b"shared engine test";
rt.send_to(echo_addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("echo actor should still work");
assert_eq!(received.0, payload);
}
// ── Safety: edge cases that previously caused panics or corruption ────────────
#[test]
fn oob_send_traps_cleanly_and_actor_survives() {
// Guest calls swactor.send with dest_ptr pointing past the end of memory.
// The host should trap the call; the actor should survive for future messages.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0 ;; return start of memory (simplistic)
)
(func (export "handle") (param i32 i32)
;; Call send with dest_ptr = 65536 (1 page = end of memory, OOB for 32 bytes)
i32.const 65536
i32.const 0
i32.const 0
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send a message — handle will try OOB send, which traps
rt.send_to(addr, ByteMessage(vec![42])).unwrap();
rt.tick();
// No message should arrive (the send was invalid)
assert!(inbox.try_recv().is_none(), "OOB send should not produce a message");
}
#[test]
fn alloc_oom_drops_message_actor_stays_alive() {
// Guest alloc always returns 0 (OOM). Message should be dropped,
// actor should remain alive for subsequent messages.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0 ;; always OOM
)
(func (export "handle") (param i32 i32)
;; Should never be called if alloc returned 0 for non-zero len
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Send a non-empty message — alloc returns 0, message should be dropped
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick();
// Actor is still alive — send another message, tick again (no panic)
rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap();
rt.tick();
}
#[test]
fn negative_alloc_ptr_drops_message() {
// Guest alloc returns -1. Host should detect the negative pointer and drop.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const -1 ;; invalid negative pointer
)
(func (export "handle") (param i32 i32))
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick(); // should not panic
// Actor survives
rt.send_to(addr, ByteMessage(vec![2])).unwrap();
rt.tick();
}
#[test]
fn handle_trap_drops_message_actor_survives() {
// Guest handle executes `unreachable`, causing a Wasm trap.
// Message should be dropped, actor should stay alive.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 256 ;; valid allocation
)
(func (export "handle") (param i32 i32)
unreachable ;; trap!
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick(); // handle traps, but actor should survive
// Actor is still alive
rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap();
rt.tick();
}
// ── Bounds safety: alloc pointer near end of linear memory ────────────────────
#[test]
fn alloc_near_end_of_memory_drops_message_actor_survives() {
// Guest alloc returns 65500 (near end of 1-page / 65536-byte memory).
// A 100-byte message means ptr+len = 65600, which exceeds memory bounds.
// The actor should drop the message and survive — same as any other
// allocation failure — rather than being permanently killed.
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 65500 ;; near end of 64KiB memory
)
(func (export "handle") (param i32 i32)
;; should never be reached if bounds check works
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Send a message whose length exceeds the remaining space at ptr 65500
rt.send_to(addr, ByteMessage(vec![0u8; 100])).unwrap();
rt.tick();
// The actor should still be alive — send another message and tick without panic
rt.send_to(addr, ByteMessage(vec![1u8; 10])).unwrap();
rt.tick();
}
// ── Edge cases: empty and oversized messages ─────────────────────────────────
#[test]
fn empty_message_is_handled_without_crash() {
// A zero-length ByteMessage should pass through the alloc/handle pipeline
// without crashing. The echo guest returns nothing (len < 32 guard).
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, ByteMessage(vec![])).unwrap();
rt.tick();
// Echo guest does `if len < 32 { return; }` — so no reply expected
assert!(inbox.try_recv().is_none(), "empty message should produce no reply");
// Actor survives — can still process a real message
let payload = b"still alive";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("actor should still be alive");
assert_eq!(received.0, payload);
}
#[test]
fn message_larger_than_linear_memory_is_dropped() {
// A message of 65537 bytes exceeds the 1-page (64KiB) guest memory.
// Guest alloc will OOM (return 0) → message dropped, actor survives.
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, ByteMessage(vec![0u8; 65537])).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "oversized message should be dropped");
// Actor survives
let payload = b"after oversize";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("actor should survive oversized message");
assert_eq!(received.0, payload);
}
// ── Sustained load: bump allocator exhaustion ────────────────────────────────
#[test]
fn sequential_messages_degrade_gracefully_after_allocator_exhaustion() {
// The echo guest has a 64KiB bump allocator that never frees. Under
// sustained load, alloc eventually returns 0 (OOM) and messages are
// silently dropped. The actor must survive throughout — no panics,
// no poisoning.
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = b"ping";
let mut echoed = 0usize;
// Send enough messages to exhaust the 64KiB heap.
// Each framed message is 36 bytes (32 addr + 4 payload), aligned to 40.
// 65536 / 40 = ~1638, but heap offset within memory varies. Send 2000.
for _ in 0..2000 {
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
if inbox.try_recv().is_some() {
echoed += 1;
}
}
// Some messages were echoed before OOM
assert!(echoed > 0, "should echo at least some messages");
// After OOM, messages were dropped — so not all 2000 echoed
assert!(echoed < 2000, "allocator should exhaust before 2000 messages");
}
// ── Fire-and-forget: guest sends to nonexistent address ──────────────────────
#[test]
fn guest_send_to_nonexistent_address_is_silently_dropped() {
// Guest sends to an all-zero 32-byte address that isn't registered in
// the runtime. The ctx.send() error is silently dropped (fire-and-forget).
// Actor must survive.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
(func (export "handle") (param i32 i32)
;; send to address at offset 0 (all zeros — no such actor)
;; with 1-byte payload at offset 32
i32.const 0 ;; dest_ptr
i32.const 32 ;; payload_ptr
i32.const 1 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![42])).unwrap();
rt.tick(); // guest sends to nonexistent address — should not panic
// Actor survives
rt.send_to(addr, ByteMessage(vec![99])).unwrap();
rt.tick();
}
// ── Builder validation: wrong export signatures ──────────────────────────────
#[test]
fn wrong_handle_signature_is_rejected() {
// Module exports `handle` with wrong signature: (i32) -> i32 instead of (i32, i32) -> ()
// Builder maps get_typed_func errors to MissingExport (signature mismatch = not found).
let wat = r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32) (result i32) i32.const 0)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
assert!(result.is_err(), "should reject wrong handle signature");
}
#[test]
fn wrong_memory_export_name_returns_missing_export() {
// Module has a memory, but exported as "mem" instead of "memory"
let wat = r#"
(module
(memory (export "mem") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
match result {
Err(WasmActorError::MissingExport("memory")) => {} // expected
Err(other) => panic!("expected MissingExport(\"memory\"), got: {other}"),
Ok(_) => panic!("should reject module without 'memory' export"),
}
}
// ── Lifecycle: graceful stop of WASM actor ───────────────────────────────────
#[test]
fn graceful_stop_cleans_up_wasm_actor() {
// After stopping a WASM actor, it should be removed from the runtime.
// The wasmtime Store is dropped cleanly (no leak, no crash).
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();
// Verify the actor works
let payload = b"before stop";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("actor should echo before stop");
assert_eq!(received.0, payload);
// Stop the actor
rt.stop_actor(addr).unwrap();
rt.tick(); // process stop signal
rt.tick(); // cleanup_dead phase
// Actor is gone — send should fail
let result = rt.send_to(addr, ByteMessage(vec![1]));
assert!(result.is_err(), "send to stopped actor should fail");
}
// ── Host import validation: negative payload_len ─────────────────────────────
#[test]
fn negative_payload_len_in_send_traps_actor_survives() {
// Guest calls swactor.send with payload_len = -1. The host import
// should trap (negative argument check), and the actor should survive.
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 i32 i32)
i32.const 0 ;; dest_ptr
i32.const 0 ;; payload_ptr
i32.const -1 ;; payload_len (negative!)
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick(); // guest calls send with negative len — should trap
// Actor survives
rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap();
rt.tick();
}
// ── Independent stores: two echo actors from same engine + bytes ─────────────
#[test]
fn two_echo_actors_from_same_engine_are_independent() {
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let actor_a = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone())
.build()
.unwrap();
let actor_b = WasmActorBuilder::new(engine, wasm_bytes)
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox_a = rt.new_inbox::<ByteMessage>().unwrap();
let inbox_b = rt.new_inbox::<ByteMessage>().unwrap();
let addr_a = rt.spawn(actor_a).unwrap();
let addr_b = rt.spawn(actor_b).unwrap();
// Send different payloads to each
rt.send_to(addr_a, framed_msg(inbox_a.addr(), b"for-a")).unwrap();
rt.send_to(addr_b, framed_msg(inbox_b.addr(), b"for-b")).unwrap();
rt.tick();
let recv_a = inbox_a.try_recv().expect("actor A should echo");
let recv_b = inbox_b.try_recv().expect("actor B should echo");
assert_eq!(recv_a.0, b"for-a");
assert_eq!(recv_b.0, b"for-b");
// Cross-check: no leakage between actors
assert!(inbox_a.try_recv().is_none());
assert!(inbox_b.try_recv().is_none());
}
// ── Watch integration: native watcher observes WASM actor death ──────────────
struct ExitWatcher {
exit_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
last_reason: std::sync::Arc<std::sync::Mutex<Option<swactor::actor::ExitReason>>>,
}
#[derive(Clone)]
struct WatchThis(ActorAddress);
impl ActorInterface for ExitWatcher {
type Incoming = WatchThis;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: WatchThis) {
ctx.watch(msg.0);
}
fn on_actor_exit(&mut self, _ctx: &Ctx, exited: swactor::actor::ActorExited) {
self.exit_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
*self.last_reason.lock().unwrap() = Some(exited.reason);
}
}
#[test]
fn native_watcher_notified_when_wasm_actor_stops() {
// A native actor watches a WASM actor. When the WASM actor is stopped,
// the watcher should receive ActorExited with ExitReason::Stopped.
let engine = SharedEngine::new().unwrap();
let wasm = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let exit_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let last_reason = std::sync::Arc::new(std::sync::Mutex::new(None));
let watcher = ExitWatcher {
exit_count: exit_count.clone(),
last_reason: last_reason.clone(),
};
let wasm_addr = rt.spawn(wasm).unwrap();
let watcher_addr = rt.spawn(watcher).unwrap();
// Tell watcher to watch the WASM actor
rt.send_to(watcher_addr, WatchThis(wasm_addr)).unwrap();
for _ in 0..3 { rt.tick(); }
// Stop the WASM actor
rt.stop_actor(wasm_addr).unwrap();
for _ in 0..5 { rt.tick(); }
assert_eq!(exit_count.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(
*last_reason.lock().unwrap(),
Some(swactor::actor::ExitReason::Stopped)
);
}
// ── WASM-to-WASM: two WASM actors communicating ─────────────────────────────
#[test]
fn wasm_to_wasm_message_relay() {
// Echo A echoes to Echo B's address, Echo B echoes to an external inbox.
// This verifies the full WASM→runtime→WASM→runtime→inbox path.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let actor_a = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone())
.build()
.unwrap();
let actor_b = WasmActorBuilder::new(engine, wasm_bytes)
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr_a = rt.spawn(actor_a).unwrap();
let addr_b = rt.spawn(actor_b).unwrap();
// Send to actor A: "echo your payload to actor B"
// Actor A receives [addr_b | payload_for_b]
// Actor A echoes payload_for_b to addr_b
// payload_for_b itself is [inbox_addr | final_payload]
// Actor B receives [inbox_addr | final_payload]
// Actor B echoes final_payload to inbox
let final_payload = b"relayed";
let payload_for_b = framed_msg(inbox.addr(), final_payload);
let msg_for_a = framed_msg(&addr_b, &payload_for_b.0);
rt.send_to(addr_a, msg_for_a).unwrap();
rt.tick(); // A receives, echoes to B
rt.tick(); // B receives, echoes to inbox
let received = inbox.try_recv().expect("should receive relayed message");
assert_eq!(received.0, final_payload);
}
// ── Integration: WasmActor alongside a native Rust actor ─────────────────────
#[derive(Clone)]
struct ForwardToWasm {
wasm_addr: ActorAddress,
inbox_addr: ActorAddress,
}
struct Forwarder;
impl ActorInterface for Forwarder {
type Incoming = ForwardToWasm;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: ForwardToWasm) {
// Build the framed message and forward to the wasm actor
let payload = b"from native";
let framed = framed_msg(&msg.inbox_addr, payload);
let _ = ctx.send(msg.wasm_addr, framed);
}
}
#[test]
fn native_actor_communicates_with_wasm_actor() {
let engine = SharedEngine::new().unwrap();
let wasm = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let wasm_addr = rt.spawn(wasm).unwrap();
let forwarder_addr = rt.spawn(Forwarder).unwrap();
rt.send_to(
forwarder_addr,
ForwardToWasm {
wasm_addr,
inbox_addr: *inbox.addr(),
},
)
.unwrap();
// Tick 1: Forwarder receives message and sends to WasmActor
rt.tick();
// Tick 2: WasmActor receives the forwarded message and echoes to inbox
rt.tick();
let received = inbox.try_recv().expect("wasm actor should have echoed");
assert_eq!(received.0, b"from native");
}
// ── Stale outbox: sends before trap leak into next handle ─────────────────────
#[test]
fn outbox_entries_from_trapped_handle_do_not_leak_into_next_call() {
// A guest that calls swactor.send() successfully, then traps.
// The outbox contains the send from before the trap.
// On the next handle call (which succeeds without sending), the stale
// outbox entry should NOT be delivered.
//
// Counter incremented BEFORE the if-branch so it persists past the trap.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $counter (mut i32) (i32.const 0))
(func (export "alloc") (param i32) (result i32)
i32.const 256
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Increment counter first (survives trap)
global.get $counter
i32.const 1
i32.add
global.set $counter
;; If counter was 0 (now 1): send then trap
global.get $counter
i32.const 1
i32.eq
if
local.get $ptr ;; dest_ptr (first 32 bytes = inbox address)
i32.const 32 ;; payload_ptr
i32.const 1 ;; payload_len
call $send
unreachable ;; trap after send
end
;; counter > 1: do nothing (no send)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// First message: guest sends to outbox then traps — stale entry in outbox
// Use framed_msg so the first 32 bytes are the inbox address
rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
// No message should have been delivered (handle trapped before outbox drain)
assert!(inbox.try_recv().is_none(), "trapped handle should not deliver messages");
// Second message: guest does nothing (counter=2, no send, no trap).
// If the outbox wasn't cleared, the stale entry would be drained here.
rt.send_to(addr, framed_msg(inbox.addr(), b"y")).unwrap();
rt.tick();
// Should still be empty — the stale outbox entry must not leak
assert!(
inbox.try_recv().is_none(),
"stale outbox entry from trapped call should not leak into next handle"
);
}
// ── Builder validation: invalid WASM bytes ───────────────────────────────────
#[test]
fn invalid_wasm_bytes_returns_wasmtime_error() {
let garbage = vec![0u8, 1, 2, 3]; // not valid wasm
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, garbage).build();
match result {
Err(WasmActorError::Wasmtime(_)) => {} // expected — compilation failure
Err(other) => panic!("expected Wasmtime error for invalid bytes, got: {other}"),
Ok(_) => panic!("should reject invalid wasm bytes"),
}
}
// ── Guest sends zero-length payload ──────────────────────────────────────────
#[test]
fn guest_send_with_zero_length_payload_delivers_empty_message() {
// Guest calls swactor.send with payload_len=0. This should produce
// a ByteMessage(vec![]) at the destination.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 256 ;; valid allocation
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Send with the first 32 bytes as dest, zero-length payload
local.get $ptr
i32.const 32 ;; payload_ptr (doesn't matter, len is 0)
i32.const 0 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Build a framed message with the inbox address as the first 32 bytes
let msg = framed_msg(inbox.addr(), b"ignored-payload");
rt.send_to(addr, msg).unwrap();
rt.tick();
let received = inbox.try_recv().expect("should receive zero-length message");
assert!(received.0.is_empty(), "payload should be empty");
}
// ── Multiple sequential traps: actor survives repeated failures ──────────────
#[test]
fn actor_survives_multiple_sequential_traps() {
// After 3 consecutive traps, the actor should still be alive and
// able to process a non-trapping message.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $counter (mut i32) (i32.const 0))
(func (export "alloc") (param i32) (result i32)
i32.const 256
)
(func (export "handle") (param $ptr i32) (param $len i32)
global.get $counter
i32.const 3
i32.lt_u
if
;; First 3 calls: trap
global.get $counter
i32.const 1
i32.add
global.set $counter
unreachable
end
;; 4th+ call: echo the message back using first 32 bytes as dest
local.get $ptr
local.get $ptr
i32.const 32
i32.add
local.get $len
i32.const 32
i32.sub
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// 3 trapping messages
for _ in 0..3 {
rt.send_to(addr, framed_msg(inbox.addr(), b"will trap")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "trapped call should produce nothing");
}
// 4th message: should succeed
let payload = b"survived";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("actor should work after multiple traps");
assert_eq!(received.0, payload);
}
// ── Builder: module with start function that traps ───────────────────────────
#[test]
fn module_with_trapping_start_function_returns_error() {
// WASM modules can have a (start) function that runs during instantiation.
// If it traps, build() should return an error.
let wat = r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param i32 i32))
(func $init unreachable)
(start $init)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
assert!(result.is_err(), "module with trapping start function should fail to build");
}
// ── Self-send: guest sends message back to own address ───────────────────────
#[test]
fn guest_self_send_creates_feedback_loop() {
// Echo guest sends its payload to a destination. If we set the dest
// to the actor's OWN address, it creates a feedback loop. The actor
// should process the self-sent message on the next tick.
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();
// Frame: dest=self, payload=[inbox_addr | "hello"]
// Echo will send [inbox_addr | "hello"] back to itself.
// On next tick, it receives [inbox_addr | "hello"], echoes "hello" to inbox.
let inner_msg = framed_msg(inbox.addr(), b"hello");
let self_msg = framed_msg(&addr, &inner_msg.0);
rt.send_to(addr, self_msg).unwrap();
rt.tick(); // actor echoes inner_msg to self
rt.tick(); // actor receives inner_msg, echoes "hello" to inbox
let received = inbox.try_recv().expect("should receive after self-send loop");
assert_eq!(received.0, b"hello");
}
// ── Amplification: guest sends many messages in one handle ───────────────────
#[test]
fn guest_sending_many_messages_in_one_handle_all_delivered() {
// A guest that calls swactor.send N times in a single handle call.
// All N messages should be delivered via the outbox drain.
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 10 messages, each with 0-byte payload
;; dest_ptr = $ptr (first 32 bytes of the incoming message)
(local $i i32)
(local.set $i (i32.const 0))
(block $break
(loop $loop
(br_if $break (i32.ge_u (local.get $i) (i32.const 10)))
(call $send (local.get $ptr) (i32.const 32) (i32.const 0))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
)
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
let mut count = 0;
while inbox.try_recv().is_some() {
count += 1;
}
assert_eq!(count, 10, "guest should have sent exactly 10 messages");
}
// ── Overlapping send regions: dest_ptr and payload_ptr overlap ───────────────
#[test]
fn overlapping_dest_and_payload_in_send_works() {
// Guest calls send with dest_ptr=0, payload_ptr=16, payload_len=32.
// The dest region [0..32] and payload region [16..48] overlap.
// Both are read-only in the host, so this should work without corruption.
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)
;; Overlapping regions
local.get $ptr ;; dest_ptr (first 32 bytes of message)
local.get $ptr
i32.const 16
i32.add ;; payload_ptr = ptr + 16 (overlaps with dest)
i32.const 32 ;; payload_len = 32
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Build a message where bytes [0..32] = inbox addr, [32..] = payload
// Guest reads dest from [0..32] (inbox addr) and payload from [16..48]
rt.send_to(addr, framed_msg(inbox.addr(), b"overlap-test-padding!")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("overlapping send should deliver");
assert_eq!(received.0.len(), 32, "payload should be 32 bytes from overlapping region");
}
// ── Type mismatch: non-ByteMessage sent to WASM actor ────────────────────────
#[test]
fn non_byte_message_to_wasm_actor_is_silently_ignored() {
// Sending a message of the wrong type (not ByteMessage) to a WASM actor.
// The runtime's handle_any downcast fails, counting a type mismatch.
// The actor should survive and still process valid ByteMessages.
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 wrong type — u32 instead of ByteMessage
// This goes through send_any with Box::new(42u32), downcast to ByteMessage fails.
rt.send_to(addr, 42u32).unwrap();
rt.tick(); // type mismatch — silently ignored
// Actor still alive — send a valid message
let payload = b"after mismatch";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("actor should work after type mismatch");
assert_eq!(received.0, payload);
}
// ── Guest state persistence: mutable global survives across messages ─────────
#[test]
fn guest_mutable_state_persists_across_messages() {
// A guest module with a mutable global counter. Each handle call increments
// the counter and includes it in the reply payload. Verifies that the
// wasmtime Store and linear memory persist between handle() calls.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $counter (mut i32) (i32.const 0))
(func (export "alloc") (param i32) (result i32)
i32.const 256
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Increment counter
global.get $counter
i32.const 1
i32.add
global.set $counter
;; Write counter value to memory at offset 200
(i32.store8 (i32.const 200) (global.get $counter))
;; Send counter byte as payload to dest at $ptr
local.get $ptr ;; dest_ptr (first 32 bytes of message)
i32.const 200 ;; payload_ptr (counter byte)
i32.const 1 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send 3 messages, each should get an incrementing counter
for expected in 1..=3u8 {
rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("should receive counter reply");
assert_eq!(received.0, vec![expected], "counter should increment per message");
}
}
// ── Spawn WASM from handler: native actor spawns WASM actor during handle ────
struct WasmSpawner {
engine: SharedEngine,
wasm_bytes: Vec<u8>,
}
#[derive(Clone)]
struct SpawnAndForward {
inbox_addr: ActorAddress,
payload: Vec<u8>,
}
impl ActorInterface for WasmSpawner {
type Incoming = SpawnAndForward;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: SpawnAndForward) {
let actor = WasmActorBuilder::new(self.engine.clone(), self.wasm_bytes.clone())
.build()
.unwrap();
let wasm_addr = ctx.spawn(actor);
let _ = ctx.send(wasm_addr.unwrap(), framed_msg(&msg.inbox_addr, &msg.payload));
}
}
#[test]
fn native_handler_spawns_wasm_actor_and_forwards_message() {
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let spawner = WasmSpawner {
engine: engine.clone(),
wasm_bytes: wasm_bytes.clone(),
};
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let spawner_addr = rt.spawn(spawner).unwrap();
rt.send_to(
spawner_addr,
SpawnAndForward {
inbox_addr: *inbox.addr(),
payload: b"spawned-echo".to_vec(),
},
)
.unwrap();
// Tick 1: Spawner receives message, spawns WASM actor, sends to it
rt.tick();
// Tick 2: WASM actor processes message and echoes to inbox
rt.tick();
let received = inbox.try_recv().expect("dynamically spawned WASM actor should echo");
assert_eq!(received.0, b"spawned-echo");
}
// ── Bounded mailbox: WASM actor with backpressure ────────────────────────────
#[test]
fn bounded_mailbox_applies_to_wasm_actor() {
// With a bounded mailbox of capacity 3, sending 10 messages should
// result in only 3 being processed (DropNewest policy).
use swactor::runtime::MailboxOverflow;
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let config = RuntimeConfig {
default_mailbox_capacity: 3,
mailbox_overflow: MailboxOverflow::DropNewest,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send 10 messages before any tick — only first 3 should be kept
for i in 0u8..10 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
assert_eq!(received.len(), 3, "bounded mailbox should limit to 3 messages");
// DropNewest keeps the first 3 sent
assert_eq!(received, vec![0, 1, 2]);
}
// ── Property: alloc failures never kill the actor ────────────────────────────
proptest! {
#[test]
fn prop_any_alloc_return_value_never_kills_actor(alloc_val in -100i32..70000) {
// Regardless of what alloc returns (negative, zero, OOB, valid),
// sending a message should never kill the actor.
let alloc_const = format!("i32.const {alloc_val}");
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)
{alloc_const}
)
(func (export "handle") (param i32 i32))
)
"#);
let wasm = wat::parse_str(&wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Send a message — should never panic or poison
rt.send_to(addr, ByteMessage(vec![0u8; 100])).unwrap();
rt.tick();
// Actor should still accept messages (not poisoned)
let result = rt.send_to(addr, ByteMessage(vec![1]));
prop_assert!(result.is_ok(), "actor should survive any alloc return value: {alloc_val}");
}
}
// ── Multi-worker: WASM actors across threads ─────────────────────────────────
#[test]
fn wasm_actor_works_on_multi_worker_runtime() {
// Spawn a WASM echo actor on a 2-worker runtime and verify message
// round-trip works across threads. This is a smoke test for Send safety
// of wasmtime Store<HostState>.
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let config = RuntimeConfig {
num_threads: 2,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = b"multi-worker";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
// Use run() to drive the runtime on background threads
let handle = rt.run().unwrap();
// Poll with retries — MT runtime timing is non-deterministic
let mut received = None;
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(25));
if let Some(msg) = inbox.try_recv() {
received = Some(msg);
break;
}
}
let received = received.expect("wasm actor should echo on MT runtime");
assert_eq!(received.0, payload);
handle.shutdown();
}
// ── Property-based: arbitrary bytes round-trip through echo ──────────────────
proptest! {
#[test]
fn prop_echo_roundtrips_arbitrary_bytes(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();
let msg = framed_msg(inbox.addr(), &payload);
rt.send_to(addr, msg).unwrap();
rt.tick();
if payload.is_empty() {
// Echo guest: if total len < 32, no reply (32B addr + 0B payload = 32, but
// the framed message is 32 + 0 = 32 bytes, and echo checks `len < 32`)
// Actually: framed_msg produces 32 + payload.len() bytes. When payload
// is empty, total is 32, and echo checks `if len < 32 { return; }`.
// len == 32 passes the check! So dest_ptr = ptr, payload_ptr = ptr+32,
// payload_len = 0 → sends a 0-byte message.
// Let's just check: if we got something, it matches.
if let Some(received) = inbox.try_recv() {
prop_assert_eq!(received.0, payload);
}
} else {
let received = inbox.try_recv().expect("echo should return non-empty payload");
prop_assert_eq!(received.0, payload);
}
}
#[test]
fn prop_double_always_sends_exactly_two_copies(payload in proptest::collection::vec(any::<u8>(), 1..500)) {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("double"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let msg = framed_msg(inbox.addr(), &payload);
rt.send_to(addr, msg).unwrap();
rt.tick();
let first = inbox.try_recv().expect("double should send first copy");
let second = inbox.try_recv().expect("double should send second copy");
prop_assert_eq!(&first.0, &payload);
prop_assert_eq!(&second.0, &payload);
prop_assert!(inbox.try_recv().is_none(), "exactly two messages expected");
}
}
// ── Alloc trap: unreachable in alloc, store must recover ─────────────────────
#[test]
fn alloc_traps_actor_survives_and_processes_next_message() {
// Guest alloc traps on first call (counter=0), succeeds on subsequent calls.
// The store must remain in a valid state after the alloc trap.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $counter (mut i32) (i32.const 0))
(func (export "alloc") (param $size i32) (result i32)
global.get $counter
i32.const 1
i32.add
global.set $counter
;; First call: trap
global.get $counter
i32.const 1
i32.eq
if
unreachable
end
;; Subsequent calls: return valid pointer
i32.const 256
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Echo: send payload back to dest in first 32 bytes
local.get $ptr
local.get $ptr
i32.const 32
i32.add
local.get $len
i32.const 32
i32.sub
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// First message: alloc traps → message dropped, no reply
rt.send_to(addr, framed_msg(inbox.addr(), b"trap-in-alloc")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "alloc trap should drop message");
// Second message: alloc succeeds → echo should work
let payload = b"after-alloc-trap";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("actor should recover after alloc trap");
assert_eq!(received.0, payload);
}
// ── memory.grow during handle: guest expands memory, sends from new region ───
#[test]
fn memory_grow_during_handle_does_not_break_actor() {
// Guest grows memory by 1 page during handle, then writes a value
// into the new region and sends it. Verifies the host's Memory
// handle tracks the new size.
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)
;; Grow memory by 1 page (64KiB → 128KiB)
(drop (memory.grow (i32.const 1)))
;; Write marker byte into new page (offset 65536+100 = 65636)
(i32.store8 (i32.const 65636) (i32.const 42))
;; Send: dest from first 32 bytes, payload from new region
local.get $ptr ;; dest_ptr
i32.const 65636 ;; payload_ptr (in grown region)
i32.const 1 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"grow-test")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("should receive from grown memory region");
assert_eq!(received.0, vec![42], "payload should be the marker byte from new page");
}
// ── send overflow: dest_ptr near i32::MAX triggers checked_add overflow ──────
#[test]
fn send_with_dest_ptr_overflow_traps_actor_survives() {
// Guest calls send with dest_ptr = i32::MAX (2147483647).
// The host's checked_add(32) overflows → trap. Actor should survive.
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 i32 i32)
i32.const 2147483647 ;; dest_ptr = i32::MAX
i32.const 0 ;; payload_ptr
i32.const 0 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick(); // send traps due to overflow — actor should survive
// Verify actor is still alive
rt.send_to(addr, ByteMessage(vec![2])).unwrap();
rt.tick();
}
// ── Exact-fit allocation: ptr + len == memory size ───────────────────────────
#[test]
fn exact_fit_allocation_at_memory_boundary_succeeds() {
// alloc returns 65536 - 10 = 65526. With a 10-byte message, the write
// region is [65526..65536] — exactly fitting in 1 page. Should succeed.
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 65526 ;; 65536 - 10 = exact fit for 10-byte message
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Just echo: send everything back. But since alloc returns
;; 65526, the message was written to [65526..65536]. We need
;; to send from there. Use a fixed dest from offset 0 (zeroes).
;; Actually, the message was copied to ptr=65526 by the host.
;; We need the first 32 bytes as dest, but our message is only
;; 10 bytes. So handle gets (ptr=65526, len=10). With len < 32,
;; the echo guest would skip it. Let's just verify handle was
;; called by sending a known byte from offset 200.
(i32.store8 (i32.const 200) (i32.const 99))
;; We can't easily echo from this offset, but we can verify
;; the handle was reached by using a global flag read in a
;; subsequent call.
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Send exactly 10 bytes — fits perfectly at ptr=65526
rt.send_to(addr, ByteMessage(vec![0u8; 10])).unwrap();
rt.tick(); // should NOT trigger OOB — exact fit
// Actor survives — the bounds check passed
rt.send_to(addr, ByteMessage(vec![1u8; 10])).unwrap();
rt.tick();
}
// ── Off-by-one: alloc returns exactly memory size ────────────────────────────
#[test]
fn alloc_returns_exactly_memory_size_drops_message() {
// alloc returns 65536 (exactly the size of 1-page memory).
// Any non-zero length message means end > mem.len(), so it should be dropped.
// For a zero-length message, ptr=65536, end=65536, which equals mem.len()
// so end > mem.len() is false — that path technically works (no-op write).
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 65536 ;; exactly at memory boundary
)
(func (export "handle") (param i32 i32))
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Non-zero message: end = 65536 + 5 = 65541 > 65536 → dropped
rt.send_to(addr, ByteMessage(vec![0u8; 5])).unwrap();
rt.tick();
// Actor survives
rt.send_to(addr, ByteMessage(vec![1u8; 5])).unwrap();
rt.tick();
}
// ── Lifecycle: spawn and immediately stop without processing messages ────────
#[test]
fn spawn_and_stop_without_messages_is_clean() {
// WASM actor spawned, immediately stopped, never processes a message.
// The wasmtime Store should be dropped cleanly.
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Stop immediately, no messages sent
rt.stop_actor(addr).unwrap();
rt.tick(); // process stop
rt.tick(); // cleanup_dead
// Actor is gone
let result = rt.send_to(addr, ByteMessage(vec![1]));
assert!(result.is_err(), "stopped actor should reject messages");
}
// ── 3-hop relay: WASM A → WASM B → WASM C → inbox ──────────────────────────
#[test]
fn three_hop_wasm_relay_delivers_final_payload() {
// Three echo actors in sequence: A echoes to B, B echoes to C, C echoes to inbox.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let actor_a = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone()).build().unwrap();
let actor_b = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone()).build().unwrap();
let actor_c = WasmActorBuilder::new(engine, wasm_bytes).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr_a = rt.spawn(actor_a).unwrap();
let addr_b = rt.spawn(actor_b).unwrap();
let addr_c = rt.spawn(actor_c).unwrap();
// Build nested framed message: A sends to B, B sends to C, C sends to inbox
let final_payload = b"3-hops";
let msg_for_c = framed_msg(inbox.addr(), final_payload);
let msg_for_b = framed_msg(&addr_c, &msg_for_c.0);
let msg_for_a = framed_msg(&addr_b, &msg_for_b.0);
rt.send_to(addr_a, msg_for_a).unwrap();
rt.tick(); // A → B
rt.tick(); // B → C
rt.tick(); // C → inbox
let received = inbox.try_recv().expect("3-hop relay should deliver");
assert_eq!(received.0, final_payload);
}
// ── memory.grow exhaustion: guest grows until failure ────────────────────────
#[test]
fn memory_grow_until_failure_actor_survives() {
// Guest calls memory.grow repeatedly until it returns -1 (failure).
// The actor should survive and the send should still work using
// memory from before the failed grow.
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)
(local $result i32)
;; Grow memory repeatedly until failure
(block $done
(loop $grow
(local.set $result (memory.grow (i32.const 100)))
(br_if $done (i32.eq (local.get $result) (i32.const -1)))
(br $grow)
)
)
;; After grow failure, write marker and send from original page
(i32.store8 (i32.const 200) (i32.const 77))
local.get $ptr ;; dest_ptr
i32.const 200 ;; payload_ptr
i32.const 1 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"grow-exhaust")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("actor should work after grow exhaustion");
assert_eq!(received.0, vec![77]);
}
// ── Stress: many WASM actors in a chain ──────────────────────────────────────
#[test]
fn ten_wasm_actors_chain_relay() {
// 10 echo actors in a chain: actor[0]→actor[1]→...→actor[9]→inbox.
// Tests that many WASM actors coexist and messages propagate through them.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let mut addrs = Vec::new();
for _ in 0..10 {
let actor = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone())
.build()
.unwrap();
addrs.push(rt.spawn(actor).unwrap());
}
// Build nested framed message from the inside out:
// actor[9] receives [inbox_addr | final_payload] → echoes final_payload to inbox
// actor[8] receives [addr[9] | msg_for_9] → echoes msg_for_9 to actor[9]
// ...
// actor[0] receives [addr[1] | msg_for_1] → echoes msg_for_1 to actor[1]
let final_payload = b"chain-10";
let mut msg = framed_msg(inbox.addr(), final_payload);
for addr in addrs[1..].iter().rev() {
msg = framed_msg(addr, &msg.0);
}
rt.send_to(addrs[0], msg).unwrap();
for _ in 0..10 {
rt.tick();
}
let received = inbox.try_recv().expect("10-actor chain should deliver");
assert_eq!(received.0, final_payload);
}
// ── send payload overflow: payload_ptr + payload_len wraps ──────────────────
#[test]
fn send_with_payload_range_overflow_traps_actor_survives() {
// Guest calls send with payload_ptr=1, payload_len=i32::MAX.
// checked_add(payload_len) overflows → trap. Actor must survive.
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 i32 i32)
i32.const 0 ;; dest_ptr (valid)
i32.const 1 ;; payload_ptr
i32.const 2147483647 ;; payload_len = i32::MAX → overflow
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick();
// Actor survives — send another
rt.send_to(addr, ByteMessage(vec![2])).unwrap();
rt.tick();
}
// ── send with payload at exact memory end ───────────────────────────────────
#[test]
fn send_payload_at_exact_memory_end_works() {
// Guest writes a byte at offset 65535 (last byte of 1-page memory) and
// sends it as a 1-byte payload. payload_end = 65535 + 1 = 65536 == mem_len.
// This should succeed (not exceed bounds).
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)
;; Write marker at last byte
(i32.store8 (i32.const 65535) (i32.const 88))
;; Send: dest from message, payload = last byte of memory
local.get $ptr ;; dest_ptr
i32.const 65535 ;; payload_ptr (last byte)
i32.const 1 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("exact-end payload should succeed");
assert_eq!(received.0, vec![88]);
}
// ── Multi-worker: two WASM actors cross-thread messaging ────────────────────
#[test]
fn wasm_actors_communicate_across_threads() {
// Two WASM echo actors on a 2-worker runtime. Actor A echoes to Actor B,
// Actor B echoes to inbox. Verifies cross-thread WASM messaging.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let actor_a = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone()).build().unwrap();
let actor_b = WasmActorBuilder::new(engine, wasm_bytes).build().unwrap();
let config = RuntimeConfig {
num_threads: 2,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr_a = rt.spawn(actor_a).unwrap();
let addr_b = rt.spawn(actor_b).unwrap();
// A receives [addr_b | [inbox_addr | "cross-thread"]]
// A echoes [inbox_addr | "cross-thread"] to B
// B echoes "cross-thread" to inbox
let final_payload = b"cross-thread";
let msg_for_b = framed_msg(inbox.addr(), final_payload);
let msg_for_a = framed_msg(&addr_b, &msg_for_b.0);
rt.send_to(addr_a, msg_for_a).unwrap();
let handle = rt.run().unwrap();
// Poll with retries — MT runtime timing is non-deterministic
let mut received = None;
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(25));
if let Some(msg) = inbox.try_recv() {
received = Some(msg);
break;
}
}
let received = received.expect("cross-thread relay should deliver");
assert_eq!(received.0, final_payload);
handle.shutdown();
}
// ── Property: any send arguments never crash the host ────────────────────────
proptest! {
#[test]
fn prop_any_send_args_never_crash_host(
dest_ptr in -100i32..70000,
payload_ptr in -100i32..70000,
payload_len in -100i32..70000,
) {
// Regardless of what arguments the guest passes to swactor.send,
// the host import should either succeed or trap — never panic.
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
)
)
"#);
let wasm = wat::parse_str(&wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Should never panic regardless of send args
rt.send_to(addr, ByteMessage(vec![0u8; 64])).unwrap();
rt.tick();
// Actor should still accept messages (not poisoned)
let result = rt.send_to(addr, ByteMessage(vec![1]));
prop_assert!(result.is_ok(), "actor must survive any send args: dest={dest_ptr} payload_ptr={payload_ptr} len={payload_len}");
}
}
// ── Guest sends to two different destinations in one handle ──────────────────
#[test]
fn guest_sends_to_two_destinations_both_delivered() {
// Guest calls send twice with different destinations.
// Both messages should be delivered in order.
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)
;; First send: dest from bytes [0..32], 1-byte payload "A" at offset 200
(i32.store8 (i32.const 200) (i32.const 65)) ;; 'A'
local.get $ptr
i32.const 200
i32.const 1
call $send
;; Second send: dest from bytes [32..64], 1-byte payload "B" at offset 201
(i32.store8 (i32.const 201) (i32.const 66)) ;; 'B'
local.get $ptr
i32.const 32
i32.add ;; second dest address at offset 32
i32.const 201
i32.const 1
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox_a = rt.new_inbox::<ByteMessage>().unwrap();
let inbox_b = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Build message with TWO destination addresses: [inbox_a_addr | inbox_b_addr | ...]
let mut msg_bytes = Vec::new();
msg_bytes.extend_from_slice(&inbox_a.addr().0);
msg_bytes.extend_from_slice(&inbox_b.addr().0);
msg_bytes.extend_from_slice(b"extra-padding");
rt.send_to(addr, ByteMessage(msg_bytes)).unwrap();
rt.tick();
let recv_a = inbox_a.try_recv().expect("inbox_a should receive");
assert_eq!(recv_a.0, b"A");
let recv_b = inbox_b.try_recv().expect("inbox_b should receive");
assert_eq!(recv_b.0, b"B");
}
// ── Guest overwrites memory after send — outbox should have a copy ──────────
#[test]
fn guest_overwriting_memory_after_send_does_not_corrupt_outbox() {
// Guest calls send (which copies data into outbox), then overwrites
// the same memory region. The outbox entry should be unaffected.
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)
;; Write "OK" at offset 200-201
(i32.store8 (i32.const 200) (i32.const 79)) ;; 'O'
(i32.store8 (i32.const 201) (i32.const 75)) ;; 'K'
;; Send payload from [200..202]
local.get $ptr
i32.const 200
i32.const 2
call $send
;; Now overwrite those bytes with "XX"
(i32.store8 (i32.const 200) (i32.const 88)) ;; 'X'
(i32.store8 (i32.const 201) (i32.const 88)) ;; 'X'
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("should receive original payload");
assert_eq!(received.0, b"OK", "outbox should have copy, not overwritten data");
}
// ── Large payload: near-capacity message through full pipeline ──────────────
#[test]
fn large_payload_near_memory_capacity() {
// Send a 60000-byte payload through the echo pipeline. This is close
// to the 64KiB memory limit. The bump allocator needs enough space.
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();
// 32-byte address + payload must fit in alloc. Echo guest starts alloc
// at offset 1024, so we have 64512 bytes. 32 + payload must be ≤ 64512.
// Use a 1000-byte payload (well within limits) for a realistic large message.
let payload: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("large payload should echo");
assert_eq!(received.0, payload);
}
// ── alloc grows memory, returns pointer in new region ───────────────────────
#[test]
fn alloc_that_grows_memory_works() {
// alloc calls memory.grow before returning a pointer in the new region.
// The host's bounds check uses memory.data_mut() AFTER alloc returns,
// so it should see the grown memory.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1) ;; starts with 1 page (65536 bytes)
(func (export "alloc") (param $size i32) (result i32)
;; Grow memory by 1 page, return pointer in the new region
(drop (memory.grow (i32.const 1)))
i32.const 65536 ;; start of new page
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Echo: send payload back to dest
local.get $ptr
local.get $ptr
i32.const 32
i32.add
local.get $len
i32.const 32
i32.sub
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = b"grown-alloc";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("alloc in grown region should work");
assert_eq!(received.0, payload);
}
// ── Message budget fairness: WASM actor processes only its budget ────────────
#[test]
fn wasm_actor_respects_message_budget() {
// With actor_message_budget=2, sending 5 messages should process at most
// 2 per tick. This verifies the budget applies to WASM actors too.
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let config = RuntimeConfig {
actor_message_budget: 2,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send 5 messages
for i in 0u8..5 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
// First tick: should process at most 2
rt.tick();
let mut count_tick1 = 0;
while inbox.try_recv().is_some() {
count_tick1 += 1;
}
assert_eq!(count_tick1, 2, "first tick should process exactly budget=2 messages");
// Second tick: another 2
rt.tick();
let mut count_tick2 = 0;
while inbox.try_recv().is_some() {
count_tick2 += 1;
}
assert_eq!(count_tick2, 2, "second tick should process next 2 messages");
// Third tick: remaining 1
rt.tick();
let mut count_tick3 = 0;
while inbox.try_recv().is_some() {
count_tick3 += 1;
}
assert_eq!(count_tick3, 1, "third tick should process remaining 1 message");
}
// ── Data segment: guest module with pre-initialized memory ──────────────────
#[test]
fn guest_with_data_segment_handles_messages_correctly() {
// A guest module with a data segment that pre-fills bytes at offset 0.
// The host writes the incoming message starting at the alloc pointer (256),
// which shouldn't conflict with the data segment.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
;; Pre-fill offset 200-203 with "DATA"
(data (i32.const 200) "DATA")
(func (export "alloc") (param i32) (result i32)
i32.const 256 ;; alloc above data segment
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Send the pre-initialized data as payload
local.get $ptr ;; dest_ptr
i32.const 200 ;; payload_ptr (data segment)
i32.const 4 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"trigger")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("should receive data segment content");
assert_eq!(received.0, b"DATA");
}
// ── Outbox isolation: two actors' outboxes don't interfere ──────────────────
#[test]
fn two_wasm_actors_outboxes_are_isolated() {
// Two WASM actors process messages in the same tick. Their outbox
// entries should not mix. Each Store has its own HostState.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let actor_a = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone()).build().unwrap();
let actor_b = WasmActorBuilder::new(engine, wasm_bytes).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox_a = rt.new_inbox::<ByteMessage>().unwrap();
let inbox_b = rt.new_inbox::<ByteMessage>().unwrap();
let addr_a = rt.spawn(actor_a).unwrap();
let addr_b = rt.spawn(actor_b).unwrap();
// Send to both in same tick
rt.send_to(addr_a, framed_msg(inbox_a.addr(), b"msg-A")).unwrap();
rt.send_to(addr_b, framed_msg(inbox_b.addr(), b"msg-B")).unwrap();
rt.tick();
let recv_a = inbox_a.try_recv().expect("actor A should echo");
let recv_b = inbox_b.try_recv().expect("actor B should echo");
assert_eq!(recv_a.0, b"msg-A");
assert_eq!(recv_b.0, b"msg-B");
// No cross-contamination
assert!(inbox_a.try_recv().is_none(), "inbox_a should have exactly 1 message");
assert!(inbox_b.try_recv().is_none(), "inbox_b should have exactly 1 message");
}
// ── Property: combined alloc + handle stress never crashes ──────────────────
proptest! {
#[test]
fn prop_random_module_behavior_never_crashes(
alloc_val in -100i32..70000,
trap_handle in proptest::bool::ANY,
send_before_trap in proptest::bool::ANY,
) {
// Fuzz the module behavior: random alloc return, optional trap in handle,
// optional send before the trap. The actor must never be poisoned.
let trap_code = if trap_handle { "unreachable" } else { "" };
let send_code = if send_before_trap {
"local.get $ptr i32.const 32 i32.const 1 call $send"
} else {
""
};
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 $ptr i32) (param $len i32)
{send_code}
{trap_code}
)
)
"#);
let wasm = wat::parse_str(&wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![0u8; 64])).unwrap();
rt.tick();
let result = rt.send_to(addr, ByteMessage(vec![1]));
prop_assert!(result.is_ok(), "actor must survive: alloc={alloc_val} trap={trap_handle} send_before={send_before_trap}");
}
}
// ── Stack overflow: deep recursion in handle ────────────────────────────────
#[test]
fn guest_stack_overflow_traps_actor_survives() {
// Guest handle calls itself recursively until stack overflow.
// Wasmtime should trap with a stack overflow error; actor must survive.
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 $recurse (param $ptr i32) (param $len i32)
local.get $ptr
local.get $len
call $recurse
)
(func (export "handle") (param $ptr i32) (param $len i32)
local.get $ptr
local.get $len
call $recurse
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick(); // stack overflow trap
// Actor survives
rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap();
rt.tick();
}
// ── Bulk memory: memory.fill and memory.copy ────────────────────────────────
#[test]
fn guest_using_bulk_memory_ops_works() {
// The engine enables bulk_memory. Guest uses memory.fill to write a
// pattern, then sends it. Verifies bulk memory operations work.
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)
;; Fill bytes [500..510] with value 42 using memory.fill
(memory.fill (i32.const 500) (i32.const 42) (i32.const 10))
;; Send 10 bytes from [500..510]
local.get $ptr
i32.const 500
i32.const 10
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"trigger")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("bulk memory fill should work");
assert_eq!(received.0, vec![42u8; 10]);
}
// ── Self-amplification: bounded by message budget ───────────────────────────
#[test]
fn self_amplification_bounded_by_budget_no_crash() {
// Guest sends 3 copies of the message back to itself. With budget=4
// each tick processes at most 4 messages. Run for a few ticks — should
// not crash or OOM.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $i i32)
(local.set $i (i32.const 0))
(block $break
(loop $loop
(br_if $break (i32.ge_u (local.get $i) (i32.const 3)))
local.get $ptr
local.get $ptr
i32.const 33
call $send
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
)
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let config = RuntimeConfig {
actor_message_budget: 4,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let addr = rt.spawn(actor).unwrap();
// Initial seed: [self_addr | marker]
let mut seed = Vec::new();
seed.extend_from_slice(&addr.0);
seed.push(0xFF);
rt.send_to(addr, ByteMessage(seed)).unwrap();
// Run for 5 ticks — should not crash
for _ in 0..5 {
rt.tick();
}
// Actor alive
rt.send_to(addr, ByteMessage(vec![0])).unwrap();
rt.tick();
}
// ── Double stop: stopping an already-stopped actor ──────────────────────────
#[test]
fn double_stop_is_idempotent() {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("silent"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.stop_actor(addr).unwrap();
rt.tick();
rt.tick();
// Second stop should fail gracefully (not panic)
let result = rt.stop_actor(addr);
assert!(result.is_err(), "stopping already-stopped actor should error");
}
// ── Disabled features: SIMD module rejected by sandboxed engine ─────────────
#[test]
fn module_using_disabled_simd_is_rejected() {
// The engine disables SIMD. A module using v128 SIMD types should
// fail to compile or instantiate.
let wat = r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param i32 i32)
;; v128.const is a SIMD instruction
v128.const i32x4 0 0 0 0
drop
)
)
"#;
let result = wat::parse_str(wat);
// If wat parses it, try to compile with the sandboxed engine
match result {
Ok(wasm) => {
let engine = SharedEngine::new().unwrap();
let build_result = WasmActorBuilder::new(engine, wasm).build();
assert!(build_result.is_err(), "SIMD module should be rejected by sandboxed engine");
}
Err(_) => {
// wat parser itself rejects SIMD — that's also fine
}
}
}
// ── Garbage address in send: any 32 bytes accepted ──────────────────────────
#[test]
fn send_with_garbage_address_bytes_silently_fails() {
// Guest sends to an address that's 32 random/garbage bytes.
// The runtime can't route to it — ctx.send() returns Err, which is
// silently dropped. Actor survives.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
;; Pre-fill offset 0-31 with garbage (0xDE repeated)
(data (i32.const 0) "\de\ad\be\ef\de\ad\be\ef\de\ad\be\ef\de\ad\be\ef\de\ad\be\ef\de\ad\be\ef\de\ad\be\ef\de\ad\be\ef")
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param i32 i32)
;; Send to garbage address at offset 0
i32.const 0 ;; dest_ptr (garbage address from data segment)
i32.const 32 ;; payload_ptr
i32.const 1 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![42])).unwrap();
rt.tick(); // send to garbage addr fails silently
// Actor survives
rt.send_to(addr, ByteMessage(vec![99])).unwrap();
rt.tick();
}
// ── Indirect call: guest uses call_indirect for handle logic ────────────────
#[test]
fn guest_using_call_indirect_works() {
// Guest uses a function table and call_indirect to invoke a function
// that calls send. Verifies table-based dispatch works in the sandbox.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(type $send_sig (func (param i32)))
;; A function that sends 1 byte from offset 200 using the dest at the param
(func $do_send (param $dest_ptr i32)
(i32.store8 (i32.const 200) (i32.const 55))
local.get $dest_ptr
i32.const 200
i32.const 1
call $send
)
(table 1 funcref)
(elem (i32.const 0) $do_send)
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Call the function at table index 0 via call_indirect
local.get $ptr
(call_indirect (type $send_sig) (i32.const 0))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"indirect")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("indirect call should deliver message");
assert_eq!(received.0, vec![55]);
}
// ── alloc returns 0 with len=0: subtle Ok(0) guard behavior ─────────────────
#[test]
fn alloc_returns_zero_for_zero_length_message_succeeds() {
// The guard `Ok(0) if len > 0 => return` only triggers when len > 0.
// For a zero-length message, alloc returning 0 should fall through and
// handle(0, 0) should be called. This tests the subtle conditional.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $called (mut i32) (i32.const 0))
(func (export "alloc") (param i32) (result i32)
i32.const 0 ;; Always return 0
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Mark that handle was called
(global.set $called (i32.const 1))
;; Write marker and send it
(i32.store8 (i32.const 200) (i32.const 77))
;; Use offset 100 as dest (will be zeroes = invalid addr, but that's fine)
i32.const 100 ;; dest_ptr (zeroes)
i32.const 200 ;; payload_ptr
i32.const 1 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Send empty message — alloc returns 0, len == 0, so guard doesn't trigger.
// handle(0, 0) should be called.
rt.send_to(addr, ByteMessage(vec![])).unwrap();
rt.tick();
// Then send a non-empty message — alloc returns 0, len > 0, guard triggers.
// handle should NOT be called. Actor survives.
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick();
// Actor still alive
rt.send_to(addr, ByteMessage(vec![])).unwrap();
rt.tick();
}
// ── Module with custom sections: should build successfully ──────────────────
#[test]
fn module_with_custom_section_builds_and_works() {
// WASM modules can have custom sections. The builder should ignore them.
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)
(i32.store8 (i32.const 200) (i32.const 99))
local.get $ptr
i32.const 200
i32.const 1
call $send
)
(@custom "my_section" "hello")
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"custom")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("module with custom section should work");
assert_eq!(received.0, vec![99]);
}
// ── Multiple engines: actors from different engines on same runtime ──────────
#[test]
fn actors_from_different_engines_coexist() {
// Two actors built from separate SharedEngine instances.
// Verifies that engine isolation doesn't cause issues when actors
// share the same runtime.
let engine_a = SharedEngine::new().unwrap();
let engine_b = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let actor_a = WasmActorBuilder::new(engine_a, wasm_bytes.clone()).build().unwrap();
let actor_b = WasmActorBuilder::new(engine_b, wasm_bytes).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox_a = rt.new_inbox::<ByteMessage>().unwrap();
let inbox_b = rt.new_inbox::<ByteMessage>().unwrap();
let addr_a = rt.spawn(actor_a).unwrap();
let addr_b = rt.spawn(actor_b).unwrap();
rt.send_to(addr_a, framed_msg(inbox_a.addr(), b"engine-A")).unwrap();
rt.send_to(addr_b, framed_msg(inbox_b.addr(), b"engine-B")).unwrap();
rt.tick();
let recv_a = inbox_a.try_recv().expect("engine A actor should echo");
let recv_b = inbox_b.try_recv().expect("engine B actor should echo");
assert_eq!(recv_a.0, b"engine-A");
assert_eq!(recv_b.0, b"engine-B");
}
// ── Error formatting: WasmActorError Display ────────────────────────────────
#[test]
fn error_display_formats_correctly() {
let missing = WasmActorError::MissingExport("memory");
assert!(missing.to_string().contains("memory"));
assert!(missing.to_string().contains("missing"));
let garbage = vec![0u8, 1, 2, 3];
let engine = SharedEngine::new().unwrap();
let build_result = WasmActorBuilder::new(engine, garbage).build();
assert!(build_result.is_err());
let wasmtime_err = build_result.err().unwrap();
assert!(wasmtime_err.to_string().contains("wasmtime"));
}
// ── Rapid lifecycle: spawn, process, stop, repeat ───────────────────────────
#[test]
fn rapid_spawn_process_stop_cycle() {
// Rapidly spawn, send, tick, stop, tick, repeat for 20 iterations.
// Tests that the runtime cleanly handles rapid WASM actor lifecycle.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
for i in 0u8..20 {
let actor = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone())
.build()
.unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
rt.tick();
let received = inbox.try_recv().expect("actor should echo before stop");
assert_eq!(received.0, vec![i]);
rt.stop_actor(addr).unwrap();
rt.tick();
rt.tick(); // cleanup
}
}
// ── Stop-send race: send after stop_actor but before tick ───────────────────
#[test]
fn send_after_stop_before_tick_is_silently_dropped() {
// Stop an actor, then immediately send a message before ticking.
// The message should be silently dropped (actor is stopping).
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();
// Verify it works first
rt.send_to(addr, framed_msg(inbox.addr(), b"alive")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_some());
// Stop then immediately send before tick processes the stop
rt.stop_actor(addr).unwrap();
// This send may or may not succeed depending on mailbox state
let _ = rt.send_to(addr, framed_msg(inbox.addr(), b"after-stop"));
rt.tick(); // processes stop signal, clears mailbox
rt.tick(); // cleanup
// No response expected — either the send failed or the message was cleared
// The key assertion: no panic or corruption
}
// ── SharedEngine Debug impl ─────────────────────────────────────────────────
#[test]
fn shared_engine_debug_does_not_panic() {
let engine = SharedEngine::new().unwrap();
let debug_str = format!("{:?}", engine);
assert!(debug_str.contains("SharedEngine"));
}
// ── ByteMessage equality and clone ──────────────────────────────────────────
#[test]
fn byte_message_traits() {
let msg1 = ByteMessage(vec![1, 2, 3]);
let msg2 = msg1.clone();
assert_eq!(msg1, msg2);
let msg3 = ByteMessage(vec![4, 5, 6]);
assert_ne!(msg1, msg3);
let debug_str = format!("{:?}", msg1);
assert!(debug_str.contains("ByteMessage"));
}
// ── Malicious guest: massive outbox (memory exhaustion defense) ─────────────
#[test]
fn guest_sending_1000_messages_in_one_handle_all_delivered() {
// A malicious guest could flood the outbox with thousands of messages.
// The host should handle this without crashing. Each message is small.
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)
(local $i i32)
(local.set $i (i32.const 0))
(block $break
(loop $loop
(br_if $break (i32.ge_u (local.get $i) (i32.const 1000)))
(call $send (local.get $ptr) (i32.const 32) (i32.const 0))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
)
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"flood")).unwrap();
rt.tick();
let mut count = 0;
while inbox.try_recv().is_some() {
count += 1;
}
assert_eq!(count, 1000, "all 1000 messages should be delivered");
}
// ── Interleaved message types: ByteMessage + watch in same tick ─────────────
#[test]
fn wasm_actor_processes_messages_and_receives_watch_notification() {
// WASM echo actor processes a message and then receives a watch
// notification for a stopped actor — both in a short sequence.
let engine = SharedEngine::new().unwrap();
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo"))
.build()
.unwrap();
let silent = WasmActorBuilder::new(engine, guest_wasm("silent"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let echo_addr = rt.spawn(echo).unwrap();
let silent_addr = rt.spawn(silent).unwrap();
rt.tick(); // let actors initialize
// Echo processes a message
rt.send_to(echo_addr, framed_msg(inbox.addr(), b"before-death")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("echo should work before death notification");
assert_eq!(received.0, b"before-death");
// Stop silent actor — echo doesn't watch it, so no notification expected
// But this tests that the runtime handles mixed actor types during cleanup
rt.stop_actor(silent_addr).unwrap();
rt.tick();
rt.tick();
// Echo still works after another actor died
rt.send_to(echo_addr, framed_msg(inbox.addr(), b"after-death")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("echo should work after other actor dies");
assert_eq!(received.0, b"after-death");
}
// ── Alloc returns i32::MAX: maximum positive value ──────────────────────────
#[test]
fn alloc_returns_i32_max_drops_message_actor_survives() {
// alloc returns i32::MAX (2147483647). (ptr as usize).saturating_add(len)
// produces a huge value, bounds check rejects. Actor survives.
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 2147483647
)
(func (export "handle") (param i32 i32))
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick();
// Actor survives
rt.send_to(addr, ByteMessage(vec![4])).unwrap();
rt.tick();
}
// ── Property: echo preserves message integrity under varied sizes ───────────
proptest! {
#[test]
fn prop_echo_preserves_payloads_of_varied_sizes(size in 1usize..2000) {
// Messages of varying sizes should echo perfectly through the pipeline.
// Tests allocation alignment and copy correctness at many sizes.
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("echo should return payload");
prop_assert_eq!(received.0, payload);
}
}
// ── Spawn and send in same tick: message delivered on first tick ─────────────
#[test]
fn wasm_actor_receives_message_sent_in_spawn_tick() {
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(), b"first-tick")).unwrap();
// Single tick should spawn the actor AND deliver the message
rt.tick();
let received = inbox.try_recv().expect("message sent before first tick should be processed");
assert_eq!(received.0, b"first-tick");
}
// ── WASM actor coexists with many native actors ─────────────────────────────
struct Counter {
count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
#[derive(Clone)]
struct Ping;
impl ActorInterface for Counter {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
#[test]
fn wasm_actor_works_alongside_many_native_actors() {
let engine = SharedEngine::new().unwrap();
let echo = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let counts: Vec<_> = (0..20)
.map(|_| std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)))
.collect();
let mut native_addrs = Vec::new();
for count in &counts {
let addr = rt.spawn(Counter { count: count.clone() }).unwrap();
native_addrs.push(addr);
}
let echo_addr = rt.spawn(echo).unwrap();
// Send to all actors in same tick
for addr in &native_addrs {
rt.send_to(*addr, Ping).unwrap();
}
rt.send_to(echo_addr, framed_msg(inbox.addr(), b"mixed")).unwrap();
rt.tick();
for (i, count) in counts.iter().enumerate() {
assert_eq!(
count.load(std::sync::atomic::Ordering::SeqCst), 1,
"native actor {i} should have processed its Ping"
);
}
let received = inbox.try_recv().expect("WASM actor should echo in mixed runtime");
assert_eq!(received.0, b"mixed");
}
// ── alloc alternates between failure and success ────────────────────────────
#[test]
fn alloc_alternates_between_failure_and_success() {
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $counter (mut i32) (i32.const 0))
(func (export "alloc") (param $size i32) (result i32)
global.get $counter
i32.const 1
i32.add
global.set $counter
;; Odd calls return -1 (invalid), even calls return 256
global.get $counter
i32.const 2
i32.rem_u
i32.const 1
i32.eq
if (result i32)
i32.const -1
else
i32.const 256
end
)
(func (export "handle") (param $ptr i32) (param $len i32)
local.get $ptr
local.get $ptr
i32.const 32
i32.add
local.get $len
i32.const 32
i32.sub
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send 6 messages: 1(fail), 2(ok), 3(fail), 4(ok), 5(fail), 6(ok)
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 even-numbered alloc calls only");
}
// ── Truncated WASM: module bytes cut mid-section ────────────────────────────
#[test]
fn truncated_wasm_bytes_returns_error() {
// Take a valid WASM module and truncate it. Should fail to compile.
let valid_wasm = guest_wasm("echo");
let truncated = valid_wasm[..valid_wasm.len() / 2].to_vec();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, truncated).build();
assert!(result.is_err(), "truncated WASM should fail to compile");
}
// ── Module with no import of swactor.send: handle that never sends ──────────
#[test]
fn module_without_send_import_can_still_process_messages() {
// A module that doesn't import swactor.send at all.
// It should build successfully (linker defines send but module doesn't import it).
// Handle can process messages without sending.
let wat = r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param i32 i32)
;; Process the message but never send anything
;; (No import of swactor.send)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick();
// Actor processed message, didn't send anything, survives
rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap();
rt.tick();
}
// ── Multi-worker stress: 10 WASM actors on 4-thread runtime ─────────────────
#[test]
fn ten_wasm_actors_on_four_thread_runtime() {
// Spawn 10 WASM echo actors on a 4-thread runtime, send a message to each,
// and verify all responses arrive.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
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(), wasm_bytes.clone())
.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();
// Poll for all 10 responses
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<_>>(), "all 10 actors should echo");
}
// ── alloc with i32::MIN: most negative value ────────────────────────────────
#[test]
fn alloc_returns_i32_min_drops_message_actor_survives() {
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 -2147483648 ;; i32::MIN
)
(func (export "handle") (param i32 i32))
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick(); // ptr < 0 guard catches i32::MIN
rt.send_to(addr, ByteMessage(vec![2])).unwrap();
rt.tick();
}
// ── Watch integration: native watcher + WASM watcher observing same death ───
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 two_watchers_both_notified_when_wasm_actor_dies() {
let engine = SharedEngine::new().unwrap();
let target = WasmActorBuilder::new(engine, guest_wasm("silent"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let count_a = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let count_b = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let target_addr = rt.spawn(target).unwrap();
let watcher_a = rt.spawn(DeathCounter { count: count_a.clone() }).unwrap();
let watcher_b = rt.spawn(DeathCounter { count: count_b.clone() }).unwrap();
// Both watchers watch the target
rt.send_to(watcher_a, WatchAddr(target_addr)).unwrap();
rt.send_to(watcher_b, WatchAddr(target_addr)).unwrap();
for _ in 0..3 { rt.tick(); }
// Kill the target
rt.stop_actor(target_addr).unwrap();
for _ in 0..5 { rt.tick(); }
assert_eq!(count_a.load(std::sync::atomic::Ordering::SeqCst), 1, "watcher A should be notified");
assert_eq!(count_b.load(std::sync::atomic::Ordering::SeqCst), 1, "watcher B should be notified");
}
// ── Division by zero: WASM trap, actor survives ─────────────────────────────
#[test]
fn guest_division_by_zero_traps_actor_survives() {
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)
;; Division by zero is a trap in WASM
local.get $len
i32.const 0
i32.div_u
drop
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick(); // div by zero trap
// Actor survives
rt.send_to(addr, ByteMessage(vec![2])).unwrap();
rt.tick();
}
// ── Module with extra exports: globals and extra functions ──────────────────
#[test]
fn module_with_extra_exports_builds_and_works() {
// Module exports extra globals and functions beyond the required ones.
// Builder should ignore them.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global (export "version") i32 (i32.const 42))
(global (export "magic") i64 (i64.const 12345))
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param $ptr i32) (param $len i32)
(i32.store8 (i32.const 200) (i32.const 7))
local.get $ptr
i32.const 200
i32.const 1
call $send
)
(func (export "extra_func") (param i32) (result i32)
local.get 0
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"extras")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("module with extras should work");
assert_eq!(received.0, vec![7]);
}
// ── Send with dest_ptr=0, all zeroes in memory: valid but unroutable ────────
#[test]
fn send_with_all_zero_dest_from_uninitialized_memory() {
// Guest reads dest address from offset 500 (uninitialized, all zeros).
// The zero address isn't routable. ctx.send fails silently.
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 i32 i32)
;; Read dest from uninitialized region (offset 500, all zeros)
i32.const 500 ;; dest_ptr
i32.const 32 ;; payload_ptr
i32.const 1 ;; payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap();
rt.tick(); // send to zero-address fails silently
// Actor survives
rt.send_to(addr, ByteMessage(vec![4])).unwrap();
rt.tick();
}
// ── Property: WASM actor survives any sequence of operations ────────────────
proptest! {
#[test]
fn prop_actor_survives_any_operation_sequence(
ops in proptest::collection::vec(
prop_oneof![
Just("send"),
Just("empty"),
Just("large"),
],
1..20
)
) {
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();
for op in &ops {
match *op {
"send" => {
rt.send_to(addr, framed_msg(inbox.addr(), b"msg")).unwrap();
}
"empty" => {
rt.send_to(addr, ByteMessage(vec![])).unwrap();
}
"large" => {
rt.send_to(addr, ByteMessage(vec![0u8; 60000])).unwrap();
}
_ => unreachable!(),
}
rt.tick();
// Drain inbox
while inbox.try_recv().is_some() {}
}
// Actor should still be alive
let result = rt.send_to(addr, ByteMessage(vec![99]));
prop_assert!(result.is_ok(), "actor must survive any operation sequence");
}
}
// ── Integer overflow in guest: wrapping arithmetic doesn't trap ─────────────
#[test]
fn guest_integer_overflow_wraps_silently() {
// WASM integers wrap on overflow (no trap). This guest adds i32::MAX + 1
// and uses the result as a send offset. The wrapping result (0) should
// produce a valid send.
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)
;; i32::MAX + 1 wraps to i32::MIN (-2147483648)
;; Use it as... nothing, just verify no trap
i32.const 2147483647
i32.const 1
i32.add
drop
;; Send normally
(i32.store8 (i32.const 200) (i32.const 33))
local.get $ptr
i32.const 200
i32.const 1
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"wrap")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("wrapping overflow should not trap");
assert_eq!(received.0, vec![33]);
}
// ── Multiple messages in one tick to same WASM actor ────────────────────────
#[test]
fn multiple_messages_in_one_tick_all_processed() {
// Send 5 messages to a WASM actor before ticking. All should be
// processed in the same tick (within the default budget of 64).
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();
for i in 0u8..5 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
assert_eq!(received, vec![0, 1, 2, 3, 4]);
}
// ── Swap actors: stop WASM, spawn new WASM at conceptually same role ────────
#[test]
fn hot_swap_wasm_actor_works() {
// Stop an echo actor, spawn a double actor in its place, verify the new
// one works correctly. Tests clean handover of actor lifecycle.
let engine = SharedEngine::new().unwrap();
// Phase 1: echo actor
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 echo_addr = rt.spawn(echo).unwrap();
rt.send_to(echo_addr, framed_msg(inbox.addr(), b"echo-phase")).unwrap();
rt.tick();
let recv = inbox.try_recv().expect("echo should work");
assert_eq!(recv.0, b"echo-phase");
// Stop echo
rt.stop_actor(echo_addr).unwrap();
rt.tick();
rt.tick();
// Phase 2: double actor
let double = WasmActorBuilder::new(engine, guest_wasm("double"))
.build()
.unwrap();
let double_addr = rt.spawn(double).unwrap();
rt.send_to(double_addr, framed_msg(inbox.addr(), b"double-phase")).unwrap();
rt.tick();
let first = inbox.try_recv().expect("double should send first");
let second = inbox.try_recv().expect("double should send second");
assert_eq!(first.0, b"double-phase");
assert_eq!(second.0, b"double-phase");
assert!(inbox.try_recv().is_none());
}
// ── Guest uses memory.copy: bulk copy within linear memory ──────────────────
#[test]
fn guest_using_memory_copy_works() {
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)
(i32.store8 (i32.const 500) (i32.const 72))
(i32.store8 (i32.const 501) (i32.const 73))
(memory.copy (i32.const 600) (i32.const 500) (i32.const 2))
local.get $ptr
i32.const 600
i32.const 2
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"copy-test")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("memory.copy should work");
assert_eq!(received.0, b"HI");
}
// ── Engine clone stress: 50 actors from same engine ─────────────────────────
#[test]
fn fifty_actors_from_same_engine() {
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let mut addrs = Vec::new();
for _ in 0..50 {
let actor = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone())
.build()
.unwrap();
addrs.push(rt.spawn(actor).unwrap());
}
rt.send_to(addrs[0], framed_msg(inbox.addr(), b"first")).unwrap();
rt.send_to(addrs[49], framed_msg(inbox.addr(), b"last")).unwrap();
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0.clone());
}
assert_eq!(received.len(), 2);
assert!(received.contains(&b"first".to_vec()));
assert!(received.contains(&b"last".to_vec()));
}
// ── Payload integrity: pattern check for copy correctness ───────────────────
#[test]
fn payload_pattern_integrity_check() {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload: Vec<u8> = (0..500).map(|i| ((i * 7 + 13) % 256) as u8).collect();
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("pattern payload should echo");
assert_eq!(received.0, payload, "payload integrity check");
}
// ── Lifecycle fuzz: echo with random stopping ───────────────────────────────
proptest! {
#[test]
fn prop_echo_lifecycle_fuzz(
num_messages in 1usize..30,
payload_sizes in proptest::collection::vec(1usize..200, 1..30),
stop_at in proptest::option::of(0usize..30),
) {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let msg_count = num_messages.min(payload_sizes.len());
let mut echoed = 0;
for i in 0..msg_count {
if stop_at == Some(i) {
let _ = rt.stop_actor(addr);
rt.tick();
rt.tick();
break;
}
let payload: Vec<u8> = (0..payload_sizes[i]).map(|j| (j % 256) as u8).collect();
let send_result = rt.send_to(addr, framed_msg(inbox.addr(), &payload));
if send_result.is_err() {
break;
}
rt.tick();
if let Some(received) = inbox.try_recv() {
prop_assert_eq!(received.0, payload);
echoed += 1;
}
}
if stop_at.is_none() || stop_at.unwrap_or(0) > 0 {
prop_assert!(echoed > 0 || stop_at == Some(0));
}
}
}
// ── OOB table access: call_indirect with bad index traps ────────────────────
#[test]
fn guest_oob_call_indirect_traps_actor_survives() {
// Guest uses call_indirect with index 99 on a table of size 1.
// This should trap. Actor should survive.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(type $void (func))
(func $noop)
(table 1 funcref)
(elem (i32.const 0) $noop)
(func (export "alloc") (param i32) (result i32) i32.const 256)
(func (export "handle") (param i32 i32)
;; call_indirect with index 99 — out of bounds
(call_indirect (type $void) (i32.const 99))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick(); // OOB trap
// Actor survives
rt.send_to(addr, ByteMessage(vec![2])).unwrap();
rt.tick();
}
// ── 100th test: comprehensive round-trip through all guest modules ──────────
#[test]
fn all_guest_modules_work_in_same_runtime() {
// Spawn one of each guest (echo, double, silent) in the same runtime.
// Send messages to all three and verify each behaves correctly.
// This is the 100th test — a comprehensive integration checkpoint.
let engine = SharedEngine::new().unwrap();
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo"))
.build().unwrap();
let double = WasmActorBuilder::new(engine.clone(), guest_wasm("double"))
.build().unwrap();
let silent = WasmActorBuilder::new(engine, guest_wasm("silent"))
.build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let echo_addr = rt.spawn(echo).unwrap();
let double_addr = rt.spawn(double).unwrap();
let silent_addr = rt.spawn(silent).unwrap();
// Send to all three
rt.send_to(echo_addr, framed_msg(inbox.addr(), b"E")).unwrap();
rt.send_to(double_addr, framed_msg(inbox.addr(), b"D")).unwrap();
rt.send_to(silent_addr, ByteMessage(b"S".to_vec())).unwrap();
rt.tick();
// Collect results
let mut messages = Vec::new();
while let Some(msg) = inbox.try_recv() {
messages.push(msg.0);
}
// Echo: 1 message, Double: 2 messages, Silent: 0 messages = 3 total
assert_eq!(messages.len(), 3, "echo(1) + double(2) + silent(0) = 3 messages");
// Verify content
let echo_count = messages.iter().filter(|m| m.as_slice() == b"E").count();
let double_count = messages.iter().filter(|m| m.as_slice() == b"D").count();
assert_eq!(echo_count, 1, "echo should send 1 copy");
assert_eq!(double_count, 2, "double should send 2 copies");
}
// ── OOB memory.fill: trap, actor survives ───────────────────────────────────
#[test]
fn guest_oob_memory_fill_traps_actor_survives() {
// Guest tries to fill past the end of memory. WASM traps on OOB bulk ops.
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 i32 i32)
;; Fill starting at 65530, length 100 — overflows 65536 boundary
(memory.fill (i32.const 65530) (i32.const 0) (i32.const 100))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, ByteMessage(vec![1])).unwrap();
rt.tick(); // OOB memory.fill traps
// Actor survives
rt.send_to(addr, ByteMessage(vec![2])).unwrap();
rt.tick();
}
// ── Native spawns WASM + sends via ctx.send, delivers in same tick ──────────
struct WasmSpawnerInline {
engine: SharedEngine,
wasm_bytes: Vec<u8>,
inbox_addr: ActorAddress,
}
#[derive(Clone)]
struct SpawnCmd;
impl ActorInterface for WasmSpawnerInline {
type Incoming = SpawnCmd;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: SpawnCmd) {
let actor = WasmActorBuilder::new(self.engine.clone(), self.wasm_bytes.clone())
.build()
.unwrap();
let wasm_addr = ctx.spawn(actor).unwrap();
let msg = framed_msg(&self.inbox_addr, b"inline-spawn");
let _ = ctx.send(wasm_addr, msg);
}
}
#[test]
fn native_spawns_wasm_and_sends_in_same_handler() {
// A native actor spawns a WASM actor and sends a message to it in the
// same handler call. The runtime's tick phases should handle this:
// phase 4 drains spawns, phase 5 delivers pending_local.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let spawner = WasmSpawnerInline {
engine: engine.clone(),
wasm_bytes: wasm_bytes.clone(),
inbox_addr: *inbox.addr(),
};
let spawner_addr = rt.spawn(spawner).unwrap();
rt.send_to(spawner_addr, SpawnCmd).unwrap();
rt.tick(); // spawner handles SpawnCmd: spawns WASM, sends to it
rt.tick(); // WASM actor processes the message, echoes to inbox
let received = inbox.try_recv().expect("inline spawn + send should work");
assert_eq!(received.0, b"inline-spawn");
}
// ── Build from same bytes multiple times: no interference ───────────────────
#[test]
fn build_many_actors_from_same_bytes_sequentially() {
// Build 10 actors sequentially from the same engine + bytes.
// Each should be completely independent.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
for i in 0u8..10 {
let actor = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone())
.build()
.unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
rt.tick();
let received = inbox.try_recv().expect("sequential build should work");
assert_eq!(received.0, vec![i]);
rt.stop_actor(addr).unwrap();
rt.tick();
rt.tick();
}
}
// ── Guest that only sends on even-numbered messages ─────────────────────────
#[test]
fn guest_conditional_send_based_on_message_content() {
// Guest only sends a reply if the first byte of payload (after the 32-byte
// address) is even. Tests that the outbox is correctly empty when the guest
// decides not to send.
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)
;; Check if byte at ptr+32 (first payload byte) is even
local.get $ptr
i32.const 32
i32.add
i32.load8_u
i32.const 2
i32.rem_u
i32.const 0
i32.eq
if
;; Even: send reply
local.get $ptr
local.get $ptr
i32.const 32
i32.add
local.get $len
i32.const 32
i32.sub
call $send
end
;; Odd: do nothing (empty outbox)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send even byte (0) — should get reply
rt.send_to(addr, framed_msg(inbox.addr(), &[0])).unwrap();
rt.tick();
assert!(inbox.try_recv().is_some(), "even byte should trigger reply");
// Send odd byte (1) — no reply
rt.send_to(addr, framed_msg(inbox.addr(), &[1])).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "odd byte should not trigger reply");
// Send even byte (2) — should get reply
rt.send_to(addr, framed_msg(inbox.addr(), &[2])).unwrap();
rt.tick();
assert!(inbox.try_recv().is_some(), "even byte should trigger reply");
}
// ── Guest with multiple memory pages ────────────────────────────────────────
#[test]
fn guest_with_multiple_initial_pages_works() {
// Module starts with 4 pages (256KiB). Alloc returns pointer in page 3.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 4) ;; 4 pages = 262144 bytes
(func (export "alloc") (param i32) (result i32)
i32.const 196608 ;; page 3 start (3 * 65536)
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Echo from page 3
local.get $ptr
local.get $ptr
i32.const 32
i32.add
local.get $len
i32.const 32
i32.sub
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = b"multi-page";
rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("multi-page alloc should work");
assert_eq!(received.0, payload);
}
// ── Long-running: echo actor processes 500 messages sequentially ────────────
#[test]
fn echo_processes_500_sequential_messages() {
// Sustained message processing without crashes, allocator exhaustion
// handling, and verified actor survival throughout.
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let mut received_count = 0;
for i in 0u16..500 {
let payload = i.to_le_bytes();
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
if let Some(msg) = inbox.try_recv() {
assert_eq!(msg.0, payload, "payload integrity at message {i}");
received_count += 1;
}
}
// With 65536-byte allocator and ~40 bytes per alloc (34 + alignment),
// all 500 messages should fit. Verify all echoed correctly.
assert_eq!(received_count, 500, "all 500 messages should echo");
}
// ── Mass spawn and stop: 100 WASM actors ────────────────────────────────────
#[test]
fn mass_spawn_and_stop_100_actors() {
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("silent");
let rt = Runtime::new(RuntimeConfig::default());
let mut addrs = Vec::new();
for _ in 0..100 {
let actor = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone())
.build().unwrap();
addrs.push(rt.spawn(actor).unwrap());
}
rt.tick();
for addr in &addrs {
rt.stop_actor(*addr).unwrap();
}
rt.tick();
rt.tick();
for addr in &addrs {
assert!(rt.send_to(*addr, ByteMessage(vec![1])).is_err());
}
}
// ── Echo to stopping actor: send silently fails ─────────────────────────────
#[test]
fn echo_to_stopping_actor_silently_fails() {
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let actor_a = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone()).build().unwrap();
let actor_b = WasmActorBuilder::new(engine, wasm_bytes).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr_a = rt.spawn(actor_a).unwrap();
let addr_b = rt.spawn(actor_b).unwrap();
rt.tick();
rt.send_to(addr_a, framed_msg(&addr_b, b"to-dying-b")).unwrap();
rt.stop_actor(addr_b).unwrap();
rt.tick(); // A echoes to B (stopping) — silently fails
rt.tick();
// A should still be alive
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
rt.send_to(addr_a, framed_msg(inbox.addr(), b"still-alive")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_some(), "actor A should survive");
}
// ── Compile-time trait checks ───────────────────────────────────────────────
#[test]
fn shared_engine_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<SharedEngine>();
}
#[test]
fn wasm_actor_is_send() {
fn assert_send<T: Send>() {}
assert_send::<WasmActor>();
}
// ── Guest writes to alloc pointer region before sending ─────────────────────
#[test]
fn guest_modifies_received_message_before_echoing() {
// Guest receives a message, XORs each payload byte with 0xFF, then
// echoes the modified payload. Verifies that the guest can mutate
// linear memory and the modified data is what gets sent.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $i i32)
(local $payload_start i32)
(local $payload_len i32)
;; payload starts at ptr+32, length is len-32
(local.set $payload_start (i32.add (local.get $ptr) (i32.const 32)))
(local.set $payload_len (i32.sub (local.get $len) (i32.const 32)))
;; Skip if no payload
(br_if 0 (i32.lt_s (local.get $payload_len) (i32.const 1)))
;; XOR each byte with 0xFF
(local.set $i (i32.const 0))
(block $break
(loop $loop
(br_if $break (i32.ge_u (local.get $i) (local.get $payload_len)))
(i32.store8
(i32.add (local.get $payload_start) (local.get $i))
(i32.xor
(i32.load8_u (i32.add (local.get $payload_start) (local.get $i)))
(i32.const 255)
)
)
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
)
)
;; Send modified payload
local.get $ptr
local.get $payload_start
local.get $payload_len
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = vec![0x00, 0x0F, 0xF0, 0xFF];
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("XOR transform should send");
let expected: Vec<u8> = payload.iter().map(|b| b ^ 0xFF).collect();
assert_eq!(received.0, expected, "payload should be XOR'd with 0xFF");
}
// ── Stop and re-spawn at same conceptual slot ───────────────────────────────
#[test]
fn stop_and_respawn_same_type_repeatedly() {
// Stop and respawn the same type of WASM actor 5 times.
// Each new instance should work independently.
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
for round in 0u8..5 {
let actor = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone())
.build().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[round])).unwrap();
rt.tick();
let received = inbox.try_recv().expect("respawned actor should echo");
assert_eq!(received.0, vec![round]);
rt.stop_actor(addr).unwrap();
rt.tick();
rt.tick();
}
}
// ── WASM actor watches another WASM actor ───────────────────────────────────
// Note: WasmActor doesn't implement on_actor_exit, so watch notifications
// are received but unhandled (default no-op). The important thing is no crash.
#[test]
fn wasm_actor_watching_another_wasm_actor_doesnt_crash() {
// Two WASM actors. We can't make one watch the other through the WASM
// ABI (ctx.watch isn't exposed to guests). But we can have a native
// watcher confirm the runtime handles WASM actors in the watch system.
// (Already covered by native_watcher_notified_when_wasm_actor_stops,
// but let's verify with two WASM actors dying in sequence.)
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("silent");
let actor_a = WasmActorBuilder::new(engine.clone(), wasm_bytes.clone()).build().unwrap();
let actor_b = WasmActorBuilder::new(engine, wasm_bytes).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr_a = rt.spawn(actor_a).unwrap();
let addr_b = rt.spawn(actor_b).unwrap();
rt.tick();
// Stop both in sequence
rt.stop_actor(addr_a).unwrap();
rt.tick();
rt.tick();
rt.stop_actor(addr_b).unwrap();
rt.tick();
rt.tick();
// Both gone, no crash
assert!(rt.send_to(addr_a, ByteMessage(vec![1])).is_err());
assert!(rt.send_to(addr_b, ByteMessage(vec![1])).is_err());
}
// ── Guest reads len parameter correctly ─────────────────────────────────────
#[test]
fn guest_receives_correct_len_parameter() {
// Guest stores the len parameter as a 4-byte LE integer at offset 200
// and sends it back. Verifies the host passes the correct length.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Store len at offset 200 as i32
(i32.store (i32.const 200) (local.get $len))
;; Send 4 bytes from offset 200
local.get $ptr
i32.const 200
i32.const 4
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send a message with 32-byte addr + 10 bytes payload = 42 bytes total
let payload = vec![0u8; 10];
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let received = inbox.try_recv().expect("should receive len value");
let len = i32::from_le_bytes(received.0.try_into().unwrap());
assert_eq!(len, 42, "guest should receive total message len (32 addr + 10 payload)");
}
// ── Guest reads ptr parameter correctly ─────────────────────────────────────
#[test]
fn guest_receives_correct_ptr_parameter() {
// Guest stores ptr at offset 200 and sends it back. The ptr should be
// the address returned by alloc (4096 in this case).
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(i32.store (i32.const 200) (local.get $ptr))
local.get $ptr
i32.const 200
i32.const 4
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"ptr-check")).unwrap();
rt.tick();
let received = inbox.try_recv().expect("should receive ptr value");
let ptr = i32::from_le_bytes(received.0.try_into().unwrap());
assert_eq!(ptr, 4096, "guest should receive ptr = alloc return value");
}
// ── Double guest with empty payload: sends two zero-length messages ─────────
#[test]
fn double_guest_with_minimal_payload() {
// Double guest with exactly 32 bytes (addr only, no payload).
// Since double checks `len < 32`, a 32-byte message passes the check.
// payload_len = 32 - 32 = 0, so it sends two zero-length messages.
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("double"))
.build()
.unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send exactly 32 bytes (just the address, no payload)
let msg = ByteMessage(inbox.addr().0.to_vec());
rt.send_to(addr, msg).unwrap();
rt.tick();
let first = inbox.try_recv().expect("double should send first empty message");
let second = inbox.try_recv().expect("double should send second empty message");
assert!(first.0.is_empty(), "payload should be empty");
assert!(second.0.is_empty(), "payload should be empty");
assert!(inbox.try_recv().is_none(), "exactly two messages");
}
// ── Mixed outbox: some sends succeed, some fail ─────────────────────────────
#[test]
fn mixed_outbox_partial_delivery() {
// Guest sends to a valid address (inbox) and an invalid address (garbage)
// in the same handle call. The valid send should deliver; the invalid one
// should silently fail. The actor should survive.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
;; Garbage address at offset 500 (all 0xDE bytes)
(data (i32.const 500) "\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de\de")
(func (export "alloc") (param i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Send #1: to valid address (from message bytes)
(i32.store8 (i32.const 200) (i32.const 65)) ;; 'A'
local.get $ptr
i32.const 200
i32.const 1
call $send
;; Send #2: to garbage address at offset 500
(i32.store8 (i32.const 201) (i32.const 66)) ;; 'B'
i32.const 500 ;; garbage dest
i32.const 201
i32.const 1
call $send
;; Send #3: back to valid address
(i32.store8 (i32.const 202) (i32.const 67)) ;; 'C'
local.get $ptr
i32.const 202
i32.const 1
call $send
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"mixed-outbox")).unwrap();
rt.tick();
// Should receive sends #1 and #3 (valid dest), but not #2 (garbage dest)
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
assert_eq!(received, vec![b'A', b'C'], "only valid-address sends should deliver");
}
// ── Drop-oldest mailbox policy with WASM actor ─────────────────────────────
#[test]
fn drop_oldest_mailbox_with_wasm_actor() {
use swactor::runtime::MailboxOverflow;
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let config = RuntimeConfig {
default_mailbox_capacity: 3,
mailbox_overflow: MailboxOverflow::DropOldest,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send 5 messages before tick — DropOldest keeps the last 3
for i in 0u8..5 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
assert_eq!(received.len(), 3, "should keep 3 messages");
// DropOldest keeps the newest: [2, 3, 4]
assert_eq!(received, vec![2, 3, 4], "DropOldest should keep newest messages");
}
// ── WASM actor echoes to inbox, inbox full — message dropped ────────────────
#[test]
fn echo_to_full_inbox_silently_drops() {
// Echo sends to an inbox that has a bounded capacity.
// If the inbox is full, the send should silently fail.
use swactor::runtime::MailboxOverflow;
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let config = RuntimeConfig {
default_mailbox_capacity: 2,
mailbox_overflow: MailboxOverflow::DropNewest,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send 5 messages — actor has capacity 2, so only first 2 are kept
// Each message echoes to inbox (also capacity 2)
for i in 0u8..5 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
// Inbox has capacity 2, so at most 2 messages received
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
assert!(received.len() <= 2, "inbox should be bounded to capacity 2");
}
// ── Ping-pong: two WASM echoes create feedback loop, budget limits it ───────
#[test]
fn pingpong_wasm_echoes_bounded_by_budget() {
// Two echo actors that send to each other. A single seed message
// should create an exponentially growing feedback loop, but
// actor_message_budget limits messages processed per tick.
let engine = SharedEngine::new().unwrap();
let echo1 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo"))
.build()
.unwrap();
let echo2 = WasmActorBuilder::new(engine, guest_wasm("echo"))
.build()
.unwrap();
let config = RuntimeConfig {
actor_message_budget: 4,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let a1 = rt.spawn(echo1).unwrap();
let _a2 = rt.spawn(echo2).unwrap();
// Seed: tell actor 1 to echo to actor 2, with addr of actor 1 as payload
// so actor 2's reply goes back to actor 1 (creating a loop).
// Actually echo sends to first 32 bytes of message, so we need to
// frame them properly: actor1 sends to actor2, actor2 echoes payload back.
// The payload itself would need to be a framed message for actor2 to
// echo back to actor1. This creates the feedback loop.
// Simpler approach: send a message to echo1 with dest=echo2.
// echo1 echoes payload to echo2. echo2 receives raw payload
// (not framed), so it can't echo further. This tests 1 hop only.
// For a true feedback loop: we need the payload itself to be a framed msg.
// msg1 -> echo1: dest=echo2, payload=framed_msg(echo1, raw)
// echo1 sends framed_msg(echo1, raw) to echo2
// echo2 receives framed_msg(echo1, raw), treats first 32 bytes as dest=echo1
// echo2 sends "raw" to echo1
// echo1 receives "raw", tries first 32 bytes as dest — but "raw" may be too short
// Let's use a self-sustaining framed payload:
// Create a payload that is itself a framed_msg(a2, framed_msg(a1, framed_msg(a2, ...)))
// This is recursive — we can just build several layers.
// Better: use a WAT module that always echoes back to the sender address
// embedded in the first 32 bytes AND re-frames the response.
// Simplest valid test: just verify budget limits processing.
// Send multiple messages and confirm not all are processed in one tick.
for _ in 0..10 {
rt.send_to(a1, framed_msg(inbox.addr(), b"ping")).unwrap();
}
rt.tick();
let mut count = 0;
while let Some(_) = inbox.try_recv() {
count += 1;
}
// Budget is 4, so actor1 should process at most 4 of the 10 messages
assert_eq!(count, 4, "budget should limit messages processed per tick");
// Second tick processes more
rt.tick();
while let Some(_) = inbox.try_recv() {
count += 1;
}
assert_eq!(count, 8, "second tick should process 4 more");
// Third tick finishes the remaining 2
rt.tick();
while let Some(_) = inbox.try_recv() {
count += 1;
}
assert_eq!(count, 10, "third tick should finish remaining messages");
}
// ── Builder rejects alloc with wrong signature ──────────────────────────────
#[test]
fn wrong_alloc_signature_two_params_rejected() {
// alloc takes (i32, i32) -> i32 instead of (i32) -> i32
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32 i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
assert!(result.is_err(), "alloc with wrong signature should be rejected");
match result.err().unwrap() {
WasmActorError::MissingExport("alloc") => {} // expected — get_typed_func fails
other => panic!("expected MissingExport(alloc), got {other}"),
}
}
// ── Builder rejects handle with wrong return type ───────────────────────────
#[test]
fn wrong_handle_return_type_rejected() {
// handle returns i32 instead of void
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32) (result i32) i32.const 0)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
assert!(result.is_err(), "handle with return type should be rejected");
match result.err().unwrap() {
WasmActorError::MissingExport("handle") => {} // expected
other => panic!("expected MissingExport(handle), got {other}"),
}
}
// ── Overlapping dest and payload in send import ─────────────────────────────
#[test]
fn send_with_overlapping_dest_and_payload() {
// Guest calls swactor.send where dest_ptr and payload region overlap.
// The send import should read both correctly (read-only aliasing is fine).
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Copy dest address from message to offset 100
;; (first 32 bytes of message = address)
(memory.copy (i32.const 100) (local.get $ptr) (i32.const 32))
;; Send with dest_ptr=100 and payload starting at offset 116
;; (overlaps with dest region 100..132 by 16 bytes)
(call $send (i32.const 100) (i32.const 116) (i32.const 4))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// The payload at offset 116..120 will be bytes 16..20 of the dest address
// (since dest is at 100..132 and payload overlaps at 116..120)
rt.send_to(addr, framed_msg(inbox.addr(), b"overlap-test")).unwrap();
rt.tick();
// Should receive something — the overlapping read is valid
let msg = inbox.try_recv().expect("should receive overlapping send");
assert_eq!(msg.0.len(), 4, "payload should be 4 bytes");
}
// ── Memory defined but not exported as "memory" ─────────────────────────────
#[test]
fn memory_not_exported_returns_missing_export() {
// Module defines memory internally but doesn't export it with the name "memory".
let wat = r#"
(module
(memory 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
// instantiation itself may fail because link_send requires memory export,
// OR build may succeed but get_memory returns None → MissingExport
assert!(result.is_err(), "missing memory export should be rejected");
match result.err().unwrap() {
WasmActorError::MissingExport("memory") => {} // expected
WasmActorError::Wasmtime(_) => {} // also acceptable — linker can't resolve memory
other => panic!("unexpected error: {other}"),
}
}
// ── Multi-value module rejected by sandboxed engine ─────────────────────────
#[test]
fn multi_value_module_rejected_by_engine() {
// Module uses multi-value returns (disabled in SharedEngine config).
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
(func $multi (result i32 i32) i32.const 1 i32.const 2)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
// Engine has multi_value disabled, so compilation should fail
assert!(result.is_err(), "multi-value module should be rejected");
}
// ── Send import reads dest at exact end of linear memory ────────────────────
#[test]
fn send_dest_at_exact_memory_boundary() {
// Guest calls swactor.send with dest_ptr such that dest_ptr + 32 == memory size.
// This should succeed because it's exactly in bounds.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Copy 32-byte address from message to end of memory - 32
;; 65536 - 32 = 65504
(memory.copy (i32.const 65504) (local.get $ptr) (i32.const 32))
;; Send with dest at very end of memory, payload at 4096
(call $send (i32.const 65504) (i32.const 4096) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"X")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("send at exact boundary should succeed");
assert_eq!(msg.0.len(), 1);
}
// ── Send dest one byte past memory boundary (OOB) ──────────────────────────
#[test]
fn send_dest_one_past_memory_boundary_traps() {
// Guest calls swactor.send with dest_ptr = memory_size - 31, so dest_ptr + 32
// exceeds memory. The send import should return an error (which becomes a trap).
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; dest_ptr = 65505, so dest_end = 65505 + 32 = 65537 > 65536
(call $send (i32.const 65505) (i32.const 4096) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"Y")).unwrap();
rt.tick();
// Send traps → outbox cleared → handle returns Err → no message delivered
assert!(inbox.try_recv().is_none(), "OOB send should trap, no delivery");
// Actor should survive (trap caught by handle)
rt.send_to(addr, framed_msg(inbox.addr(), b"Z")).unwrap();
rt.tick();
// This time no OOB send, but the module always tries the OOB send, so still trapped
assert!(inbox.try_recv().is_none(), "same module always traps");
}
// ── Reference types module rejected by engine ───────────────────────────────
#[test]
fn reference_types_module_rejected() {
// Module uses externref (reference types disabled in SharedEngine).
let wat = r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
(table 1 externref)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
assert!(result.is_err(), "reference types should be rejected by sandboxed engine");
}
// ── Two actors, one traps always, one works — independent store isolation ───
#[test]
fn trapping_actor_does_not_affect_sibling() {
// Actor 1 always traps in handle. Actor 2 echos normally.
// Verify trap in actor 1 doesn't corrupt/poison actor 2.
let trap_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 4096)
(func (export "handle") (param i32 i32)
unreachable
)
)
"#;
let trap_wasm = wat::parse_str(trap_wat).unwrap();
let engine = SharedEngine::new().unwrap();
let trapper = WasmActorBuilder::new(engine.clone(), trap_wasm).build().unwrap();
let echoer = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let trap_addr = rt.spawn(trapper).unwrap();
let echo_addr = rt.spawn(echoer).unwrap();
// Send to both in the same tick
rt.send_to(trap_addr, framed_msg(inbox.addr(), b"trap-this")).unwrap();
rt.send_to(echo_addr, framed_msg(inbox.addr(), b"echo-this")).unwrap();
rt.tick();
// Only echo actor should deliver
let mut msgs = Vec::new();
while let Some(msg) = inbox.try_recv() {
msgs.push(msg.0);
}
assert_eq!(msgs.len(), 1, "only echo actor should deliver");
assert_eq!(&msgs[0], b"echo-this");
}
// ── Alloc returns negative for non-zero len — message dropped gracefully ────
#[test]
fn alloc_returns_negative_for_nonzero_drops_message() {
// Guest alloc always returns -42 regardless of input.
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 -42)
(func (export "handle") (param i32 i32)
;; Should never be called because alloc returns negative
unreachable
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send several messages — all should be silently dropped
for i in 0..5 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
assert!(inbox.try_recv().is_none(), "negative alloc should drop messages");
// Actor should still be alive — send another message, still dropped
rt.send_to(addr, framed_msg(inbox.addr(), &[99])).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "actor alive but still drops (negative alloc)");
}
// ── WASM actor with global state accumulates across messages ────────────────
#[test]
fn global_counter_accumulates_across_messages() {
// Guest has a mutable global counter. Each handle call increments it.
// The response payload includes the counter value.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $counter (mut i32) (i32.const 0))
(func (export "alloc") (param i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Increment counter
(global.set $counter (i32.add (global.get $counter) (i32.const 1)))
;; Write counter value as single byte at offset 200
(i32.store8 (i32.const 200) (global.get $counter))
;; Send counter value back to sender (first 32 bytes of msg)
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send 5 messages across 5 ticks
for _ in 0..5 {
rt.send_to(addr, framed_msg(inbox.addr(), b"inc")).unwrap();
rt.tick();
}
let mut counter_values = Vec::new();
while let Some(msg) = inbox.try_recv() {
counter_values.push(msg.0[0]);
}
assert_eq!(counter_values, vec![1, 2, 3, 4, 5], "global state should persist across handle calls");
}
// ── Send import: payload_len = 0 is valid zero-copy send ────────────────────
#[test]
fn send_import_zero_length_payload_delivers_empty() {
// Guest calls swactor.send with payload_len=0. This should deliver an
// empty payload (not a trap, not dropped).
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Send with payload_len=0
(call $send (local.get $ptr) (i32.const 0) (i32.const 0))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"trigger")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("zero-length send should deliver");
assert!(msg.0.is_empty(), "payload should be empty");
}
// ── Multiple engines with different configurations ──────────────────────────
#[test]
fn actors_from_separate_engines_coexist() {
// Build two separate engines and spawn one actor from each.
// They should work independently on the same runtime.
let engine1 = SharedEngine::new().unwrap();
let engine2 = SharedEngine::new().unwrap();
let echo1 = WasmActorBuilder::new(engine1, guest_wasm("echo")).build().unwrap();
let echo2 = WasmActorBuilder::new(engine2, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr1 = rt.spawn(echo1).unwrap();
let addr2 = rt.spawn(echo2).unwrap();
rt.send_to(addr1, framed_msg(inbox.addr(), b"from-engine1")).unwrap();
rt.send_to(addr2, framed_msg(inbox.addr(), b"from-engine2")).unwrap();
rt.tick();
let mut payloads: Vec<Vec<u8>> = Vec::new();
while let Some(msg) = inbox.try_recv() {
payloads.push(msg.0);
}
payloads.sort();
assert_eq!(payloads, vec![b"from-engine1".to_vec(), b"from-engine2".to_vec()]);
}
// ── Rapid spawn-send-stop stress test (50 rounds) ───────────────────────────
#[test]
fn rapid_spawn_send_stop_50_rounds() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
for round in 0u8..50 {
let actor = WasmActorBuilder::new(engine.clone(), guest_wasm("echo"))
.build()
.unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[round])).unwrap();
rt.tick();
rt.stop_actor(addr);
rt.tick(); // process stop
}
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
let expected: Vec<u8> = (0..50).collect();
assert_eq!(received, expected, "all 50 rounds should deliver");
}
// ── Module with start function that succeeds ────────────────────────────────
#[test]
fn start_function_that_succeeds_allows_normal_operation() {
// Module has a start function that initializes a global.
// After start, normal handle should work.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $initialized (mut i32) (i32.const 0))
(func $init
(global.set $initialized (i32.const 42))
)
(start $init)
(func (export "alloc") (param i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Write initialized value as response
(i32.store8 (i32.const 200) (global.get $initialized))
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"check-init")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("should receive response after start");
assert_eq!(msg.0[0], 42, "start function should have initialized global to 42");
}
// ── Property: random payloads never cause host panic ────────────────────────
proptest! {
#[test]
fn prop_random_payload_sizes_never_panic(
payload in proptest::collection::vec(proptest::num::u8::ANY, 0..8192)
) {
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 raw payload (not framed) — echo will try to use first 32 bytes as dest
// which will be random garbage. This should never crash the host.
rt.send_to(addr, ByteMessage(payload)).unwrap();
rt.tick();
// We don't care what happens — just that it doesn't panic
}
}
// ── Module with multiple memory pages and data segments ─────────────────────
#[test]
fn multi_page_data_segments_persist() {
// Module starts with 3 pages and has data segments in each page.
// Handle reads from each page to verify data segments initialized correctly.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 3)
;; Data segment in page 0
(data (i32.const 100) "\AA\BB\CC")
;; Data segment in page 1 (offset 65536 + 100 = 65636)
(data (i32.const 65636) "\DD\EE\FF")
;; Data segment in page 2 (offset 131072 + 100 = 131172)
(data (i32.const 131172) "\11\22\33")
(func (export "alloc") (param i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Copy 3 bytes from each page into response buffer at 200
(i32.store8 (i32.const 200) (i32.load8_u (i32.const 100)))
(i32.store8 (i32.const 201) (i32.load8_u (i32.const 65636)))
(i32.store8 (i32.const 202) (i32.load8_u (i32.const 131172)))
(call $send (local.get $ptr) (i32.const 200) (i32.const 3))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"read-pages")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("should receive data from all pages");
assert_eq!(msg.0, vec![0xAA, 0xDD, 0x11], "data segments should be initialized across pages");
}
// ── Module that conditionally sends based on first payload byte ─────────────
#[test]
fn conditional_send_fan_out_based_on_payload() {
// Guest checks first payload byte:
// 0x01 → send to dest from msg
// 0x02 → send twice (double)
// anything else → don't send
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Read first payload byte (after 32-byte address header)
(if (i32.eq (i32.load8_u (i32.add (local.get $ptr) (i32.const 32))) (i32.const 1))
(then
;; Send payload (skip first byte) once
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 33))
(i32.sub (local.get $len) (i32.const 33))
)
)
)
(if (i32.eq (i32.load8_u (i32.add (local.get $ptr) (i32.const 32))) (i32.const 2))
(then
;; Send payload twice
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 33))
(i32.sub (local.get $len) (i32.const 33))
)
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 33))
(i32.sub (local.get $len) (i32.const 33))
)
)
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Command 0x01: send once
rt.send_to(addr, framed_msg(inbox.addr(), &[0x01, b'X'])).unwrap();
// Command 0x02: send twice
rt.send_to(addr, framed_msg(inbox.addr(), &[0x02, b'Y'])).unwrap();
// Command 0xFF: no send
rt.send_to(addr, framed_msg(inbox.addr(), &[0xFF, b'Z'])).unwrap();
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0.clone());
}
assert_eq!(received.len(), 3, "should get 1 + 2 + 0 = 3 messages");
assert_eq!(received[0], vec![b'X']);
assert_eq!(received[1], vec![b'Y']);
assert_eq!(received[2], vec![b'Y']);
}
// ── Guest allocator returns different offsets per call ───────────────────────
#[test]
fn guest_with_advancing_allocator_handles_multiple_messages() {
// Guest has a proper advancing bump allocator (not static offset).
// Each alloc call returns the next available slot. Verify messages
// don't overwrite each other when processed in the same tick.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(global $heap_ptr (mut i32) (i32.const 4096))
(func (export "alloc") (param $size i32) (result i32)
(local $ptr i32)
(local.set $ptr (global.get $heap_ptr))
;; Advance heap pointer (8-byte aligned)
(global.set $heap_ptr
(i32.and
(i32.add (i32.add (global.get $heap_ptr) (local.get $size)) (i32.const 7))
(i32.const -8)
)
)
(local.get $ptr)
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Echo: send payload (after 32-byte header) to dest (first 32 bytes)
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Send 10 messages in one tick with distinct payloads
for i in 0u8..10 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i; 16])).unwrap();
}
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0.clone());
}
assert_eq!(received.len(), 10, "all 10 messages should be echoed");
for (i, payload) in received.iter().enumerate() {
assert_eq!(payload, &vec![i as u8; 16], "payload {i} should be intact");
}
}
// ── Guest writes to memory after send — outbox snapshot safety ──────────────
#[test]
fn guest_overwrites_payload_after_send_outbox_has_copy() {
// Guest sends a message, then overwrites the same memory region.
// The outbox should hold a snapshot of the data at the time of send,
// not a reference to the mutable linear memory.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Write "AAAA" at offset 200
(i32.store (i32.const 200) (i32.const 0x41414141))
;; Send "AAAA" (4 bytes)
(call $send (local.get $ptr) (i32.const 200) (i32.const 4))
;; Overwrite the same region with "BBBB"
(i32.store (i32.const 200) (i32.const 0x42424242))
;; Send "BBBB" (4 bytes)
(call $send (local.get $ptr) (i32.const 200) (i32.const 4))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"snapshot")).unwrap();
rt.tick();
let msg1 = inbox.try_recv().expect("first send should deliver");
let msg2 = inbox.try_recv().expect("second send should deliver");
assert_eq!(msg1.0, b"AAAA", "first send should have original bytes");
assert_eq!(msg2.0, b"BBBB", "second send should have overwritten bytes");
}
// ── Alloc that does memory.grow and returns pointer in new page ─────────────
#[test]
fn alloc_grows_memory_returns_pointer_in_new_page() {
// Guest's alloc grows memory by 1 page and returns start of new page.
// Each call to alloc adds a page and returns a fresh region.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param $size i32) (result i32)
(local $old_pages i32)
;; Grow memory by 1 page, return start of new page
(local.set $old_pages (memory.grow (i32.const 1)))
;; If grow failed (returned -1), return -1
(if (result i32) (i32.eq (local.get $old_pages) (i32.const -1))
(then (i32.const -1))
(else (i32.mul (local.get $old_pages) (i32.const 65536)))
)
)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Echo payload to dest
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Each message causes a memory.grow, so memory increases: 1→2→3→4→5 pages
for i in 0u8..5 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
assert_eq!(received, vec![0, 1, 2, 3, 4], "all messages should echo through grown pages");
}
// ── Handle that traps after successful send — outbox cleared ────────────────
#[test]
fn trap_after_successful_send_clears_outbox() {
// Guest does a valid send, then traps. The outbox should be cleared
// and the send should NOT be delivered.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Write payload
(i32.store8 (i32.const 200) (i32.const 99))
;; Valid send
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
;; Now trap
unreachable
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"doomed")).unwrap();
rt.tick();
// Outbox should be cleared by the trap, so nothing delivered
assert!(inbox.try_recv().is_none(), "trap should clear all outbox sends");
// Actor survives — send another, still traps
rt.send_to(addr, framed_msg(inbox.addr(), b"also-doomed")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "actor survives but always traps");
}
// ── Send from WAT module using payload at start of memory (offset 0) ────────
#[test]
fn send_payload_at_memory_offset_zero() {
// Guest writes payload at offset 0 and sends from there.
// Tests that offset 0 is a valid payload location.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Write "OK" at offset 0
(i32.store8 (i32.const 0) (i32.const 79)) ;; 'O'
(i32.store8 (i32.const 1) (i32.const 75)) ;; 'K'
;; Send from offset 0 with len 2
(call $send (local.get $ptr) (i32.const 0) (i32.const 2))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"trigger")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("send from offset 0 should work");
assert_eq!(msg.0, b"OK");
}
// ── WasmActorError From<wasmtime::Error> conversion ─────────────────────────
#[test]
fn wasmtime_error_converts_to_wasm_actor_error() {
// Force a wasmtime::Error through the build path and verify the
// From conversion produces WasmActorError::Wasmtime variant.
let garbage = vec![0x00, 0x61, 0x73, 0x6D]; // valid magic but truncated
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, garbage).build();
assert!(result.is_err());
match result.err().unwrap() {
WasmActorError::Wasmtime(e) => {
// wasmtime::Error should have a non-empty message
let msg = format!("{e}");
assert!(!msg.is_empty(), "wasmtime error should have a message");
}
other => panic!("expected Wasmtime variant, got {other}"),
}
}
// ── 3 WASM actors on 2-thread runtime all deliver ───────────────────────────
#[test]
fn three_wasm_actors_on_two_threads() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let engine = SharedEngine::new().unwrap();
let config = RuntimeConfig {
num_threads: 2,
..RuntimeConfig::default()
};
let rt = Runtime::new(config);
let counter = Arc::new(AtomicUsize::new(0));
// Spawn 3 echo actors
let mut addrs = Vec::new();
for _ in 0..3 {
let actor = WasmActorBuilder::new(engine.clone(), guest_wasm("echo"))
.build()
.unwrap();
addrs.push(rt.spawn(actor).unwrap());
}
// Create a native counter actor that counts received messages
struct MsgCounter(Arc<AtomicUsize>);
impl ActorInterface for MsgCounter {
type Incoming = ByteMessage;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: ByteMessage) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
let counter_clone = counter.clone();
let counter_addr = rt.spawn(MsgCounter(counter_clone)).unwrap();
// Send one message to each WASM actor, echoing to the counter
for addr in &addrs {
rt.send_to(*addr, framed_msg(&counter_addr, b"ping")).unwrap();
}
let handle = rt.run().unwrap();
// Poll for all 3 messages
let mut success = false;
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(25));
if counter.load(Ordering::SeqCst) >= 3 {
success = true;
break;
}
}
handle.shutdown();
assert!(success, "all 3 WASM actors should deliver on 2-thread runtime");
}
// ── Module with if/else branching in handle ─────────────────────────────────
#[test]
fn handle_with_if_else_branching() {
// Guest uses if/else to send different payloads based on message length.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; If payload (len - 32) > 5, send "BIG", else send "SML"
(if (i32.gt_s (i32.sub (local.get $len) (i32.const 32)) (i32.const 5))
(then
(i32.store8 (i32.const 200) (i32.const 66)) ;; 'B'
(i32.store8 (i32.const 201) (i32.const 73)) ;; 'I'
(i32.store8 (i32.const 202) (i32.const 71)) ;; 'G'
(call $send (local.get $ptr) (i32.const 200) (i32.const 3))
)
(else
(i32.store8 (i32.const 200) (i32.const 83)) ;; 'S'
(i32.store8 (i32.const 201) (i32.const 77)) ;; 'M'
(i32.store8 (i32.const 202) (i32.const 76)) ;; 'L'
(call $send (local.get $ptr) (i32.const 200) (i32.const 3))
)
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Short payload (3 bytes <= 5)
rt.send_to(addr, framed_msg(inbox.addr(), b"abc")).unwrap();
// Long payload (10 bytes > 5)
rt.send_to(addr, framed_msg(inbox.addr(), b"1234567890")).unwrap();
rt.tick();
let msg1 = inbox.try_recv().expect("short message should get response");
let msg2 = inbox.try_recv().expect("long message should get response");
assert_eq!(msg1.0, b"SML");
assert_eq!(msg2.0, b"BIG");
}
// ── Module with loop/br — iterative computation in handle ───────────────────
#[test]
fn handle_with_loop_computes_sum() {
// Guest sums all payload bytes using a loop and sends the sum as a single byte.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $i i32)
(local $sum i32)
(local $payload_start i32)
(local $payload_len i32)
;; payload starts at ptr+32, length is len-32
(local.set $payload_start (i32.add (local.get $ptr) (i32.const 32)))
(local.set $payload_len (i32.sub (local.get $len) (i32.const 32)))
(local.set $i (i32.const 0))
(local.set $sum (i32.const 0))
;; Sum loop
(block $break
(loop $loop
(br_if $break (i32.ge_u (local.get $i) (local.get $payload_len)))
(local.set $sum
(i32.add
(local.get $sum)
(i32.load8_u (i32.add (local.get $payload_start) (local.get $i)))
)
)
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
)
)
;; Write sum (truncated to u8) at offset 200
(i32.store8 (i32.const 200) (local.get $sum))
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Sum of [1, 2, 3, 4, 5] = 15
rt.send_to(addr, framed_msg(inbox.addr(), &[1, 2, 3, 4, 5])).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("should receive sum");
assert_eq!(msg.0[0], 15, "sum of [1,2,3,4,5] should be 15");
// Sum of [100, 100, 56] = 256 → truncated to 0 (u8 overflow)
rt.send_to(addr, framed_msg(inbox.addr(), &[100, 100, 56])).unwrap();
rt.tick();
let msg2 = inbox.try_recv().expect("should receive truncated sum");
assert_eq!(msg2.0[0], 0, "256 truncated to u8 wraps to 0");
}
// ── Send import with dest_ptr = 0 (valid, reads from start of memory) ──────
#[test]
fn send_with_dest_ptr_zero_reads_from_memory_start() {
// Guest copies the address to offset 0, then sends with dest_ptr=0.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Copy 32-byte address from message to offset 0
(memory.copy (i32.const 0) (local.get $ptr) (i32.const 32))
;; Write payload at offset 300
(i32.store8 (i32.const 300) (i32.const 42))
;; Send with dest_ptr=0
(call $send (i32.const 0) (i32.const 300) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"zero-dest")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("dest_ptr=0 should be valid");
assert_eq!(msg.0, vec![42]);
}
// ── Wasm actors survive being stopped while message in flight ───────────────
#[test]
fn stop_actor_with_pending_messages_no_crash() {
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 messages, then stop before tick
for i in 0u8..10 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.stop_actor(addr);
rt.tick();
// The actor may or may not have processed some messages before being stopped.
// The important thing is no crash/panic.
let mut count = 0;
while let Some(_) = inbox.try_recv() {
count += 1;
}
// Count can be 0..=10, we just verify no panic
assert!(count <= 10, "at most 10 messages should be received");
}
// ── Property: build from any subset of valid WAT produces valid error ───────
proptest! {
#[test]
fn prop_truncated_wasm_never_panics(
len in 0usize..200
) {
let full_wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
)
"#;
let wasm_full = wat::parse_str(full_wat).unwrap();
// Truncate the WASM bytes
let truncated: Vec<u8> = wasm_full.iter().take(len).copied().collect();
let engine = SharedEngine::new().unwrap();
// Building from any prefix should never panic — it either succeeds or returns Err
let _result = WasmActorBuilder::new(engine, truncated).build();
}
}
// ── Message ordering: same-tick messages arrive in send order ────────────────
#[test]
fn same_tick_message_ordering_preserved() {
// Send 20 numbered messages in order. They should arrive in the same order
// within a single tick (FIFO mailbox guarantee).
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();
for i in 0u8..20 {
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
let expected: Vec<u8> = (0..20).collect();
assert_eq!(received, expected, "messages should arrive in FIFO order");
}
// ── Send import: payload_ptr + payload_len overflows usize ──────────────────
#[test]
fn send_payload_ptr_plus_len_overflow_traps() {
// Guest tries to send with payload_ptr near i32::MAX and payload_len > 0,
// causing checked_add to detect overflow.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; payload_ptr = 2147483647 (i32::MAX), payload_len = 1
;; As usize: checked_add(2147483647, 1) = 2147483648 which > mem_len
(call $send (local.get $ptr) (i32.const 2147483647) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"overflow")).unwrap();
rt.tick();
// The send should trap (OOB), clearing outbox, so no delivery
assert!(inbox.try_recv().is_none(), "payload ptr overflow should trap");
}
// ── Large number of sends in one handle (stress outbox) ─────────────────────
#[test]
fn handle_sends_100_messages_in_one_call() {
// Guest sends 100 messages in a single handle invocation.
// Tests outbox Vec capacity and drain performance.
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 i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $i i32)
(local.set $i (i32.const 0))
(block $break
(loop $loop
(br_if $break (i32.ge_u (local.get $i) (i32.const 100)))
;; Write counter byte
(i32.store8 (i32.const 200) (local.get $i))
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
)
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"burst")).unwrap();
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
assert_eq!(received.len(), 100, "should receive 100 messages from one handle");
// Each stores current `i` value which wraps at 256 but 0..100 fits in u8
let expected: Vec<u8> = (0..100).collect();
assert_eq!(received, expected, "messages should contain counter 0..100");
}
// ── Module with block/br_table (switch-like dispatch) ───────────────────────
#[test]
fn br_table_dispatch_in_handle() {
// Guest uses br_table to dispatch on first payload byte (0, 1, or default).
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $cmd i32)
(local.set $cmd (i32.load8_u (i32.add (local.get $ptr) (i32.const 32))))
(block $default
(block $case1
(block $case0
(br_table $case0 $case1 $default (local.get $cmd))
)
;; case 0: send "ZERO"
(i32.store8 (i32.const 200) (i32.const 90)) ;; 'Z'
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
return
)
;; case 1: send "ONE"
(i32.store8 (i32.const 200) (i32.const 79)) ;; 'O'
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
return
)
;; default: send "D"
(i32.store8 (i32.const 200) (i32.const 68)) ;; 'D'
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[0])).unwrap(); // case 0
rt.send_to(addr, framed_msg(inbox.addr(), &[1])).unwrap(); // case 1
rt.send_to(addr, framed_msg(inbox.addr(), &[5])).unwrap(); // default
rt.tick();
let msg0 = inbox.try_recv().unwrap();
let msg1 = inbox.try_recv().unwrap();
let msg2 = inbox.try_recv().unwrap();
assert_eq!(msg0.0, b"Z");
assert_eq!(msg1.0, b"O");
assert_eq!(msg2.0, b"D");
}
// ── Two WASM actors watching each other — one stops, other gets notified ────
#[test]
fn mutual_watch_wasm_actors() {
let engine = SharedEngine::new().unwrap();
let echo1 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let echo2 = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let exit_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let exit_count_clone = exit_count.clone();
struct WatchAndCount {
target: Option<ActorAddress>,
count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl ActorInterface for WatchAndCount {
type Incoming = ByteMessage;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: ByteMessage) {
if let Some(t) = self.target.take() {
ctx.watch(t);
}
}
fn on_actor_exit(&mut self, _ctx: &Ctx, _exited: swactor::actor::ActorExited) {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
let rt = Runtime::new(RuntimeConfig::default());
let a1 = rt.spawn(echo1).unwrap();
let a2 = rt.spawn(echo2).unwrap();
// Spawn a watcher that watches a1
let watcher = WatchAndCount {
target: Some(a1),
count: exit_count_clone,
};
let watcher_addr = rt.spawn(watcher).unwrap();
// Trigger the watcher to install the watch
rt.send_to(watcher_addr, ByteMessage(vec![])).unwrap();
rt.tick();
// Stop a1 — watcher should be notified
rt.stop_actor(a1);
rt.tick();
rt.tick(); // death notification propagates
assert_eq!(
exit_count.load(std::sync::atomic::Ordering::SeqCst), 1,
"watcher should be notified when watched WASM actor stops"
);
// a2 should still be alive and functional
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
rt.send_to(a2, framed_msg(inbox.addr(), b"alive")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("a2 should still be alive");
assert_eq!(msg.0, b"alive");
}
// ── Module with select instruction (ternary operator) ───────────────────────
#[test]
fn select_instruction_in_handle() {
// Guest uses `select` to choose between two values based on condition.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; If payload > 32 bytes (has actual content), use 'Y', else 'N'
(i32.store8 (i32.const 200)
(select
(i32.const 89) ;; 'Y' (true branch)
(i32.const 78) ;; 'N' (false branch)
(i32.gt_s (local.get $len) (i32.const 32))
)
)
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// Message with payload (len > 32)
rt.send_to(addr, framed_msg(inbox.addr(), b"content")).unwrap();
// Message without payload (len == 32)
rt.send_to(addr, framed_msg(inbox.addr(), b"")).unwrap();
rt.tick();
let msg1 = inbox.try_recv().unwrap();
let msg2 = inbox.try_recv().unwrap();
assert_eq!(msg1.0, b"Y", "message with content should select Y");
assert_eq!(msg2.0, b"N", "message without content should select N");
}
// ── WASM actor echoes to another WASM actor which echoes to native inbox ────
#[test]
fn wasm_to_wasm_to_native_relay() {
// echo1 → echo2 → inbox. Three-layer relay.
let engine = SharedEngine::new().unwrap();
let echo1 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let echo2 = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr2 = rt.spawn(echo2).unwrap();
let addr1 = rt.spawn(echo1).unwrap();
// Send to echo1 with dest=echo2. Payload is a framed message for echo2→inbox.
let inner_payload = framed_msg(inbox.addr(), b"relay-data");
rt.send_to(addr1, framed_msg(&addr2, &inner_payload.0)).unwrap();
rt.tick(); // echo1 sends to echo2
rt.tick(); // echo2 sends to inbox
let msg = inbox.try_recv().expect("should receive relayed message");
assert_eq!(msg.0, b"relay-data");
}
// ── Property: any byte pattern in dest address never panics ─────────────────
proptest! {
#[test]
fn prop_any_dest_address_bytes_never_panic(
addr_bytes in proptest::collection::vec(proptest::num::u8::ANY, 32..=32),
payload in proptest::collection::vec(proptest::num::u8::ANY, 0..64),
) {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(actor).unwrap();
// Construct a message with arbitrary 32-byte dest address + payload
let mut msg_bytes = addr_bytes;
msg_bytes.extend_from_slice(&payload);
rt.send_to(addr, ByteMessage(msg_bytes)).unwrap();
rt.tick();
// No panic = success. Message either delivers or is silently dropped.
}
}
// ── Spawn WASM actor from inside native actor's handle ──────────────────────
#[test]
fn native_handler_spawns_wasm_actor_inline() {
// A native actor receives a message and spawns a WASM echo actor in its handler,
// then sends a message to the newly spawned actor.
struct InlineSpawner {
engine: SharedEngine,
wasm_bytes: Vec<u8>,
result_inbox: ActorAddress,
}
impl ActorInterface for InlineSpawner {
type Incoming = ByteMessage;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: ByteMessage) {
let actor = WasmActorBuilder::new(self.engine.clone(), self.wasm_bytes.clone())
.build()
.unwrap();
let wasm_addr = ctx.spawn(actor).unwrap();
let _ = ctx.send(wasm_addr, framed_msg(&self.result_inbox, b"from-spawner"));
}
}
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let spawner = InlineSpawner {
engine,
wasm_bytes: guest_wasm("echo"),
result_inbox: *inbox.addr(),
};
let spawner_addr = rt.spawn(spawner).unwrap();
rt.send_to(spawner_addr, ByteMessage(vec![])).unwrap();
rt.tick(); // spawner creates WASM actor + sends message
rt.tick(); // WASM actor processes message, echoes to inbox
let msg = inbox.try_recv().expect("spawned WASM actor should echo to inbox");
assert_eq!(msg.0, b"from-spawner");
}
// ── WASM actor with local variables (stack manipulation) ────────────────────
#[test]
fn handle_uses_many_locals() {
// Guest uses several local variables for computation.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $a i32) (local $b i32) (local $c i32) (local $d i32)
(local.set $a (i32.const 10))
(local.set $b (i32.const 20))
(local.set $c (i32.add (local.get $a) (local.get $b)))
(local.set $d (i32.mul (local.get $c) (i32.const 2)))
;; d = (10 + 20) * 2 = 60
(i32.store8 (i32.const 200) (local.get $d))
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"compute")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0[0], 60, "(10+20)*2 = 60");
}
// ── WASM actor after many ticks still functions (no resource leak) ──────────
#[test]
fn wasm_actor_survives_1000_ticks() {
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 a message every 100 ticks
for i in 0u8..10 {
// 100 empty ticks
for _ in 0..100 {
rt.tick();
}
rt.send_to(addr, framed_msg(inbox.addr(), &[i])).unwrap();
rt.tick();
}
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
let expected: Vec<u8> = (0..10).collect();
assert_eq!(received, expected, "actor should still work after 1000+ ticks");
}
// ── Send to dead actor — message silently dropped ───────────────────────────
#[test]
fn send_to_dead_wasm_actor_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();
// Verify it works
rt.send_to(addr, framed_msg(inbox.addr(), b"alive")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_some());
// Stop and cleanup
rt.stop_actor(addr);
rt.tick();
rt.tick();
// Send to dead actor — should not panic
let result = rt.send_to(addr, framed_msg(inbox.addr(), b"dead"));
// Either returns Ok (message silently dropped) or Err (dead address)
// Both are acceptable — the key is no panic
drop(result);
rt.tick();
assert!(inbox.try_recv().is_none(), "dead actor should not deliver");
}
// ── Guest alloc always returns same pointer — messages overwrite each other ──
#[test]
fn static_alloc_pointer_messages_overwrite() {
// Guest alloc always returns 4096. Each message overwrites the same region.
// The last message to be processed wins. Verifies outbox snapshots correctly.
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 3 messages in same tick. Each writes to offset 4096.
// Echo reads from 4096 — should read the data written for that specific call
// because outbox snapshots at send time.
rt.send_to(addr, framed_msg(inbox.addr(), b"first")).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"second")).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"third")).unwrap();
rt.tick();
let mut payloads = Vec::new();
while let Some(msg) = inbox.try_recv() {
payloads.push(msg.0);
}
assert_eq!(payloads.len(), 3);
assert_eq!(payloads[0], b"first");
assert_eq!(payloads[1], b"second");
assert_eq!(payloads[2], b"third");
}
// ── Module with nested blocks ───────────────────────────────────────────────
#[test]
fn nested_blocks_in_handle() {
// Guest uses nested block/end to structure control flow.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(block $outer
(block $inner
;; If len <= 32 (no payload), break to outer (skip send)
(br_if $outer (i32.le_s (local.get $len) (i32.const 32)))
;; If len > 64 (big payload), break to inner (send "BIG")
(br_if $inner (i32.gt_s (local.get $len) (i32.const 64)))
;; Small payload: send "SM"
(i32.store8 (i32.const 200) (i32.const 83))
(i32.store8 (i32.const 201) (i32.const 77))
(call $send (local.get $ptr) (i32.const 200) (i32.const 2))
return
)
;; Big payload
(i32.store8 (i32.const 200) (i32.const 66))
(i32.store8 (i32.const 201) (i32.const 71))
(call $send (local.get $ptr) (i32.const 200) (i32.const 2))
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// No payload (len=32) → no send
rt.send_to(addr, framed_msg(inbox.addr(), b"")).unwrap();
// Small payload (5 bytes, total len=37) → "SM"
rt.send_to(addr, framed_msg(inbox.addr(), b"hello")).unwrap();
// Big payload (50 bytes, total len=82) → "BG"
rt.send_to(addr, framed_msg(inbox.addr(), &[b'x'; 50])).unwrap();
rt.tick();
let msg1 = inbox.try_recv().unwrap();
let msg2 = inbox.try_recv().unwrap();
assert!(inbox.try_recv().is_none(), "empty payload should not send");
assert_eq!(msg1.0, b"SM");
assert_eq!(msg2.0, b"BG");
}
// ── Guest uses memory.size to check available memory ────────────────────────
#[test]
fn guest_uses_memory_size_instruction() {
// Guest checks memory.size and sends it as a response byte.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 2)
(func (export "alloc") (param i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; memory.size returns pages (should be 2)
(i32.store8 (i32.const 200) (memory.size))
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"size")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0[0], 2, "memory.size should report 2 pages");
}
// ── Property: spawn-send-tick-stop cycle never panics ───────────────────────
proptest! {
#[test]
fn prop_spawn_send_stop_cycle_never_panics(
n_msgs in 0u8..20,
payload_len in 0usize..128,
) {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = vec![0xAB; payload_len];
for _ in 0..n_msgs {
let _ = rt.send_to(addr, framed_msg(inbox.addr(), &payload));
}
rt.tick();
rt.stop_actor(addr);
rt.tick();
// Drain inbox
while let Some(_) = inbox.try_recv() {}
}
}
// ── Guest modifies memory between alloc and handle being called ─────────────
#[test]
fn guest_start_function_modifies_alloc_region() {
// Guest's start function writes data in the alloc region (4096+).
// When handle is called, the host writes over it. Tests that
// the host always writes fresh data, not relying on zeroed memory.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
;; Pre-fill region at 4096 with 0xFF bytes via data segment
(data (i32.const 4096) "\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\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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Echo payload to dest — host wrote message at 4096, overwriting 0xFF
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"overwrite")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("host should overwrite pre-filled memory");
assert_eq!(msg.0, b"overwrite", "host should write fresh data over 0xFF");
}
// ── Engine clone shares same underlying engine ──────────────────────────────
#[test]
fn engine_clone_is_same_engine() {
let engine1 = SharedEngine::new().unwrap();
let engine2 = engine1.clone();
// Both should produce working actors
let actor1 = WasmActorBuilder::new(engine1, guest_wasm("echo")).build().unwrap();
let actor2 = WasmActorBuilder::new(engine2, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let a1 = rt.spawn(actor1).unwrap();
let a2 = rt.spawn(actor2).unwrap();
rt.send_to(a1, framed_msg(inbox.addr(), b"clone1")).unwrap();
rt.send_to(a2, framed_msg(inbox.addr(), b"clone2")).unwrap();
rt.tick();
let mut msgs: Vec<Vec<u8>> = Vec::new();
while let Some(msg) = inbox.try_recv() {
msgs.push(msg.0);
}
msgs.sort();
assert_eq!(msgs, vec![b"clone1".to_vec(), b"clone2".to_vec()]);
}
// ── Guest with i64 operations in handle ─────────────────────────────────────
#[test]
fn handle_uses_i64_operations() {
// Guest performs i64 arithmetic and stores result as i32.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Compute 1000000000 * 3 = 3000000000 (fits in i64 but not i32)
;; Wrap to i32: 3000000000 mod 2^32 = 3000000000 (fits as u32)
;; As i32: -1294967296
;; Store low byte: 3000000000 & 0xFF = 0x00
;; Actually let's just do something simpler: 100 + 200 = 300 → wrap i32
(i32.store8 (i32.const 200)
(i32.wrap_i64
(i64.add (i64.const 100) (i64.const 155))
)
)
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"i64")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0[0], 255u8, "100 + 155 = 255");
}
// ── 200 actors from same engine all process one message ─────────────────────
#[test]
fn two_hundred_actors_from_same_engine() {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let mut addrs = Vec::new();
for _ in 0..200 {
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 & 0xFF) as u8])).unwrap();
}
rt.tick();
let mut count = 0;
while let Some(_) = inbox.try_recv() {
count += 1;
}
assert_eq!(count, 200, "all 200 actors should echo");
}
// ── Handle receives exactly 32 bytes (just address, no payload) ─────────────
#[test]
fn handle_receives_just_address_no_payload() {
// Send a message that is exactly 32 bytes (just the address header).
// The echo guest will try to send payload of len-32=0 bytes.
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();
// framed_msg with empty payload = just 32-byte address
rt.send_to(addr, framed_msg(inbox.addr(), b"")).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("should receive empty echo");
assert!(msg.0.is_empty(), "echo of empty payload should be empty");
}
// ── Guest writes to last byte of linear memory ──────────────────────────────
#[test]
fn guest_writes_last_byte_of_memory() {
// Guest writes to offset 65535 (last byte of 1 page).
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Write to last byte of memory
(i32.store8 (i32.const 65535) (i32.const 77))
;; Send that byte
(call $send (local.get $ptr) (i32.const 65535) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"x")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0, vec![77], "should read from last byte of memory");
}
// ── Alloc returns ptr in middle of previously allocated region ───────────────
#[test]
fn alloc_returns_overlapping_region() {
// Guest's alloc always returns 4096 regardless of previous calls.
// When the host writes message bytes, they always go to the same spot.
// Second message in same tick overwrites first message's data.
// But outbox snapshots, so both sends deliver their respective data.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Echo: send payload back
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"AAA")).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"BB")).unwrap();
rt.tick();
let msg1 = inbox.try_recv().unwrap();
let msg2 = inbox.try_recv().unwrap();
// Both should reflect their original data despite using same alloc region
assert_eq!(msg1.0, b"AAA", "first echo should have first payload");
assert_eq!(msg2.0, b"BB", "second echo should have second payload");
}
// ── Module exports memory with non-default name (should fail) ───────────────
#[test]
fn memory_exported_with_wrong_name_fails() {
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "heap") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, wasm).build();
assert!(result.is_err(), "memory exported as 'heap' should fail");
}
// ── WASM actor receives messages from multiple senders (fan-in) ─────────────
#[test]
fn fan_in_from_multiple_native_senders() {
// 5 native actors all send to the same WASM echo actor.
// Echo sends responses back to their respective inboxes.
let engine = SharedEngine::new().unwrap();
let echo = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let echo_addr = rt.spawn(echo).unwrap();
let mut inboxes = Vec::new();
for i in 0u8..5 {
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
rt.send_to(echo_addr, framed_msg(inbox.addr(), &[i])).unwrap();
inboxes.push(inbox);
}
rt.tick();
for (i, inbox) in inboxes.iter().enumerate() {
let msg = inbox.try_recv().unwrap_or_else(|| panic!("inbox {i} should receive"));
assert_eq!(msg.0, vec![i as u8], "inbox {i} should get correct payload");
}
}
// ── Module with multiple functions calling each other ────────────────────────
#[test]
fn module_with_internal_function_calls() {
// Guest has helper functions called from handle.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func $double (param $x i32) (result i32)
(i32.mul (local.get $x) (i32.const 2))
)
(func $add_ten (param $x i32) (result i32)
(i32.add (local.get $x) (i32.const 10))
)
(func (export "alloc") (param i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Read first payload byte, double it, add 10
(i32.store8 (i32.const 200)
(call $add_ten
(call $double
(i32.load8_u (i32.add (local.get $ptr) (i32.const 32)))
)
)
)
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// input=5: double(5)=10, add_ten(10)=20
rt.send_to(addr, framed_msg(inbox.addr(), &[5])).unwrap();
// input=100: double(100)=200, add_ten(200)=210
rt.send_to(addr, framed_msg(inbox.addr(), &[100])).unwrap();
rt.tick();
let msg1 = inbox.try_recv().unwrap();
let msg2 = inbox.try_recv().unwrap();
assert_eq!(msg1.0[0], 20);
assert_eq!(msg2.0[0], 210);
}
// ── Build from pre-compiled WAT bytes (no guest dir needed) ─────────────────
#[test]
fn build_from_raw_wat_bytes() {
let wat = r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "handle") (param i32 i32))
)
"#;
// Convert WAT → WASM at runtime
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
// Verify it works (silent actor — no sends)
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"silent")).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "silent actor should not send anything");
}
// ── Rapidly build and discard actors without spawning ────────────────────────
#[test]
fn build_and_discard_100_actors() {
// Build 100 actors but don't spawn them. Tests that WasmActor drops cleanly.
let engine = SharedEngine::new().unwrap();
for _ in 0..100 {
let actor = WasmActorBuilder::new(engine.clone(), guest_wasm("echo"))
.build()
.unwrap();
drop(actor);
}
// No panic = success
}
// ── Guest with f32/f64 floating point ops ───────────────────────────────────
#[test]
fn handle_uses_floating_point() {
// Guest performs f64 arithmetic and stores result as i32.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Compute floor(3.14 * 10) = floor(31.4) = 31
(i32.store8 (i32.const 200)
(i32.trunc_f64_s
(f64.mul (f64.const 3.14) (f64.const 10.0))
)
)
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"float")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0[0], 31, "floor(3.14 * 10) = 31");
}
// ── Empty WASM bytes produces error ─────────────────────────────────────────
#[test]
fn empty_wasm_bytes_error() {
let engine = SharedEngine::new().unwrap();
let result = WasmActorBuilder::new(engine, Vec::<u8>::new()).build();
assert!(result.is_err(), "empty bytes should fail");
}
// ── Guest table with funcref (call_indirect already tested, but table.get) ──
#[test]
fn module_with_funcref_table_works() {
// Table of function references used for indirect dispatch.
// Reference types are disabled, but funcref tables should work
// since they're part of the MVP spec.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(type $handler (func (param i32 i32 i32)))
(func $send_A (param $dest i32) (param $payload i32) (param $len i32)
(i32.store8 (i32.const 200) (i32.const 65)) ;; 'A'
(call $send (local.get $dest) (i32.const 200) (i32.const 1))
)
(func $send_B (param $dest i32) (param $payload i32) (param $len i32)
(i32.store8 (i32.const 200) (i32.const 66)) ;; 'B'
(call $send (local.get $dest) (i32.const 200) (i32.const 1))
)
(table 2 funcref)
(elem (i32.const 0) $send_A $send_B)
(func (export "alloc") (param i32) (result i32) i32.const 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Dispatch based on first payload byte: 0→send_A, 1→send_B
(call_indirect (type $handler)
(local.get $ptr) ;; dest
(i32.const 0) ;; unused payload
(i32.const 0) ;; unused len
(i32.load8_u (i32.add (local.get $ptr) (i32.const 32))) ;; table index
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[0])).unwrap(); // → 'A'
rt.send_to(addr, framed_msg(inbox.addr(), &[1])).unwrap(); // → 'B'
rt.tick();
let msg1 = inbox.try_recv().unwrap();
let msg2 = inbox.try_recv().unwrap();
assert_eq!(msg1.0, b"A");
assert_eq!(msg2.0, b"B");
}
// ── Concurrent build from multiple threads ──────────────────────────────────
#[test]
fn concurrent_build_from_shared_engine() {
use std::thread;
let engine = SharedEngine::new().unwrap();
let wasm_bytes = guest_wasm("echo");
let handles: Vec<_> = (0..4)
.map(|_| {
let e = engine.clone();
let w = wasm_bytes.clone();
thread::spawn(move || {
WasmActorBuilder::new(e, w).build().unwrap()
})
})
.collect();
let actors: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All 4 actors should work on the same runtime
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
for actor in actors {
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"concurrent")).unwrap();
}
rt.tick();
let mut count = 0;
while let Some(_) = inbox.try_recv() {
count += 1;
}
assert_eq!(count, 4, "all 4 concurrently-built actors should work");
}
// ── Guest uses memory.copy for bulk data move ───────────────────────────────
#[test]
fn guest_uses_memory_copy_for_response() {
// Guest copies the entire message to a response buffer using memory.copy,
// then sends the payload portion back.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Copy entire message to offset 8192
(memory.copy (i32.const 8192) (local.get $ptr) (local.get $len))
;; Send payload (offset 8192+32) back to dest (offset 8192)
(call $send
(i32.const 8192)
(i32.add (i32.const 8192) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"bulk-copy-test")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0, b"bulk-copy-test");
}
// ── WasmActorError is Send + Sync ──────────────────────────────────────────
#[test]
fn wasm_actor_error_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<WasmActorError>();
}
// ── Message with all 256 byte values round-trips correctly ──────────────────
#[test]
fn all_256_byte_values_round_trip() {
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 all 256 byte values as payload
let payload: Vec<u8> = (0..=255).collect();
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0.len(), 256, "should receive all 256 bytes");
for (i, &byte) in msg.0.iter().enumerate() {
assert_eq!(byte, i as u8, "byte {i} should be {i}");
}
}
// ── Guest uses memory.fill to initialize a region ───────────────────────────
#[test]
fn guest_uses_memory_fill_for_response() {
// Guest fills a region with a constant byte and sends it.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Fill 10 bytes at offset 200 with value 0x42 ('B')
(memory.fill (i32.const 200) (i32.const 0x42) (i32.const 10))
(call $send (local.get $ptr) (i32.const 200) (i32.const 10))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"fill")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0, vec![0x42; 10], "should receive 10 'B' bytes");
}
// ── WASM actor processes messages after watcher is installed ─────────────────
#[test]
fn wasm_actor_works_normally_while_being_watched() {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let exit_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let exit_clone = exit_count.clone();
struct WatchAndCount2 {
target: Option<ActorAddress>,
count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl ActorInterface for WatchAndCount2 {
type Incoming = ByteMessage;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: ByteMessage) {
if let Some(t) = self.target.take() {
ctx.watch(t);
}
}
fn on_actor_exit(&mut self, _ctx: &Ctx, _exited: swactor::actor::ActorExited) {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let wasm_addr = rt.spawn(actor).unwrap();
let watcher = WatchAndCount2 { target: Some(wasm_addr), count: exit_clone };
let watcher_addr = rt.spawn(watcher).unwrap();
// Install the watch
rt.send_to(watcher_addr, ByteMessage(vec![])).unwrap();
rt.tick();
// WASM actor should still work normally
for i in 0u8..5 {
rt.send_to(wasm_addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
assert_eq!(received, vec![0, 1, 2, 3, 4], "actor should work normally while watched");
// Stop it — watcher should be notified
rt.stop_actor(wasm_addr);
rt.tick();
rt.tick();
assert_eq!(exit_count.load(std::sync::atomic::Ordering::SeqCst), 1);
}
// ── Double guest processes 0xFF payload (boundary byte value) ───────────────
#[test]
fn double_guest_with_max_byte_value() {
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, guest_wasm("double")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[0xFF])).unwrap();
rt.tick();
let msg1 = inbox.try_recv().expect("first double");
let msg2 = inbox.try_recv().expect("second double");
assert_eq!(msg1.0, vec![0xFF]);
assert_eq!(msg2.0, vec![0xFF]);
assert!(inbox.try_recv().is_none(), "exactly two copies");
}
// ── ByteMessage supports large messages (4KB) ───────────────────────────────
#[test]
fn large_4kb_message_round_trips() {
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();
// 4KB payload (each byte = position mod 256)
let payload: Vec<u8> = (0..4096).map(|i| (i & 0xFF) as u8).collect();
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0.len(), 4096);
assert_eq!(msg.0, payload);
}
// ── Guest that sends back payload length as response ────────────────────────
#[test]
fn guest_reports_payload_length() {
// Guest reads the payload length (len-32) and sends it back as a 4-byte LE integer.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Store payload_len = len - 32 as i32 at offset 200
(i32.store (i32.const 200) (i32.sub (local.get $len) (i32.const 32)))
(call $send (local.get $ptr) (i32.const 200) (i32.const 4))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[0u8; 100])).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
let len = i32::from_le_bytes([msg.0[0], msg.0[1], msg.0[2], msg.0[3]]);
assert_eq!(len, 100, "guest should report payload length of 100");
}
// ── Alloc returns 1 (odd alignment) — still works ──────────────────────────
#[test]
fn alloc_returns_odd_alignment() {
// Guest alloc returns 1 (not aligned). Host should still write correctly.
let wat = r#"
(module
(import "swactor" "send" (func $send (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 1)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Echo from offset 1
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), b"odd-align")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0, b"odd-align");
}
// ── SharedEngine clone + Debug ──────────────────────────────────────────────
#[test]
fn shared_engine_clone_and_debug() {
let engine = SharedEngine::new().unwrap();
let cloned = engine.clone();
let debug1 = format!("{:?}", engine);
let debug2 = format!("{:?}", cloned);
assert_eq!(debug1, debug2, "cloned engine should have same debug repr");
}
// ── Multiple sequential ticks without messages don't affect WASM actor ──────
#[test]
fn idle_ticks_dont_affect_wasm_actor() {
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();
// 500 idle ticks
for _ in 0..500 {
rt.tick();
}
// Should still work
rt.send_to(addr, framed_msg(inbox.addr(), b"after-idle")).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0, b"after-idle");
}
// ── WASM + native actors alternate processing in same tick ──────────────────
#[test]
fn wasm_and_native_alternate_in_same_tick() {
struct NativeEcho2;
impl ActorInterface for NativeEcho2 {
type Incoming = ByteMessage;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: ByteMessage) {
if msg.0.len() >= 32 {
let mut addr_bytes = [0u8; 32];
addr_bytes.copy_from_slice(&msg.0[..32]);
let dest = ActorAddress(addr_bytes);
let payload = msg.0[32..].to_vec();
let _ = ctx.send(dest, ByteMessage(payload));
}
}
}
let engine = SharedEngine::new().unwrap();
let wasm_echo = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let wasm_addr = rt.spawn(wasm_echo).unwrap();
let native_addr = rt.spawn(NativeEcho2).unwrap();
for i in 0u8..10 {
if i % 2 == 0 {
rt.send_to(wasm_addr, framed_msg(inbox.addr(), &[i])).unwrap();
} else {
rt.send_to(native_addr, framed_msg(inbox.addr(), &[i])).unwrap();
}
}
rt.tick();
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0[0]);
}
received.sort();
let expected: Vec<u8> = (0..10).collect();
assert_eq!(received, expected, "all 10 messages from both types should deliver");
}
// ── Guest sums two payload bytes ────────────────────────────────────────────
#[test]
fn guest_sums_two_payload_bytes() {
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(local $val i32)
(local.set $val
(i32.add
(i32.load8_u (i32.add (local.get $ptr) (i32.const 32)))
(i32.load8_u (i32.add (local.get $ptr) (i32.const 33)))
)
)
(i32.store8 (i32.const 200) (local.get $val))
(i32.store8 (i32.const 201) (local.get $val))
(call $send (local.get $ptr) (i32.const 200) (i32.const 2))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[30, 12])).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0, vec![42, 42], "30+12=42 duplicated");
}
// ── Single byte payload echo ────────────────────────────────────────────────
#[test]
fn single_byte_payload_echo() {
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(), &[0x42])).unwrap();
rt.tick();
let msg = inbox.try_recv().unwrap();
assert_eq!(msg.0, vec![0x42]);
}
// ── Module with no functions fails ──────────────────────────────────────────
#[test]
fn module_with_no_functions_fails() {
let wat = "(module (memory (export \"memory\") 1))";
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
assert!(WasmActorBuilder::new(engine, wasm).build().is_err());
}
// ── Response varies by message size ─────────────────────────────────────────
#[test]
fn response_varies_by_message_size() {
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
(i32.store8 (i32.const 200) (i32.sub (local.get $len) (i32.const 32)))
(call $send (local.get $ptr) (i32.const 200) (i32.const 1))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[])).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[1])).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[0; 50])).unwrap();
rt.send_to(addr, framed_msg(inbox.addr(), &[0; 200])).unwrap();
rt.tick();
let sizes: Vec<u8> = std::iter::from_fn(|| inbox.try_recv().map(|m| m.0[0])).collect();
assert_eq!(sizes, vec![0, 1, 50, 200]);
}
// ── Property: echo is deterministic ─────────────────────────────────────────
proptest! {
#[test]
fn prop_echo_is_deterministic(
payload in proptest::collection::vec(proptest::num::u8::ANY, 0..128)
) {
let engine = SharedEngine::new().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let actor1 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr1 = rt.spawn(actor1).unwrap();
rt.send_to(addr1, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let result1 = inbox.try_recv().map(|m| m.0);
let actor2 = WasmActorBuilder::new(engine, guest_wasm("echo")).build().unwrap();
let addr2 = rt.spawn(actor2).unwrap();
rt.send_to(addr2, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let result2 = inbox.try_recv().map(|m| m.0);
assert_eq!(result1, result2, "same input should produce same output");
}
}
// ── 200th test: comprehensive lifecycle with all guest types ────────────────
#[test]
fn comprehensive_lifecycle_all_guest_types_200th() {
// Spawn one of each guest type (echo, double, silent), send messages,
// verify outputs, stop them all, check cleanup.
let engine = SharedEngine::new().unwrap();
let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let double = WasmActorBuilder::new(engine.clone(), guest_wasm("double")).build().unwrap();
let silent = WasmActorBuilder::new(engine, guest_wasm("silent")).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let echo_addr = rt.spawn(echo).unwrap();
let double_addr = rt.spawn(double).unwrap();
let silent_addr = rt.spawn(silent).unwrap();
// Send to all three
rt.send_to(echo_addr, framed_msg(inbox.addr(), b"E")).unwrap();
rt.send_to(double_addr, framed_msg(inbox.addr(), b"D")).unwrap();
rt.send_to(silent_addr, framed_msg(inbox.addr(), b"S")).unwrap();
rt.tick();
let mut payloads: Vec<Vec<u8>> = Vec::new();
while let Some(msg) = inbox.try_recv() {
payloads.push(msg.0);
}
payloads.sort();
// Echo → "E" (1x), Double → "D" (2x), Silent → nothing
assert_eq!(payloads, vec![b"D".to_vec(), b"D".to_vec(), b"E".to_vec()]);
// Stop all
rt.stop_actor(echo_addr);
rt.stop_actor(double_addr);
rt.stop_actor(silent_addr);
rt.tick();
rt.tick();
// Send to stopped actors — silently dropped
rt.send_to(echo_addr, framed_msg(inbox.addr(), b"gone")).ok();
rt.tick();
assert!(inbox.try_recv().is_none(), "stopped actors should not deliver");
}
// ── Guest alloc returns pointer at exact page boundary ──────────────────────
#[test]
fn alloc_at_page_boundary_works() {
// Guest alloc returns 65536 - 64 = 65472. With a 64-byte message,
// end = 65472 + 64 = 65536 = memory size. Exactly in bounds.
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 65472)
(func (export "handle") (param $ptr i32) (param $len i32)
(call $send
(local.get $ptr)
(i32.add (local.get $ptr) (i32.const 32))
(i32.sub (local.get $len) (i32.const 32))
)
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
// 32 (addr) + 32 (payload) = 64 bytes → fits exactly at 65472..65536
let payload = vec![0xAB; 32];
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("exact page boundary should work");
assert_eq!(msg.0, payload);
}
// ── Guest alloc returns pointer 1 byte past page boundary — drops ───────────
#[test]
fn alloc_one_past_page_boundary_drops() {
// Guest alloc returns 65473. With 64-byte message:
// end = 65473 + 64 = 65537 > 65536. OOB, message dropped.
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 65473)
(func (export "handle") (param i32 i32)
unreachable
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let payload = vec![0xAB; 32]; // total msg = 64 bytes
rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap();
rt.tick();
assert!(inbox.try_recv().is_none(), "OOB alloc should drop message");
}
// ── Multiple runtimes with WASM actors independently ────────────────────────
#[test]
fn multiple_runtimes_with_wasm_actors() {
let engine = SharedEngine::new().unwrap();
// Runtime 1
let rt1 = Runtime::new(RuntimeConfig::default());
let inbox1 = rt1.new_inbox::<ByteMessage>().unwrap();
let actor1 = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
let addr1 = rt1.spawn(actor1).unwrap();
// Runtime 2
let rt2 = Runtime::new(RuntimeConfig::default());
let inbox2 = rt2.new_inbox::<ByteMessage>().unwrap();
let actor2 = WasmActorBuilder::new(engine, guest_wasm("double")).build().unwrap();
let addr2 = rt2.spawn(actor2).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();
let msg1 = inbox1.try_recv().unwrap();
assert_eq!(msg1.0, b"rt1");
let d1 = inbox2.try_recv().unwrap();
let d2 = inbox2.try_recv().unwrap();
assert_eq!(d1.0, b"rt2");
assert_eq!(d2.0, b"rt2");
}
// ── Guest sends back exact copy of the full message (including address) ─────
#[test]
fn guest_mirrors_full_message() {
// Guest sends back the entire message (address + payload) to the dest.
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 4096)
(func (export "handle") (param $ptr i32) (param $len i32)
;; Send the full message (including address header) as payload
(call $send (local.get $ptr) (local.get $ptr) (local.get $len))
)
)
"#;
let wasm = wat::parse_str(wat).unwrap();
let engine = SharedEngine::new().unwrap();
let actor = WasmActorBuilder::new(engine, wasm).build().unwrap();
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<ByteMessage>().unwrap();
let addr = rt.spawn(actor).unwrap();
let original_msg = framed_msg(inbox.addr(), b"mirror");
rt.send_to(addr, original_msg.clone()).unwrap();
rt.tick();
let msg = inbox.try_recv().expect("should receive mirrored full message");
// Payload = full original message (address + payload)
assert_eq!(msg.0, original_msg.0, "should receive exact copy of full message");
}