diff --git a/crates/wasm-actor/tests/wasm_actor.rs b/crates/wasm-actor/tests/wasm_actor.rs index 7966ecc..582d4c5 100644 --- a/crates/wasm-actor/tests/wasm_actor.rs +++ b/crates/wasm-actor/tests/wasm_actor.rs @@ -2059,3 +2059,173 @@ fn alloc_that_grows_memory_works() { 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::().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::().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::().unwrap(); + let inbox_b = rt.new_inbox::().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}"); + } +}