test: watch notification + stop_actor bug — failing test (#[ignore])
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
This commit is contained in:
parent
dfc9e6a392
commit
27f417754b
1 changed files with 373 additions and 0 deletions
|
|
@ -313,6 +313,379 @@ fn alloc_near_end_of_memory_drops_message_actor_survives() {
|
||||||
rt.tick();
|
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]
|
||||||
|
#[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::<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 ─────────────────────
|
// ── Integration: WasmActor alongside a native Rust actor ─────────────────────
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue