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