- guest_sends_to_self_via_framed_address: self-send bounces through, reaches inbox - guest_uses_bit_rotation: i32.rotl instruction - guest_uses_bit_counting: i32.clz, i32.ctz, i32.popcnt instructions - stop_every_other_actor_remaining_work: stop 5 of 10, other 5 still respond All 208 tests pass. No new bugs found. Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
7411 lines
No EOL
278 KiB
Rust
7411 lines
No EOL
278 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");
|
|
}
|
|
|
|
// ── Guest sends to self (WASM actor address from host) ──────────────────────
|
|
|
|
#[test]
|
|
fn guest_sends_to_self_via_framed_address() {
|
|
// The payload contains the WASM actor's own address as the dest.
|
|
// This creates a self-send that should be delivered 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 wasm_addr = rt.spawn(actor).unwrap();
|
|
|
|
// Frame with wasm_addr as dest, payload is a framed msg for inbox
|
|
let inner = framed_msg(inbox.addr(), b"self-bounce");
|
|
rt.send_to(wasm_addr, framed_msg(&wasm_addr, &inner.0)).unwrap();
|
|
rt.tick(); // echo sends inner to itself
|
|
rt.tick(); // processes inner, echoes payload to inbox
|
|
|
|
let msg = inbox.try_recv().expect("self-send should eventually reach inbox");
|
|
assert_eq!(msg.0, b"self-bounce");
|
|
}
|
|
|
|
// ── Guest with i32.rotr/rotl bit rotation ───────────────────────────────────
|
|
|
|
#[test]
|
|
fn guest_uses_bit_rotation() {
|
|
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)
|
|
;; Rotate left: 1 << 4 = 16 (i32.rotl(1, 4) = 16)
|
|
(i32.store8 (i32.const 200)
|
|
(i32.rotl (i32.const 1) (i32.const 4))
|
|
)
|
|
(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"rot")).unwrap();
|
|
rt.tick();
|
|
|
|
let msg = inbox.try_recv().unwrap();
|
|
assert_eq!(msg.0[0], 16, "rotl(1, 4) = 16");
|
|
}
|
|
|
|
// ── Guest with i32.clz/ctz/popcnt ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn guest_uses_bit_counting() {
|
|
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)
|
|
;; clz(256) = 23 (256 = 0x100, 23 leading zeros in 32-bit)
|
|
(i32.store8 (i32.const 200) (i32.clz (i32.const 256)))
|
|
;; ctz(256) = 8 (256 = 0x100, 8 trailing zeros)
|
|
(i32.store8 (i32.const 201) (i32.ctz (i32.const 256)))
|
|
;; popcnt(0xFF) = 8 (8 bits set)
|
|
(i32.store8 (i32.const 202) (i32.popcnt (i32.const 255)))
|
|
(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"bits")).unwrap();
|
|
rt.tick();
|
|
|
|
let msg = inbox.try_recv().unwrap();
|
|
assert_eq!(msg.0[0], 23, "clz(256) = 23");
|
|
assert_eq!(msg.0[1], 8, "ctz(256) = 8");
|
|
assert_eq!(msg.0[2], 8, "popcnt(255) = 8");
|
|
}
|
|
|
|
// ── Spawn 10 actors, stop every other one, remaining still work ─────────────
|
|
|
|
#[test]
|
|
fn stop_every_other_actor_remaining_work() {
|
|
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..10 {
|
|
let actor = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")).build().unwrap();
|
|
addrs.push(rt.spawn(actor).unwrap());
|
|
}
|
|
|
|
// Stop even-indexed actors
|
|
for (i, addr) in addrs.iter().enumerate() {
|
|
if i % 2 == 0 {
|
|
rt.stop_actor(*addr);
|
|
}
|
|
}
|
|
rt.tick();
|
|
rt.tick();
|
|
|
|
// Send to all — only odd-indexed should respond
|
|
for (i, addr) in addrs.iter().enumerate() {
|
|
rt.send_to(*addr, framed_msg(inbox.addr(), &[i as u8])).ok();
|
|
}
|
|
rt.tick();
|
|
|
|
let mut received = Vec::new();
|
|
while let Some(msg) = inbox.try_recv() {
|
|
received.push(msg.0[0]);
|
|
}
|
|
received.sort();
|
|
assert_eq!(received, vec![1, 3, 5, 7, 9], "only odd-indexed actors should respond");
|
|
} |