Outbound (D13, ratified 2026-08-15): a module-private table node → dedicated Sender<Frame>, populated/torn down by the manager inside the same serialized handlers that own connection lifetime (Register / Disconnect / reap / terminate), on RuntimeInner beside the exposure state. remote::send is one leaf-lock lookup + one channel send: no gen_server on the data plane (rejected send-through-manager — worse than the c8 argument, it is the data plane), no published channel a leaked conn pid could inject raw frames into (rejected send_dyn-at-conn-pid — module privacy cannot fence a published channel). Ok(()) = handed to the connection's inbox, local knowledge only (RFC §3); missing entry / closed channel = NotConnected. The outbound sender is a SEPARATE channel from cmd_tx on purpose: a clone would let the table hold lifetime authority (D9 violation); its closure is not a stop signal. Buffering toward a slow peer is unbounded (BEAM busy_dist_port shape); backpressure out of scope, documented not silently absent. Inbound: remote::deliver_named is THE resolution seam (RFC v2) — exposed-set check (unexposed = unreachable, the safety) → hash check against what the name was exposed with → registry whereis → c8's decode_deliver. Verdicts are local-only (InboundVerdict, discarded by the conn actor for now; a trace hook is the place). The conn actor gains a third select arm (outbound inbox → wire) and interprets SendNamed; Send/Monitor/Down are consumed for liveness and ignored until c10/c11. Public surface: RemoteName<M> (node, Name<M>), RemoteSendError, NotConnected, send, send_remote_raw (untyped escape hatch so tests can put deliberately wrong frames on the wire — the typed API cannot express a hash mismatch). CORE (found the hard way this chunk): publishing a second channel of the same message type on one live actor silently replaced and CLOSED the first, so a select/recv on it returned 'closed' immediately forever — a hot loop starving the single-threaded scheduler. publish_channel now asserts when an existing same-type channel's receiver is still alive AND the new sender is not a clone of it (Sender::same_channel via Arc::ptr_eq); replacing a dead-receiver channel stays silent (an actor re-registering after dropping its inbox is legit). Message names the sanctioned shapes. Three registry tests pin panic / cloned-sender-ok / dead-receiver-ok. Full default suite clean. Harness: wait_line drains stderr before dumping on timeout/EOF (eprintln! diagnostics no longer vanish); Node::transcript() accessor for ordering-proof assertions. tests/cluster_remote_send.rs 1/0, 10/10 flake runs, subprocess harness: cross-node name-send delivers; unexposed name unreachable (registered locally, never delivered); wrong hash never misroutes (unknown-hash and known-hash-wrong-channel flavours); send to an unconnected node = NotConnected locally; every frame-bearing send is Ok — 'handed to transport', asserted and documented at the test. Negatives are proven by STREAM ORDERING (they precede the positive on one in-order connection), not by sleeping. All cluster suites regression-clean (envelope 15, handshake 11, transport 11, lifecycle 1, liveness 3, connect 9, two_node 3, membership 4, mesh 2, expose 5); clippy --lib green both configs; fmt clean; default build compiles.
304 lines
11 KiB
Rust
304 lines
11 KiB
Rust
//! Mailbox-registry tests (RFC 014). Run under the scheduler: registration
|
|
//! captures a live actor's channel, resolution checks liveness.
|
|
//!
|
|
//! Workers register their *own* inbox (`register` claims the current actor);
|
|
//! the root closure resolves and sends by name. A `ready` handshake closes the
|
|
//! register-then-send race without busy-waiting on `whereis`.
|
|
|
|
use smarm::{
|
|
channel, install, register, run, send, send_dyn, send_to, spawn, unregister, whereis,
|
|
Addressable, Name, Pid, RegisterError, SendError,
|
|
};
|
|
|
|
const SVC: Name<u64> = Name::new("svc");
|
|
|
|
#[test]
|
|
fn register_then_send_by_name_delivers() {
|
|
run(|| {
|
|
let (ready_tx, ready_rx) = channel::<()>();
|
|
let (tx, rx) = channel::<u64>();
|
|
let h = spawn(move || {
|
|
register(SVC, tx).unwrap();
|
|
ready_tx.send(()).unwrap();
|
|
assert_eq!(rx.recv().unwrap(), 42);
|
|
});
|
|
ready_rx.recv().unwrap(); // worker has registered
|
|
assert_eq!(whereis("svc"), Some(h.pid()));
|
|
send(SVC, 42).unwrap();
|
|
h.join().unwrap();
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn one_actor_many_typed_channels_route_by_type() {
|
|
// Same name, two message types: capability separation falls out of the
|
|
// type parameter — `Name<u64>` and `Name<&str>` hit different channels of
|
|
// the one actor (RFC 014 §4.7).
|
|
run(|| {
|
|
let (ready_tx, ready_rx) = channel::<()>();
|
|
let (cmd_tx, cmd_rx) = channel::<u64>();
|
|
let (adm_tx, adm_rx) = channel::<&'static str>();
|
|
let h = spawn(move || {
|
|
register(Name::<u64>::new("port"), cmd_tx).unwrap();
|
|
register(Name::<&'static str>::new("port"), adm_tx).unwrap();
|
|
ready_tx.send(()).unwrap();
|
|
assert_eq!(cmd_rx.recv().unwrap(), 7);
|
|
assert_eq!(adm_rx.recv().unwrap(), "halt");
|
|
});
|
|
ready_rx.recv().unwrap();
|
|
send(Name::<u64>::new("port"), 7u64).unwrap();
|
|
send(Name::<&'static str>::new("port"), "halt").unwrap();
|
|
h.join().unwrap();
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn name_held_by_live_actor_is_taken() {
|
|
run(|| {
|
|
let (ready_tx, ready_rx) = channel::<()>();
|
|
let (tx_a, rx_a) = channel::<u64>();
|
|
let a = spawn(move || {
|
|
register(SVC, tx_a).unwrap();
|
|
ready_tx.send(()).unwrap();
|
|
assert_eq!(rx_a.recv().unwrap(), 0); // wait to be released
|
|
});
|
|
ready_rx.recv().unwrap();
|
|
// Root tries to claim a live actor's name for itself -> NameTaken.
|
|
let (tx_b, _rx_b) = channel::<u64>();
|
|
assert_eq!(
|
|
register(SVC, tx_b),
|
|
Err(RegisterError::NameTaken { holder: a.pid() })
|
|
);
|
|
send(SVC, 0).unwrap(); // release a (delivers to the holder, a)
|
|
a.join().unwrap();
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn dead_holder_is_pruned_and_name_taken_over() {
|
|
// a registers, signals, then dies. Its slot index is typically reused by b;
|
|
// b's registration must prune the stale binding and take the name over.
|
|
run(|| {
|
|
let (rt1, rr1) = channel::<()>();
|
|
let (tx1, _rx1) = channel::<u64>();
|
|
let a = spawn(move || {
|
|
register(SVC, tx1).unwrap();
|
|
rt1.send(()).unwrap(); // then return -> die, _rx1 dropped
|
|
});
|
|
rr1.recv().unwrap();
|
|
let a_pid = a.pid();
|
|
a.join().unwrap(); // a is dead; "svc" now points at a stale pid
|
|
|
|
let (rt2, rr2) = channel::<()>();
|
|
let (tx2, rx2) = channel::<u64>();
|
|
let b = spawn(move || {
|
|
register(SVC, tx2).unwrap(); // takes over the freed name
|
|
rt2.send(()).unwrap();
|
|
assert_eq!(rx2.recv().unwrap(), 9);
|
|
});
|
|
rr2.recv().unwrap();
|
|
assert_ne!(b.pid(), a_pid); // distinct incarnation even if slot reused
|
|
assert_eq!(whereis("svc"), Some(b.pid()));
|
|
send(SVC, 9).unwrap();
|
|
b.join().unwrap();
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn send_errors_unresolved_and_no_channel() {
|
|
run(|| {
|
|
// No actor at all.
|
|
assert!(matches!(
|
|
send(Name::<u64>::new("ghost"), 1u64),
|
|
Err(SendError::Unresolved(_))
|
|
));
|
|
|
|
let (ready_tx, ready_rx) = channel::<()>();
|
|
let (tx, rx) = channel::<u64>();
|
|
let h = spawn(move || {
|
|
register(Name::<u64>::new("svc2"), tx).unwrap();
|
|
ready_tx.send(()).unwrap();
|
|
assert_eq!(rx.recv().unwrap(), 0);
|
|
});
|
|
ready_rx.recv().unwrap();
|
|
// Right actor, wrong message type: it has a u64 channel, not a String.
|
|
let e = send(Name::<String>::new("svc2"), "x".to_string());
|
|
assert!(matches!(e, Err(SendError::NoChannel(_))));
|
|
assert_eq!(e.unwrap_err().into_inner(), "x"); // message handed back
|
|
send(Name::<u64>::new("svc2"), 0u64).unwrap();
|
|
h.join().unwrap();
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn unregister_frees_the_name_only() {
|
|
run(|| {
|
|
let (ready_tx, ready_rx) = channel::<()>();
|
|
let (done_tx, done_rx) = channel::<()>();
|
|
let (tx, _rx) = channel::<u64>(); // _rx moves into the actor, kept open
|
|
let h = spawn(move || {
|
|
register(SVC, tx).unwrap();
|
|
let _keep_open = _rx;
|
|
ready_tx.send(()).unwrap();
|
|
done_rx.recv().unwrap(); // released over a separate channel
|
|
});
|
|
ready_rx.recv().unwrap();
|
|
assert_eq!(whereis("svc"), Some(h.pid()));
|
|
assert_eq!(unregister("svc"), Some(h.pid()));
|
|
assert_eq!(whereis("svc"), None);
|
|
assert!(matches!(send(SVC, 1u64), Err(SendError::Unresolved(_))));
|
|
done_tx.send(()).unwrap();
|
|
h.join().unwrap();
|
|
});
|
|
}
|
|
|
|
// --- RFC 014 §4.2: direct, identity-bound addressing via `Pid<A>` ----------
|
|
|
|
// A stand-in single-message actor. `install::<Worker>` publishes its inbox and
|
|
// returns a `Pid<Worker>` that delivers `u64` to exactly that incarnation.
|
|
struct Worker;
|
|
impl Addressable for Worker {
|
|
type Msg = u64;
|
|
}
|
|
|
|
#[test]
|
|
fn install_then_send_to_pid_delivers() {
|
|
run(|| {
|
|
let (addr_tx, addr_rx) = channel::<Pid<Worker>>();
|
|
let h = spawn(move || {
|
|
let (tx, rx) = channel::<u64>();
|
|
let me = install::<Worker>(tx); // nameless publish, typed pid back
|
|
addr_tx.send(me).unwrap();
|
|
assert_eq!(rx.recv().unwrap(), 42);
|
|
});
|
|
let addr = addr_rx.recv().unwrap(); // worker installed, handed its Pid<Worker>
|
|
assert_eq!(addr.erase(), h.pid()); // same identity, just re-typed
|
|
send_to(addr, 42u64).unwrap();
|
|
h.join().unwrap();
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn send_to_does_not_redirect_after_takeover() {
|
|
// The load-bearing §4.2 property: a `Pid<A>` is identity-bound. When the
|
|
// actor dies and a new incarnation reuses the slot, sending to the *old*
|
|
// address fails `Dead` — it must never silently reach the new occupant
|
|
// (that redirect is the re-resolving `Name`'s job, not a pid's).
|
|
run(|| {
|
|
let (addr_a_tx, addr_a_rx) = channel::<Pid<Worker>>();
|
|
let (rt1, rr1) = channel::<()>();
|
|
let a = spawn(move || {
|
|
let (tx, _rx) = channel::<u64>();
|
|
let me = install::<Worker>(tx);
|
|
addr_a_tx.send(me).unwrap();
|
|
rt1.send(()).unwrap(); // then return -> die
|
|
});
|
|
let a_addr = addr_a_rx.recv().unwrap();
|
|
rr1.recv().unwrap();
|
|
a.join().unwrap(); // a dead; a_addr names a dead incarnation
|
|
|
|
let (addr_b_tx, addr_b_rx) = channel::<Pid<Worker>>();
|
|
let (rt2, rr2) = channel::<()>();
|
|
let b = spawn(move || {
|
|
let (tx, rx) = channel::<u64>();
|
|
let me = install::<Worker>(tx); // reuses a's slot index, new generation
|
|
addr_b_tx.send(me).unwrap();
|
|
rt2.send(()).unwrap();
|
|
// Only b's own message ever arrives; the stale send never redirects.
|
|
assert_eq!(rx.recv().unwrap(), 7);
|
|
});
|
|
let b_addr = addr_b_rx.recv().unwrap();
|
|
rr2.recv().unwrap();
|
|
assert_ne!(b_addr.erase(), a_addr.erase()); // distinct incarnation
|
|
assert!(matches!(send_to(a_addr, 99u64), Err(SendError::Dead(_)))); // no redirect
|
|
send_to(b_addr, 7u64).unwrap(); // b's real message
|
|
b.join().unwrap();
|
|
});
|
|
}
|
|
|
|
// --- RFC 014 §4.6: explicit bare-pid escape hatch ---------------------------
|
|
|
|
#[test]
|
|
fn send_dyn_delivers_and_reports_wrong_type() {
|
|
run(|| {
|
|
let (ready_tx, ready_rx) = channel::<()>();
|
|
let (done_tx, done_rx) = channel::<()>();
|
|
let (tx, rx) = channel::<u64>();
|
|
let h = spawn(move || {
|
|
register(Name::<u64>::new("dyn"), tx).unwrap(); // publishes a u64 channel
|
|
ready_tx.send(()).unwrap();
|
|
assert_eq!(rx.recv().unwrap(), 3);
|
|
done_rx.recv().unwrap(); // stay alive for the wrong-type probe
|
|
});
|
|
ready_rx.recv().unwrap();
|
|
let p = h.pid(); // a bare Pid<Erased>, as if recovered off a Down
|
|
send_dyn::<u64>(p, 3u64).unwrap(); // right type: delivered
|
|
// Live actor, but it has no channel for &str — the genuinely-fallible case.
|
|
assert!(matches!(
|
|
send_dyn::<&'static str>(p, "nope"),
|
|
Err(SendError::NoChannel(_))
|
|
));
|
|
done_tx.send(()).unwrap();
|
|
h.join().unwrap();
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn send_dyn_to_dead_pid_is_dead() {
|
|
run(|| {
|
|
let (ready_tx, ready_rx) = channel::<()>();
|
|
let (tx, _rx) = channel::<u64>();
|
|
let h = spawn(move || {
|
|
register(Name::<u64>::new("dyn2"), tx).unwrap();
|
|
ready_tx.send(()).unwrap(); // then return -> die
|
|
});
|
|
ready_rx.recv().unwrap();
|
|
let p = h.pid();
|
|
h.join().unwrap(); // dead; the bare pid now names a dead incarnation
|
|
assert!(matches!(send_dyn::<u64>(p, 1u64), Err(SendError::Dead(_))));
|
|
});
|
|
}
|
|
|
|
// --- one channel per message type per actor -----------------------------------
|
|
|
|
/// Registering a second name of the same message type on one actor, with a
|
|
/// *fresh* channel, would silently replace and close the first — so it
|
|
/// panics (found in RFC 010 c9). The sanctioned shapes stay quiet: bind both
|
|
/// names to a clone of one sender, or use two actors.
|
|
#[test]
|
|
#[should_panic(expected = "already publishes a live channel")]
|
|
fn second_live_channel_of_same_type_on_one_actor_panics() {
|
|
run(|| {
|
|
let (tx1, _rx1) = channel::<u64>();
|
|
let (tx2, _rx2) = channel::<u64>();
|
|
register(Name::<u64>::new("dup-a"), tx1).unwrap();
|
|
register(Name::<u64>::new("dup-b"), tx2).unwrap(); // panics
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn two_names_on_one_cloned_sender_is_fine() {
|
|
run(|| {
|
|
let (tx, rx) = channel::<u64>();
|
|
register(Name::<u64>::new("twin-a"), tx.clone()).unwrap();
|
|
register(Name::<u64>::new("twin-b"), tx).unwrap();
|
|
send(Name::<u64>::new("twin-a"), 1).unwrap();
|
|
send(Name::<u64>::new("twin-b"), 2).unwrap();
|
|
assert_eq!(rx.recv().unwrap(), 1);
|
|
assert_eq!(rx.recv().unwrap(), 2);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn replacing_a_channel_whose_receiver_is_gone_is_fine() {
|
|
run(|| {
|
|
let (tx1, rx1) = channel::<u64>();
|
|
register(Name::<u64>::new("reborn"), tx1).unwrap();
|
|
drop(rx1); // old inbox gone: replacement is the honest thing to do
|
|
let (tx2, rx2) = channel::<u64>();
|
|
register(Name::<u64>::new("reborn-2"), tx2).unwrap();
|
|
send(Name::<u64>::new("reborn-2"), 9).unwrap();
|
|
assert_eq!(rx2.recv().unwrap(), 9);
|
|
});
|
|
}
|