feat(cluster): RFC 010 c10–c16, follow-ups and Phase 6 (squash of 16ef583..d9c62a8)

Tree snapshot of d9c62a8 (2026-08-18). The 20 source commits between
16ef583 (c9) and d9c62a8 were never pushed and the clone that held them
was lost; this commit carries their combined tree verbatim so the build
history stays auditable from the c1–c9 commits below it. Original
hashes as recorded in the session handoff:

  c10  f03e94d  pid targeting + auto-serialization (RemotePid, D14 name
                on the wire); Phase 3 gate
  c11  7ef4bad  DownReason::Disconnected, wire tag 5
  c12  d124162  remote monitors (Monitor/Demonitor/Down frames)
  c13  9de967b  connection-loss synthesis (A+B: Monitors::teardown +
                unread-command Disconnected); Phase 4 gate
  c14  7e822b7  eager pg eviction (reaper actor, ReaperInboxes)
       dbe1a22  InboundVerdict::label(), trace::Event::ClusterInbound
       31a9877  tests/channel.rs monitor-churn target gated on `go`
       653559e  Discovery::Withdrawn{name, addr}
  c15  b41d76e  distributed pg: Sync on NodeUp, Join/Leave broadcast,
                NodeDown sweep, members_all; PgMsg wire type
  c16  fafa881  pick_any / dispatch_any; Phase 5 complete
  Phase 6 Tier A:
       195c73e  p4  NodeEvent::NodeDown(NodeInfo)
       48fd766  p1  connector Candidate{name, addr, state}
       ce8cf99  p2+p7 conn.rs select arms as Vec<Arm>; Outbound::Drained
       bf24988  p6  RemotePid::from_local -> Option
       9ae0380  p3  PeerStanding{Free, Claimed, Dialing}
       c7d62a1  p11 cluster::Timing knobs, threaded by value
       46f171d  p11 cluster_disconnect un-ignored on SMARM_FAST_TIMING
  Phase 6 Tier B:
       391a9ae  p5  cluster::RemoteDownReason{Local, Disconnected};
                    DownReason::Disconnected removed from core
       7ddd908  p9  pg ctl channel unconditional, one cfg seam at spawn
       d9c62a8      PeerNameMismatch parks the candidate; ClusterDial trace

Verified at d9c62a8: default 361/0, cluster 448/0, clippy --lib on
default / cluster / cluster+smarm-trace, fmt, 10x flake on
cluster_dial_mismatch, 5x on cluster_pg.
This commit is contained in:
claude-asm-audit
2026-08-18 16:00:00 +02:00
parent 16ef583455
commit 4c0e42152f
32 changed files with 4202 additions and 578 deletions
+56 -3
View File
@@ -17,6 +17,7 @@ pub mod expose;
pub mod handshake; pub mod handshake;
pub mod manager; pub mod manager;
pub mod membership; pub mod membership;
pub mod pg;
pub mod remote; pub mod remote;
pub mod transport; pub mod transport;
@@ -38,10 +39,15 @@ pub use conn::{spawn_established, ConnHandle};
pub use connect::{dial, spawn_acceptor, AcceptorHandle}; pub use connect::{dial, spawn_acceptor, AcceptorHandle};
pub use connector::{spawn_connector, ConnectorHandle}; pub use connector::{spawn_connector, ConnectorHandle};
pub use discovery::{Discovery, StaticSeeds, Strategy}; pub use discovery::{Discovery, StaticSeeds, Strategy};
pub use envelope::RemoteDownReason;
pub use expose::{expose, expose_type, type_hash, DeliverError}; pub use expose::{expose, expose_type, type_hash, DeliverError};
pub use manager::{Manager, MANAGER}; pub use manager::{Manager, MANAGER};
pub use membership::{subscribe, view, MembershipEvents, NodeEvent, NodeInfo}; pub use membership::{subscribe, view, MembershipEvents, NodeEvent, NodeInfo};
pub use remote::{NotConnected, RemoteName, RemoteSendError}; pub use pg::{dispatch_any, members_all, pick_any, DispatchAnyError, GroupMember, PgMsg, PG_NAME};
pub use remote::{
demonitor_remote, monitor_remote, send_to_remote, NotConnected, RemoteDown, RemoteMonitor,
RemoteName, RemotePid, RemoteSendError, ToRemoteError,
};
/// c6d — the derived build hash for [`handshake::LocalNode::build_hash`]: /// c6d — the derived build hash for [`handshake::LocalNode::build_hash`]:
/// two builds may mesh only when this matches, and it is a pure function of /// two builds may mesh only when this matches, and it is a pure function of
@@ -80,6 +86,41 @@ const fn fold_u32(mut h: u64, v: u32) -> u64 {
h h
} }
/// The control-plane timing knobs, all with today's fixed values as
/// defaults ([`Timing::default`]). One struct threaded explicitly to the
/// acceptor, the dial path, every connection actor and the connector — no
/// ambient state, so a test can run a fast mesh without touching globals.
/// Every node in a mesh should agree on `heartbeat_interval` <
/// `liveness_timeout`; nothing enforces it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timing {
/// Idle-connection heartbeat pace. Default [`conn::HEARTBEAT_INTERVAL`].
pub heartbeat_interval: Duration,
/// Inbound silence that tears a connection down. Default
/// [`conn::LIVENESS_TIMEOUT`].
pub liveness_timeout: Duration,
/// Per-frame handshake deadline on the accept/dial path. Default
/// [`connect::HANDSHAKE_TIMEOUT`].
pub handshake_timeout: Duration,
/// Connector redial delay after the first failure. Default
/// [`connector::INITIAL_BACKOFF`].
pub initial_backoff: Duration,
/// Connector redial delay cap. Default [`connector::MAX_BACKOFF`].
pub max_backoff: Duration,
}
impl Default for Timing {
fn default() -> Self {
Timing {
heartbeat_interval: conn::HEARTBEAT_INTERVAL,
liveness_timeout: conn::LIVENESS_TIMEOUT,
handshake_timeout: connect::HANDSHAKE_TIMEOUT,
initial_backoff: connector::INITIAL_BACKOFF,
max_backoff: connector::MAX_BACKOFF,
}
}
}
/// How to run this node: its identity and how it finds peers. /// How to run this node: its identity and how it finds peers.
pub struct Config { pub struct Config {
/// This node's claimed name — the mesh-wide identity peers dial by and /// This node's claimed name — the mesh-wide identity peers dial by and
@@ -92,6 +133,9 @@ pub struct Config {
pub listen_addr: String, pub listen_addr: String,
/// The peer-discovery strategy — [`StaticSeeds`] until richer ones land. /// The peer-discovery strategy — [`StaticSeeds`] until richer ones land.
pub strategy: Box<dyn Strategy>, pub strategy: Box<dyn Strategy>,
/// Heartbeat / liveness / handshake / backoff knobs; [`Timing::default`]
/// is the shipping configuration.
pub timing: Timing,
} }
/// A running cluster node: the supervised [`Manager`], the acceptor over the /// A running cluster node: the supervised [`Manager`], the acceptor over the
@@ -155,9 +199,18 @@ pub fn start(config: Config) -> io::Result<Cluster> {
build_hash: BUILD_HASH, build_hash: BUILD_HASH,
meta: config.meta, meta: config.meta,
}; };
// The wire identity serialized pids are stamped with (c10).
remote::set_local_identity(&local.node_name, local.incarnation);
// The pg actor (Phase 5): subscribes membership, owns the "pg" name.
pg::attach_cluster();
let listener = TcpTransport.listen(&config.listen_addr)?; let listener = TcpTransport.listen(&config.listen_addr)?;
let acceptor = spawn_acceptor(listener, local.clone()); let acceptor = spawn_acceptor(listener, local.clone(), config.timing);
let connector = spawn_connector(Box::new(TcpTransport), local.clone(), config.strategy); let connector = spawn_connector(
Box::new(TcpTransport),
local.clone(),
config.strategy,
config.timing,
);
Ok(Cluster { Ok(Cluster {
_sup: sup, _sup: sup,
acceptor, acceptor,
+377 -48
View File
@@ -27,21 +27,52 @@
//! drained onto the wire in the same loop — and inbound *interpretation*: //! drained onto the wire in the same loop — and inbound *interpretation*:
//! `SendNamed` goes to the one resolution seam, //! `SendNamed` goes to the one resolution seam,
//! [`remote::deliver_named`](crate::cluster::remote::deliver_named). //! [`remote::deliver_named`](crate::cluster::remote::deliver_named).
//! Frames the connection actor has no business with yet (`Send`, `Monitor`, //! `Send` goes to the pid seam (c10). The outbound
//! …: c10/c11) are consumed for liveness and otherwise ignored. The outbound
//! sender is a separate channel from `cmd_tx` on purpose: closing it is not //! sender is a separate channel from `cmd_tx` on purpose: closing it is not
//! a stop signal — lifetime authority stays with the [`ConnHandle`] (D9). //! a stop signal — lifetime authority stays with the [`ConnHandle`] (D9).
//!
//! c12 adds the monitor plane, and it lives *here* on purpose. Two tables,
//! both owned by this actor and dying with the connection:
//!
//! - **outstanding** — monitors *this* node holds on actors at the peer:
//! `monitor_id → (target, Sender<RemoteDown>)`. Fed by
//! [`MonCmd`](crate::cluster::remote::MonCmd) from `monitor_remote`; the
//! actor records the id and *then* emits the `Monitor` frame, so a `Down`
//! frame can never race an entry that isn't there yet. An inbound `Down`
//! removes the entry and delivers.
//! - **watched** — monitors the *peer* holds on actors here: `monitor_id →
//! local Monitor`. An inbound `Monitor` is admitted only for a pid that
//! was exposed or crossed the wire (`is_watchable`, D12): a corpse answers
//! with its recorded terminal reason (RFC §6), an unwatchable or unknown
//! pid with `NoProc` — indistinguishable from dead, so nothing leaks. A
//! live watchable pid gets a local monitor whose `rx` is one more arm of
//! the select; its `Down` goes back as a frame.
//!
//! Because both tables are actor state, connection loss (c13) needs no
//! second bookkeeping owner: this actor's exit is the one place that knows
//! every monitor the link was carrying. `Monitors::teardown` runs on every
//! exit path and answers each outstanding monitor with `Disconnected` —
//! the roadmap's "partition vs. death" contrast: an actor that dies sends
//! its true reason over the link, a link that dies says only that.
use std::collections::HashMap;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::channel::{channel, try_select_timeout, Receiver, Selectable, Sender}; use crate::channel::{channel, try_select_timeout, Receiver, Selectable, Sender};
use crate::cluster::envelope::Frame; use crate::cluster::envelope::{Frame, RemoteDownReason};
use crate::cluster::handshake::Peer; use crate::cluster::handshake::Peer;
use crate::cluster::manager::{Call, Registered, Reply, MANAGER}; use crate::cluster::manager::{Call, Registered, Reply, MANAGER};
use crate::cluster::remote::deliver_named; use crate::cluster::remote::{
deliver_named, deliver_to_pid, InboundVerdict, MonCmd, RemoteDown, RemotePid,
};
use crate::cluster::transport::FramedConn; use crate::cluster::transport::FramedConn;
use crate::cluster::Timing;
use crate::gen_server; use crate::gen_server;
use crate::pid::Pid; use crate::monitor::{
demonitor, is_watchable, monitor, terminal_reason, DownReason, Monitor, MonitorId,
};
use crate::pid::{Erased, Pid};
use crate::scheduler::spawn; use crate::scheduler::spawn;
/// Commands to a running connection actor. /// Commands to a running connection actor.
@@ -57,11 +88,11 @@ enum Cmd {
/// to establish it. /// to establish it.
pub struct ConnHandle { pub struct ConnHandle {
cmd_tx: Sender<Cmd>, cmd_tx: Sender<Cmd>,
/// The connection's dedicated outbound inbox. The manager moves this /// The connection's dedicated outbound inboxes — frames and monitor
/// into the outbound table on `Register` (see /// commands. The manager moves them into the outbound table on
/// [`take_outbound`](ConnHandle::take_outbound)); a `Duplicate` verdict /// `Register` (see [`take_outbound`](ConnHandle::take_outbound)); a
/// drops it with the handle. /// `Duplicate` verdict drops them with the handle.
out_tx: Option<Sender<Frame>>, out_tx: Option<(Sender<Frame>, Sender<MonCmd>)>,
} }
impl std::fmt::Debug for ConnHandle { impl std::fmt::Debug for ConnHandle {
@@ -78,9 +109,9 @@ impl ConnHandle {
let _ = self.cmd_tx.send(Cmd::Shutdown); let _ = self.cmd_tx.send(Cmd::Shutdown);
} }
/// Manager-only: take the outbound sender to bind into the outbound /// Manager-only: take the outbound senders to bind into the outbound
/// table. Once, at registration. /// table. Once, at registration.
pub(crate) fn take_outbound(&mut self) -> Option<Sender<Frame>> { pub(crate) fn take_outbound(&mut self) -> Option<(Sender<Frame>, Sender<MonCmd>)> {
self.out_tx.take() self.out_tx.take()
} }
} }
@@ -96,11 +127,16 @@ pub struct RegisterRefused;
/// actor's [`ConnHandle`]; the caller gets only the [`Pid`], because /// actor's [`ConnHandle`]; the caller gets only the [`Pid`], because
/// connection lifetime belongs to the table and not to the establishing /// connection lifetime belongs to the table and not to the establishing
/// actor. A refusal has already stopped the actor and closed the socket. /// actor. A refusal has already stopped the actor and closed the socket.
pub fn spawn_established(framed: FramedConn, peer: Peer) -> Result<Pid, RegisterRefused> { pub fn spawn_established(
framed: FramedConn,
peer: Peer,
timing: Timing,
) -> Result<Pid, RegisterRefused> {
let (cmd_tx, cmd_rx) = channel(); let (cmd_tx, cmd_rx) = channel();
let (out_tx, out_rx) = channel(); let (out_tx, out_rx) = channel();
let (mon_tx, mon_rx) = channel();
let reg_peer = peer.clone(); let reg_peer = peer.clone();
let pid = spawn(move || run(framed, peer, cmd_rx, out_rx)).pid(); let pid = spawn(move || run(framed, peer, timing, cmd_rx, out_rx, mon_rx)).pid();
match gen_server::call( match gen_server::call(
MANAGER, MANAGER,
Call::Register { Call::Register {
@@ -108,7 +144,7 @@ pub fn spawn_established(framed: FramedConn, peer: Peer) -> Result<Pid, Register
pid, pid,
handle: ConnHandle { handle: ConnHandle {
cmd_tx, cmd_tx,
out_tx: Some(out_tx), out_tx: Some((out_tx, mon_tx)),
}, },
}, },
) { ) {
@@ -120,25 +156,136 @@ pub fn spawn_established(framed: FramedConn, peer: Peer) -> Result<Pid, Register
} }
} }
/// How often this end emits [`Frame::Heartbeat`] on an idle connection. The /// Default for [`Timing::heartbeat_interval`]: how often this end emits
/// first one goes out immediately at spawn, so the peer's liveness window /// [`Frame::Heartbeat`] on an idle connection. The first one goes out
/// starts fed. /// immediately at spawn, so the peer's liveness window starts fed.
pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1); pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
/// How long the connection may go without a single inbound frame before it /// Default for [`Timing::liveness_timeout`]: how long the connection may go
/// is declared dead and torn down. Any inbound frame resets the window — /// without a single inbound frame before it is declared dead and torn down. Any inbound frame resets the window —
/// heartbeats keep an idle connection alive, and real traffic (c8+) counts /// heartbeats keep an idle connection alive, and real traffic (c8+) counts
/// for free. Fixed by design (RFC v2 §5): this is the control connection, a /// for free. Fixed by design (RFC v2 §5): this is the control connection, a
/// heartbeat can never queue behind bulk traffic, so a fixed timeout is an /// heartbeat can never queue behind bulk traffic, so a fixed timeout is an
/// honest detector. /// honest detector.
pub const LIVENESS_TIMEOUT: Duration = Duration::from_secs(4); pub const LIVENESS_TIMEOUT: Duration = Duration::from_secs(4);
fn run(mut framed: FramedConn, _peer: Peer, cmd_rx: Receiver<Cmd>, out_rx: Receiver<Frame>) { fn run(
mut framed: FramedConn,
_peer: Peer,
timing: Timing,
cmd_rx: Receiver<Cmd>,
out_rx: Receiver<Frame>,
mon_rx: Receiver<MonCmd>,
) {
let mut mons = Monitors::default();
match framed.readable_arm() { match framed.readable_arm() {
Some(arm) => run_live(&mut framed, arm, &cmd_rx, &out_rx), Some(arm) => run_live(
&mut framed,
arm,
timing,
&cmd_rx,
&out_rx,
&mon_rx,
&mut mons,
),
None => run_inert(&cmd_rx), None => run_inert(&cmd_rx),
} }
framed.close(); framed.close();
mons.teardown(&mon_rx);
}
/// The monitor plane's two tables (module docs). Owned by the actor.
#[derive(Default)]
struct Monitors {
/// Monitors this node holds on peer actors: id → (target, delivery).
outstanding: HashMap<MonitorId, (RemotePid<Erased>, Sender<RemoteDown>)>,
/// Monitors the peer holds on local actors: id → the local monitor.
watched: HashMap<MonitorId, Monitor>,
}
impl Monitors {
/// The connection is gone, whatever the exit path (liveness expiry,
/// EOF, wire failure, commanded stop): release the peer's local
/// monitors, and answer every one of ours with `Disconnected` — nothing
/// more can be known about those actors. Commands still sitting in the
/// inbox are folded in first (a `Monitor` handed to us but never
/// processed gets its notice too; a `Demonitor` still cancels), so the
/// only registration that can miss this is one that lands after the
/// drain and before the inbox drops — the reader side backstops that
/// (`RemoteMonitor`). Entries leave the table as they are answered, and
/// this runs once per actor, so no monitor sees two notices.
fn teardown(&mut self, mon_rx: &Receiver<MonCmd>) {
for (_, m) in self.watched.drain() {
let _ = demonitor(&m);
}
while let Ok(Some(cmd)) = mon_rx.try_recv() {
match cmd {
MonCmd::Monitor { id, target, tx } => {
self.outstanding.insert(id, (target, tx));
}
MonCmd::Demonitor { id } => {
self.outstanding.remove(&id);
}
}
}
for (_, (pid, tx)) in self.outstanding.drain() {
let _ = tx.send(RemoteDown {
pid,
reason: RemoteDownReason::Disconnected,
});
}
}
/// Admit a peer's `Monitor` for local `(index, generation)`. Returns
/// the reason to answer with at once, or `None` if a live monitor was
/// installed. Corpse → recorded terminal reason (RFC §6, and only
/// watchable deaths are recorded); live watchable → monitor; anything
/// else → `NoProc`. The check-then-monitor race (dies in between) is
/// closed on the read side: a `NoProc` from a monitor we installed on a
/// live pid is upgraded through `terminal_reason` in `sweep_watched`.
fn admit(&mut self, id: MonitorId, index: u32, generation: u32) -> Option<DownReason> {
let pid = Pid::new(index, generation);
if let Some(reason) = terminal_reason(pid) {
return Some(reason);
}
if !is_watchable(pid) {
return Some(DownReason::NoProc);
}
let m = monitor(pid);
self.watched.insert(id, m);
None
}
fn cancel(&mut self, id: MonitorId) {
if let Some(m) = self.watched.remove(&id) {
let _ = demonitor(&m);
}
}
/// Collect every local `Down` that has arrived for a peer-held monitor.
fn sweep_watched(&mut self) -> Vec<(MonitorId, DownReason)> {
let mut fired = Vec::new();
for (id, m) in self.watched.iter() {
if let Ok(Some(down)) = m.rx.try_recv() {
let reason = match down.reason {
DownReason::NoProc => terminal_reason(m.target).unwrap_or(DownReason::NoProc),
r => r,
};
fired.push((*id, reason));
}
}
for (id, _) in &fired {
self.watched.remove(id);
}
fired
}
/// The peer reports a monitored actor down: deliver locally.
fn down(&mut self, id: MonitorId, reason: RemoteDownReason) {
if let Some((pid, tx)) = self.outstanding.remove(&id) {
let _ = tx.send(RemoteDown { pid, reason });
}
}
} }
/// The steady-state loop over an fd-backed connection: one /// The steady-state loop over an fd-backed connection: one
@@ -148,15 +295,39 @@ fn run(mut framed: FramedConn, _peer: Peer, cmd_rx: Receiver<Cmd>, out_rx: Recei
fn run_live( fn run_live(
framed: &mut FramedConn, framed: &mut FramedConn,
arm: crate::scheduler::FdArm, arm: crate::scheduler::FdArm,
timing: Timing,
cmd_rx: &Receiver<Cmd>, cmd_rx: &Receiver<Cmd>,
out_rx: &Receiver<Frame>, out_rx: &Receiver<Frame>,
mon_rx: &Receiver<MonCmd>,
mons: &mut Monitors,
) { ) {
let mut next_hb = Instant::now(); let mut next_hb = Instant::now();
let mut live_until = Instant::now() + LIVENESS_TIMEOUT; let mut live_until = Instant::now() + timing.liveness_timeout;
// The outbound sender lives in the manager's table and is dropped on // The outbound senders live in the manager's table and are dropped on
// unbind; after that this arm would wake forever, so it drops out of // unbind; after that these arms would wake forever, so they drop out of
// the select (not a stop signal — see the module docs). // the select (not a stop signal — see the module docs).
let mut out_open = true; let mut out_open = true;
let mut mon_open = true;
// Which wait each select arm stands for. Built in lockstep with the
// `Selectable` vector each iteration, so a wake is decoded by name and
// never by position.
enum Arm {
Cmd,
Fd,
Out,
Mon,
/// A peer-held local monitor (any of them: firing sweeps them all).
Watched,
}
fn push<'s>(
arms: &mut Vec<&'s dyn Selectable>,
what: &mut Vec<Arm>,
s: &'s dyn Selectable,
a: Arm,
) {
arms.push(s);
what.push(a);
}
loop { loop {
let now = Instant::now(); let now = Instant::now();
if now >= live_until { if now >= live_until {
@@ -166,33 +337,57 @@ fn run_live(
if framed.send(&Frame::Heartbeat).is_err() { if framed.send(&Frame::Heartbeat).is_err() {
break; break;
} }
next_hb = now + HEARTBEAT_INTERVAL; next_hb = now + timing.heartbeat_interval;
} }
let wait = next_hb.min(live_until).saturating_duration_since(now); let wait = next_hb.min(live_until).saturating_duration_since(now);
// Arm indices: 0 cmd, 1 fd, 2 outbound (when open). let mut arms: Vec<&dyn Selectable> = Vec::new();
let mut arms: Vec<&dyn Selectable> = vec![cmd_rx, &arm]; let mut what: Vec<Arm> = Vec::new();
push(&mut arms, &mut what, cmd_rx, Arm::Cmd);
push(&mut arms, &mut what, &arm, Arm::Fd);
if out_open { if out_open {
arms.push(out_rx); push(&mut arms, &mut what, out_rx, Arm::Out);
} }
match try_select_timeout(&arms, wait) { if mon_open {
Ok(Some(0)) => { push(&mut arms, &mut what, mon_rx, Arm::Mon);
}
for m in mons.watched.values() {
push(&mut arms, &mut what, &m.rx, Arm::Watched);
}
match try_select_timeout(&arms, wait).map(|i| i.map(|i| &what[i])) {
Ok(Some(Arm::Cmd)) => {
if should_stop(cmd_rx) { if should_stop(cmd_rx) {
break; break;
} }
} }
Ok(Some(1)) => match pump_readable(framed) { Ok(Some(Arm::Fd)) => match pump_readable(framed, mons) {
Pump::Ended => break, Pump::Ended => break,
Pump::Frames(n) => { Pump::Frames(n) => {
if n > 0 { if n > 0 {
live_until = Instant::now() + LIVENESS_TIMEOUT; live_until = Instant::now() + timing.liveness_timeout;
} }
} }
}, },
Ok(Some(_)) => match pump_outbound(framed, out_rx) { Ok(Some(Arm::Out)) => match pump_outbound(framed, out_rx) {
Outbound::Sent => {} Outbound::Drained => {}
Outbound::Closed => out_open = false, Outbound::Closed => out_open = false,
Outbound::WireFailed => break, Outbound::WireFailed => break,
}, },
Ok(Some(Arm::Mon)) => match pump_moncmds(framed, mon_rx, mons) {
Outbound::Drained => {}
Outbound::Closed => mon_open = false,
Outbound::WireFailed => break,
},
Ok(Some(Arm::Watched)) => {
for (id, reason) in mons.sweep_watched() {
let frame = Frame::Down {
monitor_id: id.0,
reason: reason.into(),
};
if framed.send(&frame).is_err() {
return;
}
}
}
// A deadline passed; the top of the loop acts on whichever. // A deadline passed; the top of the loop acts on whichever.
Ok(None) => {} Ok(None) => {}
// The fd arm failed to register — the connection is gone. // The fd arm failed to register — the connection is gone.
@@ -201,9 +396,42 @@ fn run_live(
} }
} }
/// What one outbound wake yielded. /// Drain the monitor-command inbox: record, then emit (module docs).
fn pump_moncmds(
framed: &mut FramedConn,
mon_rx: &Receiver<MonCmd>,
mons: &mut Monitors,
) -> Outbound {
loop {
match mon_rx.try_recv() {
Ok(Some(MonCmd::Monitor { id, target, tx })) => {
let frame = Frame::Monitor {
monitor_id: id.0,
index: target.index(),
generation: target.generation(),
};
mons.outstanding.insert(id, (target, tx));
if framed.send(&frame).is_err() {
return Outbound::WireFailed;
}
}
Ok(Some(MonCmd::Demonitor { id })) => {
if mons.outstanding.remove(&id).is_some()
&& framed.send(&Frame::Demonitor { monitor_id: id.0 }).is_err()
{
return Outbound::WireFailed;
}
}
Ok(None) => return Outbound::Drained,
Err(_) => return Outbound::Closed,
}
}
}
/// What one outbound-side wake (frames or monitor commands) yielded.
enum Outbound { enum Outbound {
Sent, /// Everything queued went onto the wire; the inbox is open and empty.
Drained,
/// The manager unbound this connection's sender; nothing more will come. /// The manager unbound this connection's sender; nothing more will come.
Closed, Closed,
/// The socket refused a write: the connection is gone. /// The socket refused a write: the connection is gone.
@@ -219,7 +447,7 @@ fn pump_outbound(framed: &mut FramedConn, out_rx: &Receiver<Frame>) -> Outbound
return Outbound::WireFailed; return Outbound::WireFailed;
} }
} }
Ok(None) => return Outbound::Sent, Ok(None) => return Outbound::Drained,
Err(_) => return Outbound::Closed, Err(_) => return Outbound::Closed,
} }
} }
@@ -261,16 +489,26 @@ enum Pump {
Frames(usize), Frames(usize),
} }
/// Surface an inbound verdict: one `smarm-trace` event, nothing else — it
/// is local knowledge (RFC §3). A no-op without the feature.
fn note_verdict(verdict: InboundVerdict) {
#[cfg(feature = "smarm-trace")]
crate::te!(crate::trace::Event::ClusterInbound(verdict.label()));
#[cfg(not(feature = "smarm-trace"))]
drop(verdict);
}
/// Consume one readable wake: exactly one socket read (which cannot block /// Consume one readable wake: exactly one socket read (which cannot block
/// after a level-triggered readable indication), then drain every complete /// after a level-triggered readable indication), then drain every complete
/// frame the buffer now holds. A blocking `recv` here would park the actor /// frame the buffer now holds. A blocking `recv` here would park the actor
/// past its heartbeat and liveness deadlines whenever a frame arrives split. /// past its heartbeat and liveness deadlines whenever a frame arrives split.
/// Every consumed frame counts for liveness; `SendNamed` additionally goes /// Every consumed frame counts for liveness; `SendNamed` goes to the one
/// to the one inbound resolution seam. Its verdict is local knowledge only /// name-resolution seam and `Send` to the pid seam. Verdicts are local
/// — nothing goes back on the wire (RFC §3) — and is currently discarded /// knowledge only — nothing goes back on the wire (RFC §3) — and surface
/// (a future trace hook is the place to surface it). Frames for later /// as one `smarm-trace` `ClusterInbound` event each (zero cost off).
/// chunks (`Send` c10, `Monitor`/`Down` c11) are consumed and ignored. /// `Monitor`/`Demonitor`/`Down` go to the [`Monitors`] tables; a `Monitor`
fn pump_readable(framed: &mut FramedConn) -> Pump { /// that can be answered at once is answered inline.
fn pump_readable(framed: &mut FramedConn, mons: &mut Monitors) -> Pump {
let eof = match framed.read_once() { let eof = match framed.read_once() {
Ok(n) => n == 0, Ok(n) => n == 0,
Err(_) => return Pump::Ended, Err(_) => return Pump::Ended,
@@ -280,13 +518,43 @@ fn pump_readable(framed: &mut FramedConn) -> Pump {
match framed.next_buffered() { match framed.next_buffered() {
Ok(Some(frame)) => { Ok(Some(frame)) => {
got += 1; got += 1;
if let Frame::SendNamed { match frame {
Frame::SendNamed {
name, name,
type_hash, type_hash,
payload, payload,
} = frame } => {
{ note_verdict(deliver_named(&name, type_hash, &payload));
let _verdict = deliver_named(&name, type_hash, &payload); }
Frame::Send {
index,
generation,
type_hash,
payload,
} => {
note_verdict(deliver_to_pid(index, generation, type_hash, &payload));
}
Frame::Monitor {
monitor_id,
index,
generation,
} => {
let id = MonitorId(monitor_id);
if let Some(reason) = mons.admit(id, index, generation) {
let frame = Frame::Down {
monitor_id,
reason: reason.into(),
};
if framed.send(&frame).is_err() {
return Pump::Ended;
}
}
}
Frame::Demonitor { monitor_id } => mons.cancel(MonitorId(monitor_id)),
Frame::Down { monitor_id, reason } => mons.down(MonitorId(monitor_id), reason),
// Heartbeat: liveness only. Handshake frames after
// establishment: ignored.
_ => {}
} }
} }
Ok(None) => break, Ok(None) => break,
@@ -299,3 +567,64 @@ fn pump_readable(framed: &mut FramedConn) -> Pump {
Pump::Frames(got) Pump::Frames(got)
} }
} }
#[cfg(test)]
mod tests {
//! `Monitors::teardown` in isolation: the actor-side half of c13, pinned
//! separately because from the outside it is indistinguishable from the
//! read-side backstop in `RemoteMonitor` (both yield `Disconnected`).
use super::*;
use crate::pg::Incarnation;
fn pid(index: u32) -> RemotePid<Erased> {
RemotePid::from_parts("peer", Incarnation::new(1), index, 1)
}
#[test]
fn teardown_answers_every_outstanding_and_unread_monitor_once() {
crate::run(|| {
let mut mons = Monitors::default();
let (mon_tx, mon_rx) = channel::<MonCmd>();
// Already registered.
let (tx1, rx1) = channel::<RemoteDown>();
mons.outstanding.insert(MonitorId(1), (pid(1), tx1));
// In the inbox, never processed.
let (tx2, rx2) = channel::<RemoteDown>();
mon_tx
.send(MonCmd::Monitor {
id: MonitorId(2),
target: pid(2),
tx: tx2,
})
.ok()
.unwrap();
// Registered, then cancelled in the inbox: silence.
let (tx3, rx3) = channel::<RemoteDown>();
mons.outstanding.insert(MonitorId(3), (pid(3), tx3));
mon_tx
.send(MonCmd::Demonitor { id: MonitorId(3) })
.ok()
.unwrap();
mons.teardown(&mon_rx);
let d1 = rx1.recv().unwrap();
assert_eq!(
(d1.pid, d1.reason),
(pid(1), RemoteDownReason::Disconnected)
);
let d2 = rx2.recv().unwrap();
assert_eq!(
(d2.pid, d2.reason),
(pid(2), RemoteDownReason::Disconnected)
);
// Cancelled: no notice was sent (its sender is dropped, channel
// closed-empty), and nobody got a second one.
assert!(rx3.try_recv().is_err());
assert!(rx1.try_recv().is_err());
assert!(rx2.try_recv().is_err());
assert!(mons.outstanding.is_empty());
});
}
}
+48 -28
View File
@@ -27,16 +27,17 @@ use crate::channel::{channel, Receiver, Selectable, Sender};
use crate::cluster::conn::spawn_established; use crate::cluster::conn::spawn_established;
use crate::cluster::envelope::{Frame, RejectReason}; use crate::cluster::envelope::{Frame, RejectReason};
use crate::cluster::handshake::{ use crate::cluster::handshake::{
HelloCtx, Initiator, InitiatorOutcome, Local, Peer, Responder, ResponderOutcome, Initiator, InitiatorOutcome, Local, Peer, PeerStanding, Responder, ResponderOutcome,
}; };
use crate::cluster::manager::{Call, Reply, MANAGER}; use crate::cluster::manager::{Call, Reply, MANAGER};
use crate::cluster::transport::{FramedConn, Listener, RecvError, SendError, Transport}; use crate::cluster::transport::{FramedConn, Listener, RecvError, SendError, Transport};
use crate::cluster::Timing;
use crate::gen_server; use crate::gen_server;
use crate::pid::Pid; use crate::pid::Pid;
use crate::scheduler::{self, spawn}; use crate::scheduler::{self, spawn};
/// How long either side waits for the peer's handshake frame before giving /// Default for [`Timing::handshake_timeout`]: how long either side waits for
/// up and closing. Enforced on the path via [`FramedConn::recv_deadline`], /// the peer's handshake frame before giving up and closing. Enforced on the path via [`FramedConn::recv_deadline`],
/// so a peer that connects and goes silent cannot wedge the acceptor. /// so a peer that connects and goes silent cannot wedge the acceptor.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
@@ -119,7 +120,7 @@ pub fn dial_handshake(
/// Accept-side path step: read the first frame, judge it, answer or close. /// Accept-side path step: read the first frame, judge it, answer or close.
/// ///
/// `ctx_for` supplies the [`HelloCtx`] for the *offered* name — knowledge /// `standing_of` supplies the [`PeerStanding`] of the *offered* name — knowledge
/// only the frame reveals, which is why it is a callback and not a value /// only the frame reveals, which is why it is a callback and not a value
/// (the integrated acceptor asks the manager; loopback tests fabricate). /// (the integrated acceptor asks the manager; loopback tests fabricate).
/// It is not called when the first frame is not a `Hello`. /// It is not called when the first frame is not a `Hello`.
@@ -129,7 +130,7 @@ pub fn dial_handshake(
pub fn accept_handshake( pub fn accept_handshake(
framed: &mut FramedConn, framed: &mut FramedConn,
local: Local, local: Local,
ctx_for: impl FnOnce(&str) -> HelloCtx, standing_of: impl FnOnce(&str) -> PeerStanding,
deadline: Instant, deadline: Instant,
) -> Result<Peer, HandshakeError> { ) -> Result<Peer, HandshakeError> {
let frame = match framed.recv_deadline(deadline) { let frame = match framed.recv_deadline(deadline) {
@@ -143,11 +144,11 @@ pub fn accept_handshake(
return Err(from_recv(e)); return Err(from_recv(e));
} }
}; };
let ctx = match &frame { let standing = match &frame {
Frame::Hello { node_name, .. } => ctx_for(node_name), Frame::Hello { node_name, .. } => standing_of(node_name),
_ => HelloCtx::default(), _ => PeerStanding::Free,
}; };
match Responder::new(local).on_frame(frame, ctx) { match Responder::new(local).on_frame(frame, standing) {
ResponderOutcome::Accepted { reply, peer } => { ResponderOutcome::Accepted { reply, peer } => {
if let Err(e) = framed.send(&reply) { if let Err(e) = framed.send(&reply) {
framed.close(); framed.close();
@@ -199,6 +200,21 @@ pub enum DialError {
Duplicate, Duplicate,
} }
impl DialError {
/// A short static label per kind, for the `smarm-trace` `ClusterDial`
/// event; the payload (io error, names) is not carried.
pub fn label(&self) -> &'static str {
match self {
DialError::AlreadyDialing => "already_dialing",
DialError::ManagerUnavailable => "manager_unavailable",
DialError::Connect(_) => "connect",
DialError::Handshake(_) => "handshake",
DialError::PeerNameMismatch { .. } => "peer_name_mismatch",
DialError::Duplicate => "duplicate",
}
}
}
/// Dial `peer_name` at `addr` and run the handshake, keeping the manager's /// Dial `peer_name` at `addr` and run the handshake, keeping the manager's
/// dial-intent set honest around it: the intent is registered *before* /// dial-intent set honest around it: the intent is registered *before*
/// connecting (so a crossing inbound `Hello` sees it) and cleared the /// connecting (so a crossing inbound `Hello` sees it) and cleared the
@@ -211,6 +227,7 @@ pub fn dial(
addr: &str, addr: &str,
peer_name: &str, peer_name: &str,
local: &Local, local: &Local,
timing: Timing,
) -> Result<Pid, DialError> { ) -> Result<Pid, DialError> {
let me = scheduler::self_pid(); let me = scheduler::self_pid();
match gen_server::call( match gen_server::call(
@@ -224,7 +241,7 @@ pub fn dial(
Ok(Reply::DialBegan(false)) => return Err(DialError::AlreadyDialing), Ok(Reply::DialBegan(false)) => return Err(DialError::AlreadyDialing),
_ => return Err(DialError::ManagerUnavailable), _ => return Err(DialError::ManagerUnavailable),
} }
let result = connect_and_shake(transport, addr, local); let result = connect_and_shake(transport, addr, local, timing);
// Cleared immediately on outcome — a stale intent during the established // Cleared immediately on outcome — a stale intent during the established
// window would corrupt later tie-breaks. Synchronous (a call): the // window would corrupt later tie-breaks. Synchronous (a call): the
// intent is provably gone before anything else happens. // intent is provably gone before anything else happens.
@@ -242,17 +259,18 @@ pub fn dial(
got: peer.node_name, got: peer.node_name,
}); });
} }
spawn_established(framed, peer).map_err(|_| DialError::Duplicate) spawn_established(framed, peer, timing).map_err(|_| DialError::Duplicate)
} }
fn connect_and_shake( fn connect_and_shake(
transport: &dyn Transport, transport: &dyn Transport,
addr: &str, addr: &str,
local: &Local, local: &Local,
timing: Timing,
) -> Result<(FramedConn, Peer), DialError> { ) -> Result<(FramedConn, Peer), DialError> {
let conn = transport.dial(addr).map_err(DialError::Connect)?; let conn = transport.dial(addr).map_err(DialError::Connect)?;
let mut framed = FramedConn::new(conn); let mut framed = FramedConn::new(conn);
let deadline = Instant::now() + HANDSHAKE_TIMEOUT; let deadline = Instant::now() + timing.handshake_timeout;
let peer = dial_handshake(&mut framed, local, deadline).map_err(DialError::Handshake)?; let peer = dial_handshake(&mut framed, local, deadline).map_err(DialError::Handshake)?;
Ok((framed, peer)) Ok((framed, peer))
} }
@@ -286,14 +304,19 @@ impl AcceptorHandle {
/// fd-backed ([`Listener::readable_arm`]); the loopback listener is not, /// fd-backed ([`Listener::readable_arm`]); the loopback listener is not,
/// and its acceptor exits immediately — loopback handshakes are driven /// and its acceptor exits immediately — loopback handshakes are driven
/// synchronously through the path fns instead, per D8. /// synchronously through the path fns instead, per D8.
pub fn spawn_acceptor(listener: Box<dyn Listener>, local: Local) -> AcceptorHandle { pub fn spawn_acceptor(listener: Box<dyn Listener>, local: Local, timing: Timing) -> AcceptorHandle {
let addr = listener.local_addr(); let addr = listener.local_addr();
let (cmd_tx, cmd_rx) = channel(); let (cmd_tx, cmd_rx) = channel();
spawn(move || accept_loop(listener, local, cmd_rx)); spawn(move || accept_loop(listener, local, timing, cmd_rx));
AcceptorHandle { cmd_tx, addr } AcceptorHandle { cmd_tx, addr }
} }
fn accept_loop(mut listener: Box<dyn Listener>, local: Local, cmd_rx: Receiver<()>) { fn accept_loop(
mut listener: Box<dyn Listener>,
local: Local,
timing: Timing,
cmd_rx: Receiver<()>,
) {
loop { loop {
let Some(arm) = listener.readable_arm() else { let Some(arm) = listener.readable_arm() else {
return; return;
@@ -311,7 +334,7 @@ fn accept_loop(mut listener: Box<dyn Listener>, local: Local, cmd_rx: Receiver<(
Ok(conn) => conn, Ok(conn) => conn,
Err(_) => return, // listener itself is broken Err(_) => return, // listener itself is broken
}; };
handle_inbound(FramedConn::new(conn), &local); handle_inbound(FramedConn::new(conn), &local, timing);
} }
Err(_) => return, // fd arm failed to register: listener is gone Err(_) => return, // fd arm failed to register: listener is gone
} }
@@ -319,27 +342,24 @@ fn accept_loop(mut listener: Box<dyn Listener>, local: Local, cmd_rx: Receiver<(
} }
/// Run the accept-side handshake for one inbound connection, asking the /// Run the accept-side handshake for one inbound connection, asking the
/// manager for the [`HelloCtx`], and hand the established connection to the /// manager for the [`PeerStanding`], and hand the established connection to the
/// manager. Every failure was already resolved on the path (reject sent / /// manager. Every failure was already resolved on the path (reject sent /
/// closed, or the registration refused and the actor stopped), so there is /// closed, or the registration refused and the actor stopped), so there is
/// nothing for the acceptor to carry forward. /// nothing for the acceptor to carry forward.
fn handle_inbound(mut framed: FramedConn, local: &Local) { fn handle_inbound(mut framed: FramedConn, local: &Local, timing: Timing) {
let deadline = Instant::now() + HANDSHAKE_TIMEOUT; let deadline = Instant::now() + timing.handshake_timeout;
let ctx_for = |name: &str| match gen_server::call( let standing_of = |name: &str| match gen_server::call(
MANAGER, MANAGER,
Call::HelloCtx { Call::Standing {
peer_name: name.to_string(), peer_name: name.to_string(),
}, },
) { ) {
Ok(Reply::HelloCtx(ctx)) => ctx, Ok(Reply::Standing(s)) => s,
// Manager unreachable: nobody could register this connection anyway, // Manager unreachable: nobody could register this connection anyway,
// so claim the name taken and reject rather than accept an orphan. // so claim the name taken and reject rather than accept an orphan.
_ => HelloCtx { _ => PeerStanding::Claimed,
name_claimed: true,
dialing_this_peer: false,
},
}; };
if let Ok(peer) = accept_handshake(&mut framed, local.clone(), ctx_for, deadline) { if let Ok(peer) = accept_handshake(&mut framed, local.clone(), standing_of, deadline) {
let _ = spawn_established(framed, peer); let _ = spawn_established(framed, peer, timing);
} }
} }
+144 -57
View File
@@ -15,9 +15,14 @@
//! `node_down` resume immediately (a fresh sequence — the reconnect case is //! `node_down` resume immediately (a fresh sequence — the reconnect case is
//! the one backoff exists to pace, but the *first* retry after a death //! the one backoff exists to pace, but the *first* retry after a death
//! should be prompt). A candidate bearing our own name is parked permanently //! should be prompt). A candidate bearing our own name is parked permanently
//! — that seed is us. Every other failure retries: in particular a //! — that seed is us; so is one whose address answers as a different name
//! `NameTaken` reject can be our own ghost at the peer, not yet reaped by //! (`PeerNameMismatch`: a misconfigured or stale seed — each retry would
//! its liveness timer, so it must not park. //! only blip the peer's membership). Every other failure retries: in
//! particular a `NameTaken` reject can be our own ghost at the peer, not
//! yet reaped by its liveness timer, so it must not park. Each attempt's
//! outcome is one `smarm-trace` `ClusterDial` event. A [`Discovery::Withdrawn`]
//! drops its `(name, addr)` from the dial set — only that: a live
//! connection is membership's, and a re-announce re-adds it fresh.
//! //!
//! Dials run **inline in the loop** — the same deliberate serialization as //! Dials run **inline in the loop** — the same deliberate serialization as
//! the acceptor (c6b): each attempt is bounded by the connect + handshake //! the acceptor (c6b): each attempt is bounded by the connect + handshake
@@ -25,21 +30,23 @@
//! unreachable seeds would stretch the loop's latency; revisit if a real //! unreachable seeds would stretch the loop's latency; revisit if a real
//! deployment ever hits that shape. //! deployment ever hits that shape.
use std::collections::{HashMap, HashSet}; use std::collections::HashSet;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::channel::{channel, select, select_timeout, Receiver, Selectable, Sender}; use crate::channel::{channel, select, select_timeout, Receiver, Selectable, Sender};
use crate::cluster::connect::dial; use crate::cluster::connect::{dial, DialError};
use crate::cluster::discovery::{Discovery, Strategy}; use crate::cluster::discovery::{Discovery, Strategy};
use crate::cluster::handshake::Local; use crate::cluster::handshake::Local;
use crate::cluster::membership::{subscribe, NodeEvent}; use crate::cluster::membership::{subscribe, NodeEvent};
use crate::cluster::transport::Transport; use crate::cluster::transport::Transport;
use crate::pg::NodeId; use crate::cluster::Timing;
use crate::scheduler::spawn; use crate::scheduler::spawn;
/// First retry delay after a failed dial attempt. /// Default for [`Timing::initial_backoff`]: first retry delay after a failed
/// dial attempt.
pub const INITIAL_BACKOFF: Duration = Duration::from_millis(250); pub const INITIAL_BACKOFF: Duration = Duration::from_millis(250);
/// Backoff ceiling: an unreachable seed is retried this often, forever. /// Default for [`Timing::max_backoff`]: an unreachable seed is retried this
/// often, forever.
pub const MAX_BACKOFF: Duration = Duration::from_secs(5); pub const MAX_BACKOFF: Duration = Duration::from_secs(5);
enum Cmd { enum Cmd {
@@ -60,15 +67,74 @@ impl ConnectorHandle {
} }
} }
/// One discovered `(name, addr)` and its dial state. /// One discovered `(name, addr)` and our dial intent towards it.
struct Candidate { struct Candidate {
name: String, name: String,
addr: String, addr: String,
/// This seed is the local node itself: never dialed. state: State,
parked: bool, }
/// The connector's *intent* for a candidate. Whether the peer is currently
/// up is a separate, name-keyed membership fact (`up` in [`run`]): a
/// candidate can arrive after its peer's `node_up` (the snapshot lands
/// before the strategy has said anything), so "up" cannot live on the
/// candidate alone — it is a filter over dialing, not a candidate state.
enum State {
/// Never dialed: this seed is the local node itself, or the address
/// answered as a *different* name than the one seeded
/// (`DialError::PeerNameMismatch` — a misconfigured or stale seed;
/// redialing would only blip the peer's membership forever). The way
/// back is the strategy's: `Withdrawn` then a fresh `Candidate`.
Parked,
/// Dial when due; on failure, back off.
Dialing {
/// Delay to apply after the *next* failure. /// Delay to apply after the *next* failure.
backoff: Duration, backoff: Duration,
next_attempt: Instant, next_attempt: Instant,
},
}
impl State {
fn fresh(timing: &Timing) -> Self {
State::Dialing {
backoff: timing.initial_backoff,
next_attempt: Instant::now(),
}
}
}
impl Candidate {
/// The retry deadline, if this candidate is dialing at all.
fn due(&self) -> Option<Instant> {
match self.state {
State::Parked => None,
State::Dialing { next_attempt, .. } => Some(next_attempt),
}
}
/// A dial attempt was made: schedule the retry, grow the backoff.
fn attempted(&mut self, timing: &Timing) {
if let State::Dialing {
backoff,
next_attempt,
} = &mut self.state
{
*next_attempt = Instant::now() + *backoff;
*backoff = (*backoff * 2).min(timing.max_backoff);
}
}
/// The peer came up: the next sequence (after a later `node_down`)
/// starts from the initial delay again.
fn peer_up(&mut self, timing: &Timing) {
if let State::Dialing { backoff, .. } = &mut self.state {
*backoff = timing.initial_backoff;
}
}
/// The peer went down: redial promptly, fresh sequence.
fn peer_down(&mut self, timing: &Timing) {
if matches!(self.state, State::Dialing { .. }) {
self.state = State::fresh(timing);
}
}
} }
/// Spawn the connector actor. The strategy is spawned as its child; the /// Spawn the connector actor. The strategy is spawned as its child; the
@@ -78,9 +144,10 @@ pub fn spawn_connector(
transport: Box<dyn Transport>, transport: Box<dyn Transport>,
local: Local, local: Local,
strategy: Box<dyn Strategy>, strategy: Box<dyn Strategy>,
timing: Timing,
) -> ConnectorHandle { ) -> ConnectorHandle {
let (cmd_tx, cmd_rx) = channel(); let (cmd_tx, cmd_rx) = channel();
spawn(move || run(transport, local, strategy, cmd_rx)); spawn(move || run(transport, local, strategy, timing, cmd_rx));
ConnectorHandle { cmd_tx } ConnectorHandle { cmd_tx }
} }
@@ -88,10 +155,11 @@ fn run(
transport: Box<dyn Transport>, transport: Box<dyn Transport>,
local: Local, local: Local,
strategy: Box<dyn Strategy>, strategy: Box<dyn Strategy>,
timing: Timing,
cmd_rx: Receiver<Cmd>, cmd_rx: Receiver<Cmd>,
) { ) {
// Membership is the connector's source of truth for "who is up" — the // Membership is the connector's source of truth for "who is up" — the
// snapshot seeds `connected` before any candidate arrives. // snapshot seeds `up` before any candidate arrives.
let Some(events) = subscribe() else { let Some(events) = subscribe() else {
return; // no manager, no cluster to connect return; // no manager, no cluster to connect
}; };
@@ -99,8 +167,7 @@ fn run(
spawn(move || strategy.run(disc_tx)); spawn(move || strategy.run(disc_tx));
let mut cands: Vec<Candidate> = Vec::new(); let mut cands: Vec<Candidate> = Vec::new();
let mut connected: HashSet<String> = HashSet::new(); let mut up: HashSet<String> = HashSet::new();
let mut names: HashMap<NodeId, String> = HashMap::new(); // NodeDown carries only the id
let mut strategy_done = false; let mut strategy_done = false;
loop { loop {
@@ -111,9 +178,9 @@ fn run(
Drained::Open => {} Drained::Open => {}
} }
if !strategy_done { if !strategy_done {
strategy_done = drain_discoveries(&disc_rx, &local, &mut cands); strategy_done = drain_discoveries(&disc_rx, &local, &timing, &mut cands);
} }
match drain_events(&events.rx, &mut connected, &mut names, &mut cands) { match drain_events(&events.rx, &timing, &mut up, &mut cands) {
Drained::Stop => return, // manager gone: the cluster is tearing down Drained::Stop => return, // manager gone: the cluster is tearing down
Drained::Open => {} Drained::Open => {}
} }
@@ -122,23 +189,26 @@ fn run(
let now = Instant::now(); let now = Instant::now();
for c in cands for c in cands
.iter_mut() .iter_mut()
.filter(|c| !c.parked && !connected.contains(&c.name) && c.next_attempt <= now) .filter(|c| !up.contains(&c.name) && c.due().is_some_and(|d| d <= now))
{ {
// The outcome does not branch the bookkeeping: on success the // On success the manager's node_up is on its way and lands in
// manager's node_up is on its way and flips `connected` (backing // `up` (backing off meanwhile keeps a racing re-attempt from
// off meanwhile keeps a racing re-attempt from spinning); every // spinning); every failure retries — see the module docs —
// failure retries — see the module docs. // except a peer-name mismatch, which parks the candidate.
let _ = dial(&*transport, &c.addr, &c.name, &local); let outcome = dial(&*transport, &c.addr, &c.name, &local, timing);
c.next_attempt = Instant::now() + c.backoff; note_dial(&outcome);
c.backoff = (c.backoff * 2).min(MAX_BACKOFF); match outcome {
Err(DialError::PeerNameMismatch { .. }) => c.state = State::Parked,
_ => c.attempted(&timing),
}
} }
// Wait: until the earliest retry deadline among actionable // Wait: until the earliest retry deadline among actionable
// candidates, or indefinitely if none is pending. // candidates, or indefinitely if none is pending.
let deadline = cands let deadline = cands
.iter() .iter()
.filter(|c| !c.parked && !connected.contains(&c.name)) .filter(|c| !up.contains(&c.name))
.map(|c| c.next_attempt) .filter_map(Candidate::due)
.min(); .min();
let mut arms: Vec<&dyn Selectable> = vec![&cmd_rx, &events.rx]; let mut arms: Vec<&dyn Selectable> = vec![&cmd_rx, &events.rx];
if !strategy_done { if !strategy_done {
@@ -170,23 +240,31 @@ fn drain_cmd(rx: &Receiver<Cmd>) -> Drained {
} }
/// Pull every pending discovery into the candidate set (deduplicated by /// Pull every pending discovery into the candidate set (deduplicated by
/// `(name, addr)`; a candidate bearing the local name is parked). Returns /// `(name, addr)`; a candidate bearing the local name is parked; a
/// `true` once the strategy's channel closes — it has said all it will. /// `Withdrawn` removes its pair from the dial set and nothing else — see
fn drain_discoveries(rx: &Receiver<Discovery>, local: &Local, cands: &mut Vec<Candidate>) -> bool { /// [`Discovery::Withdrawn`]). Returns `true` once the strategy's channel
/// closes — it has said all it will.
fn drain_discoveries(
rx: &Receiver<Discovery>,
local: &Local,
timing: &Timing,
cands: &mut Vec<Candidate>,
) -> bool {
loop { loop {
match rx.try_recv() { match rx.try_recv() {
Ok(Some(Discovery::Withdrawn { name, addr })) => {
cands.retain(|c| !(c.name == name && c.addr == addr));
}
Ok(Some(Discovery::Candidate { name, addr })) => { Ok(Some(Discovery::Candidate { name, addr })) => {
if cands.iter().any(|c| c.name == name && c.addr == addr) { if cands.iter().any(|c| c.name == name && c.addr == addr) {
continue; continue;
} }
let parked = name == local.node_name; let state = if name == local.node_name {
cands.push(Candidate { State::Parked
name, } else {
addr, State::fresh(timing)
parked, };
backoff: INITIAL_BACKOFF, cands.push(Candidate { name, addr, state });
next_attempt: Instant::now(),
});
} }
Ok(None) => return false, Ok(None) => return false,
Err(_) => return true, // strategy done; its candidates live on here Err(_) => return true, // strategy done; its candidates live on here
@@ -194,36 +272,45 @@ fn drain_discoveries(rx: &Receiver<Discovery>, local: &Local, cands: &mut Vec<Ca
} }
} }
/// Fold pending membership events into `connected` (and the id→name map). /// Fold pending membership events into `up`; each is also a transition on
/// `node_up` resets its candidates' backoff; `node_down` schedules a prompt /// that peer's candidates (see [`Candidate::peer_up`] / [`peer_down`]).
/// redial with a fresh sequence. ///
/// [`peer_down`]: Candidate::peer_down
fn drain_events( fn drain_events(
rx: &Receiver<NodeEvent>, rx: &Receiver<NodeEvent>,
connected: &mut HashSet<String>, timing: &Timing,
names: &mut HashMap<NodeId, String>, up: &mut HashSet<String>,
cands: &mut [Candidate], cands: &mut [Candidate],
) -> Drained { ) -> Drained {
loop { loop {
match rx.try_recv() { match rx.try_recv() {
Ok(Some(NodeEvent::NodeUp(info))) => { Ok(Some(NodeEvent::NodeUp(info))) => {
names.insert(info.node, info.name.clone()); cands
for c in cands.iter_mut().filter(|c| c.name == info.name) { .iter_mut()
c.backoff = INITIAL_BACKOFF; .filter(|c| c.name == info.name)
} .for_each(|c| c.peer_up(timing));
connected.insert(info.name); up.insert(info.name);
}
Ok(Some(NodeEvent::NodeDown { node })) => {
if let Some(name) = names.remove(&node) {
connected.remove(&name);
let now = Instant::now();
for c in cands.iter_mut().filter(|c| c.name == name) {
c.backoff = INITIAL_BACKOFF;
c.next_attempt = now;
}
} }
Ok(Some(NodeEvent::NodeDown(info))) => {
up.remove(&info.name);
cands
.iter_mut()
.filter(|c| c.name == info.name)
.for_each(|c| c.peer_down(timing));
} }
Ok(None) => return Drained::Open, Ok(None) => return Drained::Open,
Err(_) => return Drained::Stop, Err(_) => return Drained::Stop,
} }
} }
} }
/// Surface a dial outcome: one `smarm-trace` event, nothing else. The
/// connector's bookkeeping is decided by the caller.
fn note_dial(outcome: &Result<crate::pid::Pid, DialError>) {
#[cfg(feature = "smarm-trace")]
crate::te!(crate::trace::Event::ClusterDial(
outcome.as_ref().map_or_else(DialError::label, |_| "ok")
));
#[cfg(not(feature = "smarm-trace"))]
let _ = outcome;
}
+11 -2
View File
@@ -20,13 +20,22 @@ use crate::channel::Sender;
/// A discovery event, as pushed by a [`Strategy`]. /// A discovery event, as pushed by a [`Strategy`].
/// ///
/// Additive-only for now (candidates are announced, never withdrawn); /// `Candidate` announces, `Withdrawn` retracts — the primitive pair. A
/// `#[non_exhaustive]` so expiry can land later without breaking strategies. /// strategy that wants TTL semantics builds them on top (track its own
/// last-seen times, emit `Withdrawn` on expiry); the connector deliberately
/// has no clock of its own for candidates (D11: strategies never
/// re-announce, the connector owns retry). `#[non_exhaustive]` so more can
/// land without breaking strategies.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive] #[non_exhaustive]
pub enum Discovery { pub enum Discovery {
/// A peer worth dialing: its claimed node name and a dialable address. /// A peer worth dialing: its claimed node name and a dialable address.
Candidate { name: String, addr: String }, Candidate { name: String, addr: String },
/// Stop dialing this `(name, addr)`. Dial-set only: a connection that
/// is already up is membership's business and is left alone; an
/// attempt in flight completes on its own; a later `Candidate` for the
/// same pair re-adds it with fresh backoff. Unknown pairs are ignored.
Withdrawn { name: String, addr: String },
} }
/// A source of peers to dial. Implementations are spawned as actors by the /// A source of peers to dial. Implementations are spawned as actors by the
+49 -11
View File
@@ -81,10 +81,45 @@ pub enum Frame {
}, },
Down { Down {
monitor_id: u64, monitor_id: u64,
reason: DownReason, reason: RemoteDownReason,
}, },
} }
/// Why a remotely-monitored actor is reported down: either the target's own
/// terminal [`DownReason`] as its node recorded it, or the *link* to that
/// node was lost (or absent) — which says nothing about the actor itself.
///
/// This is the cluster-side widening of `DownReason` (p5): `Disconnected`
/// is a fact about a connection, never about a local actor, so it lives
/// here rather than in the core enum — a local `Down` can never carry it,
/// and matches on `DownReason` stay exhaustive over actor outcomes only.
/// On the wire `Local(r)` uses `r`'s tag and `Disconnected` is tag 5,
/// bound since c11; no peer emits it today (a lost link is synthesized
/// locally), but the codec honours it both ways.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteDownReason {
/// The target itself terminated; the peer reported this reason.
Local(DownReason),
/// The link to the target's node was lost or was never up.
Disconnected,
}
impl RemoteDownReason {
/// The actor's own reason, if this was not a link loss.
pub fn local(self) -> Option<DownReason> {
match self {
RemoteDownReason::Local(r) => Some(r),
RemoteDownReason::Disconnected => None,
}
}
}
impl From<DownReason> for RemoteDownReason {
fn from(r: DownReason) -> Self {
RemoteDownReason::Local(r)
}
}
// Frame tags. 0 is deliberately unassigned so an all-zero buffer never parses. // Frame tags. 0 is deliberately unassigned so an all-zero buffer never parses.
const TAG_HELLO: u8 = 1; const TAG_HELLO: u8 = 1;
const TAG_HELLO_ACK: u8 = 2; const TAG_HELLO_ACK: u8 = 2;
@@ -101,11 +136,12 @@ const REJ_HASH_MISMATCH: u8 = 1;
const REJ_NAME_TAKEN: u8 = 2; const REJ_NAME_TAKEN: u8 = 2;
const REJ_PROTO_VERSION: u8 = 3; const REJ_PROTO_VERSION: u8 = 3;
// DownReason tags. c11 adds `Disconnected = 5`; do not reuse tags. // DownReason tags. Do not reuse tags.
const DR_EXIT: u8 = 1; const DR_EXIT: u8 = 1;
const DR_PANIC: u8 = 2; const DR_PANIC: u8 = 2;
const DR_STOPPED: u8 = 3; const DR_STOPPED: u8 = 3;
const DR_NOPROC: u8 = 4; const DR_NOPROC: u8 = 4;
const DR_DISCONNECTED: u8 = 5;
/// Frame could not be encoded. The output buffer is left exactly as it was. /// Frame could not be encoded. The output buffer is left exactly as it was.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -272,10 +308,11 @@ impl Frame {
out.push(TAG_DOWN); out.push(TAG_DOWN);
put_u64(out, *monitor_id); put_u64(out, *monitor_id);
out.push(match reason { out.push(match reason {
DownReason::Exit => DR_EXIT, RemoteDownReason::Local(DownReason::Exit) => DR_EXIT,
DownReason::Panic => DR_PANIC, RemoteDownReason::Local(DownReason::Panic) => DR_PANIC,
DownReason::Stopped => DR_STOPPED, RemoteDownReason::Local(DownReason::Stopped) => DR_STOPPED,
DownReason::NoProc => DR_NOPROC, RemoteDownReason::Local(DownReason::NoProc) => DR_NOPROC,
RemoteDownReason::Disconnected => DR_DISCONNECTED,
}); });
} }
} }
@@ -364,13 +401,14 @@ impl Frame {
TAG_DOWN => Frame::Down { TAG_DOWN => Frame::Down {
monitor_id: r.u64()?, monitor_id: r.u64()?,
reason: match r.u8()? { reason: match r.u8()? {
DR_EXIT => DownReason::Exit, DR_EXIT => RemoteDownReason::Local(DownReason::Exit),
DR_PANIC => DownReason::Panic, DR_PANIC => RemoteDownReason::Local(DownReason::Panic),
DR_STOPPED => DownReason::Stopped, DR_STOPPED => RemoteDownReason::Local(DownReason::Stopped),
DR_NOPROC => DownReason::NoProc, DR_NOPROC => RemoteDownReason::Local(DownReason::NoProc),
DR_DISCONNECTED => RemoteDownReason::Disconnected,
t => { t => {
return Err(DecodeError::UnknownEnumTag { return Err(DecodeError::UnknownEnumTag {
what: "DownReason", what: "RemoteDownReason",
tag: t, tag: t,
}) })
} }
+16 -11
View File
@@ -25,14 +25,19 @@ pub struct Peer {
pub meta: NodeMeta, pub meta: NodeMeta,
} }
/// Driver-supplied context for an inbound `Hello` — knowledge the pure /// Driver-supplied standing of the *offered* name at this node — knowledge
/// machine cannot have (c6 owns the connection table and dial set). /// the pure machine cannot have (c6 owns the connection table and dial
#[derive(Debug, Clone, Copy, Default)] /// set). One answer, in the responder's own precedence: an established
pub struct HelloCtx { /// peer under that name outranks an in-flight dial to it.
/// The offered name is already claimed by an established peer. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub name_claimed: bool, pub enum PeerStanding {
/// We have our own dial in flight to this peer name. /// Neither connected to nor dialing that name.
pub dialing_this_peer: bool, #[default]
Free,
/// An established peer already holds that name.
Claimed,
/// We have our own dial in flight to that name.
Dialing,
} }
/// Simultaneous-connect tie-break: does the connection dialed by /// Simultaneous-connect tie-break: does the connection dialed by
@@ -124,7 +129,7 @@ impl Responder {
/// name → tie-break: validity before identity. `HelloReject` is the /// name → tie-break: validity before identity. `HelloReject` is the
/// cross-version compatibility anchor, so a version-mismatched peer /// cross-version compatibility anchor, so a version-mismatched peer
/// still gets one. /// still gets one.
pub fn on_frame(self, frame: Frame, ctx: HelloCtx) -> ResponderOutcome { pub fn on_frame(self, frame: Frame, standing: PeerStanding) -> ResponderOutcome {
let Frame::Hello { let Frame::Hello {
proto_version, proto_version,
build_hash, build_hash,
@@ -147,13 +152,13 @@ impl Responder {
if build_hash != self.local.build_hash { if build_hash != self.local.build_hash {
return reject(RejectReason::HashMismatch); return reject(RejectReason::HashMismatch);
} }
if node_name == self.local.node_name || ctx.name_claimed { if node_name == self.local.node_name || standing == PeerStanding::Claimed {
return reject(RejectReason::NameTaken); return reject(RejectReason::NameTaken);
} }
// Simultaneous connect: the inbound frame is the peer's dial. If our // Simultaneous connect: the inbound frame is the peer's dial. If our
// own in-flight dial wins instead, drop this one silently — the peer // own in-flight dial wins instead, drop this one silently — the peer
// computes the same verdict (see `dial_wins`). // computes the same verdict (see `dial_wins`).
if ctx.dialing_this_peer && !dial_wins(&node_name, &self.local.node_name) { if standing == PeerStanding::Dialing && !dial_wins(&node_name, &self.local.node_name) {
return ResponderOutcome::TieBreakLoss; return ResponderOutcome::TieBreakLoss;
} }
+20 -17
View File
@@ -24,7 +24,7 @@ use std::collections::HashMap;
use crate::channel::Sender; use crate::channel::Sender;
use crate::cluster::conn::ConnHandle; use crate::cluster::conn::ConnHandle;
use crate::cluster::handshake::{HelloCtx, Peer}; use crate::cluster::handshake::{Peer, PeerStanding};
use crate::cluster::membership::{NodeEvent, NodeInfo}; use crate::cluster::membership::{NodeEvent, NodeInfo};
use crate::cluster::remote::{bind_outbound, unbind_outbound}; use crate::cluster::remote::{bind_outbound, unbind_outbound};
use crate::gen_server::{GenServer, GenServerCtx, GenServerName, Watcher}; use crate::gen_server::{GenServer, GenServerCtx, GenServerName, Watcher};
@@ -53,7 +53,7 @@ pub struct Manager {
conns: HashMap<String, ConnEntry>, conns: HashMap<String, ConnEntry>,
/// In-flight dial intents: peer name -> the actor performing the dial. /// In-flight dial intents: peer name -> the actor performing the dial.
/// Registered *before* connecting so a crossing inbound `Hello` sees it /// Registered *before* connecting so a crossing inbound `Hello` sees it
/// (the `dialing_this_peer` half of [`HelloCtx`]); cleared the moment /// ([`PeerStanding::Dialing`]); cleared the moment
/// the dial resolves, and — because the dialer is monitored — on the /// the dial resolves, and — because the dialer is monitored — on the
/// dialer's death, so a panicking dial can never wedge the tie-break. /// dialer's death, so a panicking dial can never wedge the tie-break.
dials: HashMap<String, Pid>, dials: HashMap<String, Pid>,
@@ -143,9 +143,9 @@ pub enum Call {
/// The dial to `name` resolved (either way): drop the intent. A call, /// The dial to `name` resolved (either way): drop the intent. A call,
/// not a cast, so the intent is provably gone before the dialer moves on. /// not a cast, so the intent is provably gone before the dialer moves on.
DialEnd { name: String }, DialEnd { name: String },
/// The [`HelloCtx`] for an inbound `Hello` offering `peer_name` — the /// The [`PeerStanding`] of an inbound `Hello` offering `peer_name` — the
/// accept path asks this between reading the frame and judging it. /// accept path asks this between reading the frame and judging it.
HelloCtx { peer_name: String }, Standing { peer_name: String },
/// Subscribe `tx` to membership events, snapshot-then-stream: one /// Subscribe `tx` to membership events, snapshot-then-stream: one
/// [`NodeEvent::NodeUp`] per live peer is queued into `tx` before this /// [`NodeEvent::NodeUp`] per live peer is queued into `tx` before this
/// call answers, so the stream is exact from its first event (handlers /// call answers, so the stream is exact from its first event (handlers
@@ -166,7 +166,7 @@ pub enum Reply {
/// `false`: another dial to this name is already in flight — do not dial. /// `false`: another dial to this name is already in flight — do not dial.
DialBegan(bool), DialBegan(bool),
DialEnded, DialEnded,
HelloCtx(HelloCtx), Standing(PeerStanding),
Subscribed, Subscribed,
View(Vec<NodeInfo>), View(Vec<NodeInfo>),
} }
@@ -206,8 +206,8 @@ impl GenServer for Manager {
} }
// The outbound table (c9) is maintained here, inside the same // The outbound table (c9) is maintained here, inside the same
// serialized handlers that own the connection's lifetime. // serialized handlers that own the connection's lifetime.
if let Some(out) = handle.take_outbound() { if let Some((frames, monitors)) = handle.take_outbound() {
bind_outbound(&peer.node_name, out); bind_outbound(&peer.node_name, peer.incarnation, frames, monitors);
} }
let info = NodeInfo { let info = NodeInfo {
node: self.node_id(&peer.node_name, peer.incarnation.get()), node: self.node_id(&peer.node_name, peer.incarnation.get()),
@@ -230,9 +230,7 @@ impl GenServer for Manager {
// Dropping the entry drops the handle, which stops the actor. // Dropping the entry drops the handle, which stops the actor.
if let Some(entry) = self.conns.remove(&name) { if let Some(entry) = self.conns.remove(&name) {
unbind_outbound(&name); unbind_outbound(&name);
self.emit(&NodeEvent::NodeDown { self.emit(&NodeEvent::NodeDown(entry.info));
node: entry.info.node,
});
} }
Reply::Disconnected Reply::Disconnected
} }
@@ -255,10 +253,15 @@ impl GenServer for Manager {
self.dials.remove(&name); self.dials.remove(&name);
Reply::DialEnded Reply::DialEnded
} }
Call::HelloCtx { peer_name } => Reply::HelloCtx(HelloCtx { Call::Standing { peer_name } => {
name_claimed: self.conns.contains_key(&peer_name), Reply::Standing(if self.conns.contains_key(&peer_name) {
dialing_this_peer: self.dials.contains_key(&peer_name), PeerStanding::Claimed
}), } else if self.dials.contains_key(&peer_name) {
PeerStanding::Dialing
} else {
PeerStanding::Free
})
}
Call::Subscribe { tx } => { Call::Subscribe { tx } => {
// The snapshot: queued before `tx` joins the list, and — the // The snapshot: queued before `tx` joins the list, and — the
// handlers being serialized — before any later event. // handlers being serialized — before any later event.
@@ -279,13 +282,13 @@ impl GenServer for Manager {
self.conns.retain(|name, entry| { self.conns.retain(|name, entry| {
let dead = entry.pid == down.pid; let dead = entry.pid == down.pid;
if dead { if dead {
downs.push((name.clone(), entry.info.node)); downs.push((name.clone(), entry.info.clone()));
} }
!dead !dead
}); });
for (name, node) in downs { for (name, info) in downs {
unbind_outbound(&name); unbind_outbound(&name);
self.emit(&NodeEvent::NodeDown { node }); self.emit(&NodeEvent::NodeDown(info));
} }
self.dials.retain(|_, pid| *pid != down.pid); self.dials.retain(|_, pid| *pid != down.pid);
} }
+3 -3
View File
@@ -57,9 +57,9 @@ pub enum NodeEvent {
/// A peer's control connection established and registered. /// A peer's control connection established and registered.
NodeUp(NodeInfo), NodeUp(NodeInfo),
/// That peer's connection ended — reaped, commanded down, or the manager /// That peer's connection ended — reaped, commanded down, or the manager
/// itself shut down. Which [`NodeInfo`] this id named was delivered in /// itself shut down. Carries the same [`NodeInfo`] the corresponding
/// the corresponding `NodeUp`. /// `NodeUp` delivered, so consumers need no id→name reverse map.
NodeDown { node: NodeId }, NodeDown(NodeInfo),
} }
/// A live membership subscription: the receiving end of the event stream /// A live membership subscription: the receiving end of the event stream
+546
View File
@@ -0,0 +1,546 @@
//! RFC 010 c15 — distributed process groups (Phase 5).
//!
//! The Erlang `pg` shape (D18): every node's group store is the union of
//! its own local members and each peer's *announced* local members. There
//! is one **pg actor** per node — the c14 reaper grown up — and it is the
//! only writer of remote entries and the only sender of announcements:
//!
//! - **Origin owns its members.** Joins are local (`pg::join`), the eager
//! reaper is the liveness authority, and the origin announces every
//! change: `Join`/`Leave` incrementally to every up node, and a full
//! `Sync` of its local groups to a peer the moment that peer comes up
//! (`NodeUp`). Nobody monitors a remote member; a peer's `NodeDown` sweeps
//! every member it announced.
//! - **Transport is a pure consumer** of Phase 3/4: the exposed name
//! [`PG_NAME`] (`"pg"`) carrying [`PgMsg`] over postcard, sent with
//! [`remote::send`]. No new frame, no manager change.
//! - **No anti-entropy.** Per-origin ordering rides the single TCP link:
//! the actor sends `Sync` to a peer *before* it can send that peer any
//! `Join`/`Leave` (both from the same loop, over the same connection), and
//! a reconnect is a fresh `NodeUp` ⇒ fresh `Sync` replacing that peer's
//! set wholesale.
//! - **Local API unchanged.** `members`/`pick`/`dispatch` stay local-only
//! (`get_local_members`); a remote entry in the store carries the peer's
//! `NodeId` and never surfaces there. Cluster-wide reads are the new,
//! additive [`members_all`] over [`GroupMember`] (c16 adds `pick_any` /
//! `dispatch_any`).
//!
//! ## Ordering inside the node
//!
//! `pg::join`/`pg::leave` mutate the store on the caller's thread and then
//! *announce* to the actor's control inbox. Because the store op precedes the
//! announcement and the actor re-reads the store before broadcasting a
//! `Joined`, an announcement that has been overtaken (the member left or died
//! before the actor got to it) is dropped rather than advertised: the wire
//! never sees a `Join` for a member the origin no longer holds. `Leave`
//! broadcasts unconditionally — a spurious `Leave` is a no-op at the peer.
//!
//! Inbound: `NodeUp` is emitted by the manager on the accept/connect path,
//! *before* the peer's connection actor exists, so it is queued on the
//! membership stream before any frame from that peer can reach this inbox.
//! The actor still drains membership before it interprets a `PgMsg` whose
//! sender it does not know, and drops the message if the sender is still not
//! up (a ghost — its next `NodeUp` brings a `Sync`).
use std::collections::HashMap;
use crate::channel::{channel, select, Receiver, Selectable};
use crate::cluster::expose::expose;
use crate::cluster::membership::{subscribe, MembershipEvents, NodeEvent, NodeInfo};
use crate::cluster::remote::{
self, local_identity, send_to_remote, RemoteName, RemotePid, ToRemoteError,
};
use crate::monitor::Down;
use crate::pg::{
live, member_for, reaper_inboxes, sweep_local_death, Incarnation, Member, Membership, PgEvent,
};
use crate::pid::{assert_type, Addressable, Erased, Pid};
use crate::registry::{register, send_to, SendError};
use crate::scheduler::with_runtime;
use crate::Name;
/// The exposed name every node's pg actor answers under.
pub const PG_NAME: Name<PgMsg> = Name::new("pg");
/// The pg wire protocol. Every variant is origin-authored: `from` / the
/// pid's node is the node whose local members are being described.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PgMsg {
/// The origin's complete local membership, sent to a peer on `NodeUp`.
/// Replaces whatever the receiver held for that origin.
Sync {
from: String,
groups: Vec<(String, Vec<RemotePid<Erased>>)>,
},
/// The origin added `pid` (its own) to `group`.
Join {
group: String,
pid: RemotePid<Erased>,
},
/// The origin removed `pid` from `group` — voluntary leave or death.
Leave {
group: String,
pid: RemotePid<Erased>,
},
}
// Hand-rolled serde (the crate carries no serde-derive), as a 3-tuple with a
// leading tag: (0, from, groups) | (1, group, pid) | (2, group, pid).
impl serde::Serialize for PgMsg {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeTuple;
let mut t = s.serialize_tuple(3)?;
match self {
PgMsg::Sync { from, groups } => {
t.serialize_element(&0u8)?;
t.serialize_element(from)?;
t.serialize_element(groups)?;
}
PgMsg::Join { group, pid } => {
t.serialize_element(&1u8)?;
t.serialize_element(group)?;
t.serialize_element(pid)?;
}
PgMsg::Leave { group, pid } => {
t.serialize_element(&2u8)?;
t.serialize_element(group)?;
t.serialize_element(pid)?;
}
}
t.end()
}
}
impl<'de> serde::Deserialize<'de> for PgMsg {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = PgMsg;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a pg message tuple")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> Result<PgMsg, A::Error> {
use serde::de::Error;
let tag: u8 = seq
.next_element()?
.ok_or_else(|| A::Error::custom("pg: missing tag"))?;
let text: String = seq
.next_element()?
.ok_or_else(|| A::Error::custom("pg: missing name"))?;
match tag {
0 => {
let groups = seq
.next_element()?
.ok_or_else(|| A::Error::custom("pg: missing groups"))?;
Ok(PgMsg::Sync { from: text, groups })
}
1 | 2 => {
let pid = seq
.next_element()?
.ok_or_else(|| A::Error::custom("pg: missing pid"))?;
Ok(if tag == 1 {
PgMsg::Join { group: text, pid }
} else {
PgMsg::Leave { group: text, pid }
})
}
t => Err(A::Error::custom(format!("pg: unknown tag {t}"))),
}
}
}
d.deserialize_tuple(3, V)
}
}
/// A member of a group as the cluster sees it: on this node (a plain
/// [`Pid`], sendable locally) or on a peer (a [`RemotePid`], sendable via
/// [`send_to_remote`](remote::send_to_remote)). `Pid` cannot hold a remote
/// (D14), hence the two-variant shape.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupMember {
Local(Pid),
Remote(RemotePid<Erased>),
}
/// Every member of `group` cluster-wide, in the store's order: local members
/// filtered by the same liveness backstop as [`members`](crate::pg::members),
/// remote members exactly as their origins last announced them. Must run
/// inside [`run`](crate::run).
pub fn members_all(group: &str) -> Vec<GroupMember> {
with_runtime(|inner| {
let me = inner.node_id;
let pg = inner.process_groups.lock();
pg.all_of(group)
.into_iter()
.filter_map(|m| {
if m.node == me {
live(inner, m.pid).then_some(GroupMember::Local(m.pid))
} else {
// A remote entry always has its node's name recorded
// (they land under the same lock); a missing one is a
// node already swept, so it hides rather than misnames.
pg.node_name(m.node).map(|name| {
GroupMember::Remote(RemotePid::from_parts(
name,
m.incarnation,
m.pid.index(),
m.pid.generation(),
))
})
}
})
.collect()
})
}
/// One member of `group` cluster-wide, or `None` if it has none: the first
/// entry in the store's order (this node's members in join order first when
/// they joined first — the same stateless first-live scan as
/// [`pick`](crate::pg::pick), extended over the peers' announced members).
/// Must run inside [`run`](crate::run).
pub fn pick_any(group: &str) -> Option<GroupMember> {
members_all(group).into_iter().next()
}
/// Why [`dispatch_any`] handed `msg` back.
#[derive(Debug)]
pub enum DispatchAnyError<M> {
/// The group has no member anywhere.
NoMember(M),
/// The pick was local and the local typed send failed.
Local(SendError<M>),
/// The pick was remote and the remote send failed at this node.
Remote(ToRemoteError<M>),
}
impl<M> DispatchAnyError<M> {
/// The undelivered message.
pub fn into_inner(self) -> M {
match self {
DispatchAnyError::NoMember(m) => m,
DispatchAnyError::Local(e) => e.into_inner(),
DispatchAnyError::Remote(e) => e.into_inner(),
}
}
}
/// [`pick_any`] and send in one step, returning the member reached: a local
/// pick goes through [`send_to`], a remote one through [`send_to_remote`]
/// (so `Ok` for a remote member means "handed to the connection", RFC 010
/// §3). Homogeneous pool assumed, as for [`dispatch`](crate::pg::dispatch);
/// a wrong `A` degrades to a clean error at the target, never a misroute.
/// Must run inside [`run`](crate::run).
pub fn dispatch_any<A>(group: &str, msg: A::Msg) -> Result<GroupMember, DispatchAnyError<A::Msg>>
where
A: Addressable,
A::Msg: serde::Serialize,
{
match pick_any(group) {
None => Err(DispatchAnyError::NoMember(msg)),
Some(GroupMember::Local(pid)) => send_to(assert_type::<A>(pid), msg)
.map(|()| GroupMember::Local(pid))
.map_err(DispatchAnyError::Local),
Some(GroupMember::Remote(rp)) => send_to_remote(rp.clone().assert_type::<A>(), msg)
.map(|()| GroupMember::Remote(rp))
.map_err(DispatchAnyError::Remote),
}
}
/// Attach the pg actor to the running cluster. Called once by
/// `cluster::start` after the manager is up and the local identity is set;
/// spawns the actor if this run has not joined anything yet.
pub(crate) fn attach_cluster() {
let _ = reaper_inboxes().ctl.send(PgEvent::Attach);
}
/// The attached half of the actor's state: who is up (by name) and the
/// membership stream.
struct Attached {
events: MembershipEvents,
peers: HashMap<String, NodeInfo>,
/// This node's wire identity: `Sync`'s `from`, and the stamp on every
/// pid we ship (attach requires it, so no `None` path exists here).
me: String,
incarnation: Incarnation,
}
/// The pg actor: the c14 reaper (`deaths`), the local API's announcements
/// (`ctl`), and — once attached — the membership stream and the exposed
/// `"pg"` inbox, all in one drain-then-select loop. `deaths`/`ctl` closing
/// is the run tearing down; the membership stream closing is the manager
/// gone (detach, keep reaping).
pub(crate) fn actor(deaths: Receiver<Down>, ctl: Receiver<PgEvent>) {
let (pg_tx, pg_rx) = channel::<PgMsg>();
let mut cl: Option<Attached> = None;
loop {
loop {
match deaths.try_recv() {
Ok(Some(down)) => on_death(cl.as_ref(), down.pid),
Ok(None) => break,
Err(_) => return,
}
}
loop {
match ctl.try_recv() {
Ok(Some(PgEvent::Attach)) => {
// Own the name BEFORE subscribing (which yields to the
// manager): a peer's first frame must find "pg" exposed
// and resolvable, or it is dropped. Idempotent for a
// re-attach: same actor, same channel (the registry
// refuses a *second* live one).
let _ = register(PG_NAME, pg_tx.clone());
expose(PG_NAME);
if let Some(a) = attach() {
cl = Some(a);
}
}
Ok(Some(PgEvent::Joined { group, pid })) => on_joined(cl.as_ref(), &group, pid),
Ok(Some(PgEvent::Left { group, pid })) => on_left(cl.as_ref(), &group, pid),
Ok(None) => break,
Err(_) => return,
}
}
if let Some(a) = cl.as_mut() {
if !drain_events(a) {
cl = None;
continue;
}
loop {
match pg_rx.try_recv() {
Ok(Some(msg)) => on_msg(a, msg),
Ok(None) => break,
Err(_) => return, // our own inbox: only on teardown
}
}
}
// Wait. Control first (attach/teardown must be prompt), then deaths,
// then the cluster arms.
let mut arms: Vec<&dyn Selectable> = vec![&ctl, &deaths];
if let Some(a) = cl.as_ref() {
arms.push(&a.events.rx);
arms.push(&pg_rx);
}
let _ = select(&arms);
}
}
fn attach() -> Option<Attached> {
let events = subscribe()?;
let (me, incarnation) = local_identity()?;
Some(Attached {
events,
peers: HashMap::new(),
me,
incarnation,
})
}
/// Fold pending membership events: `NodeUp` ⇒ record + `Sync` that peer;
/// `NodeDown` ⇒ sweep every member it announced. `false` when the stream
/// has closed.
fn drain_events(a: &mut Attached) -> bool {
loop {
match a.events.rx.try_recv() {
Ok(Some(NodeEvent::NodeUp(info))) => {
let name = info.name.clone();
a.peers.insert(name.clone(), info);
// Snapshot under the store lock, then stamp wire pids
// outside it (`from_local` marks watchable under the slot's
// cold lock — Leaf-on-Leaf nesting is asserted).
let local: Vec<(String, Vec<Pid>)> =
with_runtime(|inner| inner.process_groups.lock().groups_on(inner.node_id));
let groups = local
.into_iter()
.map(|(g, pids)| (g, pids.into_iter().map(|p| wire(a, p)).collect()))
.collect();
let msg = PgMsg::Sync {
from: a.me.clone(),
groups,
};
let _ = remote::send(RemoteName::new(name, PG_NAME), msg);
}
Ok(Some(NodeEvent::NodeDown(info))) => {
a.peers.remove(&info.name);
with_runtime(|inner| {
let mut pg = inner.process_groups.lock();
pg.remove_where(|m| m.node == info.node);
pg.forget_node_name(info.node);
});
}
Ok(None) => return true,
Err(_) => return false,
}
}
}
/// Send `msg` to every up peer. `NotConnected` is ignored: that peer's
/// `NodeDown` is on its way and its next `NodeUp` gets a `Sync`.
fn broadcast(a: &Attached, msg: PgMsg) {
for name in a.peers.keys() {
let _ = remote::send(RemoteName::new(name.clone(), PG_NAME), msg.clone());
}
}
fn on_death(a: Option<&Attached>, pid: Pid) {
let evicted = sweep_local_death(pid);
if let Some(a) = a {
for (group, ms) in evicted {
broadcast(
a,
PgMsg::Leave {
group,
pid: wire(a, ms.member.pid),
},
);
}
}
}
fn on_joined(a: Option<&Attached>, group: &str, pid: Pid) {
let Some(a) = a else { return };
// Re-check: a leave/death may have overtaken the announcement.
let still = with_runtime(|inner| {
let m = member_for(inner, pid);
inner.process_groups.lock().contains(group, &m)
});
if still {
broadcast(
a,
PgMsg::Join {
group: group.to_owned(),
pid: wire(a, pid),
},
);
}
}
fn on_left(a: Option<&Attached>, group: &str, pid: Pid) {
let Some(a) = a else { return };
broadcast(
a,
PgMsg::Leave {
group: group.to_owned(),
pid: wire(a, pid),
},
);
}
/// The wire form of a local member pid, stamped with the identity the
/// actor was attached with (marks watchable, like `from_local`).
fn wire(a: &Attached, pid: Pid) -> RemotePid<Erased> {
RemotePid::from_local_at(pid, a.me.clone(), a.incarnation)
}
/// The named origin's `NodeInfo`, if it is up. A second look at the
/// membership stream covers a `NodeUp` that landed after this loop
/// iteration's drain; anything still unknown is a ghost and is dropped.
fn origin(a: &mut Attached, name: &str) -> Option<NodeInfo> {
if let Some(i) = a.peers.get(name) {
return Some(i.clone());
}
drain_events(a);
a.peers.get(name).cloned()
}
/// `origin`, additionally requiring `pid` to be stamped with the origin's
/// current incarnation — a pid from a previous life of that node is a ghost.
fn origin_of(a: &mut Attached, pid: &RemotePid<Erased>) -> Option<NodeInfo> {
origin(a, pid.node()).filter(|i| i.incarnation == pid.incarnation())
}
fn remote_membership(origin: &NodeInfo, pid: &RemotePid<Erased>) -> Membership {
Membership {
member: Member {
node: origin.node,
incarnation: origin.incarnation,
pid: Pid::new(pid.index(), pid.generation()),
},
monitor: None,
}
}
fn on_msg(a: &mut Attached, msg: PgMsg) {
match msg {
PgMsg::Sync { from, groups } => {
let Some(info) = origin(a, &from) else { return };
with_runtime(|inner| {
let mut pg = inner.process_groups.lock();
pg.remove_where(|m| m.node == info.node);
pg.set_node_name(info.node, info.name.clone());
for (group, pids) in &groups {
// Origin-authored: only its own current-incarnation pids.
for p in pids
.iter()
.filter(|p| p.node() == from && p.incarnation() == info.incarnation)
{
pg.join(group, remote_membership(&info, p));
}
}
});
}
PgMsg::Join { group, pid } => {
let Some(info) = origin_of(a, &pid) else {
return;
};
with_runtime(|inner| {
let mut pg = inner.process_groups.lock();
pg.set_node_name(info.node, info.name.clone());
pg.join(&group, remote_membership(&info, &pid));
});
}
PgMsg::Leave { group, pid } => {
let Some(info) = origin_of(a, &pid) else {
return;
};
with_runtime(|inner| {
let ms = remote_membership(&info, &pid);
inner.process_groups.lock().leave(&group, ms.member);
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cluster::envelope::{decode_payload, encode_payload};
#[test]
fn pg_msg_roundtrips_every_variant() {
let p = RemotePid::<Erased>::from_parts("a", Incarnation::new(9), 3, 1);
for m in [
PgMsg::Sync {
from: "a".into(),
groups: vec![
("g".into(), vec![p.clone(), p.clone()]),
("h".into(), vec![]),
],
},
PgMsg::Sync {
from: "a".into(),
groups: vec![],
},
PgMsg::Join {
group: "g".into(),
pid: p.clone(),
},
PgMsg::Leave {
group: "g".into(),
pid: p.clone(),
},
] {
let bytes = encode_payload(&m).unwrap();
let back: PgMsg = decode_payload(&bytes).unwrap();
assert_eq!(back, m);
}
}
#[test]
fn pg_msg_rejects_unknown_tag() {
let bytes = encode_payload(&(7u8, "x", 0u32)).unwrap();
assert!(decode_payload::<PgMsg>(&bytes).is_err());
}
}
+605 -10
View File
@@ -41,15 +41,41 @@
//! Refusals are silent to the sender by design (§3: send failure reflects //! Refusals are silent to the sender by design (§3: send failure reflects
//! local knowledge only); they are observable locally as the returned //! local knowledge only); they are observable locally as the returned
//! [`InboundVerdict`], which the conn actor may log or count. //! [`InboundVerdict`], which the conn actor may log or count.
//!
//! ## Pids (c10, D14)
//!
//! [`RemotePid<A>`] = `(node_name, incarnation, index, generation)` +
//! phantom — identity-bound, dead when that incarnation dies, never
//! redirects. The node travels as its **name** (a global identifier, so a pid
//! forwarded through a third node needs no re-mapping); NodeId is a local
//! alias and never crosses. A local `Pid<A>` serializes *as* a `RemotePid`
//! stamped from the ambient [local identity](set_local_identity); a
//! `RemotePid` deserializes into `Pid<A>` only when it names this node (the
//! collapse), else it is a decode error — fields that may hold a pid from
//! anywhere are typed `RemotePid<A>`.
//!
//! [`send_to_remote`] is the pid-targeted send. A self-node pid short-
//! circuits to the local typed send with the message object itself — no
//! encode, no frame (zero-copy-equivalent). Otherwise the outbound table
//! (widened to carry each node's **current incarnation**) does the RFC v2 §3
//! check at the send site: a pid of a dead incarnation is
//! [`ToRemoteError::DeadIncarnation`] and no frame is emitted. Inbound
//! `Send` frames are delivered by index/generation through c8's
//! [`decode_deliver`]: the target actor's published channel for the exposed
//! type is the only route (the reply-to path requires
//! [`expose_type`](crate::cluster::expose::expose_type) at the receiver).
use std::cell::Cell;
use std::collections::HashMap; use std::collections::HashMap;
use std::marker::PhantomData; use std::marker::PhantomData;
use crate::channel::Sender; use crate::channel::{channel, Receiver, RecvError, Selectable, Sender};
use crate::cluster::envelope::{encode_payload, Frame, PayloadError}; use crate::cluster::envelope::{encode_payload, Frame, PayloadError, RemoteDownReason};
use crate::cluster::expose::{decode_deliver, exposed_hash, type_hash, DeliverError}; use crate::cluster::expose::{decode_deliver, exposed_hash, type_hash, DeliverError};
use crate::pid::Name; use crate::monitor::{demonitor, monitor, Monitor, MonitorId};
use crate::registry::whereis; use crate::pg::Incarnation;
use crate::pid::{Addressable, Erased, Name, Pid};
use crate::registry::{send_to, whereis, SendError};
use crate::scheduler::with_runtime; use crate::scheduler::with_runtime;
/// A name on a specific remote node: `(node_name, Name<M>)`. Sendable via /// A name on a specific remote node: `(node_name, Name<M>)`. Sendable via
@@ -111,26 +137,97 @@ impl<M> RemoteSendError<M> {
} }
} }
/// The outbound table, one per runtime (a `RuntimeInner` field). /// The outbound table, one per runtime (a `RuntimeInner` field): per live
/// node, its current incarnation (the RFC v2 §3 send-site check) and the
/// connection's dedicated outbound sender. Plus this node's own wire
/// identity, which pid serialization stamps.
pub(crate) struct Outbound { pub(crate) struct Outbound {
by_node: HashMap<String, Sender<Frame>>, by_node: HashMap<String, Route>,
local: Option<(String, Incarnation)>,
}
/// One live connection as the outbound path sees it: the peer's current
/// incarnation and the two inboxes of its connection actor — frames (c9)
/// and monitor bookkeeping (c12, [`MonCmd`]).
pub(crate) struct Route {
incarnation: Incarnation,
frames: Sender<Frame>,
monitors: Sender<MonCmd>,
} }
impl Outbound { impl Outbound {
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Outbound { Outbound {
by_node: HashMap::new(), by_node: HashMap::new(),
local: None,
} }
} }
} }
/// Manager-only: bind `node`'s outbound channel. Called inside `Register`. /// Set this node's wire identity — what serialized pids are stamped with
pub(crate) fn bind_outbound(node: &str, tx: Sender<Frame>) { /// and what a `RemotePid` must name to collapse. `cluster::start` sets it;
/// exposed for local tests. Must run inside [`run`](crate::run).
pub fn set_local_identity(node: &str, incarnation: Incarnation) {
with_runtime(|inner| { with_runtime(|inner| {
inner.outbound.lock().by_node.insert(node.to_string(), tx); inner.outbound.lock().local = Some((node.to_string(), incarnation));
}); });
} }
/// This node's wire identity, if set. Must run inside [`run`](crate::run).
pub fn local_identity() -> Option<(String, Incarnation)> {
with_runtime(|inner| inner.outbound.lock().local.clone())
}
/// Manager-only: bind `node`'s outbound channels at `incarnation`. Called
/// inside `Register`.
pub(crate) fn bind_outbound(
node: &str,
incarnation: Incarnation,
frames: Sender<Frame>,
monitors: Sender<MonCmd>,
) {
with_runtime(|inner| {
inner.outbound.lock().by_node.insert(
node.to_string(),
Route {
incarnation,
frames,
monitors,
},
);
});
}
/// Test probe: bind an arbitrary sender as `node`'s outbound so a test can
/// assert what frames leave — or don't. Same table, same lookup as the real
/// path (this is how "no frame emitted" is asserted at the frame level).
/// Frames only: there is no connection actor behind a probe, so a
/// [`monitor_remote`] against a probed node reports `Disconnected`.
pub fn bind_outbound_probe(node: &str, incarnation: Incarnation, tx: Sender<Frame>) {
drop(bind_outbound_probe_with_monitors(node, incarnation, tx));
}
/// The monitor half of a probed node's inbox: opaque, held only to be
/// dropped. See [`bind_outbound_probe_with_monitors`].
pub struct MonitorInbox {
_rx: Receiver<MonCmd>,
}
/// Test probe: like [`bind_outbound_probe`], but the monitor-command
/// receiver is handed back instead of dropped, so a test can stage the
/// c13 drain gap — a `Monitor` command that reached the connection's inbox
/// and dies unread when the inbox is dropped. While the inbox lives,
/// [`monitor_remote`] against the probed node is simply in flight.
pub fn bind_outbound_probe_with_monitors(
node: &str,
incarnation: Incarnation,
tx: Sender<Frame>,
) -> MonitorInbox {
let (mon_tx, mon_rx) = channel();
bind_outbound(node, incarnation, tx, mon_tx);
MonitorInbox { _rx: mon_rx }
}
/// Manager-only: unbind `node`'s outbound channel. Called on `Disconnect`, /// Manager-only: unbind `node`'s outbound channel. Called on `Disconnect`,
/// reap, and manager shutdown. Dropping the sender is what closes the conn /// reap, and manager shutdown. Dropping the sender is what closes the conn
/// actor's outbound arm — but that arm's closure is NOT a stop signal (the /// actor's outbound arm — but that arm's closure is NOT a stop signal (the
@@ -191,13 +288,249 @@ pub fn send_remote_raw(
/// One lookup, one send. Clone the sender out under the lock and send /// One lookup, one send. Clone the sender out under the lock and send
/// outside it (a channel send can unpark the conn actor). /// outside it (a channel send can unpark the conn actor).
fn hand_to_connection(node: &str, frame: Frame) -> Result<(), NotConnected> { fn hand_to_connection(node: &str, frame: Frame) -> Result<(), NotConnected> {
let tx = with_runtime(|inner| inner.outbound.lock().by_node.get(node).cloned()); let tx = with_runtime(|inner| {
inner
.outbound
.lock()
.by_node
.get(node)
.map(|r| r.frames.clone())
});
match tx { match tx {
Some(tx) => tx.send(frame).map_err(|_| NotConnected), Some(tx) => tx.send(frame).map_err(|_| NotConnected),
None => Err(NotConnected), None => Err(NotConnected),
} }
} }
// ---- pids ---------------------------------------------------------------
/// A pid on some node: `(node_name, incarnation, index, generation)` plus
/// the actor type. See the module docs. Serializes as a 4-tuple.
pub struct RemotePid<A> {
node: String,
incarnation: Incarnation,
index: u32,
generation: u32,
_marker: PhantomData<fn() -> A>,
}
impl<A> RemotePid<A> {
/// Build from raw parts (tests, and codecs re-hydrating a pid).
pub fn from_parts(
node: impl Into<String>,
incarnation: Incarnation,
index: u32,
generation: u32,
) -> Self {
RemotePid {
node: node.into(),
incarnation,
index,
generation,
_marker: PhantomData,
}
}
/// The wire form of a local pid, stamped with this node's identity, and
/// **marked watchable** — asking for the wire form *is* the intent to
/// ship the pid, so this is the same D12 set-site as `Pid::serialize`
/// (c12 made it explicit: a peer may monitor exactly the pids that
/// crossed, and a pid handed out via `from_local` in a hand-built reply
/// has crossed). Must run inside [`run`](crate::run).
///
/// `None` when this runtime has no wire identity (no `cluster::start`,
/// no [`set_local_identity`]): such a pid cannot name a node, and a
/// stamped `("", 0)` would be dropped by every peer with no signal.
/// The pid is not marked watchable in that case either.
pub fn from_local(pid: Pid<A>) -> Option<Self> {
let (node, incarnation) = local_identity()?;
Some(Self::from_local_at(pid, node, incarnation))
}
/// `from_local` with the identity supplied by the caller — for a holder
/// that already carries the node's identity (the pg actor) and must not
/// have a `None` path. Marks watchable like `from_local`.
pub(crate) fn from_local_at(pid: Pid<A>, node: String, incarnation: Incarnation) -> Self {
crate::monitor::mark_watchable(pid);
RemotePid::from_parts(node, incarnation, pid.index(), pid.generation())
}
/// The collapse: `Some(local pid)` iff this pid names this very node
/// (name and incarnation). Must run inside [`run`](crate::run).
pub fn local(&self) -> Option<Pid<A>> {
let (n, i) = local_identity()?;
(n == self.node && i == self.incarnation)
.then(|| crate::pid::assert_type::<A>(Pid::new(self.index, self.generation)))
}
/// Drop the actor type: the untyped `RemotePid<Erased>`, the form
/// [`RemoteDown`] and [`RemoteMonitor`] carry (mirrors [`Pid::erase`]).
pub fn erase(self) -> RemotePid<Erased> {
RemotePid::from_parts(self.node, self.incarnation, self.index, self.generation)
}
/// Re-type an erased pid as `RemotePid<B>` — the unchecked mirror of
/// `pid::assert_type`, with the same degradation: a wrong `B` means the
/// target refuses the payload's hash (never a misroute).
pub(crate) fn assert_type<B>(self) -> RemotePid<B> {
RemotePid::from_parts(self.node, self.incarnation, self.index, self.generation)
}
pub fn node(&self) -> &str {
&self.node
}
pub fn incarnation(&self) -> Incarnation {
self.incarnation
}
pub fn index(&self) -> u32 {
self.index
}
pub fn generation(&self) -> u32 {
self.generation
}
}
impl<A> Clone for RemotePid<A> {
fn clone(&self) -> Self {
RemotePid::from_parts(
self.node.clone(),
self.incarnation,
self.index,
self.generation,
)
}
}
impl<A> PartialEq for RemotePid<A> {
fn eq(&self, o: &Self) -> bool {
self.node == o.node
&& self.incarnation == o.incarnation
&& self.index == o.index
&& self.generation == o.generation
}
}
impl<A> Eq for RemotePid<A> {}
impl<A> std::fmt::Debug for RemotePid<A> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"<{}.{}@{}#{}>",
self.index,
self.generation,
self.node,
self.incarnation.get()
)
}
}
impl<A> serde::Serialize for RemotePid<A> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
(
self.node.as_str(),
self.incarnation.get(),
self.index,
self.generation,
)
.serialize(s)
}
}
impl<'de, A> serde::Deserialize<'de> for RemotePid<A> {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let (node, inc, index, generation) = <(String, u32, u32, u32)>::deserialize(d)?;
Ok(RemotePid::from_parts(
node,
Incarnation::new(inc),
index,
generation,
))
}
}
/// Why a pid-targeted send did not leave this node. Local knowledge only.
#[derive(Debug)]
pub enum ToRemoteError<M> {
/// No live connection to the pid's node.
NotConnected(M),
/// The pid's incarnation is not that node's current one (RFC v2 §3): the
/// actor died with its incarnation. Detected at the send site; no frame.
DeadIncarnation(M),
/// The payload did not serialize.
Encode(M, PayloadError),
/// The pid collapsed to a local one and the local typed send failed.
Local(SendError<M>),
}
impl<M> ToRemoteError<M> {
/// The undelivered message.
pub fn into_inner(self) -> M {
match self {
ToRemoteError::NotConnected(m)
| ToRemoteError::DeadIncarnation(m)
| ToRemoteError::Encode(m, _) => m,
ToRemoteError::Local(e) => e.into_inner(),
}
}
}
/// Send `msg` to a pid, wherever it lives. Self-node pids short-circuit to
/// the local typed send with `msg` itself (no encode, no frame); others go
/// out as a `Send` frame after the incarnation check. `Ok(())` for a remote
/// target = handed to the connection's inbox. Must run inside
/// [`run`](crate::run).
pub fn send_to_remote<A>(target: RemotePid<A>, msg: A::Msg) -> Result<(), ToRemoteError<A::Msg>>
where
A: Addressable,
A::Msg: serde::Serialize,
{
if let Some(local) = target.local() {
return send_to(local, msg).map_err(ToRemoteError::Local);
}
let route = with_runtime(|inner| {
inner
.outbound
.lock()
.by_node
.get(&target.node)
.map(|r| (r.incarnation, r.frames.clone()))
});
let (current, tx) = match route {
Some(r) => r,
None => return Err(ToRemoteError::NotConnected(msg)),
};
if current != target.incarnation {
return Err(ToRemoteError::DeadIncarnation(msg));
}
let payload = match encode_payload(&msg) {
Ok(p) => p,
Err(e) => return Err(ToRemoteError::Encode(msg, e)),
};
let frame = Frame::Send {
index: target.index,
generation: target.generation,
type_hash: type_hash::<A::Msg>(),
payload,
};
tx.send(frame).map_err(|_| ToRemoteError::NotConnected(msg))
}
/// The inbound `Send` seam: deliver `payload` under `type_hash` to the local
/// actor `(index, generation)`. Node and incarnation are implicit in the
/// connection (bound at handshake) — the frame carries only the slot
/// identity. Delivery goes through c8's decoder table, so only types the
/// receiver has [`expose_type`](crate::cluster::expose::expose_type)d (or
/// exposed by name) can land; anything else is refused, never misrouted.
pub fn deliver_to_pid(
index: u32,
generation: u32,
type_hash: u64,
payload: &[u8],
) -> InboundVerdict {
let pid = Pid::new(index, generation);
match decode_deliver(type_hash, pid, payload) {
Ok(()) => InboundVerdict::Delivered,
Err(e) => InboundVerdict::Refused(e),
}
}
/// What the inbound seam did with a `SendNamed`. Local observability only; /// What the inbound seam did with a `SendNamed`. Local observability only;
/// nothing goes back on the wire (RFC §3). /// nothing goes back on the wire (RFC §3).
#[derive(Debug)] #[derive(Debug)]
@@ -217,6 +550,20 @@ pub enum InboundVerdict {
Refused(DeliverError), Refused(DeliverError),
} }
impl InboundVerdict {
/// A short static label for tracing/counting (`smarm-trace` records one
/// `ClusterInbound` event per frame with it).
pub fn label(&self) -> &'static str {
match self {
InboundVerdict::Delivered => "delivered",
InboundVerdict::NotExposed => "not_exposed",
InboundVerdict::HashMismatch { .. } => "hash_mismatch",
InboundVerdict::Unresolved => "unresolved",
InboundVerdict::Refused(_) => "refused",
}
}
}
/// THE inbound resolution seam: exposed-set check → hash check → registry /// THE inbound resolution seam: exposed-set check → hash check → registry
/// resolution → c8 delivery. See the module docs. Must run inside /// resolution → c8 delivery. See the module docs. Must run inside
/// [`run`](crate::run) — the conn actor's context. /// [`run`](crate::run) — the conn actor's context.
@@ -238,3 +585,251 @@ pub fn deliver_named(name: &str, type_hash: u64, payload: &[u8]) -> InboundVerdi
Err(e) => InboundVerdict::Refused(e), Err(e) => InboundVerdict::Refused(e),
} }
} }
// ---- monitors (c12) -----------------------------------------------------
/// Bookkeeping commands from [`monitor_remote`]/[`demonitor_remote`] to the
/// connection actor that owns the link to the target's node. The actor
/// records the registration and *then* emits the `Monitor` frame itself, so
/// a `Down` can never arrive at a table that does not yet know the id. It
/// lives in the actor (not on `RuntimeInner`) so the bookkeeping dies with
/// the connection — exactly what c13 needs to synthesize `Disconnected`.
pub(crate) enum MonCmd {
Monitor {
id: MonitorId,
target: RemotePid<Erased>,
tx: Sender<RemoteDown>,
},
Demonitor {
id: MonitorId,
},
}
/// A remotely-monitored actor's termination notice — the cluster analog of
/// [`Down`](crate::monitor::Down), with the pid in its wire form because it
/// may name any node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteDown {
/// The pid that was being monitored.
pub pid: RemotePid<Erased>,
/// How it went down. `Disconnected` means the *link* to its node was
/// lost (or absent) — nothing is known about the actor itself.
pub reason: RemoteDownReason,
}
enum Watch {
/// The target collapsed to this node: an ordinary local monitor,
/// translated on read.
Local(Monitor),
/// The target is elsewhere: the connection actor for its node holds the
/// registration and forwards the peer's `Down` frame here. The
/// `RemoteState` is the read-side backstop (c13): a channel that closes
/// while `Live` — the connection died with our command unread — reads
/// as `Disconnected` once; afterwards, and after a cancel, closed is
/// just closed.
Remote(Receiver<RemoteDown>, Cell<RemoteState>),
}
/// Where a remote-watch stands from the reader's side.
#[derive(Clone, Copy, PartialEq, Eq)]
enum RemoteState {
/// No notice yet, not cancelled: a closed channel means `Disconnected`.
Live,
/// The one notice has been read (or synthesized): nothing more is due.
Done,
/// `demonitor_remote` ran: never synthesize.
Cancelled,
}
/// A live remote monitor: read its one [`RemoteDown`] with
/// [`recv`](RemoteMonitor::recv)/[`try_recv`](RemoteMonitor::try_recv), or
/// fold it into a `select` via [`arm`](RemoteMonitor::arm). Distinct from
/// [`Monitor`] on purpose: its target is a [`RemotePid`], its notice a
/// [`RemoteDown`], and it can report `Disconnected` — none of which a local
/// monitor can express. Dropping it discards an unread notice, like the
/// local one; after [`demonitor_remote`] the channel is closed and empty, so
/// `recv` errs rather than parking — also like the local one.
///
/// Exactly one notice is guaranteed even if the connection actor dies with
/// the registration unread (the c13 drain gap): a channel that closes
/// before any notice — and before any cancel — reads as `Disconnected`,
/// once. The next read is the ordinary closed-channel `Err`.
pub struct RemoteMonitor {
/// This registration's process-unique id — minted here, echoed by the
/// peer in its `Down` frame.
pub id: MonitorId,
/// The pid being monitored.
pub target: RemotePid<Erased>,
watch: Watch,
}
impl RemoteMonitor {
/// Block (cooperatively) for the notice.
pub fn recv(&self) -> Result<RemoteDown, RecvError> {
match &self.watch {
Watch::Local(m) => m.rx.recv().map(|d| RemoteDown {
pid: self.target.clone(),
reason: d.reason.into(),
}),
Watch::Remote(rx, st) => match rx.recv() {
Ok(d) => {
st.set(RemoteState::Done);
Ok(d)
}
Err(e) => self.closed(st).ok_or(e),
},
}
}
/// The notice if it has arrived; `Ok(None)` if not yet.
pub fn try_recv(&self) -> Result<Option<RemoteDown>, RecvError> {
match &self.watch {
Watch::Local(m) => m.rx.try_recv().map(|o| {
o.map(|d| RemoteDown {
pid: self.target.clone(),
reason: d.reason.into(),
})
}),
Watch::Remote(rx, st) => match rx.try_recv() {
Ok(Some(d)) => {
st.set(RemoteState::Done);
Ok(Some(d))
}
Ok(None) => Ok(None),
Err(e) => self.closed(st).map(Some).ok_or(e),
},
}
}
/// The channel closed. While `Live` — no notice yet, no cancel — that
/// is the connection having died with our registration unread, so
/// synthesize the one `Disconnected` and mark `Done`; otherwise closed
/// is just closed.
fn closed(&self, st: &Cell<RemoteState>) -> Option<RemoteDown> {
if st.get() != RemoteState::Live {
return None;
}
st.set(RemoteState::Done);
Some(RemoteDown {
pid: self.target.clone(),
reason: RemoteDownReason::Disconnected,
})
}
/// The selectable arm: readiness means [`try_recv`](Self::try_recv)
/// will yield the notice.
pub fn arm(&self) -> &dyn Selectable {
match &self.watch {
Watch::Local(m) => &m.rx,
Watch::Remote(rx, _) => rx,
}
}
}
impl std::fmt::Debug for RemoteMonitor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RemoteMonitor")
.field("id", &self.id)
.field("target", &self.target)
.finish_non_exhaustive()
}
}
/// Monitor `target`, wherever it lives. Exactly one [`RemoteDown`] arrives:
///
/// - self-node pid ⇒ an ordinary local monitor underneath (same NoProc rule);
/// - no live connection to the pid's node ⇒ `Disconnected`, queued at once
/// (the remote analog of NoProc: nothing can be known);
/// - the pid's incarnation is not the node's current one ⇒ `NoProc`, queued
/// at once — the node is *known* to have restarted, so its actor is a
/// corpse, not a partition (RFC v2 §3);
/// - otherwise the connection actor registers the id and sends `Monitor`;
/// the peer answers with the true terminal reason on exit, or immediately
/// with the recorded reason for a corpse (`terminal_reason`, RFC §6) or
/// `NoProc` for a pid it never exposed and never shipped.
///
/// The connection dropping while the monitor is outstanding delivers
/// `Disconnected` (c13): the connection actor synthesizes it on teardown,
/// and the monitor's own read path backstops the case where the actor died
/// with the registration still unread. Must run inside [`run`](crate::run).
pub fn monitor_remote<A>(target: RemotePid<A>) -> RemoteMonitor {
if let Some(local) = target.local() {
let m = monitor(local);
return RemoteMonitor {
id: m.id,
target: target.erase(),
watch: Watch::Local(m),
};
}
let target = target.erase();
let (id, route) = with_runtime(|inner| {
let id = inner.alloc_monitor_id();
let route = inner
.outbound
.lock()
.by_node
.get(&target.node)
.map(|r| (r.incarnation, r.monitors.clone()));
(id, route)
});
let (tx, rx) = channel::<RemoteDown>();
let immediate = match route {
None => Some(RemoteDownReason::Disconnected),
Some((current, _)) if current != target.incarnation => {
Some(RemoteDownReason::Local(crate::monitor::DownReason::NoProc))
}
Some((_, mon_tx)) => {
let cmd = MonCmd::Monitor {
id,
target: target.clone(),
tx: tx.clone(),
};
match mon_tx.send(cmd) {
Ok(()) => None,
Err(_) => Some(RemoteDownReason::Disconnected), // actor already gone
}
}
};
if let Some(reason) = immediate {
let _ = tx.send(RemoteDown {
pid: target.clone(),
reason,
});
}
RemoteMonitor {
id,
target,
watch: Watch::Remote(rx, Cell::new(RemoteState::Live)),
}
}
/// Cancel `m`. No future notice will be *sent* for it; a notice already in
/// flight from the peer is dropped on arrival, and one already sitting in
/// `m` is discarded when `m` is dropped (same contract as
/// [`demonitor`]). Unlike the local form this returns nothing: the
/// registration is owned by the connection actor, so whether the `Down`
/// beat the cancel is not local knowledge. Must run inside
/// [`run`](crate::run).
pub fn demonitor_remote(m: &RemoteMonitor) {
match &m.watch {
Watch::Local(local) => {
let _ = demonitor(local);
}
Watch::Remote(_, st) => {
// Cancel first: a channel closing after this is closed, not a
// Disconnected notice — the caller asked for silence.
st.set(RemoteState::Cancelled);
let mon_tx = with_runtime(|inner| {
inner
.outbound
.lock()
.by_node
.get(&m.target.node)
.map(|r| r.monitors.clone())
});
if let Some(mon_tx) = mon_tx {
let _ = mon_tx.send(MonCmd::Demonitor { id: m.id });
}
}
}
}
+65 -35
View File
@@ -146,16 +146,32 @@ pub struct Monitor {
pub fn monitor<A>(target: Pid<A>) -> Monitor { pub fn monitor<A>(target: Pid<A>) -> Monitor {
let target = target.erase(); let target = target.erase();
let (tx, rx) = channel::<Down>(); let (tx, rx) = channel::<Down>();
let id = with_runtime(|inner| inner.alloc_monitor_id());
if !register_monitor(target, id, &tx) {
let _ = tx.send(Down {
pid: target,
reason: DownReason::NoProc,
});
}
Monitor { id, target, rx }
}
// Implementation note: registration happens under the target's cold /// Register a monitor `id` on `target` that delivers its `Down` to `tx` — the
// lock. `tx.clone()` takes the channel's own lock, a Channel-class /// primitive under [`monitor`], split out so a caller can fan many monitors
// RawMutex, which is explicitly permitted under a Leaf (cold) lock by /// into ONE channel (process groups: every membership's death lands on the
// the lock order documented in raw_mutex.rs. We must still not *send* /// reaper's single inbox). Returns `false` if `target` is already gone, in
// under the lock, since `Sender::send` can unpark a parked receiver, /// which case nothing is registered and the caller decides what to queue
// and there's no reason to nest that. /// (`monitor` sends `NoProc`). The caller allocates `id` up front so it can
let (id, registered) = with_runtime(|inner| { /// record the registration *before* arming it.
let id = inner.alloc_monitor_id(); ///
let registered = match inner.slot_at(target) { /// Implementation note: registration happens under the target's cold lock.
/// `tx.clone()` takes the channel's own lock, a Channel-class RawMutex, which
/// is explicitly permitted under a Leaf (cold) lock by the lock order
/// documented in raw_mutex.rs. We must still not *send* under the lock, since
/// `Sender::send` can unpark a parked receiver, and there's no reason to nest
/// that.
pub(crate) fn register_monitor(target: Pid, id: MonitorId, tx: &Sender<Down>) -> bool {
with_runtime(|inner| match inner.slot_at(target) {
Some(slot) => { Some(slot) => {
let mut cold = slot.cold.lock(); let mut cold = slot.cold.lock();
if slot.is_live_for(target) { if slot.is_live_for(target) {
@@ -166,18 +182,30 @@ pub fn monitor<A>(target: Pid<A>) -> Monitor {
} }
} }
None => false, None => false,
}; })
(id, registered)
});
if !registered {
let _ = tx.send(Down {
pid: target,
reason: DownReason::NoProc,
});
} }
Monitor { id, target, rx } /// Remove registration `id` from `target` — the primitive under
/// [`demonitor`], for callers that hold only the id (see
/// [`register_monitor`]). `None` if the registration is not there: already
/// fired, already removed, or the slot has moved on to a new tenant.
///
/// The registration is removed under the target's cold lock, but the
/// `Sender` is moved *out* and dropped only after the lock is released.
/// Dropping the last sender runs `Sender::drop`, which may unpark a parked
/// receiver; legal under a cold lock, but pointless to nest.
pub(crate) fn unregister_monitor(target: Pid, id: MonitorId) -> Option<MonitorId> {
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
let slot = inner.slot_at(target)?;
let mut cold = slot.cold.lock();
if slot.generation() != target.generation() {
return None; // slot reused; the Down already fired
}
let pos = cold.monitors.iter().position(|(mid, _)| *mid == id)?;
Some(cold.monitors.remove(pos))
});
// `removed`'s sender drops here, outside the lock.
removed.map(|(id, _sender)| id)
} }
/// Flag `target`'s tenancy as watchable: its death will stamp the slot's /// Flag `target`'s tenancy as watchable: its death will stamp the slot's
@@ -210,6 +238,23 @@ pub fn mark_watchable<A>(target: Pid<A>) {
}); });
} }
/// Whether `target` is live *and* its tenancy is watchable. The cluster's
/// remote-monitor admission check (RFC 010 c12): a peer may monitor a pid only
/// if that pid was exposed or crossed the wire (the D12 set-sites), and a
/// live-but-unwatchable pid answers exactly like a dead one — no liveness leak
/// beyond what `watchable` already grants. Same context contract as
/// [`monitor`].
#[cfg(feature = "cluster")]
pub(crate) fn is_watchable<A>(target: Pid<A>) -> bool {
let target = target.erase();
with_runtime(|inner| {
inner.slot_at(target).is_some_and(|slot| {
let cold = slot.cold.lock();
slot.is_live_for(target) && cold.watchable
})
})
}
/// The terminal [`DownReason`] of the tenancy `target` names, if that tenancy /// The terminal [`DownReason`] of the tenancy `target` names, if that tenancy
/// ever registered a name and is the *most recent named* death of its slot: /// ever registered a name and is the *most recent named* death of its slot:
/// finalize stamps the slot with `(generation, reason)` for once-registered /// finalize stamps the slot with `(generation, reason)` for once-registered
@@ -249,20 +294,5 @@ pub fn terminal_reason<A>(target: Pid<A>) -> Option<DownReason> {
/// instead of, or in addition to, calling this: dropping the [`Monitor`] /// instead of, or in addition to, calling this: dropping the [`Monitor`]
/// closes its receiver and any queued notice is discarded with it. /// closes its receiver and any queued notice is discarded with it.
pub fn demonitor(m: &Monitor) -> Option<MonitorId> { pub fn demonitor(m: &Monitor) -> Option<MonitorId> {
// Implementation note: the registration is removed under the target's unregister_monitor(m.target, m.id)
// cold lock, but the `Sender` is moved *out* and dropped only after the
// lock is released. Dropping the last sender runs `Sender::drop`, which
// may unpark a parked receiver; legal under a cold lock, but pointless
// to nest.
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
let slot = inner.slot_at(m.target)?;
let mut cold = slot.cold.lock();
if slot.generation() != m.target.generation() {
return None; // slot reused; the Down already fired
}
let pos = cold.monitors.iter().position(|(mid, _)| *mid == m.id)?;
Some(cold.monitors.remove(pos))
});
// `removed`'s sender drops here, outside the lock.
removed.map(|(id, _sender)| id)
} }
+463 -248
View File
@@ -99,10 +99,13 @@
//! ## Identity and clustering //! ## Identity and clustering
//! //!
//! A group member is described by a [`Member`] — a [`Pid`] plus a [`NodeId`] and //! A group member is described by a [`Member`] — a [`Pid`] plus a [`NodeId`] and
//! an [`Incarnation`]. Today everything is single-node, those two fields are //! an [`Incarnation`]. Everything on this page is **local**: you pass and
//! fixed defaults, and you only ever pass and receive a plain [`Pid`]: the extra //! receive plain [`Pid`]s, and [`members`] / [`pick`] / [`dispatch`] only ever
//! identity is carried so this API will not have to change when groups learn to //! name actors on this node (Erlang's `get_local_members`). With the `cluster`
//! span a cluster. //! feature a group also holds the members other nodes have announced, carried
//! under their [`NodeId`]; those never surface here — the cluster-wide reads
//! live in [`cluster::pg`](crate::cluster::pg) (`members_all` and friends) and
//! return a `Local | Remote` member type, since a [`Pid`] cannot hold a remote.
//! //!
//! ## Running context //! ## Running context
//! //!
@@ -110,10 +113,11 @@
//! from inside [`run`](crate::run) (that is, on an actor thread). Calling one //! from inside [`run`](crate::run) (that is, on an actor thread). Calling one
//! from outside a running runtime panics. //! from outside a running runtime panics.
use crate::monitor::{demonitor, monitor, Monitor}; use crate::channel::{channel, Sender};
use crate::monitor::{register_monitor, unregister_monitor, Down, DownReason, MonitorId};
use crate::pid::{assert_type, Addressable, Pid}; use crate::pid::{assert_type, Addressable, Pid};
use crate::registry::{send_to, SendError}; use crate::registry::{send_to, SendError};
use crate::scheduler::with_runtime; use crate::scheduler::{spawn_under, with_runtime};
use std::collections::HashMap; use std::collections::HashMap;
/// A cluster node handle. A `u32` integer handle, *not* an interned atom — the /// A cluster node handle. A `u32` integer handle, *not* an interned atom — the
@@ -186,13 +190,15 @@ pub struct Member {
pub pid: Pid, pub pid: Pid,
} }
/// One membership: a [`Member`] and the [`Monitor`] that watches its liveness. /// One membership: a [`Member`] and the id of the monitor that watches its
/// The monitor lives *alongside* the group entry so a group is /// liveness. The monitor's `Down` is delivered to the group reaper's single
/// self-contained: draining the membership tells us whether the member is /// inbox (see [`ProcessGroups::deaths`]), so the membership carries only what
/// still alive, and dropping the membership drops its monitor. /// [`leave`] needs to tear the registration down: the id.
struct Membership { pub(crate) struct Membership {
member: Member, pub(crate) member: Member,
monitor: Monitor, /// `None` for a remote member (cluster): the origin node is its liveness
/// authority; nothing here watches it.
pub(crate) monitor: Option<MonitorId>,
} }
/// The store: `name → multiset<Member>`. Within a single group a `Member` /// The store: `name → multiset<Member>`. Within a single group a `Member`
@@ -202,46 +208,60 @@ struct Membership {
/// ///
/// Locking discipline. Held under one Leaf-class `RawMutex` on `RuntimeInner`, /// Locking discipline. Held under one Leaf-class `RawMutex` on `RuntimeInner`,
/// mirroring the registry, and never held together with another Leaf lock (it /// mirroring the registry, and never held together with another Leaf lock (it
/// never touches the registry or a slot's cold lock). The two operations that /// never touches the registry or a slot's cold lock). Monitor registration and
/// do need another lock are kept off the group-lock path: /// removal take the target's cold lock (also Leaf), so they run *before* /
/// *after* the group lock, never under it — see [`join`] for the ordering that
/// makes that safe. Nothing under this lock ever touches a channel.
/// ///
/// - `monitor()` / `demonitor()` take the target's cold lock (also Leaf), so /// Eviction is *eager*: every membership's monitor delivers to the one
/// they run *before* / *after* the group lock, never under it. /// `deaths` channel, drained by a per-run reaper actor that sweeps the dead
/// - draining a monitor with `try_recv` takes the channel's Channel-class /// pid out of every group the moment its `Down` is scheduled. The read path
/// lock, which the lock order permits *under* a Leaf; a channel critical /// keeps a slot-liveness backstop for the window between a death and the
/// section only does the lock-free unpark protocol, so no Leaf ever nests /// reaper's turn.
/// under it.
///
/// Evicted and rejected [`Monitor`]s are therefore dropped only *after* the
/// group lock is released, so a receiver-drop never runs a wakeup under the
/// lock — the same discipline as `demonitor`.
pub(crate) struct ProcessGroups { pub(crate) struct ProcessGroups {
groups: HashMap<String, Vec<Membership>>, groups: HashMap<String, Vec<Membership>>,
/// The reaper's inboxes: every membership monitor is registered against
/// a clone of `deaths`. `None` until the first `join` of a run spawns
/// the reaper; a stale one (receiver gone with the previous run's
/// teardown) is detected via `receiver_alive` and replaced.
reaper: Option<ReaperInboxes>,
/// `NodeId → node name` for every peer with members in the store, kept
/// by the pg actor under this lock, so a stored remote member can be
/// rendered back to its wire identity without asking anyone.
#[cfg(feature = "cluster")]
node_names: HashMap<NodeId, String>,
} }
impl ProcessGroups { impl ProcessGroups {
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Self { Self {
groups: HashMap::new(), groups: HashMap::new(),
reaper: None,
#[cfg(feature = "cluster")]
node_names: HashMap::new(),
} }
} }
/// Insert `ms` into `group`. Idempotent on the *member*: if the member is /// Forget the reaper. Called at the start of every `run()` so a stopped
/// already present the new membership is handed back (`Some`) so the caller /// reaper from a previous run is never sent to; `join` respawns.
/// can tear its now-redundant monitor down outside the lock; `None` means pub(crate) fn reset_reaper(&mut self) {
/// it was inserted. self.reaper = None;
fn join(&mut self, group: &str, ms: Membership) -> Option<Membership> { }
/// Insert `ms` into `group`. Idempotent on the *member*: `false` means the
/// member was already present and nothing changed; `true` means inserted.
pub(crate) fn join(&mut self, group: &str, ms: Membership) -> bool {
let v = self.groups.entry(group.to_owned()).or_default(); let v = self.groups.entry(group.to_owned()).or_default();
if v.iter().any(|e| e.member == ms.member) { if v.iter().any(|e| e.member == ms.member) {
return Some(ms); return false;
} }
v.push(ms); v.push(ms);
None true
} }
/// Remove `member`'s membership from `group`, returning it (so the caller /// Remove `member`'s membership from `group`, returning it (so the caller
/// can `demonitor` it outside the lock). An emptied group is pruned. /// can unregister its monitor outside the lock). An emptied group is pruned.
fn leave(&mut self, group: &str, member: Member) -> Option<Membership> { pub(crate) fn leave(&mut self, group: &str, member: Member) -> Option<Membership> {
let v = self.groups.get_mut(group)?; let v = self.groups.get_mut(group)?;
let pos = v.iter().position(|e| e.member == member)?; let pos = v.iter().position(|e| e.member == member)?;
let removed = v.remove(pos); let removed = v.remove(pos);
@@ -252,20 +272,23 @@ impl ProcessGroups {
} }
/// The one dumb eviction primitive: drop every member matching `pred` from /// The one dumb eviction primitive: drop every member matching `pred` from
/// every group, pruning emptied groups, and return the evicted memberships' /// every group, pruning emptied groups, and return the evicted
/// monitors for the caller to drop outside the lock. The primitive does not /// memberships with the group each was in. The primitive does not know
/// know *why* a member leaves; that is the caller's concern. Its callers are /// *why* a member leaves; that is the caller's concern. Its callers are
/// the death hook (`reap_group`) and, once clustering lands, an /// the reaper (a local death) and the cluster's node-down / re-sync
/// incarnation-eviction sweep — both over this same predicate path, which is /// sweeps — all over this same predicate path, which is the whole reason
/// the whole reason to shape eviction as a predicate. Insertion order within /// to shape eviction as a predicate. Insertion order within a group is
/// a group is preserved (`members` / `pick` are order-stable). /// preserved (`members` / `pick` are order-stable).
fn remove_where(&mut self, mut pred: impl FnMut(&Member) -> bool) -> Vec<Monitor> { pub(crate) fn remove_where(
&mut self,
mut pred: impl FnMut(&Member) -> bool,
) -> Vec<(String, Membership)> {
let mut evicted = Vec::new(); let mut evicted = Vec::new();
self.groups.retain(|_, v| { self.groups.retain(|g, v| {
let mut i = 0; let mut i = 0;
while i < v.len() { while i < v.len() {
if pred(&v[i].member) { if pred(&v[i].member) {
evicted.push(v.remove(i).monitor); evicted.push((g.clone(), v.remove(i)));
} else { } else {
i += 1; i += 1;
} }
@@ -275,38 +298,6 @@ impl ProcessGroups {
evicted evicted
} }
/// Drain-on-contact death hook. The registry can prune a stale binding
/// lazily, on contact, because it only ever resolves one binding at a time;
/// a group is *iterated* — `members` fans out to everyone — so it must not
/// carry a dead member across a broadcast. Every group operation reaps the
/// group it touches first.
///
/// Drains every membership monitor in `group` with a non-blocking
/// `try_recv`: a delivered `Down` (any reason) or a closed channel means
/// that member is dead. On the first death detected, sweep *all* of the
/// dead pids out of *every* group via [`remove_where`] — a death is removed
/// from each group it joined, not just the one being touched. Returns the
/// evicted monitors to drop outside the lock.
fn reap_group(&mut self, group: &str) -> Vec<Monitor> {
let dead: Vec<Pid> = {
let Some(v) = self.groups.get(group) else {
return Vec::new();
};
v.iter()
.filter_map(|e| match e.monitor.rx.try_recv() {
// A Down arrived, or the channel closed and drained: dead.
Ok(Some(_)) | Err(_) => Some(e.member.pid),
// Empty but open — the sender still lives in the slot: alive.
Ok(None) => None,
})
.collect()
};
if dead.is_empty() {
return Vec::new();
}
self.remove_where(|m| dead.contains(&m.pid))
}
/// Raw enumeration of a group's members — no liveness filtering. Used by /// Raw enumeration of a group's members — no liveness filtering. Used by
/// tests to assert storage state independently of the read-path backstop. /// tests to assert storage state independently of the read-path backstop.
#[cfg(test)] #[cfg(test)]
@@ -317,16 +308,24 @@ impl ProcessGroups {
.unwrap_or_default() .unwrap_or_default()
} }
/// Live members of `group`, in insertion order. The `is_live` oracle is the /// Live members of `group` **on `node`**, in insertion order. The
/// read-path backstop: a member whose slot is already dead is /// `is_live` oracle is the read-path backstop: a member whose slot is
/// dropped from the *result* even if its `Down` has not been drained yet. /// already dead is dropped from the *result* even if the reaper has not
/// Backstop only — the entry stays in storage; eviction is the monitor's /// swept it yet. Backstop only — the entry stays in storage; eviction is
/// job (`reap_group`). /// the reaper's job. The node filter is what keeps the local API local:
fn members_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Vec<Pid> { /// a remote member's `pid` is another node's slot bits, meaningless to
/// `is_live` and to any local send.
fn members_where(
&self,
group: &str,
node: NodeId,
mut is_live: impl FnMut(Pid) -> bool,
) -> Vec<Pid> {
self.groups self.groups
.get(group) .get(group)
.map(|v| { .map(|v| {
v.iter() v.iter()
.filter(|e| e.member.node == node)
.map(|e| e.member.pid) .map(|e| e.member.pid)
.filter(|&p| is_live(p)) .filter(|&p| is_live(p))
.collect() .collect()
@@ -334,19 +333,210 @@ impl ProcessGroups {
.unwrap_or_default() .unwrap_or_default()
} }
/// The first live member of `group` in insertion order — stateless /// The first live member of `group` on `node` in insertion order —
/// first-live `pick`, with the same read-path backstop as `members_where`. /// stateless first-live `pick`, with the same read-path backstop and node
fn first_member_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Option<Pid> { /// filter as `members_where`.
fn first_member_where(
&self,
group: &str,
node: NodeId,
mut is_live: impl FnMut(Pid) -> bool,
) -> Option<Pid> {
self.groups self.groups
.get(group)? .get(group)?
.iter() .iter()
.filter(|e| e.member.node == node)
.map(|e| e.member.pid) .map(|e| e.member.pid)
.find(|&p| is_live(p)) .find(|&p| is_live(p))
} }
} }
/// The store's cluster-side surface: raw reads the pg actor needs to speak
/// for this node (`Sync`, membership checks) and the peer-name memo. One
/// `cfg` block: everything here exists only when there is a mesh.
#[cfg(feature = "cluster")]
impl ProcessGroups {
/// Does `group` hold `member` right now? (Raw storage, no liveness.)
pub(crate) fn contains(&self, group: &str, member: &Member) -> bool {
self.groups
.get(group)
.is_some_and(|v| v.iter().any(|e| e.member == *member))
}
/// Every stored member of `group`, any node, insertion order. Raw storage.
pub(crate) fn all_of(&self, group: &str) -> Vec<Member> {
self.groups
.get(group)
.map(|v| v.iter().map(|e| e.member).collect())
.unwrap_or_default()
}
/// `(group, [pid])` for every group with a member on `node` — the
/// `Sync` payload. Raw storage; groups with no such member are omitted.
pub(crate) fn groups_on(&self, node: NodeId) -> Vec<(String, Vec<Pid>)> {
let mut out: Vec<(String, Vec<Pid>)> = self
.groups
.iter()
.filter_map(|(g, v)| {
let pids: Vec<Pid> = v
.iter()
.filter(|e| e.member.node == node)
.map(|e| e.member.pid)
.collect();
(!pids.is_empty()).then(|| (g.clone(), pids))
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
/// Record / forget the name behind a peer's `NodeId`.
pub(crate) fn set_node_name(&mut self, node: NodeId, name: String) {
self.node_names.insert(node, name);
}
pub(crate) fn forget_node_name(&mut self, node: NodeId) {
self.node_names.remove(&node);
}
pub(crate) fn node_name(&self, node: NodeId) -> Option<&str> {
self.node_names.get(&node).map(String::as_str)
}
}
/// The group reaper: one detached actor per run, spawned by the first `join`,
/// parked on the shared `deaths` inbox. Every local membership's monitor
/// delivers here, so a death is swept out of *every* group it joined as soon
/// as the reaper is scheduled — no group operation has to happen first.
/// Sweeps by `(node, pid)`: only local members, since a remote member's pid
/// bits are meaningless here. Exits when the last sender is gone, i.e. never
/// during a run (the store holds one); the run's teardown stops it like any
/// other parked actor. Spawned under `ROOT_PID` so its exit signal is absorbed
/// rather than delivered to whichever supervisor's child happened to join
/// first.
///
/// Under `cluster` the same actor is the node's **pg actor** (RFC 010 Phase
/// 5, c15): it also drains a control inbox of local join/leave announcements,
/// the membership stream and the exposed `"pg"` inbox — see
/// [`crate::cluster::pg`]. Its store-side sweep is unchanged.
#[cfg(not(feature = "cluster"))]
fn reaper(rx: crate::channel::Receiver<Down>, ctl: crate::channel::Receiver<PgEvent>) {
// No mesh: nothing to tell about joins/leaves. Drop the control inbox
// so announcements are refused at the sender rather than queued.
drop(ctl);
while let Ok(down) = rx.recv() {
sweep_local_death(down.pid);
// Evicted memberships hold only ids; their monitors have fired.
}
}
/// What the local API tells the reaper besides deaths (which arrive as
/// [`Down`] on their own inbox — that channel's type is fixed by the monitor
/// primitive, so the two cannot be one enum). The default reaper has no use
/// for these; the cluster's pg actor broadcasts them (RFC 010 Phase 5).
// The default reaper never looks inside — that is the point, not a bug.
#[cfg_attr(not(feature = "cluster"), allow(dead_code))]
pub(crate) enum PgEvent {
/// `join` inserted `pid` into `group`. The consumer re-checks the store
/// before acting on it.
Joined { group: String, pid: Pid },
/// `leave` removed `pid` from `group`.
Left { group: String, pid: Pid },
/// `cluster::start` has the manager up and the local identity set: take
/// a membership subscription, register + expose the `"pg"` name, and
/// start speaking to peers.
#[cfg(feature = "cluster")]
Attach,
}
/// Evict the local member `pid` from every group. The reaper's one store
/// operation; returns what was evicted with its group (the cluster's
/// `Leave` broadcast wants both).
pub(crate) fn sweep_local_death(pid: Pid) -> Vec<(String, Membership)> {
with_runtime(|inner| {
let node = inner.node_id;
inner
.process_groups
.lock()
.remove_where(|m| m.node == node && m.pid == pid)
})
}
/// The reaper's inboxes. `deaths` is the liveness authority for the set
/// (`ctl` is created and dropped with it, on the same actor).
#[derive(Clone)]
pub(crate) struct ReaperInboxes {
pub(crate) deaths: Sender<Down>,
/// The control inbox: local `join`/`leave` announce here (see
/// [`PgEvent`]). The default reaper closes it on entry.
pub(crate) ctl: Sender<PgEvent>,
}
impl ReaperInboxes {
fn alive(&self) -> bool {
self.deaths.receiver_alive()
}
}
/// Live senders for the reaper's inboxes, spawning the reaper if this run has
/// none yet. Two racing first-spawns may both spawn; the loser's senders drop
/// on return, its spare reaper sees a closed inbox and exits.
pub(crate) fn reaper_inboxes() -> ReaperInboxes {
let existing = with_runtime(|inner| {
let pg = inner.process_groups.lock();
pg.reaper.clone().filter(ReaperInboxes::alive)
});
if let Some(r) = existing {
return r;
}
let (tx, rx) = channel::<Down>();
let (ctl_tx, ctl_rx) = channel::<PgEvent>();
// Detached: the handle drops here. The reaper's lifetime is the run's.
// The ONE seam between the local store and the cluster: same inboxes,
// different body.
#[cfg(not(feature = "cluster"))]
let _ = spawn_under(crate::runtime::ROOT_PID, move || reaper(rx, ctl_rx));
#[cfg(feature = "cluster")]
let _ = spawn_under(crate::runtime::ROOT_PID, move || {
crate::cluster::pg::actor(rx, ctl_rx)
});
let fresh = ReaperInboxes {
deaths: tx,
ctl: ctl_tx,
};
with_runtime(|inner| {
let mut pg = inner.process_groups.lock();
match &pg.reaper {
Some(r) if r.alive() => r.clone(),
_ => {
pg.reaper = Some(fresh.clone());
fresh
}
}
})
}
/// A live sender for the reaper's `deaths` inbox (spawning it if needed).
fn deaths_sender() -> Sender<Down> {
reaper_inboxes().deaths
}
/// Announce a local group change to the reaper, if this run has one. A
/// closed inbox is the default reaper (uninterested) or a run tearing down.
fn announce(msg: PgEvent) {
let ctl = with_runtime(|inner| {
inner
.process_groups
.lock()
.reaper
.as_ref()
.map(|r| r.ctl.clone())
});
if let Some(ctl) = ctl {
let _ = ctl.send(msg);
}
}
/// Build the full member identity for `pid` from runtime identity. /// Build the full member identity for `pid` from runtime identity.
fn member_for(inner: &crate::runtime::RuntimeInner, pid: Pid) -> Member { pub(crate) fn member_for(inner: &crate::runtime::RuntimeInner, pid: Pid) -> Member {
Member { Member {
node: inner.node_id, node: inner.node_id,
incarnation: inner.incarnation, incarnation: inner.incarnation,
@@ -358,7 +548,7 @@ fn member_for(inner: &crate::runtime::RuntimeInner, pid: Pid) -> Member {
/// no lock — identical to the registry's guard. The read-path backstop: a /// no lock — identical to the registry's guard. The read-path backstop: a
/// generation is never reused, so a dead member is detectable independently of /// generation is never reused, so a dead member is detectable independently of
/// whether its monitor `Down` has been drained yet. /// whether its monitor `Down` has been drained yet.
fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool { pub(crate) fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid)) inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
} }
@@ -367,61 +557,76 @@ fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
/// added the membership, `false` if it was already a member. /// added the membership, `false` if it was already a member.
/// ///
/// Installs a monitor on `pid` so the actor's death evicts it from the group /// Installs a monitor on `pid` so the actor's death evicts it from the group
/// automatically — you never have to remove a dead member yourself. A redundant /// automatically — you never have to remove a dead member yourself. Joining a
/// (idempotent) join tears its extra monitor back down. /// pid that is already dead is accepted and evicted the same way (via a
/// `NoProc` notice), so it never shows up in a read.
/// ///
/// Panics if called outside `Runtime::run()`. /// Panics if called outside `Runtime::run()`.
pub fn join<A>(group: impl Into<String>, pid: Pid<A>) -> bool { pub fn join<A>(group: impl Into<String>, pid: Pid<A>) -> bool {
let group = group.into(); let group = group.into();
let pid = pid.erase(); let pid = pid.erase();
// Install the monitor BEFORE taking the group lock: monitor() acquires the let deaths = deaths_sender();
// target's cold lock (Leaf), and two Leaf locks are never held at once. The // Record the membership BEFORE arming its monitor: the reaper sweeps by
// registration races `finalize_actor` under that cold lock exactly as every // pid on the first `Down`, so a `Down` that could precede the entry would
// other monitor does, so no death can slip between the join and the monitor // leave a corpse in storage forever (visible to no read — the backstop
// being in place. // hides it — but a leak, and once groups are clustered a member that
let mon = monitor(pid); // would be announced). Arming after insertion means every `Down` finds
// its entry. The monitor id is allocated up front so `leave` can tear the
let (rejected, reaped) = with_runtime(|inner| { // registration down even if it lands in the tiny window before arming (an
// orphaned registration is harmless: its `Down` names a pid whose
// membership is gone, and the sweep finds nothing).
let id = with_runtime(|inner| inner.alloc_monitor_id());
let inserted = with_runtime(|inner| {
let ms = Membership { let ms = Membership {
member: member_for(inner, pid), member: member_for(inner, pid),
monitor: mon, monitor: Some(id),
}; };
let mut pg = inner.process_groups.lock(); inner.process_groups.lock().join(&group, ms)
let reaped = pg.reap_group(&group); });
let rejected = pg.join(&group, ms); if !inserted {
(rejected, reaped) return false;
}
// Tell the reaper (the cluster's pg actor re-checks the store before it
// broadcasts, so a `leave`/death that overtakes this announcement is
// never advertised as a join).
announce(PgEvent::Joined {
group: group.clone(),
pid,
}); });
// Outside the group lock: drop the reaped (dead) monitors, and if this join // Outside the group lock: registration takes the target's cold lock (Leaf).
// was redundant, demonitor + drop the extra monitor we just installed. // The registration races `finalize_actor` under that cold lock exactly as
drop(reaped); // every other monitor does, so no death can slip between the join and the
match rejected { // monitor being in place.
Some(dup) => { if !register_monitor(pid, id, &deaths) {
demonitor(&dup.monitor); // Already gone: queue the notice ourselves, exactly as `monitor` does.
false let _ = deaths.send(Down {
} pid,
None => true, reason: DownReason::NoProc,
});
} }
true
} }
/// Drop `pid`'s membership of `group`. Returns whether a membership was /// Drop `pid`'s membership of `group`. Returns whether a membership was
/// removed. The membership's monitor is demonitored and dropped. /// removed. The membership's monitor registration is torn down.
/// ///
/// Panics if called outside `Runtime::run()`. /// Panics if called outside `Runtime::run()`.
pub fn leave<A>(group: &str, pid: Pid<A>) -> bool { pub fn leave<A>(group: &str, pid: Pid<A>) -> bool {
let pid = pid.erase(); let pid = pid.erase();
let (removed, reaped) = with_runtime(|inner| { let removed = with_runtime(|inner| {
let member = member_for(inner, pid); let member = member_for(inner, pid);
let mut pg = inner.process_groups.lock(); inner.process_groups.lock().leave(group, member)
let reaped = pg.reap_group(group);
let removed = pg.leave(group, member);
(removed, reaped)
}); });
drop(reaped);
match removed { match removed {
Some(ms) => { Some(ms) => {
demonitor(&ms.monitor); if let Some(id) = ms.monitor {
unregister_monitor(pid, id);
}
announce(PgEvent::Left {
group: group.to_owned(),
pid,
});
true true
} }
None => false, None => false,
@@ -431,21 +636,19 @@ pub fn leave<A>(group: &str, pid: Pid<A>) -> bool {
/// Every live member of `group`, in the order they joined. Returns an empty /// Every live member of `group`, in the order they joined. Returns an empty
/// vector if the group does not exist or has no live members. /// vector if the group does not exist or has no live members.
/// ///
/// Dead members are never returned: the group is pruned of anything that has /// Dead members are never returned: the reaper evicts a member as soon as its
/// died before the read, and as a backstop a member whose slot is already dead /// death is processed, and as a backstop a member whose slot is already dead
/// is dropped from the result even in the brief window before its death has /// is dropped from the result even in the brief window before the reaper's
/// been fully processed. /// turn.
/// ///
/// Panics if called outside `Runtime::run()`. /// Panics if called outside `Runtime::run()`.
pub fn members(group: &str) -> Vec<Pid> { pub fn members(group: &str) -> Vec<Pid> {
let (pids, reaped) = with_runtime(|inner| { with_runtime(|inner| {
let mut pg = inner.process_groups.lock(); inner
let reaped = pg.reap_group(group); .process_groups
let pids = pg.members_where(group, |pid| live(inner, pid)); .lock()
(pids, reaped) .members_where(group, inner.node_id, |pid| live(inner, pid))
}); })
drop(reaped);
pids
} }
/// One live member of `group`, or `None` if the group is empty (or every /// One live member of `group`, or `None` if the group is empty (or every
@@ -455,14 +658,12 @@ pub fn members(group: &str) -> Vec<Pid> {
/// ///
/// Panics if called outside `Runtime::run()`. /// Panics if called outside `Runtime::run()`.
pub fn pick(group: &str) -> Option<Pid> { pub fn pick(group: &str) -> Option<Pid> {
let (picked, reaped) = with_runtime(|inner| { with_runtime(|inner| {
let mut pg = inner.process_groups.lock(); inner
let reaped = pg.reap_group(group); .process_groups
let picked = pg.first_member_where(group, |pid| live(inner, pid)); .lock()
(picked, reaped) .first_member_where(group, inner.node_id, |pid| live(inner, pid))
}); })
drop(reaped);
picked
} }
/// Typed [`pick`]: one live member of `group` as a [`Pid<A>`](Pid). /// Typed [`pick`]: one live member of `group` as a [`Pid<A>`](Pid).
@@ -505,8 +706,8 @@ pub fn dispatch<A: Addressable>(group: &str, msg: A::Msg) -> Result<Pid<A>, Send
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::channel::{channel, Sender}; use crate::scheduler::spawn;
use crate::monitor::{Down, DownReason, MonitorId}; use std::time::{Duration, Instant};
fn member(index: u32, generation: u32) -> Member { fn member(index: u32, generation: u32) -> Member {
Member { Member {
@@ -516,33 +717,21 @@ mod tests {
} }
} }
/// A synthetic membership with a real (but slot-less) monitor channel. The /// A synthetic membership: the store never looks at the id.
/// returned `Sender` stands in for the slot's `Down` sender: hold it to fn synth(index: u32, generation: u32) -> Membership {
/// keep the member "alive" (`try_recv` → `Ok(None)`), `send` a `Down` to Membership {
/// simulate death, or `drop` it to simulate a drained/closed channel.
fn synth(index: u32, generation: u32) -> (Membership, Sender<Down>) {
let pid = Pid::new(index, generation);
let (tx, rx) = channel::<Down>();
let ms = Membership {
member: member(index, generation), member: member(index, generation),
monitor: Monitor { monitor: Some(MonitorId(0)),
id: MonitorId(0), }
target: pid,
rx,
},
};
(ms, tx)
} }
#[test] #[test]
fn join_is_idempotent_within_a_group() { fn join_is_idempotent_within_a_group() {
let mut pg = ProcessGroups::new(); let mut pg = ProcessGroups::new();
let (a, _ta) = synth(1, 0); assert!(pg.join("workers", synth(1, 0)), "first join inserts");
let (b, _tb) = synth(1, 0);
assert!(pg.join("workers", a).is_none(), "first join inserts");
assert!( assert!(
pg.join("workers", b).is_some(), !pg.join("workers", synth(1, 0)),
"second identical join is handed back" "second identical join is refused"
); );
assert_eq!(pg.members_of("workers"), vec![member(1, 0)]); assert_eq!(pg.members_of("workers"), vec![member(1, 0)]);
} }
@@ -550,12 +739,9 @@ mod tests {
#[test] #[test]
fn same_pid_in_many_groups_is_independent() { fn same_pid_in_many_groups_is_independent() {
let mut pg = ProcessGroups::new(); let mut pg = ProcessGroups::new();
let (a, _ta) = synth(1, 0); pg.join("a", synth(1, 0));
let (b, _tb) = synth(1, 0); pg.join("b", synth(1, 0));
let (c, _tc) = synth(2, 0); pg.join("b", synth(2, 0));
pg.join("a", a);
pg.join("b", b);
pg.join("b", c);
assert_eq!(pg.members_of("a"), vec![member(1, 0)]); assert_eq!(pg.members_of("a"), vec![member(1, 0)]);
assert_eq!(pg.members_of("b"), vec![member(1, 0), member(2, 0)]); assert_eq!(pg.members_of("b"), vec![member(1, 0), member(2, 0)]);
} }
@@ -564,11 +750,9 @@ mod tests {
fn distinct_generations_are_distinct_members() { fn distinct_generations_are_distinct_members() {
// ABA guard: same slot index, different generation = different actor. // ABA guard: same slot index, different generation = different actor.
let mut pg = ProcessGroups::new(); let mut pg = ProcessGroups::new();
let (a, _ta) = synth(1, 0); assert!(pg.join("g", synth(1, 0)));
let (b, _tb) = synth(1, 1);
assert!(pg.join("g", a).is_none());
assert!( assert!(
pg.join("g", b).is_none(), pg.join("g", synth(1, 1)),
"different generation is a distinct member" "different generation is a distinct member"
); );
assert_eq!(pg.members_of("g"), vec![member(1, 0), member(1, 1)]); assert_eq!(pg.members_of("g"), vec![member(1, 0), member(1, 1)]);
@@ -577,10 +761,8 @@ mod tests {
#[test] #[test]
fn leave_removes_one_membership_and_prunes_empty_groups() { fn leave_removes_one_membership_and_prunes_empty_groups() {
let mut pg = ProcessGroups::new(); let mut pg = ProcessGroups::new();
let (a, _ta) = synth(1, 0); pg.join("g", synth(1, 0));
let (b, _tb) = synth(2, 0); pg.join("g", synth(2, 0));
pg.join("g", a);
pg.join("g", b);
assert!(pg.leave("g", member(1, 0)).is_some()); assert!(pg.leave("g", member(1, 0)).is_some());
assert_eq!(pg.members_of("g"), vec![member(2, 0)]); assert_eq!(pg.members_of("g"), vec![member(2, 0)]);
assert!( assert!(
@@ -598,7 +780,7 @@ mod tests {
#[test] #[test]
fn remove_where_sweeps_every_group() { fn remove_where_sweeps_every_group() {
let mut pg = ProcessGroups::new(); let mut pg = ProcessGroups::new();
for (g, (m, _t)) in [ for (g, m) in [
("a", synth(1, 0)), ("a", synth(1, 0)),
("a", synth(2, 0)), ("a", synth(2, 0)),
("b", synth(1, 0)), ("b", synth(1, 0)),
@@ -616,104 +798,137 @@ mod tests {
#[test] #[test]
fn remove_where_can_match_an_incarnation_sweep() { fn remove_where_can_match_an_incarnation_sweep() {
// Shape check for the later evict_incarnation(node, inc) caller. // Shape check for the node-down / incarnation sweep caller.
let mut pg = ProcessGroups::new(); let mut pg = ProcessGroups::new();
let pid = Pid::new(1, 0); let stale = Membership {
let (tx, rx) = channel::<Down>();
let dead = Membership {
member: Member { member: Member {
node: DEFAULT_NODE_ID, node: DEFAULT_NODE_ID,
incarnation: Incarnation::new(7), incarnation: Incarnation::new(7),
pid, pid: Pid::new(1, 0),
},
monitor: Monitor {
id: MonitorId(0),
target: pid,
rx,
}, },
monitor: Some(MonitorId(0)),
}; };
let _keep = tx; pg.join("g", stale);
let (live, _tl) = synth(2, 0); pg.join("g", synth(2, 0));
pg.join("g", dead);
pg.join("g", live);
let evicted = pg.remove_where(|mem| mem.incarnation == Incarnation::new(7)); let evicted = pg.remove_where(|mem| mem.incarnation == Incarnation::new(7));
assert_eq!(evicted.len(), 1); assert_eq!(evicted.len(), 1);
assert_eq!(pg.members_of("g"), vec![member(2, 0)]); assert_eq!(pg.members_of("g"), vec![member(2, 0)]);
} }
#[test] #[test]
fn reap_keeps_live_members() { fn read_backstop_hides_a_member_the_reaper_has_not_yet_swept() {
let mut pg = ProcessGroups::new(); let mut pg = ProcessGroups::new();
let (a, _ta) = synth(1, 0); // sender held: member stays alive pg.join("g", synth(1, 0));
pg.join("a", a); pg.join("g", synth(2, 0));
assert!(pg.reap_group("a").is_empty(), "no deaths");
assert_eq!(pg.members_of("a"), vec![member(1, 0)]);
}
#[test]
fn reap_evicts_a_dead_member_and_sweeps_all_its_groups() {
let mut pg = ProcessGroups::new();
let (a1, ta1) = synth(1, 0); // pid 1 in group a
let (a2, _ta2) = synth(2, 0); // pid 2 in group a (stays alive)
let (b1, _tb1) = synth(1, 0); // pid 1 in group b
pg.join("a", a1);
pg.join("a", a2);
pg.join("b", b1);
// pid 1 dies: its group-a monitor receives a Down. Its group-b monitor
// has not — reap must still sweep pid 1 out of b by the pid predicate.
ta1.send(Down {
pid: Pid::new(1, 0),
reason: DownReason::Exit,
})
.unwrap();
let evicted = pg.reap_group("a");
assert_eq!(
evicted.len(),
2,
"pid 1's memberships in both a and b are evicted"
);
assert_eq!(pg.members_of("a"), vec![member(2, 0)]);
assert!(pg.members_of("b").is_empty(), "swept from b too; pruned");
}
#[test]
fn reap_treats_a_closed_channel_as_dead() {
let mut pg = ProcessGroups::new();
let (a, ta) = synth(1, 0);
pg.join("a", a);
drop(ta); // sender gone, queue empty → try_recv = Err(RecvError) = dead
let evicted = pg.reap_group("a");
assert_eq!(evicted.len(), 1);
assert!(pg.members_of("a").is_empty());
}
#[test]
fn read_backstop_hides_a_member_the_monitor_has_not_yet_reaped() {
let mut pg = ProcessGroups::new();
// Both senders held: reap_group would see Ok(None) and evict neither.
let (a, _ta) = synth(1, 0);
let (b, _tb) = synth(2, 0);
pg.join("g", a);
pg.join("g", b);
// The slot-word oracle already reports pid 1 dead (finalize window), // The slot-word oracle already reports pid 1 dead (finalize window),
// ahead of any Down delivery. // ahead of the reaper's turn.
let dead = Pid::new(1, 0); let dead = Pid::new(1, 0);
let oracle = |pid: Pid| pid != dead; let oracle = |pid: Pid| pid != dead;
assert_eq!( assert_eq!(
pg.members_where("g", oracle), pg.members_where("g", DEFAULT_NODE_ID, oracle),
vec![Pid::new(2, 0)], vec![Pid::new(2, 0)],
"dead pid filtered from read" "dead pid filtered from read"
); );
assert_eq!( assert_eq!(
pg.first_member_where("g", oracle), pg.first_member_where("g", DEFAULT_NODE_ID, oracle),
Some(Pid::new(2, 0)), Some(Pid::new(2, 0)),
"pick skips the dead first member" "pick skips the dead first member"
); );
// Backstop does not evict — that stays the monitor's job; raw storage // Backstop does not evict — that stays the reaper's job; raw storage
// still holds both until reap runs. // still holds both until it runs.
assert_eq!(pg.members_of("g"), vec![member(1, 0), member(2, 0)]); assert_eq!(pg.members_of("g"), vec![member(1, 0), member(2, 0)]);
} }
// ---- reaper: eager eviction against a live runtime ----
/// Raw storage view for a group, bypassing the read-path backstop.
fn stored(group: &str) -> Vec<Member> {
with_runtime(|inner| inner.process_groups.lock().members_of(group))
}
/// Cooperative wait (`smarm::sleep`, never an OS block) until `pred`.
fn wait_until(what: &str, mut pred: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(2);
while !pred() {
assert!(Instant::now() < deadline, "timed out waiting for: {what}");
crate::sleep(Duration::from_millis(1));
}
}
#[test]
fn a_death_is_swept_from_storage_without_any_group_operation() {
crate::run(|| {
let (tx, rx) = channel::<()>();
let w = spawn(move || {
rx.recv().unwrap();
});
let pid = w.pid();
join("a", pid);
join("b", pid);
assert_eq!(stored("a"), vec![member_for_test(pid)]);
tx.send(()).unwrap();
w.join().unwrap();
// No members()/pick()/join() on a or b from here on: the reaper
// alone must clear both.
wait_until("reaper sweeps a and b", || {
stored("a").is_empty() && stored("b").is_empty()
});
});
}
#[test]
fn a_dead_at_join_pid_is_swept_from_storage() {
crate::run(|| {
let h = spawn(|| {});
let pid = h.pid();
h.join().unwrap();
assert!(join("late", pid), "join is accepted; eviction is uniform");
wait_until("reaper sweeps the NoProc member", || {
stored("late").is_empty()
});
});
}
#[test]
fn leave_then_death_does_not_disturb_a_rejoined_group() {
// A monitor unregistered by `leave` must not fire later; the pid's
// fresh membership after re-join is swept exactly once, by its own
// monitor, on death.
crate::run(|| {
let (tx, rx) = channel::<()>();
let w = spawn(move || {
rx.recv().unwrap();
});
let pid = w.pid();
join("g", pid);
assert!(leave("g", pid));
assert!(join("g", pid));
assert_eq!(members("g"), vec![pid]);
tx.send(()).unwrap();
w.join().unwrap();
wait_until("reaper sweeps g", || stored("g").is_empty());
});
}
#[test]
fn reaper_is_respawned_for_a_second_run_of_the_same_runtime() {
let rt = crate::runtime::init(crate::runtime::Config::exact(1));
let body = || {
let h = spawn(|| {});
let pid = h.pid();
h.join().unwrap();
join("g", pid);
wait_until("reaper sweeps g", || stored("g").is_empty());
};
rt.run(body);
rt.run(body);
}
fn member_for_test(pid: Pid) -> Member {
with_runtime(|inner| member_for(inner, pid))
}
} }
+41
View File
@@ -292,3 +292,44 @@ mod typed_pid_tests {
assert_send_sync::<Name<CounterMsg>>(); assert_send_sync::<Name<CounterMsg>>();
} }
} }
// ---- RFC 010 c10: pids auto-serialize (cluster feature) ---------------------
/// A local `Pid<A>` serializes as a
/// [`RemotePid<A>`](crate::cluster::remote::RemotePid): the wire form stamps
/// this node's name and incarnation from the ambient runtime, so a pid can
/// sit inside any message field and reply-to needs no ceremony (RFC 010 §3,
/// "sugar not a bear trap"). Serializing a pid also marks it **watchable**
/// — the wire crossing is the cluster's `mark_watchable` set-site (D12), the
/// exact analog of the membrane crossing.
///
/// Must run inside `run()` (the ambient identity lives on the runtime); a
/// runtime without a cluster identity cannot serialize a pid at all — it is
/// a serialize error, surfacing as the send's `Encode` failure — rather than
/// a `("", 0)` stamp that every peer would silently drop.
#[cfg(feature = "cluster")]
impl<A: 'static> serde::Serialize for Pid<A> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
crate::cluster::remote::RemotePid::<A>::from_local(*self)
.ok_or_else(|| serde::ser::Error::custom("pid serialized with no local node identity"))?
.serialize(s)
}
}
/// Deserializing into a `Pid<A>` is the **collapse**: it succeeds only when
/// the wire pid names this very node (name and incarnation both), and is a
/// decode error otherwise — a foreign pid cannot become a local `Pid`.
/// Fields that may hold a pid from anywhere are `RemotePid<A>`.
#[cfg(feature = "cluster")]
impl<'de, A: 'static> serde::Deserialize<'de> for Pid<A> {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let rp = crate::cluster::remote::RemotePid::<A>::deserialize(d)?;
rp.local().ok_or_else(|| {
serde::de::Error::custom(format!(
"pid {}@{} is not local to this node",
rp.index(),
rp.node()
))
})
}
}
+3
View File
@@ -1358,6 +1358,9 @@ impl Runtime {
self.inner.root_exited.store(false, Ordering::Relaxed); self.inner.root_exited.store(false, Ordering::Relaxed);
self.inner.root_swept.store(false, Ordering::Relaxed); self.inner.root_swept.store(false, Ordering::Relaxed);
self.inner.set_root(initial_handle.pid()); self.inner.set_root(initial_handle.pid());
// A previous run's group reaper was stopped with that run; forget it
// so the first `join` of this run spawns a fresh one.
self.inner.process_groups.lock().reset_reaper();
// Launch N-1 extra scheduler threads, named `smarm-sched-{slot}` so // Launch N-1 extra scheduler threads, named `smarm-sched-{slot}` so
// they are identifiable in `/proc/<pid>/task/*/comm`, stack dumps and // they are identifiable in `/proc/<pid>/task/*/comm`, stack dumps and
+9
View File
@@ -65,6 +65,13 @@ mod inner {
// RFC 005 wake slot // RFC 005 wake slot
SlotPush(Pid), // actor-context wake parked in the waking thread's slot SlotPush(Pid), // actor-context wake parked in the waking thread's slot
SlotPop(Pid), // scheduler resumed a pid from its own slot SlotPop(Pid), // scheduler resumed a pid from its own slot
// Cluster (RFC 010): the conn actor's verdict on one inbound frame —
// local knowledge only, never on the wire; the label is
// `InboundVerdict::label()`. No pid: a refused frame has none.
ClusterInbound(&'static str),
// Cluster (RFC 010): the connector's verdict on one dial attempt —
// `"ok"` or `DialError::label()`. No pid.
ClusterDial(&'static str),
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -271,6 +278,8 @@ mod inner {
Event::Dequeue(p) => ("dequeue".into(), p.index()), Event::Dequeue(p) => ("dequeue".into(), p.index()),
Event::SlotPush(p) => ("slot_push".into(), p.index()), Event::SlotPush(p) => ("slot_push".into(), p.index()),
Event::SlotPop(p) => ("slot_pop".into(), p.index()), Event::SlotPop(p) => ("slot_pop".into(), p.index()),
Event::ClusterInbound(v) => (format!("cluster_inbound {v}"), 0),
Event::ClusterDial(v) => (format!("cluster_dial {v}"), 0),
} }
} }
+8 -1
View File
@@ -137,11 +137,18 @@ fn channel_ops_interleaved_with_monitor_churn_multi_thread() {
for i in 0..32i64 { for i in 0..32i64 {
let tx = tx.clone(); let tx = tx.clone();
handles.push(spawn(move || { handles.push(spawn(move || {
// Short-lived target whose death fires the monitor below. // Short-lived target whose death fires the monitor below. It
// is gated: on a multi-thread scheduler it could otherwise
// run and exit before `monitor` registers, and monitoring a
// corpse queues `NoProc` by contract — the point here is a
// `Down` sent from finalize, so register first, then release.
let (go_tx, go_rx) = channel::<()>();
let t = spawn(move || { let t = spawn(move || {
let _ = go_rx.recv();
tx.send(i).unwrap(); tx.send(i).unwrap();
}); });
let m = smarm::monitor(t.pid()); let m = smarm::monitor(t.pid());
go_tx.send(()).unwrap();
t.join().unwrap(); t.join().unwrap();
// Down delivery exercises send-from-finalize. // Down delivery exercises send-from-finalize.
let d = m.rx.recv().unwrap(); let d = m.rx.recv().unwrap();
+5 -2
View File
@@ -21,6 +21,7 @@ use smarm::cluster::manager::{Call, Manager, Reply, MANAGER};
use smarm::cluster::spawn_established; use smarm::cluster::spawn_established;
use smarm::cluster::transport::tcp::TcpTransport; use smarm::cluster::transport::tcp::TcpTransport;
use smarm::cluster::transport::{Conn, FramedConn, Transport}; use smarm::cluster::transport::{Conn, FramedConn, Transport};
use smarm::cluster::Timing;
use smarm::gen_server::{self, GenServerBuilder}; use smarm::gen_server::{self, GenServerBuilder};
use smarm::pg::Incarnation; use smarm::pg::Incarnation;
use smarm::{run, sleep}; use smarm::{run, sleep};
@@ -81,8 +82,10 @@ fn connection_up_commanded_shutdown_and_eof_all_reflected_in_table() {
// Manage the `a` ends as peers node-b and node-c; keep the `b` far ends // Manage the `a` ends as peers node-b and node-c; keep the `b` far ends
// open so neither socket is closed from the far side yet. // open so neither socket is closed from the far side yet.
spawn_established(FramedConn::new(a1), peer("node-b")).expect("node-b registers"); spawn_established(FramedConn::new(a1), peer("node-b"), Timing::default())
spawn_established(FramedConn::new(a2), peer("node-c")).expect("node-c registers"); .expect("node-b registers");
spawn_established(FramedConn::new(a2), peer("node-c"), Timing::default())
.expect("node-c registers");
// Up: both connections register and the table shows them. // Up: both connections register and the table shows them.
wait_peers(&["node-b", "node-c"]); wait_peers(&["node-b", "node-c"]);
+5 -3
View File
@@ -22,6 +22,7 @@ use smarm::cluster::manager::{Call, Manager, Reply, MANAGER};
use smarm::cluster::spawn_established; use smarm::cluster::spawn_established;
use smarm::cluster::transport::tcp::TcpTransport; use smarm::cluster::transport::tcp::TcpTransport;
use smarm::cluster::transport::{Conn, FramedConn, Transport}; use smarm::cluster::transport::{Conn, FramedConn, Transport};
use smarm::cluster::Timing;
use smarm::gen_server::{self, GenServerBuilder}; use smarm::gen_server::{self, GenServerBuilder};
use smarm::pg::Incarnation; use smarm::pg::Incarnation;
use smarm::{run, sleep, spawn}; use smarm::{run, sleep, spawn};
@@ -83,7 +84,8 @@ fn heartbeats_are_sent_unprompted() {
.expect("manager name is free"); .expect("manager name is free");
let (a, b) = pair(&TcpTransport); let (a, b) = pair(&TcpTransport);
spawn_established(FramedConn::new(a), peer("hb-send")).expect("register"); spawn_established(FramedConn::new(a), peer("hb-send"), Timing::default())
.expect("register");
let mut far = FramedConn::new(b); let mut far = FramedConn::new(b);
let frame = far let frame = far
@@ -111,7 +113,7 @@ fn mute_peer_is_torn_down_after_liveness_timeout() {
.expect("manager name is free"); .expect("manager name is free");
let (a, b) = pair(&TcpTransport); let (a, b) = pair(&TcpTransport);
spawn_established(FramedConn::new(a), peer("mute")).expect("register"); spawn_established(FramedConn::new(a), peer("mute"), Timing::default()).expect("register");
// Held open and silent: no frames, no EOF. (Unread inbound // Held open and silent: no frames, no EOF. (Unread inbound
// heartbeats sit in kernel buffers; they are 5 bytes each.) // heartbeats sit in kernel buffers; they are 5 bytes each.)
let _far = FramedConn::new(b); let _far = FramedConn::new(b);
@@ -139,7 +141,7 @@ fn heartbeats_keep_the_connection_alive() {
.expect("manager name is free"); .expect("manager name is free");
let (a, b) = pair(&TcpTransport); let (a, b) = pair(&TcpTransport);
spawn_established(FramedConn::new(a), peer("kept")).expect("register"); spawn_established(FramedConn::new(a), peer("kept"), Timing::default()).expect("register");
// The far heartbeat pump: interval-paced sends until told to stop, // The far heartbeat pump: interval-paced sends until told to stop,
// then holds the socket open, silent, so the eventual teardown is // then holds the socket open, silent, so the eventual teardown is
+28 -18
View File
@@ -19,11 +19,12 @@ use smarm::cluster::connect::{
HANDSHAKE_TIMEOUT, HANDSHAKE_TIMEOUT,
}; };
use smarm::cluster::envelope::{Frame, NodeMeta, RejectReason}; use smarm::cluster::envelope::{Frame, NodeMeta, RejectReason};
use smarm::cluster::handshake::{HelloCtx, Local}; use smarm::cluster::handshake::{Local, PeerStanding};
use smarm::cluster::manager::{Call, Manager, Reply, MANAGER}; use smarm::cluster::manager::{Call, Manager, Reply, MANAGER};
use smarm::cluster::transport::loopback::LoopbackTransport; use smarm::cluster::transport::loopback::LoopbackTransport;
use smarm::cluster::transport::tcp::TcpTransport; use smarm::cluster::transport::tcp::TcpTransport;
use smarm::cluster::transport::{FramedConn, Transport}; use smarm::cluster::transport::{FramedConn, Transport};
use smarm::cluster::Timing;
use smarm::gen_server::{self, GenServerBuilder}; use smarm::gen_server::{self, GenServerBuilder};
use smarm::pg::Incarnation; use smarm::pg::Incarnation;
use smarm::{run, sleep}; use smarm::{run, sleep};
@@ -100,7 +101,7 @@ fn loopback_happy_path_establishes_both_ends() {
local("node-b"), local("node-b"),
|name| { |name| {
assert_eq!(name, "node-a"); assert_eq!(name, "node-a");
HelloCtx::default() PeerStanding::Free
}, },
no_deadline(), no_deadline(),
) )
@@ -118,7 +119,7 @@ fn loopback_hash_mismatch_rejected_with_frame_then_eof() {
let mut wrong = local("node-b"); let mut wrong = local("node-b");
wrong.build_hash ^= 1; wrong.build_hash ^= 1;
let responder = std::thread::spawn(move || { let responder = std::thread::spawn(move || {
accept_handshake(&mut accepted, wrong, |_| HelloCtx::default(), no_deadline()) accept_handshake(&mut accepted, wrong, |_| PeerStanding::Free, no_deadline())
}); });
// The dial side receives the reject frame — the compatibility anchor. // The dial side receives the reject frame — the compatibility anchor.
match dial_handshake(&mut dialer, &local("node-a"), no_deadline()) { match dial_handshake(&mut dialer, &local("node-a"), no_deadline()) {
@@ -142,10 +143,7 @@ fn loopback_tie_break_loser_closed_silently() {
accept_handshake( accept_handshake(
&mut accepted, &mut accepted,
local("node-a"), local("node-a"),
|_| HelloCtx { |_| PeerStanding::Dialing,
name_claimed: false,
dialing_this_peer: true,
},
no_deadline(), no_deadline(),
) )
}); });
@@ -178,7 +176,7 @@ fn loopback_read_ahead_past_hello_survives_into_established_conn() {
let peer = accept_handshake( let peer = accept_handshake(
&mut accepted, &mut accepted,
local("node-b"), local("node-b"),
|_| HelloCtx::default(), |_| PeerStanding::Free,
no_deadline(), no_deadline(),
) )
.unwrap(); .unwrap();
@@ -216,7 +214,7 @@ fn tcp_silent_peer_times_out_on_the_accept_path() {
let r = accept_handshake( let r = accept_handshake(
&mut accepted, &mut accepted,
local("node-b"), local("node-b"),
|_| HelloCtx::default(), |_| PeerStanding::Free,
Instant::now() + Duration::from_millis(200), Instant::now() + Duration::from_millis(200),
); );
let _ = tx.send(r); let _ = tx.send(r);
@@ -253,7 +251,7 @@ fn tcp_duplicate_name_rejected_by_acceptor() {
.start() .start()
.expect("manager name is free"); .expect("manager name is free");
let listener = TcpTransport.listen("127.0.0.1:0").unwrap(); let listener = TcpTransport.listen("127.0.0.1:0").unwrap();
let acceptor = spawn_acceptor(listener, local("node-b")); let acceptor = spawn_acceptor(listener, local("node-b"), Timing::default());
let addr = acceptor.local_addr().to_string(); let addr = acceptor.local_addr().to_string();
// First dial offering "dup-node": establishes and registers. // First dial offering "dup-node": establishes and registers.
@@ -328,12 +326,12 @@ fn dial_intent_cleared_when_dialer_dies() {
// While the dialer lives, the intent is visible. // While the dialer lives, the intent is visible.
match gen_server::call( match gen_server::call(
MANAGER, MANAGER,
Call::HelloCtx { Call::Standing {
peer_name: "ghost".into(), peer_name: "ghost".into(),
}, },
) { ) {
Ok(Reply::HelloCtx(ctx)) => assert!(ctx.dialing_this_peer), Ok(Reply::Standing(s)) => assert_eq!(s, PeerStanding::Dialing),
other => panic!("HelloCtx failed: {other:?}"), other => panic!("PeerStanding failed: {other:?}"),
} }
// Kill it; the monitor must clear the intent without cooperation. // Kill it; the monitor must clear the intent without cooperation.
go_tx.send(()).unwrap(); go_tx.send(()).unwrap();
@@ -341,11 +339,11 @@ fn dial_intent_cleared_when_dialer_dies() {
loop { loop {
match gen_server::call( match gen_server::call(
MANAGER, MANAGER,
Call::HelloCtx { Call::Standing {
peer_name: "ghost".into(), peer_name: "ghost".into(),
}, },
) { ) {
Ok(Reply::HelloCtx(ctx)) if !ctx.dialing_this_peer => break, Ok(Reply::Standing(s)) if s != PeerStanding::Dialing => break,
_ if Instant::now() > deadline => { _ if Instant::now() > deadline => {
panic!("dial intent not cleared after dialer death") panic!("dial intent not cleared after dialer death")
} }
@@ -372,7 +370,7 @@ fn role_hs_listener() {
.start() .start()
.expect("manager name is free"); .expect("manager name is free");
let listener = TcpTransport.listen("127.0.0.1:0").unwrap(); let listener = TcpTransport.listen("127.0.0.1:0").unwrap();
let acceptor = spawn_acceptor(listener, local("node-b")); let acceptor = spawn_acceptor(listener, local("node-b"), Timing::default());
println!("LISTENING {}", acceptor.local_addr()); println!("LISTENING {}", acceptor.local_addr());
wait_peers(&["node-a"]); wait_peers(&["node-a"]);
println!("PEERS node-a"); println!("PEERS node-a");
@@ -389,7 +387,13 @@ fn role_hs_dialer() {
.expect("manager name is free"); .expect("manager name is free");
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
smarm::spawn(move || { smarm::spawn(move || {
let r = dial(&TcpTransport, &addr, "node-b", &local("node-a")); let r = dial(
&TcpTransport,
&addr,
"node-b",
&local("node-a"),
Timing::default(),
);
let _ = tx.send(r); let _ = tx.send(r);
}); });
if let Err(e) = poll_recv(&rx, "dial outcome") { if let Err(e) = poll_recv(&rx, "dial outcome") {
@@ -459,7 +463,13 @@ fn concurrent_dial_to_same_name_refused() {
// (the addr is unroutable on purpose — it must never be dialed). // (the addr is unroutable on purpose — it must never be dialed).
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
smarm::spawn(move || { smarm::spawn(move || {
let r = dial(&TcpTransport, "127.0.0.1:1", "node-x", &local("node-a")); let r = dial(
&TcpTransport,
"127.0.0.1:1",
"node-x",
&local("node-a"),
Timing::default(),
);
let _ = tx.send(r); let _ = tx.send(r);
}); });
match poll_recv(&rx, "second dial outcome") { match poll_recv(&rx, "second dial outcome") {
+115
View File
@@ -0,0 +1,115 @@
//! RFC 010 — a seed whose address answers as a *different* name
//! (`DialError::PeerNameMismatch`) is dialed once and then parked: the
//! connector must not redial it on backoff forever.
//!
//! Observed from the misdialed peer: each such dial establishes at the
//! responder (it registers, `node_up`), then the dialer closes on the name
//! check (`node_down`) — one membership blip per attempt. Cross-process: a
//! *server* named `server` subscribes and reports; a *client* on fast
//! timing (50–500ms backoff) seeds `("wrongname", server_addr)`. After the
//! first blip the server counts further `NodeUp`s across 2s — several
//! backoff periods. Parked ⇒ zero. Negative-control-verified: with the park
//! stubbed out the count is ≥ 1 in the same window.
#![cfg(feature = "cluster")]
mod common;
use common::{maybe_child, spawn_node};
use smarm::cluster::envelope::NodeMeta;
use smarm::cluster::membership::{subscribe, NodeEvent};
use smarm::cluster::{start, Config, StaticSeeds, Timing};
use std::time::{Duration, Instant};
const ROLES: &[(&str, fn())] = &[("server", role_server), ("client", role_client)];
fn meta() -> NodeMeta {
NodeMeta {
role: "mismatch".into(),
region: "local".into(),
}
}
fn timing() -> Timing {
Timing {
initial_backoff: Duration::from_millis(50),
max_backoff: Duration::from_millis(500),
..Timing::default()
}
}
fn role_server() {
smarm::run(|| {
let cluster = start(Config {
node_name: "server".into(),
meta: meta(),
listen_addr: std::env::var("SMARM_LISTEN_ADDR")
.unwrap_or_else(|_| "127.0.0.1:0".into()),
strategy: Box::new(StaticSeeds::new(Vec::<(String, String)>::new())),
timing: timing(),
})
.expect("binds");
let ev = subscribe().unwrap();
println!("LISTENING {}", cluster.local_addr());
// First blip: the misdialed client establishes, then closes on us.
loop {
match ev.rx.recv() {
Ok(NodeEvent::NodeDown(i)) if i.name == "client" => break,
Ok(_) => continue,
Err(_) => panic!("manager gone"),
}
}
println!("BLIP");
// Now count further NodeUps across several backoff periods.
let mut more = 0usize;
let t0 = Instant::now();
while t0.elapsed() < Duration::from_millis(2000) {
match ev.rx.try_recv() {
Ok(Some(NodeEvent::NodeUp(i))) if i.name == "client" => more += 1,
Ok(_) => {}
Err(_) => panic!("manager gone"),
}
smarm::sleep(Duration::from_millis(50));
}
println!("MORE {more}");
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
fn role_client() {
let server_addr = std::env::var("SMARM_SERVER_ADDR").expect("SMARM_SERVER_ADDR");
smarm::run(move || {
let _cluster = start(Config {
node_name: "client".into(),
meta: meta(),
listen_addr: "127.0.0.1:0".into(),
strategy: Box::new(StaticSeeds::new(vec![(
"wrongname".to_string(),
server_addr,
)])),
timing: timing(),
})
.expect("binds");
println!("CLIENT UP");
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
#[test]
fn mismatched_seed_is_dialed_once_then_parked() {
maybe_child(ROLES);
let mut server = spawn_node("server", &[]);
let saddr = server.wait_listening();
let mut client = spawn_node("client", &[("SMARM_SERVER_ADDR", &saddr)]);
client.wait_line("CLIENT UP", |l| l == "CLIENT UP");
server.wait_line("BLIP", |l| l == "BLIP");
let line = server.wait_line("MORE", |l| l.starts_with("MORE "));
let more: usize = line.split_whitespace().nth(1).unwrap().parse().unwrap();
assert_eq!(
more, 0,
"mismatched seed was redialed {more}× after being parked"
);
}
+379
View File
@@ -0,0 +1,379 @@
//! RFC 010 c13 — connection-loss synthesis.
//!
//! Local suite (`run()`, no network): the read-side backstop. A
//! `RemoteMonitor` whose channel closes without a notice reads as
//! `Disconnected` exactly once (a `Monitor` command that reached the conn
//! actor's inbox but was never processed — the drain gap); after
//! `demonitor_remote` a closed channel stays a plain `Err`, never a notice.
//!
//! Cross-process: the headline contrast — an actor's own death gives its
//! TRUE reason, loss of the LINK gives `Disconnected` (both a commanded
//! `Disconnect` and a SIGKILLed peer process are `Disconnected` from the
//! monitor's view: nobody is left to say otherwise). Reconnect does not
//! resurrect: the old monitor yields nothing more, proven by stream ORDER
//! (a fresh monitor over the new link delivers first). The ignored test
//! trips liveness by SIGSTOP and then drops the link too, asserting exactly
//! one notice for one monitor.
#![cfg(feature = "cluster")]
mod common;
use common::{maybe_child, spawn_node};
use smarm::cluster::envelope::NodeMeta;
use smarm::cluster::expose::{expose, expose_type};
use smarm::cluster::manager::{Call, Reply, MANAGER};
use smarm::cluster::membership::{subscribe, NodeEvent};
use smarm::cluster::remote::{
self, demonitor_remote, monitor_remote, send_to_remote, RemoteName, RemotePid,
};
use smarm::cluster::{start, Config, RemoteDownReason, StaticSeeds, Timing};
use smarm::pg::Incarnation;
use smarm::{
channel, gen_server, install, register, run, spawn, Addressable, DownReason, Erased, Name, Pid,
};
use std::collections::HashMap;
use std::time::Duration;
// ---- message types (hand-rolled serde; the crate is derive-less) ---------
#[derive(Debug)]
struct Ctl {
cmd: String,
reply_to: RemotePid<Client>,
}
#[derive(Debug)]
struct Answer {
text: String,
pid: Option<RemotePid<Erased>>,
}
struct Client;
impl Addressable for Client {
type Msg = Answer;
}
impl serde::Serialize for Ctl {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeTuple;
let mut t = s.serialize_tuple(2)?;
t.serialize_element(&self.cmd)?;
t.serialize_element(&self.reply_to)?;
t.end()
}
}
impl<'de> serde::Deserialize<'de> for Ctl {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let (cmd, reply_to) = <(String, RemotePid<Client>)>::deserialize(d)?;
Ok(Ctl { cmd, reply_to })
}
}
impl serde::Serialize for Answer {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeTuple;
let mut t = s.serialize_tuple(2)?;
t.serialize_element(&self.text)?;
t.serialize_element(&self.pid)?;
t.end()
}
}
impl<'de> serde::Deserialize<'de> for Answer {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let (text, pid) = <(String, Option<RemotePid<Erased>>)>::deserialize(d)?;
Ok(Answer { text, pid })
}
}
// ================= local suite =========================================
/// A `Monitor` command handed to the connection but never processed (its
/// receiver dropped unread) reads as `Disconnected` — once. A second read
/// is the ordinary closed-channel `Err`, so "exactly one notice" holds.
#[test]
fn unread_command_reads_as_disconnected_once() {
maybe_child(ROLES);
run(|| {
remote::set_local_identity("me", Incarnation::new(7));
let (probe_tx, _probe_rx) = channel();
let inbox =
remote::bind_outbound_probe_with_monitors("peer", Incarnation::new(5), probe_tx);
let target = RemotePid::<Erased>::from_parts("peer", Incarnation::new(5), 9, 1);
let m = monitor_remote(target.clone());
assert!(
matches!(m.try_recv(), Ok(None)),
"command is in flight, no notice yet"
);
drop(inbox); // the conn actor died with the command unread
let d = m.recv().unwrap();
assert_eq!(d.pid, target);
assert_eq!(d.reason, RemoteDownReason::Disconnected);
assert!(
m.recv().is_err(),
"second read is closed, not a second notice"
);
assert!(m.try_recv().is_err());
});
}
/// After `demonitor_remote`, a closed channel is a closed channel: no
/// notice is synthesized for a monitor the caller cancelled.
#[test]
fn cancelled_monitor_never_synthesizes() {
maybe_child(ROLES);
run(|| {
remote::set_local_identity("me", Incarnation::new(7));
let (probe_tx, _probe_rx) = channel();
let inbox =
remote::bind_outbound_probe_with_monitors("peer", Incarnation::new(5), probe_tx);
let target = RemotePid::<Erased>::from_parts("peer", Incarnation::new(5), 9, 1);
let m = monitor_remote(target);
demonitor_remote(&m);
drop(inbox);
assert!(m.recv().is_err());
assert!(m.try_recv().is_err());
});
}
// ================= cross-process ======================================
const ROLES: &[(&str, fn())] = &[
("server", role_server),
("client", role_client),
("client_stop", role_client_stop),
];
const CTL: Name<Ctl> = Name::new("c13.ctl");
fn cfg(name: &str, seeds: Vec<(String, String)>) -> Config {
Config {
node_name: name.to_string(),
meta: NodeMeta {
role: "c13".into(),
region: "local".into(),
},
listen_addr: std::env::var("SMARM_LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:0".into()),
strategy: Box::new(StaticSeeds::new(seeds)),
timing: timing(),
}
}
/// The p11 knobs make the liveness test fast: both roles of that test are
/// spawned with `SMARM_FAST_TIMING=1` and agree on a 100ms heartbeat /
/// 500ms liveness window. Everything else runs the shipping defaults.
fn timing() -> Timing {
if std::env::var_os("SMARM_FAST_TIMING").is_some() {
Timing {
heartbeat_interval: Duration::from_millis(100),
liveness_timeout: Duration::from_millis(500),
initial_backoff: Duration::from_millis(50),
max_backoff: Duration::from_millis(500),
..Timing::default()
}
} else {
Timing::default()
}
}
fn wait_up(events: &smarm::cluster::membership::MembershipEvents, who: &str) {
loop {
match events.rx.recv() {
Ok(NodeEvent::NodeUp(i)) if i.name == who => return,
Ok(_) => continue,
Err(_) => panic!("manager gone"),
}
}
}
fn disconnect(name: &str) {
assert!(matches!(
gen_server::call(
MANAGER,
Call::Disconnect {
name: name.to_string()
}
),
Ok(Reply::Disconnected)
));
}
/// Server: `spawn` ⇒ a parked worker (answer carries its pid);
/// `kill:<index>` releases it, whereupon it returns (Exit).
fn role_server() {
smarm::run(move || {
let cluster = start(cfg("server", vec![])).expect("binds");
println!("LISTENING {}", cluster.local_addr());
let (tx, rx) = channel::<Ctl>();
register(CTL, tx).unwrap();
expose(CTL);
println!("READY");
let mut workers: HashMap<u32, smarm::channel::Sender<()>> = HashMap::new();
loop {
let ctl = rx.recv().unwrap();
println!("CTL {}", ctl.cmd);
let (text, pid): (String, Option<RemotePid<Erased>>) = match ctl.cmd.as_str() {
"spawn" => {
let (go_tx, go_rx) = channel::<()>();
let p: Pid = spawn(move || {
let _ = go_rx.recv();
})
.pid();
workers.insert(p.index(), go_tx);
(
"ok".into(),
Some(RemotePid::from_local(p).expect("identity set")),
)
}
other => {
let idx: u32 = other.strip_prefix("kill:").unwrap().parse().unwrap();
if let Some(go) = workers.remove(&idx) {
let _ = go.send(());
}
("killed".into(), None)
}
};
send_to_remote(ctl.reply_to, Answer { text, pid }).unwrap();
}
});
}
/// Client-side setup shared by both client roles: join, expose the reply
/// path, hand back an `ask` closure and the membership stream.
fn client_setup() -> (
smarm::cluster::Cluster,
smarm::cluster::membership::MembershipEvents,
impl Fn(&str) -> Answer,
) {
let server_addr = std::env::var("SMARM_SERVER_ADDR").expect("SMARM_SERVER_ADDR");
let cluster = start(cfg("client", vec![("server".into(), server_addr)])).expect("binds");
let ev = subscribe().unwrap();
wait_up(&ev, "server");
let (tx, rx) = channel::<Answer>();
let me: Pid<Client> = install::<Client>(tx);
expose_type::<Answer>();
let ask = move |cmd: &str| -> Answer {
remote::send(
RemoteName::new("server", CTL),
Ctl {
cmd: cmd.into(),
reply_to: RemotePid::from_local(me).expect("identity set"),
},
)
.unwrap();
rx.recv().unwrap()
};
(cluster, ev, ask)
}
fn role_client() {
smarm::run(move || {
let (_cluster, ev, ask) = client_setup();
// 1. Headline: actor death ⇒ TRUE reason; link cut ⇒ Disconnected.
let a = ask("spawn").pid.unwrap();
let b = ask("spawn").pid.unwrap();
let ma = monitor_remote(a.clone());
let mb = monitor_remote(b.clone());
ask(&format!("kill:{}", a.index()));
let d = ma.recv().unwrap();
assert_eq!(d.pid, a);
println!("DOWN actor {:?}", d.reason);
disconnect("server");
let d = mb.recv().unwrap();
assert_eq!(d.pid, b);
println!("DOWN link {:?}", d.reason);
// 2. Reconnect does not resurrect. The connector redials on
// node_down; over the NEW link a fresh monitor delivers, while
// the old one (already answered) yields nothing further — order
// proves it, and `b` is even still alive on the server.
wait_up(&ev, "server");
println!("RECONNECTED");
let c = ask("spawn").pid.unwrap();
let mc = monitor_remote(c.clone());
ask(&format!("kill:{}", b.index()));
ask(&format!("kill:{}", c.index()));
assert_eq!(mc.recv().unwrap().reason, DownReason::Exit.into());
let stray = matches!(mb.try_recv(), Ok(Some(_)));
println!("RESURRECT stray={stray}");
// 3. Peer PROCESS killed ⇒ Disconnected too (nobody is left to send
// Down): the parent SIGKILLs the server once it sees the marker.
let e = ask("spawn").pid.unwrap();
let me_ = monitor_remote(e.clone());
println!("KILL SERVER NOW");
let d = me_.recv().unwrap();
assert_eq!(d.pid, e);
println!("DOWN procdeath {:?}", d.reason);
println!("CLIENT DONE");
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
/// The slow role: liveness expiry (peer SIGSTOPped) followed by the link
/// dropping for real (peer SIGKILLed) — one monitor, exactly one notice.
fn role_client_stop() {
smarm::run(move || {
let (_cluster, _ev, ask) = client_setup();
let a = ask("spawn").pid.unwrap();
let ma = monitor_remote(a.clone());
println!("STOP SERVER NOW");
let d = ma.recv().unwrap(); // liveness expiry, ~liveness_timeout
assert_eq!(d.pid, a);
println!("DOWN stopped {:?}", d.reason);
println!("KILL SERVER NOW");
// Give the drop every chance to produce a second notice, then look.
smarm::sleep(Duration::from_secs(1));
let dup = matches!(ma.try_recv(), Ok(Some(_)));
println!("DUPLICATE dup={dup}");
println!("CLIENT DONE");
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
/// The Phase 4 c13 gate: partition vs. death distinguishable; nothing
/// survives reconnect; a dead peer process is a Disconnected too.
#[test]
fn link_loss_is_disconnected_and_does_not_survive_reconnect() {
maybe_child(ROLES);
let mut server = spawn_node("server", &[]);
let saddr = server.wait_listening();
server.wait_line("READY", |l| l == "READY");
let mut client = spawn_node("client", &[("SMARM_SERVER_ADDR", &saddr)]);
client.wait_line("DOWN actor Local(Exit)", |l| l == "DOWN actor Local(Exit)");
client.wait_line("DOWN link Disconnected", |l| l == "DOWN link Disconnected");
client.wait_line("RECONNECTED", |l| l == "RECONNECTED");
client.wait_line("RESURRECT stray=false", |l| l == "RESURRECT stray=false");
client.wait_line("KILL SERVER NOW", |l| l == "KILL SERVER NOW");
server.kill();
client.wait_line("DOWN procdeath Disconnected", |l| {
l == "DOWN procdeath Disconnected"
});
client.wait_line("CLIENT DONE", |l| l == "CLIENT DONE");
}
/// Covers the invariant the headline test cannot: liveness expiry and the
/// transport drop both firing for the same connection yield ONE notice.
/// Runs on the fast [`timing`] (both roles) — was `#[ignore]`d at the 4s
/// default until the p11 knobs landed.
#[test]
fn timeout_then_drop_yields_one_notice() {
maybe_child(ROLES);
let fast = ("SMARM_FAST_TIMING", "1");
let mut server = spawn_node("server", &[fast]);
let saddr = server.wait_listening();
server.wait_line("READY", |l| l == "READY");
let mut client = spawn_node("client_stop", &[("SMARM_SERVER_ADDR", &saddr), fast]);
client.wait_line("STOP SERVER NOW", |l| l == "STOP SERVER NOW");
let spid = server.pid().expect("server alive") as libc::pid_t;
assert_eq!(unsafe { libc::kill(spid, libc::SIGSTOP) }, 0);
client.wait_line("DOWN stopped Disconnected", |l| {
l == "DOWN stopped Disconnected"
});
client.wait_line("KILL SERVER NOW", |l| l == "KILL SERVER NOW");
server.kill(); // SIGKILL works on a stopped process; Drop would too
client.wait_line("DUPLICATE dup=false", |l| l == "DUPLICATE dup=false");
client.wait_line("CLIENT DONE", |l| l == "CLIENT DONE");
}
+161
View File
@@ -0,0 +1,161 @@
//! RFC 010 — `Discovery::Withdrawn`: a strategy retracts a candidate and the
//! connector stops dialing it.
//!
//! Cross-process: a plain *server* node, and a *client* whose strategy is a
//! script: announce a decoy `(ghost, addr)` where `addr` is a raw
//! `TcpListener` the client itself holds (an OS thread accepts and
//! immediately closes, so every dial fails at handshake and the connector
//! keeps retrying on backoff — the accept count is the dial count); after a
//! beat, withdraw the decoy and announce the real server. The client waits
//! for the server's `node_up` — which is *after* the withdrawal in the
//! strategy's own stream — then watches the decoy's accept count stay flat
//! across a window longer than the pending backoff. Before withdrawal it
//! must have been climbing (≥ 1), or the negative proves nothing.
#![cfg(feature = "cluster")]
mod common;
use common::{maybe_child, spawn_node};
use smarm::channel::Sender;
use smarm::cluster::discovery::{Discovery, Strategy};
use smarm::cluster::envelope::NodeMeta;
use smarm::cluster::membership::{subscribe, NodeEvent};
use smarm::cluster::{start, Config, StaticSeeds, Timing};
use std::net::TcpListener;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
const ROLES: &[(&str, fn())] = &[("server", role_server), ("client", role_client)];
fn meta() -> NodeMeta {
NodeMeta {
role: "withdraw".into(),
region: "local".into(),
}
}
fn role_server() {
smarm::run(|| {
let cluster = start(Config {
node_name: "server".into(),
meta: meta(),
listen_addr: std::env::var("SMARM_LISTEN_ADDR")
.unwrap_or_else(|_| "127.0.0.1:0".into()),
strategy: Box::new(StaticSeeds::new(Vec::<(String, String)>::new())),
timing: Timing::default(),
})
.expect("binds");
println!("LISTENING {}", cluster.local_addr());
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
/// Scripted strategy: decoy, pause, withdraw decoy, real server, done.
struct Script {
decoy: String,
server: String,
}
impl Strategy for Script {
fn run(self: Box<Self>, out: Sender<Discovery>) {
let _ = out.send(Discovery::Candidate {
name: "ghost".into(),
addr: self.decoy.clone(),
});
// Long enough for the 250ms/500ms retries to land: ≥ 3 dials.
smarm::sleep(Duration::from_millis(1100));
let _ = out.send(Discovery::Withdrawn {
name: "ghost".into(),
addr: self.decoy,
});
let _ = out.send(Discovery::Candidate {
name: "server".into(),
addr: self.server,
});
}
}
fn role_client() {
let server_addr = std::env::var("SMARM_SERVER_ADDR").expect("SMARM_SERVER_ADDR");
// The decoy: accept-and-close on an OS thread; count every accept.
let decoy = TcpListener::bind("127.0.0.1:0").unwrap();
let decoy_addr = decoy.local_addr().unwrap().to_string();
let dials = Arc::new(AtomicUsize::new(0));
let counter = dials.clone();
std::thread::spawn(move || {
for conn in decoy.incoming() {
counter.fetch_add(1, Ordering::SeqCst);
drop(conn);
}
});
smarm::run(move || {
let _cluster = start(Config {
node_name: "client".into(),
meta: meta(),
listen_addr: "127.0.0.1:0".into(),
strategy: Box::new(Script {
decoy: decoy_addr,
server: server_addr,
}),
timing: Timing::default(),
})
.expect("binds");
let ev = subscribe().unwrap();
loop {
match ev.rx.recv() {
Ok(NodeEvent::NodeUp(i)) if i.name == "server" => break,
Ok(_) => continue,
Err(_) => panic!("manager gone"),
}
}
// The withdrawal preceded the server candidate in the strategy's
// stream, so it has been applied. Any dial that started before it
// is bounded by the connect+handshake deadlines; let it drain, then
// hold the count flat across a window longer than the pending
// backoff would be (1s at this point, 2s next).
let before = dials.load(Ordering::SeqCst);
smarm::sleep(Duration::from_millis(500));
let settled = dials.load(Ordering::SeqCst);
let t0 = Instant::now();
while t0.elapsed() < Duration::from_millis(3000) {
smarm::sleep(Duration::from_millis(100));
}
let after = dials.load(Ordering::SeqCst);
println!("WITHDRAWN before={before} settled={settled} after={after}");
println!("CLIENT DONE");
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
#[test]
fn withdrawn_candidate_is_no_longer_dialed() {
maybe_child(ROLES);
let mut server = spawn_node("server", &[]);
let saddr = server.wait_listening();
let mut client = spawn_node("client", &[("SMARM_SERVER_ADDR", &saddr)]);
let line = client.wait_line("WITHDRAWN", |l| l.starts_with("WITHDRAWN "));
let mut nums = line
.split_whitespace()
.skip(1)
.map(|kv| kv.split_once('=').unwrap().1.parse::<usize>().unwrap());
let (before, settled, after) = (
nums.next().unwrap(),
nums.next().unwrap(),
nums.next().unwrap(),
);
assert!(
before >= 1,
"decoy was never dialed; the negative proves nothing: {line}"
);
assert_eq!(
settled, after,
"connector kept dialing a withdrawn candidate: {line}"
);
client.wait_line("CLIENT DONE", |l| l == "CLIENT DONE");
}
+7 -2
View File
@@ -8,6 +8,7 @@ use smarm::cluster::envelope::{
decode_payload, encode_payload, DecodeError, Frame, NodeMeta, RejectReason, MAX_FRAME_LEN, decode_payload, encode_payload, DecodeError, Frame, NodeMeta, RejectReason, MAX_FRAME_LEN,
PROTO_VERSION, PROTO_VERSION,
}; };
use smarm::cluster::RemoteDownReason;
use smarm::monitor::DownReason; use smarm::monitor::DownReason;
use smarm::pg::Incarnation; use smarm::pg::Incarnation;
@@ -55,7 +56,11 @@ fn all_frames() -> Vec<Frame> {
Frame::Demonitor { monitor_id: 77 }, Frame::Demonitor { monitor_id: 77 },
Frame::Down { Frame::Down {
monitor_id: 77, monitor_id: 77,
reason: DownReason::Panic, reason: RemoteDownReason::Local(DownReason::Panic),
},
Frame::Down {
monitor_id: 78,
reason: RemoteDownReason::Disconnected,
}, },
] ]
} }
@@ -151,7 +156,7 @@ fn unknown_enum_tags() {
assert_eq!( assert_eq!(
Frame::decode(&buf), Frame::decode(&buf),
Err(DecodeError::UnknownEnumTag { Err(DecodeError::UnknownEnumTag {
what: "DownReason", what: "RemoteDownReason",
tag: 200 tag: 200
}) })
); );
+10 -19
View File
@@ -5,7 +5,7 @@
use smarm::cluster::envelope::{Frame, NodeMeta, RejectReason, PROTO_VERSION}; use smarm::cluster::envelope::{Frame, NodeMeta, RejectReason, PROTO_VERSION};
use smarm::cluster::handshake::{ use smarm::cluster::handshake::{
dial_wins, HelloCtx, Initiator, InitiatorOutcome, Local, Responder, ResponderOutcome, dial_wins, Initiator, InitiatorOutcome, Local, PeerStanding, Responder, ResponderOutcome,
}; };
use smarm::pg::Incarnation; use smarm::pg::Incarnation;
@@ -42,7 +42,7 @@ fn happy_path_establishes_both_ends() {
assert_eq!(hello, hello_from("alpha"), "initiator emits its identity"); assert_eq!(hello, hello_from("alpha"), "initiator emits its identity");
let responder = Responder::new(local("beta")); let responder = Responder::new(local("beta"));
let (reply, peer) = match responder.on_frame(hello, HelloCtx::default()) { let (reply, peer) = match responder.on_frame(hello, PeerStanding::Free) {
ResponderOutcome::Accepted { reply, peer } => (reply, peer), ResponderOutcome::Accepted { reply, peer } => (reply, peer),
other => panic!("expected Accepted, got {other:?}"), other => panic!("expected Accepted, got {other:?}"),
}; };
@@ -82,7 +82,7 @@ fn hash_mismatch_rejected() {
incarnation: Incarnation::new(7), incarnation: Incarnation::new(7),
meta: local("alpha").meta, meta: local("alpha").meta,
}; };
match responder.on_frame(hello, HelloCtx::default()) { match responder.on_frame(hello, PeerStanding::Free) {
ResponderOutcome::Rejected { reply, reason } => { ResponderOutcome::Rejected { reply, reason } => {
assert_eq!(reason, RejectReason::HashMismatch); assert_eq!(reason, RejectReason::HashMismatch);
assert_eq!(reply, Frame::HelloReject { reason }); assert_eq!(reply, Frame::HelloReject { reason });
@@ -112,7 +112,7 @@ fn proto_version_mismatch_rejected_and_checked_first() {
incarnation: Incarnation::new(7), incarnation: Incarnation::new(7),
meta: local("alpha").meta, meta: local("alpha").meta,
}; };
match responder.on_frame(hello, HelloCtx::default()) { match responder.on_frame(hello, PeerStanding::Free) {
ResponderOutcome::Rejected { reason, .. } => { ResponderOutcome::Rejected { reason, .. } => {
assert_eq!(reason, RejectReason::ProtoVersion); assert_eq!(reason, RejectReason::ProtoVersion);
} }
@@ -123,10 +123,7 @@ fn proto_version_mismatch_rejected_and_checked_first() {
#[test] #[test]
fn claimed_name_rejected() { fn claimed_name_rejected() {
let responder = Responder::new(local("beta")); let responder = Responder::new(local("beta"));
let ctx = HelloCtx { let ctx = PeerStanding::Claimed;
name_claimed: true,
dialing_this_peer: false,
};
match responder.on_frame(hello_from("alpha"), ctx) { match responder.on_frame(hello_from("alpha"), ctx) {
ResponderOutcome::Rejected { reason, .. } => { ResponderOutcome::Rejected { reason, .. } => {
assert_eq!(reason, RejectReason::NameTaken); assert_eq!(reason, RejectReason::NameTaken);
@@ -139,7 +136,7 @@ fn claimed_name_rejected() {
fn own_name_offered_rejected_as_name_taken() { fn own_name_offered_rejected_as_name_taken() {
// Self-connect or genuine collision: the responder's own name arrives. // Self-connect or genuine collision: the responder's own name arrives.
let responder = Responder::new(local("beta")); let responder = Responder::new(local("beta"));
match responder.on_frame(hello_from("beta"), HelloCtx::default()) { match responder.on_frame(hello_from("beta"), PeerStanding::Free) {
ResponderOutcome::Rejected { reason, .. } => { ResponderOutcome::Rejected { reason, .. } => {
assert_eq!(reason, RejectReason::NameTaken); assert_eq!(reason, RejectReason::NameTaken);
} }
@@ -158,10 +155,7 @@ fn hash_checked_before_name() {
incarnation: Incarnation::new(7), incarnation: Incarnation::new(7),
meta: local("alpha").meta, meta: local("alpha").meta,
}; };
let ctx = HelloCtx { let ctx = PeerStanding::Claimed;
name_claimed: true,
dialing_this_peer: false,
};
match responder.on_frame(hello, ctx) { match responder.on_frame(hello, ctx) {
ResponderOutcome::Rejected { reason, .. } => { ResponderOutcome::Rejected { reason, .. } => {
assert_eq!(reason, RejectReason::HashMismatch); assert_eq!(reason, RejectReason::HashMismatch);
@@ -184,10 +178,7 @@ fn dial_wins_is_deterministic_and_antisymmetric() {
fn simultaneous_connect_exactly_one_side_accepts() { fn simultaneous_connect_exactly_one_side_accepts() {
// alpha and beta dial each other at once. Each responder sees the peer's // alpha and beta dial each other at once. Each responder sees the peer's
// Hello while its own dial is in flight. // Hello while its own dial is in flight.
let ctx = HelloCtx { let ctx = PeerStanding::Dialing;
name_claimed: false,
dialing_this_peer: true,
};
// On beta: inbound is alpha's dial; alpha < beta, so the inbound wins. // On beta: inbound is alpha's dial; alpha < beta, so the inbound wins.
let on_beta = Responder::new(local("beta")).on_frame(hello_from("alpha"), ctx); let on_beta = Responder::new(local("beta")).on_frame(hello_from("alpha"), ctx);
@@ -208,7 +199,7 @@ fn simultaneous_connect_exactly_one_side_accepts() {
#[test] #[test]
fn tiebreak_loss_only_applies_when_dialing() { fn tiebreak_loss_only_applies_when_dialing() {
// Same inbound Hello, no dial in flight: plain accept. // Same inbound Hello, no dial in flight: plain accept.
let on_alpha = Responder::new(local("alpha")).on_frame(hello_from("beta"), HelloCtx::default()); let on_alpha = Responder::new(local("alpha")).on_frame(hello_from("beta"), PeerStanding::Free);
assert!(matches!(on_alpha, ResponderOutcome::Accepted { .. })); assert!(matches!(on_alpha, ResponderOutcome::Accepted { .. }));
} }
@@ -225,7 +216,7 @@ fn garbage_before_hello_fails_without_reply() {
}, },
Frame::Demonitor { monitor_id: 3 }, Frame::Demonitor { monitor_id: 3 },
] { ] {
let out = Responder::new(local("beta")).on_frame(frame.clone(), HelloCtx::default()); let out = Responder::new(local("beta")).on_frame(frame.clone(), PeerStanding::Free);
match out { match out {
ResponderOutcome::Failed(f) => assert_eq!(f, frame), ResponderOutcome::Failed(f) => assert_eq!(f, frame),
other => panic!("expected Failed({frame:?}), got {other:?}"), other => panic!("expected Failed({frame:?}), got {other:?}"),
+27 -18
View File
@@ -18,6 +18,7 @@ use smarm::cluster::membership::{subscribe, view, MembershipEvents, NodeEvent};
use smarm::cluster::spawn_established; use smarm::cluster::spawn_established;
use smarm::cluster::transport::tcp::TcpTransport; use smarm::cluster::transport::tcp::TcpTransport;
use smarm::cluster::transport::{Conn, FramedConn, Transport}; use smarm::cluster::transport::{Conn, FramedConn, Transport};
use smarm::cluster::Timing;
use smarm::gen_server::{self, GenServerBuilder}; use smarm::gen_server::{self, GenServerBuilder};
use smarm::pg::{Incarnation, NodeId}; use smarm::pg::{Incarnation, NodeId};
use smarm::run; use smarm::run;
@@ -85,8 +86,10 @@ fn subscriber_sees_up_and_down() {
let t = TcpTransport; let t = TcpTransport;
let (a1, b1) = pair(&t); let (a1, b1) = pair(&t);
let (a2, b2) = pair(&t); let (a2, b2) = pair(&t);
spawn_established(FramedConn::new(a1), peer("node-b", 1)).expect("node-b registers"); spawn_established(FramedConn::new(a1), peer("node-b", 1), Timing::default())
spawn_established(FramedConn::new(a2), peer("node-c", 1)).expect("node-c registers"); .expect("node-b registers");
spawn_established(FramedConn::new(a2), peer("node-c", 1), Timing::default())
.expect("node-c registers");
let up_b = match next_event(&ev, "node_up(node-b)") { let up_b = match next_event(&ev, "node_up(node-b)") {
NodeEvent::NodeUp(info) => { NodeEvent::NodeUp(info) => {
@@ -110,14 +113,14 @@ fn subscriber_sees_up_and_down() {
disconnect("node-b"); disconnect("node-b");
assert_eq!( assert_eq!(
next_event(&ev, "node_down(node-b)"), next_event(&ev, "node_down(node-b)"),
NodeEvent::NodeDown { node: up_b.node } NodeEvent::NodeDown(up_b.clone())
); );
// Peer EOF, no command: down with node-c's id. // Peer EOF, no command: down with node-c's id.
drop(b2); drop(b2);
assert_eq!( assert_eq!(
next_event(&ev, "node_down(node-c)"), next_event(&ev, "node_down(node-c)"),
NodeEvent::NodeDown { node: up_c.node } NodeEvent::NodeDown(up_c.clone())
); );
assert_quiet(&ev); assert_quiet(&ev);
@@ -140,8 +143,10 @@ fn late_subscriber_gets_snapshot_and_view_agrees() {
let t = TcpTransport; let t = TcpTransport;
let (a1, b1) = pair(&t); let (a1, b1) = pair(&t);
let (a2, b2) = pair(&t); let (a2, b2) = pair(&t);
spawn_established(FramedConn::new(a1), peer("node-b", 1)).expect("node-b registers"); spawn_established(FramedConn::new(a1), peer("node-b", 1), Timing::default())
spawn_established(FramedConn::new(a2), peer("node-c", 1)).expect("node-c registers"); .expect("node-b registers");
spawn_established(FramedConn::new(a2), peer("node-c", 1), Timing::default())
.expect("node-c registers");
let ev = subscribe().expect("manager is up"); let ev = subscribe().expect("manager is up");
let mut names = Vec::new(); let mut names = Vec::new();
@@ -190,20 +195,25 @@ fn restart_gets_new_id_blip_keeps_id() {
other => panic!("expected node_up ({what}), got {other:?}"), other => panic!("expected node_up ({what}), got {other:?}"),
} }
}; };
let down_id = |e: NodeEvent, what: &str| -> NodeId {
match e {
NodeEvent::NodeDown(info) => info.node,
other => panic!("expected node_down ({what}), got {other:?}"),
}
};
// Up at incarnation 1, then the peer dies (EOF). // Up at incarnation 1, then the peer dies (EOF).
let (a1, b1) = pair(&t); let (a1, b1) = pair(&t);
spawn_established(FramedConn::new(a1), peer("node-b", 1)).expect("registers"); spawn_established(FramedConn::new(a1), peer("node-b", 1), Timing::default())
.expect("registers");
let id1 = id(next_event(&ev, "node_up inc 1"), "inc 1"); let id1 = id(next_event(&ev, "node_up inc 1"), "inc 1");
drop(b1); drop(b1);
assert_eq!( assert_eq!(down_id(next_event(&ev, "node_down inc 1"), "inc 1"), id1);
next_event(&ev, "node_down inc 1"),
NodeEvent::NodeDown { node: id1 }
);
// Restart: new incarnation, new id — the ghost's id is not reused. // Restart: new incarnation, new id — the ghost's id is not reused.
let (a2, b2) = pair(&t); let (a2, b2) = pair(&t);
spawn_established(FramedConn::new(a2), peer("node-b", 2)).expect("registers"); spawn_established(FramedConn::new(a2), peer("node-b", 2), Timing::default())
.expect("registers");
let id2 = id(next_event(&ev, "node_up inc 2"), "inc 2"); let id2 = id(next_event(&ev, "node_up inc 2"), "inc 2");
assert_ne!( assert_ne!(
id1, id2, id1, id2,
@@ -212,12 +222,10 @@ fn restart_gets_new_id_blip_keeps_id() {
// Blip: the same incarnation reconnects and keeps its id. // Blip: the same incarnation reconnects and keeps its id.
disconnect("node-b"); disconnect("node-b");
assert_eq!( assert_eq!(down_id(next_event(&ev, "node_down inc 2"), "inc 2"), id2);
next_event(&ev, "node_down inc 2"),
NodeEvent::NodeDown { node: id2 }
);
let (a3, b3) = pair(&t); let (a3, b3) = pair(&t);
spawn_established(FramedConn::new(a3), peer("node-b", 2)).expect("registers"); spawn_established(FramedConn::new(a3), peer("node-b", 2), Timing::default())
.expect("registers");
let id3 = id(next_event(&ev, "node_up after blip"), "blip"); let id3 = id(next_event(&ev, "node_up after blip"), "blip");
assert_eq!( assert_eq!(
id2, id3, id2, id3,
@@ -247,7 +255,8 @@ fn dead_subscriber_is_pruned() {
let t = TcpTransport; let t = TcpTransport;
let (a1, b1) = pair(&t); let (a1, b1) = pair(&t);
spawn_established(FramedConn::new(a1), peer("node-b", 1)).expect("registers"); spawn_established(FramedConn::new(a1), peer("node-b", 1), Timing::default())
.expect("registers");
match next_event(&live, "node_up despite a dead co-subscriber") { match next_event(&live, "node_up despite a dead co-subscriber") {
NodeEvent::NodeUp(info) => assert_eq!(info.name, "node-b"), NodeEvent::NodeUp(info) => assert_eq!(info.name, "node-b"),
other => panic!("expected node_up, got {other:?}"), other => panic!("expected node_up, got {other:?}"),
+4 -8
View File
@@ -24,9 +24,7 @@ mod common;
use common::{maybe_child, spawn_node, Node}; use common::{maybe_child, spawn_node, Node};
use smarm::cluster::envelope::NodeMeta; use smarm::cluster::envelope::NodeMeta;
use smarm::cluster::membership::{subscribe, NodeEvent}; use smarm::cluster::membership::{subscribe, NodeEvent};
use smarm::cluster::{start, Config, StaticSeeds}; use smarm::cluster::{start, Config, StaticSeeds, Timing};
use smarm::pg::NodeId;
use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
const ROLES: &[(&str, fn())] = &[("node", role_node)]; const ROLES: &[(&str, fn())] = &[("node", role_node)];
@@ -56,21 +54,19 @@ fn role_node() {
}, },
listen_addr: listen, listen_addr: listen,
strategy: Box::new(StaticSeeds::new(seeds)), strategy: Box::new(StaticSeeds::new(seeds)),
timing: Timing::default(),
}) })
.expect("listener binds"); .expect("listener binds");
println!("LISTENING {}", cluster.local_addr()); println!("LISTENING {}", cluster.local_addr());
let events = subscribe().expect("manager is up"); let events = subscribe().expect("manager is up");
let mut names: HashMap<NodeId, String> = HashMap::new();
loop { loop {
match events.rx.recv() { match events.rx.recv() {
Ok(NodeEvent::NodeUp(info)) => { Ok(NodeEvent::NodeUp(info)) => {
names.insert(info.node, info.name.clone());
println!("MEMBER-UP {} inc={}", info.name, info.incarnation.get()); println!("MEMBER-UP {} inc={}", info.name, info.incarnation.get());
} }
Ok(NodeEvent::NodeDown { node }) => { Ok(NodeEvent::NodeDown(info)) => {
let name = names.remove(&node).unwrap_or_else(|| "?".to_string()); println!("MEMBER-DOWN {}", info.name);
println!("MEMBER-DOWN {name}");
} }
Err(_) => break, // manager gone; park below regardless Err(_) => break, // manager gone; park below regardless
} }
+359
View File
@@ -0,0 +1,359 @@
//! RFC 010 c12 — remote monitors.
//!
//! Local suite (`run()`, no network): the immediate answers — no connection
//! ⇒ `Disconnected`, dead incarnation ⇒ `NoProc` — and the self-node
//! collapse (a plain local monitor underneath, incl. `demonitor_remote`).
//!
//! Cross-process: a *server* exposes a control name and spawns workers on
//! request, replying with each worker's pid (via `RemotePid::from_local`,
//! the D12 set-site) or, for the deliberately unshipped one, only its raw
//! slot numbers. The *client* monitors them and asserts: kill ⇒ the true
//! reason (Exit / Panic); a corpse ⇒ its recorded terminal reason, not
//! NoProc; a live pid that never crossed the wire ⇒ NoProc (no liveness
//! leak); a demonitor racing the kill ⇒ no notice, proven by stream ORDER
//! (a later notice on the same connection arrives while the earlier slot
//! is still empty), not by sleeping.
#![cfg(feature = "cluster")]
mod common;
use common::{maybe_child, spawn_node};
use smarm::cluster::envelope::NodeMeta;
use smarm::cluster::expose::{expose, expose_type};
use smarm::cluster::membership::{subscribe, NodeEvent};
use smarm::cluster::remote::{
self, demonitor_remote, monitor_remote, send_to_remote, RemoteName, RemotePid,
};
use smarm::cluster::{start, Config, RemoteDownReason, StaticSeeds, Timing};
use smarm::pg::Incarnation;
use smarm::{channel, install, register, run, spawn, Addressable, DownReason, Erased, Name, Pid};
use std::collections::HashMap;
use std::time::Duration;
// ---- message types (hand-rolled serde; the crate is derive-less) ---------
#[derive(Debug)]
struct Ctl {
cmd: String,
reply_to: RemotePid<Client>,
}
#[derive(Debug)]
struct Answer {
text: String,
pid: Option<RemotePid<Erased>>,
}
struct Client;
impl Addressable for Client {
type Msg = Answer;
}
impl serde::Serialize for Ctl {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeTuple;
let mut t = s.serialize_tuple(2)?;
t.serialize_element(&self.cmd)?;
t.serialize_element(&self.reply_to)?;
t.end()
}
}
impl<'de> serde::Deserialize<'de> for Ctl {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let (cmd, reply_to) = <(String, RemotePid<Client>)>::deserialize(d)?;
Ok(Ctl { cmd, reply_to })
}
}
impl serde::Serialize for Answer {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeTuple;
let mut t = s.serialize_tuple(2)?;
t.serialize_element(&self.text)?;
t.serialize_element(&self.pid)?;
t.end()
}
}
impl<'de> serde::Deserialize<'de> for Answer {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let (text, pid) = <(String, Option<RemotePid<Erased>>)>::deserialize(d)?;
Ok(Answer { text, pid })
}
}
// ================= local suite =========================================
/// No connection to the pid's node: `Disconnected` at once — the remote
/// analog of NoProc, and the first thing c11's variant is for.
#[test]
fn unconnected_node_is_disconnected_immediately() {
maybe_child(ROLES);
run(|| {
remote::set_local_identity("me", Incarnation::new(7));
let ghost = RemotePid::<Erased>::from_parts("nowhere", Incarnation::new(1), 3, 1);
let m = monitor_remote(ghost.clone());
let d = m.recv().unwrap();
assert_eq!(d.pid, ghost);
assert_eq!(d.reason, RemoteDownReason::Disconnected);
});
}
/// The node is connected but the pid names an earlier incarnation: the
/// actor is a known corpse (RFC v2 §3), so `NoProc` at once — never
/// `Disconnected`, nothing on the wire.
#[test]
fn dead_incarnation_is_noproc_immediately() {
maybe_child(ROLES);
run(|| {
remote::set_local_identity("me", Incarnation::new(7));
let (probe_tx, probe_rx) = channel();
remote::bind_outbound_probe("peer", Incarnation::new(5), probe_tx);
let stale = RemotePid::<Erased>::from_parts("peer", Incarnation::new(4), 9, 1);
let m = monitor_remote(stale);
assert_eq!(m.recv().unwrap().reason, DownReason::NoProc.into());
assert!(probe_rx.try_recv().unwrap().is_none(), "no frame emitted");
});
}
/// A self-node pid collapses to an ordinary local monitor: the true reason
/// on exit, and `demonitor_remote` cancels it.
#[test]
fn self_node_pid_collapses_to_local_monitor() {
maybe_child(ROLES);
run(|| {
remote::set_local_identity("me", Incarnation::new(7));
let (go_tx, go_rx) = channel::<()>();
let (go2_tx, go2_rx) = channel::<()>();
let a = spawn(move || {
let _ = go_rx.recv();
})
.pid();
let b = spawn(move || {
let _ = go2_rx.recv();
})
.pid();
let ma = monitor_remote(RemotePid::from_local(a).expect("identity set"));
let mb = monitor_remote(RemotePid::from_local(b).expect("identity set"));
assert_ne!(ma.id, mb.id);
assert!(ma.target.local() == Some(a));
demonitor_remote(&mb);
go2_tx.send(()).unwrap();
go_tx.send(()).unwrap();
let d = ma.recv().unwrap();
assert_eq!(d.reason, DownReason::Exit.into());
assert_eq!(d.pid.local(), Some(a));
// `a` is down (its notice arrived), and `b` was killed first on the
// same scheduler — a notice for `b` would be here by now. After a
// demonitor the channel is closed-empty (`Err`), like the local one.
assert!(matches!(mb.try_recv(), Ok(None) | Err(_)));
});
}
// ================= cross-process ======================================
const ROLES: &[(&str, fn())] = &[("server", role_server), ("client", role_client)];
const CTL: Name<Ctl> = Name::new("c12.ctl");
fn cfg(name: &str, seeds: Vec<(String, String)>) -> Config {
Config {
node_name: name.to_string(),
meta: NodeMeta {
role: "c12".into(),
region: "local".into(),
},
listen_addr: std::env::var("SMARM_LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:0".into()),
strategy: Box::new(StaticSeeds::new(seeds)),
timing: Timing::default(),
}
}
fn wait_up(events: &smarm::cluster::membership::MembershipEvents, who: &str) {
loop {
match events.rx.recv() {
Ok(NodeEvent::NodeUp(i)) if i.name == who => return,
Ok(_) => continue,
Err(_) => panic!("manager gone"),
}
}
}
/// Server commands (all answered to `reply_to`):
/// - `spawn:exit` / `spawn:panic` — a parked worker; `kill:<index>` releases
/// it, whereupon it returns / panics. Answer carries its pid.
/// - `spawn:corpse` — a worker that has already exited when the answer is
/// sent; the pid was shipped (watchable) before it died.
/// - `spawn:unwatched` — a parked worker whose pid is NEVER shipped; the
/// answer carries only `text = "slot:<index>:<generation>"`.
fn role_server() {
smarm::run(move || {
let cluster = start(cfg("server", vec![])).expect("binds");
println!("LISTENING {}", cluster.local_addr());
let (tx, rx) = channel::<Ctl>();
register(CTL, tx).unwrap();
expose(CTL);
println!("READY");
let mut workers: HashMap<u32, smarm::channel::Sender<()>> = HashMap::new();
loop {
let ctl = rx.recv().unwrap();
println!("CTL {}", ctl.cmd);
let (text, pid): (String, Option<RemotePid<Erased>>) = match ctl.cmd.as_str() {
"spawn:exit" | "spawn:panic" => {
let panic = ctl.cmd == "spawn:panic";
let (go_tx, go_rx) = channel::<()>();
let p: Pid = spawn(move || {
let _ = go_rx.recv();
if panic {
panic!("worker asked to panic");
}
})
.pid();
workers.insert(p.index(), go_tx);
(
"ok".into(),
Some(RemotePid::from_local(p).expect("identity set")),
)
}
"spawn:corpse" => {
let p: Pid = spawn(|| {}).pid();
let rp = RemotePid::from_local(p).expect("identity set"); // shipped ⇒ watchable
let m = smarm::monitor(p);
let _ = m.rx.recv(); // dead before the answer goes out
("ok".into(), Some(rp))
}
"spawn:unwatched" => {
let (go_tx, go_rx) = channel::<()>();
let p: Pid = spawn(move || {
let _ = go_rx.recv();
})
.pid();
workers.insert(p.index(), go_tx);
(format!("slot:{}:{}", p.index(), p.generation()), None)
}
other => {
let idx: u32 = other.strip_prefix("kill:").unwrap().parse().unwrap();
if let Some(go) = workers.remove(&idx) {
let _ = go.send(());
}
("killed".into(), None)
}
};
send_to_remote(ctl.reply_to, Answer { text, pid }).unwrap();
}
});
}
fn role_client() {
let server_addr = std::env::var("SMARM_SERVER_ADDR").expect("SMARM_SERVER_ADDR");
smarm::run(move || {
let _cluster = start(cfg("client", vec![("server".into(), server_addr)])).expect("binds");
let ev = subscribe().unwrap();
wait_up(&ev, "server");
let (tx, rx) = channel::<Answer>();
let me: Pid<Client> = install::<Client>(tx);
expose_type::<Answer>();
let ask = |cmd: &str| -> Answer {
remote::send(
RemoteName::new("server", CTL),
Ctl {
cmd: cmd.into(),
reply_to: RemotePid::from_local(me).expect("identity set"),
},
)
.unwrap();
rx.recv().unwrap()
};
let server_inc = ev_incarnation();
// 1. kill ⇒ true reason (Exit).
let a = ask("spawn:exit").pid.unwrap();
let ma = monitor_remote(a.clone());
ask(&format!("kill:{}", a.index()));
let d = ma.recv().unwrap();
assert_eq!(d.pid, a);
println!("DOWN exit {:?}", d.reason);
// 2. kill ⇒ true reason (Panic).
let b = ask("spawn:panic").pid.unwrap();
let mb = monitor_remote(b.clone());
ask(&format!("kill:{}", b.index()));
println!("DOWN panic {:?}", mb.recv().unwrap().reason);
// 3. corpse ⇒ recorded terminal reason, not NoProc.
let c = ask("spawn:corpse").pid.unwrap();
println!("DOWN corpse {:?}", monitor_remote(c).recv().unwrap().reason);
// 4. live but never shipped/exposed ⇒ NoProc (no leak); a made-up
// slot on the same node ⇒ NoProc too, indistinguishably.
let ans = ask("spawn:unwatched");
let mut it = ans.text.strip_prefix("slot:").unwrap().split(':');
let (idx, gen): (u32, u32) = (
it.next().unwrap().parse().unwrap(),
it.next().unwrap().parse().unwrap(),
);
let hidden = RemotePid::<Erased>::from_parts("server", server_inc, idx, gen);
println!(
"DOWN hidden {:?}",
monitor_remote(hidden).recv().unwrap().reason
);
let bogus = RemotePid::<Erased>::from_parts("server", server_inc, 100_000, 1);
println!(
"DOWN bogus {:?}",
monitor_remote(bogus).recv().unwrap().reason
);
// 5. demonitor races the kill: no notice for `d1`, proven by order —
// `d2`'s notice (same connection, later) arrives while `d1`'s
// slot is still empty.
let d1 = ask("spawn:exit").pid.unwrap();
let m1 = monitor_remote(d1.clone());
demonitor_remote(&m1);
ask(&format!("kill:{}", d1.index()));
let d2 = ask("spawn:exit").pid.unwrap();
let m2 = monitor_remote(d2.clone());
ask(&format!("kill:{}", d2.index()));
assert_eq!(m2.recv().unwrap().reason, DownReason::Exit.into());
// Closed-empty (`Err`) or open-empty (`Ok(None)`) both mean no notice.
let stray = matches!(m1.try_recv(), Ok(Some(_)));
println!("DEMONITOR stray={stray}");
println!("CLIENT DONE");
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
/// The server's incarnation as this node sees it — for building pids by hand.
fn ev_incarnation() -> Incarnation {
smarm::cluster::membership::view()
.expect("manager up")
.into_iter()
.find(|i| i.name == "server")
.map(|i| i.incarnation)
.expect("server in view")
}
/// The Phase 4 c12 gate: remote monitors report the true reason, honour
/// corpses, leak nothing for unshipped pids, and cancel cleanly.
#[test]
fn remote_monitors_report_true_reasons() {
maybe_child(ROLES);
let mut server = spawn_node("server", &[]);
let saddr = server.wait_listening();
server.wait_line("READY", |l| l == "READY");
let mut client = spawn_node("client", &[("SMARM_SERVER_ADDR", &saddr)]);
client.wait_line("DOWN exit Local(Exit)", |l| l == "DOWN exit Local(Exit)");
client.wait_line("DOWN panic Local(Panic)", |l| {
l == "DOWN panic Local(Panic)"
});
client.wait_line("DOWN corpse Local(Exit)", |l| {
l == "DOWN corpse Local(Exit)"
});
client.wait_line("DOWN hidden Local(NoProc)", |l| {
l == "DOWN hidden Local(NoProc)"
});
client.wait_line("DOWN bogus Local(NoProc)", |l| {
l == "DOWN bogus Local(NoProc)"
});
client.wait_line("DEMONITOR stray=false", |l| l == "DEMONITOR stray=false");
client.wait_line("CLIENT DONE", |l| l == "CLIENT DONE");
}
+254
View File
@@ -0,0 +1,254 @@
//! RFC 010 c15 — distributed pg: sync on `NodeUp`, incremental
//! `Join`/`Leave`, eager eviction announced, `NodeDown` sweep.
//!
//! Two nodes. The *origin* joins two local workers to `"pool"` before the
//! *observer* connects (so the observer's view comes from `Sync`), exposes a
//! `"go"` command inbox and then does exactly what the observer tells it:
//! kill one worker, join a third, leave with the second. The observer drives
//! that script through the cluster itself and asserts every step from
//! `members_all` — never touching the group on its own side, except once to
//! prove a mixed local+remote group reads correctly and that `members` stays
//! local. `dispatch_any` is exercised both ways: into the origin's worker
//! (remote pick, `send_to_remote`) and, once the origin is gone, into the
//! observer's own (local pick, `send_to`). Finally the parent SIGKILLs the
//! origin: the observer must sweep every remote member on `NodeDown`.
#![cfg(feature = "cluster")]
mod common;
use common::{maybe_child, spawn_node};
use smarm::cluster::envelope::NodeMeta;
use smarm::cluster::expose::{expose, expose_type};
use smarm::cluster::membership::{subscribe, NodeEvent};
use smarm::cluster::remote::{self, RemoteName};
use smarm::cluster::{
dispatch_any, members_all, pick_any, start, Config, DispatchAnyError, GroupMember, StaticSeeds,
Timing,
};
use smarm::{channel, join, leave, members, register, send_to, spawn_addr, Addressable, Name, Pid};
use std::time::{Duration, Instant};
const GO: Name<u8> = Name::new("go");
const POOL: &str = "pool";
/// A pool worker's message: `"die"` stops it, anything else is printed.
#[derive(Debug, PartialEq)]
struct Job(String);
struct Worker;
impl Addressable for Worker {
type Msg = Job;
}
impl serde::Serialize for Job {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de> serde::Deserialize<'de> for Job {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
String::deserialize(d).map(Job)
}
}
const ROLES: &[(&str, fn())] = &[("origin", role_origin), ("observer", role_observer)];
fn cfg(name: &str, seeds: Vec<(String, String)>) -> Config {
Config {
node_name: name.into(),
meta: NodeMeta {
role: "c15".into(),
region: "local".into(),
},
listen_addr: "127.0.0.1:0".into(),
strategy: Box::new(StaticSeeds::new(seeds)),
timing: Timing::default(),
}
}
/// A pool worker: prints every job it is handed, exits on `"die"`.
fn worker() -> Pid<Worker> {
spawn_addr::<Worker>(|rx| {
while let Ok(Job(s)) = rx.recv() {
if s == "die" {
return;
}
println!("JOB {s}");
}
})
}
fn role_origin() {
smarm::run(|| {
let cluster = start(cfg("origin", vec![])).expect("binds");
// Remote dispatch lands here only for a type this node accepts.
expose_type::<Job>();
let w1 = worker();
let w2 = worker();
assert!(join(POOL, w1));
assert!(join(POOL, w2));
let (go_tx, go_rx) = channel::<u8>();
register(GO, go_tx).unwrap();
expose(GO);
println!("LISTENING {}", cluster.local_addr());
println!("JOINED 2");
loop {
match go_rx.recv().unwrap() {
1 => {
send_to(w1, Job("die".into())).unwrap();
println!("KILLED w1");
}
2 => {
assert!(leave(POOL, w2));
println!("LEFT w2");
}
3 => {
let w3 = worker();
assert!(join(POOL, w3));
println!("JOINED w3");
}
n => panic!("unknown command {n}"),
}
}
});
}
fn remote_count(group: &str) -> usize {
members_all(group)
.iter()
.filter(|m| matches!(m, GroupMember::Remote(_)))
.count()
}
/// Cooperative poll until `pred`; panics (with the last view) on timeout.
fn wait_view(what: &str, group: &str, pred: impl Fn(&[GroupMember]) -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let v = members_all(group);
if pred(&v) {
return;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {what}; view = {v:?}"
);
smarm::sleep(Duration::from_millis(5));
}
}
fn role_observer() {
let origin_addr = std::env::var("SMARM_ORIGIN_ADDR").expect("SMARM_ORIGIN_ADDR");
smarm::run(move || {
let _cluster = start(cfg("observer", vec![("origin".into(), origin_addr)])).expect("binds");
let ev = subscribe().unwrap();
loop {
match ev.rx.recv().unwrap() {
NodeEvent::NodeUp(i) if i.name == "origin" => break,
_ => {}
}
}
let go = |n: u8| remote::send(RemoteName::new("origin", GO), n).unwrap();
// Sync: both pre-existing members arrive with no join on this side.
wait_view("sync of 2 remote members", POOL, |v| {
v.len() == 2 && v.iter().all(|m| matches!(m, GroupMember::Remote(_)))
});
let synced = members_all(POOL);
assert!(synced.iter().all(|m| match m {
GroupMember::Remote(p) => p.node() == "origin",
GroupMember::Local(_) => false,
}));
println!("SEES 2");
// Origin-side death: the origin's reaper announces the leave.
go(1);
wait_view("death evicted on observer", POOL, |v| v.len() == 1);
println!("SEES 1 after death");
// Incremental Join.
go(3);
wait_view("incremental join", POOL, |v| v.len() == 2);
println!("SEES 2 after join");
// Voluntary Leave.
go(2);
wait_view("incremental leave", POOL, |v| v.len() == 1);
println!("SEES 1 after leave");
// Mixed group: our own member sits beside the remote one in
// `members_all`; `members` stays local-only.
let me = worker();
assert!(join(POOL, me));
wait_view("mixed local+remote", POOL, |v| {
v.len() == 2 && v.contains(&GroupMember::Local(me.erase()))
});
assert_eq!(
members(POOL),
vec![me.erase()],
"local API never shows remotes"
);
assert_eq!(remote_count(POOL), 1);
println!("MIXED ok");
// dispatch_any: the store's first entry is the origin's w3 (it was
// announced before we joined), so the pick is remote and the job
// crosses the wire — the origin's worker prints it.
let picked = pick_any(POOL).expect("pool has members");
assert!(
matches!(picked, GroupMember::Remote(_)),
"first entry is remote: {picked:?}"
);
let reached = dispatch_any::<Worker>(POOL, Job("from-observer".into())).unwrap();
assert_eq!(reached, picked);
println!("DISPATCHED remote");
println!("PARK");
// Parent SIGKILLs the origin now: NodeDown must sweep its member,
// ours must survive.
wait_view("node_down sweep", POOL, |v| {
v == [GroupMember::Local(me.erase())]
});
assert_eq!(members(POOL), vec![me.erase()]);
println!("SWEPT");
// Now the only member is ours: a local pick, a local send.
let reached = dispatch_any::<Worker>(POOL, Job("local".into())).unwrap();
assert_eq!(reached, GroupMember::Local(me.erase()));
// And an empty group hands the message back.
match dispatch_any::<Worker>("nobody", Job("lost".into())) {
Err(DispatchAnyError::NoMember(Job(s))) => assert_eq!(s, "lost"),
other => panic!("expected NoMember, got {other:?}"),
}
println!("DISPATCHED local");
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
/// The Phase 5 gate: sync, join, leave, death, node_down — all observed from
/// the peer, none of them a group operation on the peer — plus dispatch_any
/// reaching a remote member and a local one.
#[test]
fn groups_span_two_nodes() {
maybe_child(ROLES);
let mut origin = spawn_node("origin", &[]);
let addr = origin.wait_listening();
origin.wait_line("JOINED 2", |l| l == "JOINED 2");
let mut observer = spawn_node("observer", &[("SMARM_ORIGIN_ADDR", &addr)]);
observer.wait_line("SEES 2", |l| l == "SEES 2");
origin.wait_line("KILLED w1", |l| l == "KILLED w1");
observer.wait_line("SEES 1 after death", |l| l == "SEES 1 after death");
origin.wait_line("JOINED w3", |l| l == "JOINED w3");
observer.wait_line("SEES 2 after join", |l| l == "SEES 2 after join");
origin.wait_line("LEFT w2", |l| l == "LEFT w2");
observer.wait_line("SEES 1 after leave", |l| l == "SEES 1 after leave");
observer.wait_line("MIXED ok", |l| l == "MIXED ok");
observer.wait_line("DISPATCHED remote", |l| l == "DISPATCHED remote");
origin.wait_line("JOB from-observer", |l| l == "JOB from-observer");
observer.wait_line("PARK", |l| l == "PARK");
origin.kill();
observer.wait_line("SWEPT", |l| l == "SWEPT");
// Order between the root's line and the worker's is scheduling; wait
// for the later one to be certain both happened.
observer.wait_line("DISPATCHED local", |l| l == "DISPATCHED local");
observer.wait_line("JOB local", |l| l == "JOB local");
}
+356
View File
@@ -0,0 +1,356 @@
//! RFC 010 c10 — pid targeting + auto-serialization. The Phase 3 gate:
//! cross-node call/reply with no ceremony, under the subprocess harness.
//!
//! Local suite (`run()`, no network): serialize/deserialize shapes,
//! self-collapse, the outside-runtime contract, the local send-site
//! incarnation check with a probe proving **no frame is emitted**.
//!
//! Cross-process: two nodes. The *server* exposes a `Name<Req>`; the
//! *client* sends a `Req` carrying its own `Pid<Reply>` (auto-serialized to
//! a `RemotePid` on the wire); the server replies via `send_to_remote`
//! straight back to that pid — no name at the client end, no ceremony. A
//! third-node roundtrip: the client's pid travels client→server→relay→
//! server→client, and still delivers.
#![cfg(feature = "cluster")]
mod common;
use common::{maybe_child, spawn_node};
use smarm::cluster::envelope::{encode_payload, Frame, NodeMeta};
use smarm::cluster::expose::{expose, type_hash};
use smarm::cluster::membership::{subscribe, NodeEvent};
use smarm::cluster::remote::{self, send_to_remote, RemoteName, RemotePid, ToRemoteError};
use smarm::cluster::{start, Config, StaticSeeds, Timing};
use smarm::pg::Incarnation;
use smarm::{channel, install, register, run, Addressable, Name, Pid};
use std::time::Duration;
// ---- message types (std-only payloads; the crate's serde is derive-less,
// so wire types are hand-rolled with serde's tuple/seq API via `serde::ser`
// impls below — the same thing a user's derive would generate) ------------
/// A request carrying a reply-to. Serialize/Deserialize are written by hand
/// here for exactly one reason: this crate deliberately does not pull in
/// serde-derive. Field 1 is the auto-serializing pid.
#[derive(Debug, PartialEq)]
struct Req {
text: String,
reply_to: RemotePid<Replier>,
}
#[derive(Debug, PartialEq)]
struct Reply(String);
struct Replier;
impl Addressable for Replier {
type Msg = Reply;
}
impl serde::Serialize for Req {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeTuple;
let mut t = s.serialize_tuple(2)?;
t.serialize_element(&self.text)?;
t.serialize_element(&self.reply_to)?;
t.end()
}
}
impl<'de> serde::Deserialize<'de> for Req {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let (text, reply_to) = <(String, RemotePid<Replier>)>::deserialize(d)?;
Ok(Req { text, reply_to })
}
}
impl serde::Serialize for Reply {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de> serde::Deserialize<'de> for Reply {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
String::deserialize(d).map(Reply)
}
}
// ================= local suite =========================================
/// A local `Pid<A>` serializes as a `RemotePid<A>` stamped with this node's
/// identity; deserializing it back on the same node collapses to the same
/// local pid (`local()` is `Some`, `Pid` round-trips).
#[test]
fn local_pid_serializes_and_collapses_on_self() {
maybe_child(ROLES);
run(|| {
// The local identity is set by cluster::start; the local suite sets
// it directly.
remote::set_local_identity("me", Incarnation::new(7));
let (tx, _rx) = channel::<Reply>();
let me: Pid<Replier> = install::<Replier>(tx);
let bytes = encode_payload(&me).unwrap();
let rp: RemotePid<Replier> = smarm::cluster::envelope::decode_payload(&bytes).unwrap();
assert_eq!(rp.node(), "me");
assert_eq!(rp.incarnation(), Incarnation::new(7));
assert_eq!(
rp.local(),
Some(me),
"self-node pid collapses to the local pid"
);
// Deserializing straight into Pid<A> works for a self-node pid...
let back: Pid<Replier> = smarm::cluster::envelope::decode_payload(&bytes).unwrap();
assert_eq!(back, me);
// ...and FAILS for a foreign one (collapse is literal: node == self).
let foreign = RemotePid::<Replier>::from_parts("elsewhere", Incarnation::new(1), 3, 1);
let fbytes = encode_payload(&foreign).unwrap();
assert!(smarm::cluster::envelope::decode_payload::<Pid<Replier>>(&fbytes).is_err());
assert_eq!(foreign.local(), None);
});
}
/// `send_to_remote` short-circuits locally for a self-node pid — the
/// zero-copy-equivalent collapse: the message object itself lands in the
/// local channel, no encode, no frame.
#[test]
fn send_to_remote_collapses_locally_for_self() {
maybe_child(ROLES);
run(|| {
remote::set_local_identity("me", Incarnation::new(7));
let (tx, rx) = channel::<Reply>();
let me: Pid<Replier> = install::<Replier>(tx);
let rp = RemotePid::from_local(me).expect("identity set");
// Probe the outbound path: nothing must be handed to any connection.
let (probe_tx, probe_rx) = channel::<Frame>();
remote::bind_outbound_probe("me", Incarnation::new(7), probe_tx);
send_to_remote(rp, Reply("hi".into())).unwrap();
assert_eq!(rx.recv().unwrap(), Reply("hi".into()));
assert!(
matches!(probe_rx.try_recv(), Ok(None)),
"no frame for a local collapse"
);
});
}
/// RFC v2 §3: a `RemotePid` whose incarnation is not the current one for its
/// node fails at the local send site with `DeadIncarnation`, and NO frame
/// is emitted — asserted on a probe sender bound as that node's outbound.
#[test]
fn stale_incarnation_rejected_locally_no_frame() {
maybe_child(ROLES);
run(|| {
remote::set_local_identity("me", Incarnation::new(7));
let (probe_tx, probe_rx) = channel::<Frame>();
remote::bind_outbound_probe("peer", Incarnation::new(5), probe_tx);
let stale = RemotePid::<Replier>::from_parts("peer", Incarnation::new(4), 9, 1);
match send_to_remote(stale, Reply("late".into())) {
Err(ToRemoteError::DeadIncarnation(Reply(s))) => assert_eq!(s, "late"),
other => panic!("expected DeadIncarnation, got {other:?}"),
}
assert!(
matches!(probe_rx.try_recv(), Ok(None)),
"stale pid must emit no frame"
);
// The current incarnation goes through: a Send frame with the pid's
// (index, generation) and Reply's hash lands on the probe.
let live = RemotePid::<Replier>::from_parts("peer", Incarnation::new(5), 9, 1);
send_to_remote(live, Reply("now".into())).unwrap();
match probe_rx.recv().unwrap() {
Frame::Send {
index,
generation,
type_hash: h,
payload,
} => {
assert_eq!((index, generation), (9, 1));
assert_eq!(h, type_hash::<Reply>());
let r: Reply = smarm::cluster::envelope::decode_payload(&payload).unwrap();
assert_eq!(r, Reply("now".into()));
}
f => panic!("expected Send, got {f:?}"),
}
// Unknown node: NotConnected, no frame anywhere.
let nowhere = RemotePid::<Replier>::from_parts("nowhere", Incarnation::new(1), 1, 1);
assert!(matches!(
send_to_remote(nowhere, Reply("x".into())),
Err(ToRemoteError::NotConnected(_))
));
});
}
// ================= cross-process gate ==================================
const ROLES: &[(&str, fn())] = &[
("server", role_server),
("client", role_client),
("relay", role_relay),
];
const ECHO: Name<Req> = Name::new("c10.echo");
const RELAY: Name<Req> = Name::new("c10.relay");
fn cfg(name: &str, seeds: Vec<(String, String)>) -> Config {
Config {
node_name: name.to_string(),
meta: NodeMeta {
role: "c10".into(),
region: "local".into(),
},
listen_addr: std::env::var("SMARM_LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:0".into()),
strategy: Box::new(StaticSeeds::new(seeds)),
timing: Timing::default(),
}
}
fn wait_up(events: &smarm::cluster::membership::MembershipEvents, who: &str) {
loop {
match events.rx.recv() {
Ok(NodeEvent::NodeUp(i)) if i.name == who => return,
Ok(_) => continue,
Err(_) => panic!("manager gone"),
}
}
}
/// Server: exposes ECHO; each Req is answered by `send_to_remote` to its
/// reply_to — the server never learns a name for the client. If the Req text
/// starts with "via-relay:", it forwards the whole Req (reply_to and all) to
/// the relay node instead, which sends it back here; the second arrival is
/// answered normally. That is the pid's third-node roundtrip.
fn role_server() {
let relay_addr = std::env::var("SMARM_RELAY_ADDR").ok();
smarm::run(move || {
let seeds = relay_addr
.map(|a| vec![("relay".to_string(), a)])
.unwrap_or_default();
let cluster = start(cfg("server", seeds)).expect("binds");
println!("LISTENING {}", cluster.local_addr());
let (tx, rx) = channel::<Req>();
register(ECHO, tx).unwrap();
expose(ECHO);
println!("READY");
loop {
let req = rx.recv().unwrap();
if let Some(rest) = req.text.strip_prefix("via-relay:") {
let fwd = Req {
text: format!("relayed:{rest}"),
reply_to: req.reply_to,
};
remote::send(RemoteName::new("relay", RELAY), fwd).unwrap();
println!("FORWARDED");
continue;
}
println!("REQ {}", req.text);
send_to_remote(req.reply_to, Reply(format!("echo:{}", req.text))).unwrap();
}
});
}
/// Relay: exposes RELAY; bounces every Req straight back to the server's
/// ECHO, untouched. The client's pid inside it now crosses relay→server.
fn role_relay() {
let server_addr = std::env::var("SMARM_SERVER_ADDR").expect("SMARM_SERVER_ADDR");
smarm::run(move || {
let cluster = start(cfg("relay", vec![("server".into(), server_addr)])).expect("binds");
println!("LISTENING {}", cluster.local_addr());
let (tx, rx) = channel::<Req>();
register(RELAY, tx).unwrap();
expose(RELAY);
let ev = subscribe().unwrap();
wait_up(&ev, "server");
println!("READY");
loop {
let req = rx.recv().unwrap();
println!("RELAYING {}", req.text);
remote::send(RemoteName::new("server", ECHO), req).unwrap();
}
});
}
/// Client: connects to server, installs a Reply inbox on its own pid,
/// declares it accepts `Reply` (`expose_type` — the RFC's one kept piece of
/// ceremony: nothing is remotely deliverable by default), sends a Req with
/// `reply_to = my pid` (auto-serialized), awaits the reply.
fn role_client() {
let server_addr = std::env::var("SMARM_SERVER_ADDR").expect("SMARM_SERVER_ADDR");
let via_relay = std::env::var("SMARM_VIA_RELAY").is_ok();
smarm::run(move || {
let _cluster = start(cfg("client", vec![("server".into(), server_addr)])).expect("binds");
let ev = subscribe().unwrap();
wait_up(&ev, "server");
println!("MEMBER-UP server");
let (tx, rx) = channel::<Reply>();
let me: Pid<Replier> = install::<Replier>(tx);
// The one deliberate line: a pid-targeted inbound is deliverable only
// for types this node has said it accepts (RFC §4, the safety).
smarm::cluster::expose::expose_type::<Reply>();
let text = if via_relay { "via-relay:ping" } else { "ping" };
remote::send(
RemoteName::new("server", ECHO),
Req {
text: text.into(),
reply_to: RemotePid::from_local(me).expect("identity set"),
},
)
.unwrap();
println!("SENT");
let Reply(s) = rx.recv().unwrap();
println!("REPLY {s}");
loop {
smarm::sleep(Duration::from_secs(3600));
}
});
}
/// The gate: cross-node call/reply with no ceremony.
#[test]
fn cross_node_call_reply_no_ceremony() {
maybe_child(ROLES);
let mut server = spawn_node("server", &[]);
let saddr = server.wait_listening();
server.wait_line("READY", |l| l == "READY");
let mut client = spawn_node("client", &[("SMARM_SERVER_ADDR", &saddr)]);
client.wait_line("SENT", |l| l == "SENT");
server.wait_line("REQ ping", |l| l == "REQ ping");
client.wait_line("REPLY echo:ping", |l| l == "REPLY echo:ping");
}
/// The client's pid, round-tripped through a third node, still delivers.
#[test]
fn pid_roundtrips_through_third_node() {
maybe_child(ROLES);
// Relay needs the server address; server needs the relay address —
// pre-reserve the relay port (same accepted micro-window as cluster_mesh).
let relay_addr = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().to_string()
};
let mut server = spawn_node("server", &[("SMARM_RELAY_ADDR", &relay_addr)]);
let saddr = server.wait_listening();
server.wait_line("READY", |l| l == "READY");
let mut relay = spawn_node(
"relay",
&[
("SMARM_SERVER_ADDR", &saddr),
("SMARM_LISTEN_ADDR", &relay_addr),
],
);
let _ = relay.wait_listening();
relay.wait_line("READY", |l| l == "READY");
let mut client = spawn_node(
"client",
&[("SMARM_SERVER_ADDR", &saddr), ("SMARM_VIA_RELAY", "1")],
);
client.wait_line("SENT", |l| l == "SENT");
server.wait_line("FORWARDED", |l| l == "FORWARDED");
relay.wait_line("RELAYING", |l| l.starts_with("RELAYING"));
server.wait_line("REQ relayed:ping", |l| l == "REQ relayed:ping");
client.wait_line("REPLY echo:relayed:ping", |l| {
l == "REPLY echo:relayed:ping"
});
}
+2 -1
View File
@@ -33,7 +33,7 @@ use smarm::cluster::envelope::NodeMeta;
use smarm::cluster::expose::expose; use smarm::cluster::expose::expose;
use smarm::cluster::membership::{subscribe, NodeEvent}; use smarm::cluster::membership::{subscribe, NodeEvent};
use smarm::cluster::remote::{send_remote_raw, RemoteName, RemoteSendError}; use smarm::cluster::remote::{send_remote_raw, RemoteName, RemoteSendError};
use smarm::cluster::{start, Config, StaticSeeds}; use smarm::cluster::{start, Config, StaticSeeds, Timing};
use smarm::{channel, register, Name}; use smarm::{channel, register, Name};
use std::time::Duration; use std::time::Duration;
@@ -51,6 +51,7 @@ fn base_config(name: &str, seeds: Vec<(String, String)>) -> Config {
}, },
listen_addr: "127.0.0.1:0".to_string(), listen_addr: "127.0.0.1:0".to_string(),
strategy: Box::new(StaticSeeds::new(seeds)), strategy: Box::new(StaticSeeds::new(seeds)),
timing: Timing::default(),
} }
} }
+13 -20
View File
@@ -1,5 +1,6 @@
//! Process-group tests that run under the scheduler: `join` installs a real //! Process-group tests that run under the scheduler: `join` installs a real
//! monitor on a live actor, and a real death drives eviction on next contact. //! monitor on a live actor, and a real death drives eviction (the reaper
//! actor sweeps it; the read path hides it in the meantime).
//! (Pure structural invariants live in the `pg` unit tests.) //! (Pure structural invariants live in the `pg` unit tests.)
use smarm::{channel, members, pick, run, spawn}; use smarm::{channel, members, pick, run, spawn};
@@ -35,19 +36,15 @@ fn a_dead_actor_vanishes_from_every_group_it_joined() {
assert_eq!(members("g1"), vec![pid]); assert_eq!(members("g1"), vec![pid]);
assert_eq!(members("g2"), vec![pid]); assert_eq!(members("g2"), vec![pid]);
// Release and reap the actor. finalize_actor queues the Down to our // Release the actor. finalize_actor queues the Down to the reaper and
// monitors before unparking joiners, so by the time join() returns the // marks the slot dead before unparking joiners, so by the time join()
// Down is already waiting in the membership channel. // returns every read hides the pid whether or not the reaper has run.
tx.send(()).unwrap(); tx.send(()).unwrap();
h.join().unwrap(); h.join().unwrap();
// Drain-on-contact: touching g1 detects the death and sweeps the pid // Gone from every group it joined, not just one.
// out of every group (g2 included), not just g1. assert!(members("g1").is_empty(), "gone from g1");
assert!(members("g1").is_empty(), "evicted from the touched group"); assert!(members("g2").is_empty(), "and from g2");
assert!(
members("g2").is_empty(),
"and swept from the untouched group"
);
assert_eq!(pick("g1"), None); assert_eq!(pick("g1"), None);
}); });
} }
@@ -86,11 +83,7 @@ fn live_members_survive_a_peers_death() {
tx_a.send(()).unwrap(); tx_a.send(()).unwrap();
a.join().unwrap(); a.join().unwrap();
assert_eq!( assert_eq!(members("svc"), vec![b.pid()], "only the dead peer is gone");
members("svc"),
vec![b.pid()],
"only the dead peer is reaped"
);
assert_eq!(pick("svc"), Some(b.pid())); assert_eq!(pick("svc"), Some(b.pid()));
tx_b.send(()).unwrap(); tx_b.send(()).unwrap();
@@ -123,18 +116,18 @@ fn leave_drops_a_membership_without_affecting_others() {
} }
#[test] #[test]
fn joining_an_already_dead_pid_is_evicted_on_next_contact() { fn joining_an_already_dead_pid_never_shows_in_a_read() {
run(|| { run(|| {
let h = spawn(|| {}); let h = spawn(|| {});
let pid = h.pid(); let pid = h.pid();
h.join().unwrap(); // actor is finalized before we join it to anything h.join().unwrap(); // actor is finalized before we join it to anything
// monitor() on a gone pid queues a NoProc Down immediately, so the // join() on a gone pid queues a NoProc Down to the reaper immediately;
// membership is reaped the next time the group is touched. // reads never show it either way (slot-liveness backstop).
join("late", pid); join("late", pid);
assert!( assert!(
members("late").is_empty(), members("late").is_empty(),
"dead-at-join member is reaped on read" "dead-at-join member never reads as live"
); );
assert_eq!(pick("late"), None); assert_eq!(pick("late"), None);
}); });