//! Actor-runtime behavioral tests for `SwimActor` (SWIM_ACTOR_SPEC §2–§4). //! //! These drive real `SwimActor`s inside a swactor `Runtime` and observe ONLY the //! spec boundary (§6.3): the `MembershipChanged` notification stream delivered to //! a subscriber inbox. They never inspect probe phases, queue contents, or the //! `NodeAction` enum — they exercise the actorization seams end to end: //! - the single `SwimIn` Incoming enum (§4.1), //! - the clock as `Tick{now}` (§4.2), //! - the `NodeId`→`ActorAddress` Binding (`PeerDirectory`, §3.2), //! - send-failure-as-message driving detection (§4.3), //! - `Subscribe` + `MembershipChanged` as the sole observable (§6.3). //! //! Eventuality is pinned by driving to a converge-or-timeout condition, never a //! fixed tick count tuned to pass. use std::collections::BTreeMap; use std::sync::Arc; use std::time::{Duration, Instant}; use swactor::runtime::{Runtime, RuntimeConfig}; use swactor::std::StdExtension; use distribution::swim::actor::{ MembershipChanged, PeerDirectory, SharedPeerDirectory, SwimActor, SwimIn, }; use distribution::swim::member_list::MemberList; use distribution::swim::probe::{ProbeMode, SwimConfig}; use distribution::types::{MemberState, NodeId}; const TICK: Duration = Duration::from_millis(10); fn id(byte: u8) -> NodeId { NodeId([byte; 32]) } /// A brisk Periodic config so detection completes in a manageable number of /// rounds: detection ≈ 2·probe_timeout (direct+indirect) + suspicion_timeout. fn brisk_config() -> SwimConfig { SwimConfig { probe_interval: TICK, probe_timeout: TICK * 2, indirect_probes: 2, suspicion_timeout: TICK * 3, dead_reprobe_interval: Duration::ZERO, probe_mode: ProbeMode::Periodic, lifeguard: None, } } /// A cluster of `n` `SwimActor`s in one runtime, each with its own /// `MembershipChanged` subscriber inbox, and a shared Binding. struct ActorCluster { rt: Runtime, ids: Vec, addrs: Vec, inboxes: Vec>, dir: SharedPeerDirectory, streams: Vec>, clock: Instant, step: u64, } impl ActorCluster { /// Spawn `n` actors. Nodes `1..n` join via node 0 (the seed). Returns once /// the join requests have been issued (not yet converged). fn new(n: usize) -> Self { let rt = Runtime::new(RuntimeConfig::default()).with_extension(Arc::new(StdExtension::new())); let dir = SharedPeerDirectory::new(); let now = Instant::now(); let ids: Vec = (0..n).map(|i| id(i as u8)).collect(); let mut addrs = Vec::new(); let mut inboxes = Vec::new(); for &nid in &ids { let addr = rt .spawn(SwimActor::new( nid, brisk_config(), now, Arc::new(dir.clone()), )) .expect("spawn SwimActor"); // Generation 0 binding; the actor resolves NodeId→ActorAddress here. dir.bind(nid, addr, 0); let inbox = rt.new_inbox::().expect("inbox"); rt.send_to( addr, SwimIn::Subscribe { observer: *inbox.addr(), }, ) .unwrap(); addrs.push(addr); inboxes.push(inbox); } let mut c = ActorCluster { rt, ids, addrs, inboxes, dir, streams: vec![Vec::new(); n], clock: now, step: 0, }; // Settle the Subscribe messages. c.pump(4); // Nodes 1..n join via the seed (node 0). let seed = c.ids[0]; for i in 1..n { let addr = c.addrs[i]; c.rt.send_to(addr, SwimIn::Join { seeds: vec![seed] }) .unwrap(); } c.pump(8); c.drain(); c } fn pump(&self, n: usize) { for _ in 0..n { self.rt.tick(); } } /// Drain every inbox into its per-node `MembershipChanged` stream. fn drain(&mut self) { for (i, inbox) in self.inboxes.iter().enumerate() { while let Some(m) = inbox.try_recv() { self.streams[i].push(m); } } } /// Advance the clock and deliver a `Tick{now}` to every actor whose NodeId /// is still bound (an unbound node is unreachable — genuinely silent). Then /// pump the runtime so the resulting sends/acks/gossip settle, and drain. fn round(&mut self) { self.step += 1; self.clock += TICK; let now = self.clock; for i in 0..self.ids.len() { if self.dir.resolve(&self.ids[i]).is_some() { let _ = self.rt.send_to(self.addrs[i], SwimIn::Tick { now }); } } // Enough internal ticks for a ping→ack and a relay chain plus the // notification hop to settle within one round. self.pump(8); self.drain(); } fn run_until bool>(&mut self, cap: usize, cond: F) -> bool { if cond(self) { return true; } for _ in 0..cap { self.round(); if cond(self) { return true; } } false } /// Reconstruct node `observer`'s membership view by folding its /// `MembershipChanged` stream under the §7 merge rule (the Goal-7 fold). fn folded_view(&self, observer: usize) -> BTreeMap<[u8; 32], MemberState> { // A sentinel self-id that never appears in any stream, so every entry is // stored (self is never stored, §7 inv. 4). let mut ml = MemberList::new(id(250)); for m in &self.streams[observer] { ml.apply(m.node_id, m.state, m.incarnation); } ml.all_members() .iter() .map(|e| (e.node_id.0, e.state)) .collect() } /// Does `observer`'s folded view show `subject` in `state`? fn sees(&self, observer: usize, subject: NodeId, state: MemberState) -> bool { self.folded_view(observer).get(&subject.0) == Some(&state) } /// Every node's folded stream shows every other node Alive. fn all_converged_alive(&self) -> bool { let n = self.ids.len(); (0..n).all(|o| (0..n).all(|s| o == s || self.sees(o, id(s as u8), MemberState::Alive))) } } #[test] fn actors_converge_to_a_shared_alive_view_via_the_membership_changed_stream() { // §6.3 + §3.2 + §4.2: actors that join through the seed reach a shared Alive // view, observed solely through the MembershipChanged notification stream // each subscriber receives — the actorization seams carry gossip end to end. let mut c = ActorCluster::new(4); assert!( c.run_until(400, |c| c.all_converged_alive()), "actors did not converge to a shared Alive view through MembershipChanged" ); } #[test] fn an_unreachable_actor_is_detected_dead_by_survivors() { // §4.3 + §9: once a node becomes unreachable (its Binding is dropped, so a // survivor's probe genuinely never gets an Ack), the survivors must converge // on it being Dead — real detection driven by SendFailed + probe timeout, // not an injected death. let mut c = ActorCluster::new(4); assert!( c.run_until(400, |c| c.all_converged_alive()), "precondition: must converge" ); // Genuinely silence node 3: drop its binding so nobody can deliver to it, and // stop ticking it (round() skips unbound nodes). let dead = 3usize; c.dir.unbind(&id(dead as u8)); let detected = c.run_until(2000, |c| { (0..4).all(|o| o == dead || c.sees(o, id(dead as u8), MemberState::Dead)) }); assert!( detected, "survivors must converge on the unreachable node being Dead" ); }