test: stale outbox leak on trap — failing test (#[ignore])

When a guest calls swactor.send then traps, the outbox entry survives
and leaks into the next successful handle() call. The outbox should be
cleared when handle traps, since the guest's operation was incomplete.

Also adds: invalid WASM bytes test, zero-length payload send test,
multiple sequential traps test (all passing).

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
Claude 2026-02-13 08:46:30 +00:00
parent e7efab4157
commit 148edb9ac5

View file

@ -740,6 +740,191 @@ fn native_actor_communicates_with_wasm_actor() {
assert_eq!(received.0, b"from native");
}
// ── Stale outbox: sends before trap leak into next handle ─────────────────────
#[test]
#[ignore] // BUG: outbox not cleared on handle trap — stale entries leak into next call
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);
}
// ── Multi-worker: WASM actors across threads ─────────────────────────────────
#[test]