From 27f417754b11cef00a5becacd2b025cccfffc96f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 08:31:29 +0000 Subject: [PATCH] =?UTF-8?q?test:=20watch=20notification=20+=20stop=5Factor?= =?UTF-8?q?=20bug=20=E2=80=94=20failing=20test=20(#[ignore])?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native watcher watching a WASM actor does not receive ActorExited when the target is stopped via rt.stop_actor(). Root cause: StopSignal interception in tick_all (worker.rs:734) sets stopping=true but does not push to the deaths vector, so phase 5b watch notifications never fire for externally-stopped actors. Also adds P0-2 through P1-7 test scenarios (all passing): - empty message, oversized message, allocator exhaustion - nonexistent address send, wrong export signature/name - graceful stop, negative payload_len, independent stores - WASM-to-WASM relay Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski --- crates/wasm-actor/tests/wasm_actor.rs | 373 ++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) diff --git a/crates/wasm-actor/tests/wasm_actor.rs b/crates/wasm-actor/tests/wasm_actor.rs index 6322a0f..3011d2c 100644 --- a/crates/wasm-actor/tests/wasm_actor.rs +++ b/crates/wasm-actor/tests/wasm_actor.rs @@ -313,6 +313,379 @@ fn alloc_near_end_of_memory_drops_message_actor_survives() { 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::().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::().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::().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::().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::().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 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, + last_reason: std::sync::Arc>>, +} + +#[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] +#[ignore] // BUG: StopSignal handling in tick_all doesn't push to deaths vec — watchers never notified +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::().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)]