summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/main.rs2
-rw-r--r--src/simtest/mod.rs284
-rw-r--r--src/simtest/timing.rs219
-rw-r--r--src/swarm/bootstrap.rs5
-rw-r--r--src/transport/mod.rs2
-rw-r--r--src/transport/mycelium.rs28
-rw-r--r--src/transport/sim.rs444
7 files changed, 976 insertions, 8 deletions
diff --git a/src/main.rs b/src/main.rs
index 0a978fe..6d2329f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,6 +21,8 @@ mod force;
mod logging;
mod node;
mod remake;
+#[cfg(test)]
+mod simtest;
mod state;
mod swarm;
mod transport;
diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs
new file mode 100644
index 0000000..2e33310
--- /dev/null
+++ b/src/simtest/mod.rs
@@ -0,0 +1,284 @@
+//! Tests of whole nodes on the simulated network (`transport::sim`): several nodes in
+//! one process, built and started as `main` does (`node`), talking through the
+//! Mycelium wire encoding.
+//!
+//! The clock is real (see `transport::sim` for why), so the nodes run with a short
+//! keepalive and tests wait for conditions with a timeout instead of advancing time.
+//! Timeouts are generous: a test fails only if something doesn't happen at all.
+//!
+//! `RUST_LOG=continuum=debug cargo test simtest -- --nocapture` shows the nodes' logs
+//! (all nodes interleaved; messages name peers by ID).
+
+mod timing;
+
+use std::collections::HashSet;
+use std::future::Future;
+use std::sync::Arc;
+use std::time::Duration;
+
+use automerge::{AutoCommit, ChangeHash};
+use serde_json::Value as JsonValue;
+use tokio::time::Instant;
+
+use crate::document::replica::Epoch;
+use crate::document::store;
+use crate::files::Files;
+use crate::node::{self, Node, NodeConfig};
+use crate::state::{AppState, PeerState};
+use crate::swarm::bootstrap::{self, Start};
+use crate::transport::sim::SimNet;
+
+/// Keepalive of simulated nodes: dormancy after 1 s, probes of dormant peers every
+/// 2.5 s (`Timing::from_keepalive`). Sync reply timeouts and heals don't scale with it
+/// (10 s and 20 s at least).
+pub(crate) const KEEPALIVE: Duration = Duration::from_millis(250);
+
+/// A simulated swarm: the net and the nodes started on it.
+pub(crate) struct Sim {
+ pub(crate) net: SimNet,
+ /// Every node started, for the description of a failed wait.
+ nodes: std::sync::Mutex<Vec<(String, AppState)>>,
+}
+
+impl Sim {
+ pub(crate) fn new(seed: u64) -> Self {
+ init_logging();
+ Self {
+ net: SimNet::new(seed),
+ nodes: Default::default(),
+ }
+ }
+
+ /// Starts node `id` with the test defaults: builds it, starts its workers, and
+ /// bootstraps it in the background (through `seeds`, other nodes' IDs). Returns at
+ /// once; [`wait_ready`] waits for it.
+ pub(crate) fn start(&self, id: &str, seeds: &[&str], start: Start) -> AppState {
+ self.start_with(id, seeds, start, |_| {})
+ }
+
+ /// [`start`](Self::start), with the configuration adjusted first.
+ pub(crate) fn start_with(
+ &self,
+ id: &str,
+ seeds: &[&str],
+ start: Start,
+ adjust: impl FnOnce(&mut NodeConfig),
+ ) -> AppState {
+ let mut config = NodeConfig {
+ transport: self.net.transport(id),
+ initial_epoch: Epoch::GENESIS,
+ initial_doc: AutoCommit::new(),
+ earlier_actors: Vec::new(),
+ keepalive: KEEPALIVE,
+ neighbours_k: 8,
+ alias: Some(id.to_owned()),
+ electorate_window: Duration::from_secs(30 * 24 * 3600),
+ blob_chunk_bytes: 64 * 1024,
+ swarm_key: None,
+ protocol_writers: HashSet::new(),
+ files: Arc::new(Files::default()),
+ state_file: None,
+ document_file: None,
+ config_report: JsonValue::Null,
+ };
+ adjust(&mut config);
+ let state = Node::new(config).spawn_workers();
+ self.nodes
+ .lock()
+ .unwrap()
+ .push((id.to_owned(), state.clone()));
+
+ let seeds: Vec<String> = seeds.iter().map(|seed| (*seed).to_owned()).collect();
+ let bootstrapping = state.clone();
+ let id = id.to_owned();
+ tokio::spawn(async move {
+ if let Err(error) = bootstrap::bootstrap(&bootstrapping, &seeds, start).await {
+ panic!("bootstrapping {id} failed: {error:#}");
+ }
+ node::spawn_ready_workers(&bootstrapping);
+ });
+ state
+ }
+}
+
+/// Logs to the test output if `RUST_LOG` is set.
+fn init_logging() {
+ if std::env::var_os("RUST_LOG").is_some() {
+ let _ = tracing_subscriber::fmt()
+ .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
+ .with_test_writer()
+ .compact()
+ .try_init();
+ }
+}
+
+impl Sim {
+ /// Waits until `condition` holds, checking every 20 ms. After `timeout`, panics with
+ /// a description of every node (`describe`).
+ pub(crate) async fn wait_until<F, Fut>(&self, what: &str, timeout: Duration, mut condition: F)
+ where
+ F: FnMut() -> Fut,
+ Fut: Future<Output = bool>,
+ {
+ let deadline = Instant::now() + timeout;
+ loop {
+ if condition().await {
+ return;
+ }
+ if Instant::now() >= deadline {
+ panic!(
+ "timed out after {timeout:?} waiting until {what}\n{}",
+ self.describe().await
+ );
+ }
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ }
+
+ /// Waits until every node is ready.
+ pub(crate) async fn wait_ready(&self, nodes: &[&AppState], timeout: Duration) {
+ self.wait_until("every node is ready", timeout, move || async move {
+ for node in nodes {
+ if !node.is_ready().await {
+ return false;
+ }
+ }
+ true
+ })
+ .await;
+ }
+
+ /// Waits until every node has the same heads.
+ pub(crate) async fn wait_converged(&self, nodes: &[&AppState], timeout: Duration) {
+ self.wait_until(
+ "every node has the same heads",
+ timeout,
+ move || async move {
+ let first = heads(nodes[0]).await;
+ for node in &nodes[1..] {
+ if heads(node).await != first {
+ return false;
+ }
+ }
+ true
+ },
+ )
+ .await;
+ }
+
+ /// Every node's state as far as sync and liveness go, and what the net counted.
+ pub(crate) async fn describe(&self) -> String {
+ use std::fmt::Write;
+
+ let nodes = self.nodes.lock().unwrap().clone();
+ let now = Instant::now();
+ let ago = |at: Option<Instant>| at.map(|at| now.saturating_duration_since(at));
+ let mut out = format!("net: {:?}\n", self.net.counts());
+ for (id, node) in &nodes {
+ let heads: Vec<String> = heads(node)
+ .await
+ .iter()
+ .map(|head| head.to_string()[..8].to_owned())
+ .collect();
+ let _ = writeln!(
+ out,
+ "node {id}: ready={} epoch={} heads={heads:?} initial_sync_done={}",
+ node.is_ready().await,
+ node.local_epoch(),
+ node.initial_sync_done
+ .load(std::sync::atomic::Ordering::Acquire),
+ );
+ let _ = writeln!(
+ out,
+ " view: {:?}",
+ *node
+ .neighbours
+ .read()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ );
+ let peers: Vec<_> = node
+ .peer_states
+ .read()
+ .await
+ .iter()
+ .map(|(peer, state)| (peer.clone(), Arc::clone(state)))
+ .collect();
+ for (peer, peer_state) in peers {
+ let p = peer_state.lock().await;
+ let _ = writeln!(
+ out,
+ " peer {peer}: neighbour={} leads={} standing={:?} liveness={:?} hello={:?} \
+ reports_ready={:?} epoch={:?} last_seen_ago={:?} last_sync_rx_ago={:?} \
+ unreachable={} their_heads={:?} in_flight={} sending={} reply_due_in={:?} \
+ retry_at={:?}",
+ crate::swarm::topology::is_neighbour(node, &peer),
+ p.leads,
+ p.standing,
+ p.liveness,
+ p.hello_state,
+ p.reports_ready,
+ p.epoch,
+ ago(p.last_seen),
+ ago(p.last_sync_rx),
+ p.is_unreachable(&node.timing, now),
+ p.sync.state.their_heads.as_ref().map(|heads| heads
+ .iter()
+ .map(|head| head.to_string()[..8].to_owned())
+ .collect::<Vec<_>>()),
+ p.sync.state.in_flight,
+ p.sync.sending.is_some(),
+ p.sync
+ .reply
+ .as_ref()
+ .map(|wait| wait.deadline.saturating_duration_since(now)),
+ p.sync.retry_at.map(|at| at.saturating_duration_since(now)),
+ );
+ }
+ }
+ out
+ }
+}
+
+fn path(path: &str) -> Vec<String> {
+ path.split('/').map(str::to_owned).collect()
+}
+
+/// Writes `value` at `path` (`a/b/c`) as one change, as the API does.
+pub(crate) async fn write(node: &AppState, at: &str, value: JsonValue) {
+ let at = path(at);
+ node.mutate_doc(move |doc| store::write_path(doc, &at, &value))
+ .await
+ .expect("write");
+}
+
+/// The value at `path` (`a/b/c`), if any.
+pub(crate) async fn read(node: &AppState, at: &str) -> Option<JsonValue> {
+ store::read_path(&node.doc.read().await.doc, &path(at)).expect("read")
+}
+
+pub(crate) async fn heads(node: &AppState) -> Vec<ChangeHash> {
+ let mut heads = node.doc.read().await.heads().to_vec();
+ heads.sort_unstable();
+ heads
+}
+
+/// Whether `node` has `peer` among its neighbours as computed from the member list.
+/// Before that list is known everyone counts as a neighbour, so this waits for it.
+pub(crate) fn has_neighbour(node: &AppState, peer: &str) -> bool {
+ node.neighbours
+ .read()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .is_some()
+ && crate::swarm::topology::is_neighbour(node, peer)
+}
+
+/// Something about `node`'s state for `peer`; None if it doesn't know the peer.
+pub(crate) async fn peer<T>(
+ node: &AppState,
+ peer: &str,
+ f: impl FnOnce(&PeerState) -> T,
+) -> Option<T> {
+ let peer_state = node.get_peer_state(peer).await?;
+ let peer_state = peer_state.lock().await;
+ Some(f(&peer_state))
+}
diff --git a/src/simtest/timing.rs b/src/simtest/timing.rs
new file mode 100644
index 0000000..2633235
--- /dev/null
+++ b/src/simtest/timing.rs
@@ -0,0 +1,219 @@
+//! The timing rules of `peers.md` on whole nodes: joining and converging, dormancy and
+//! return, and late messages that must not undo what newer ones said.
+
+use std::time::Duration;
+
+use serde_json::json;
+
+use super::{Sim, has_neighbour, heads, peer, read, write};
+use crate::document::replica::Epoch;
+use crate::state::{AppState, Liveness};
+use crate::swarm::bootstrap::Start;
+use crate::transport::sim::LinkParams;
+use crate::transport::{HeadsSummary, HelloRequest, Keepalive, ParsedMessage, RunId};
+
+/// Long enough for anything that should happen within a few keepalives.
+const SOON: Duration = Duration::from_secs(10);
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn three_nodes_join_and_converge() {
+ let sim = Sim::new(1);
+ let a = sim.start("a", &[], Start::Genesis);
+ let b = sim.start("b", &["a"], Start::Join);
+ let c = sim.start("c", &["a"], Start::Join);
+ let (a, b, c) = (&a, &b, &c);
+ sim.wait_ready(&[a, b, c], SOON).await;
+
+ let swarm = a.swarm_id().await;
+ assert!(swarm.is_some());
+ assert_eq!(b.swarm_id().await, swarm);
+ assert_eq!(c.swarm_id().await, swarm);
+
+ // A write anywhere reaches everyone.
+ write(b, "users/bob", json!({"name": "Bob"})).await;
+ sim.wait_until("c has b's write", SOON, move || async move {
+ read(c, "users/bob/name").await == Some(json!("Bob"))
+ })
+ .await;
+
+ // Concurrent writes merge to one document.
+ write(a, "users/alice", json!(1)).await;
+ write(c, "users/carol", json!(3)).await;
+ sim.wait_converged(&[a, b, c], SOON).await;
+ for node in [a, b, c] {
+ assert_eq!(read(node, "users/alice").await, Some(json!(1)));
+ assert_eq!(read(node, "users/carol").await, Some(json!(3)));
+ }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn converges_with_duplicated_and_reordered_messages() {
+ let sim = Sim::new(2);
+ sim.net.set_default_link(LinkParams {
+ duplicate: 0.3,
+ delay: (Duration::from_millis(1), Duration::from_millis(40)),
+ ..LinkParams::default()
+ });
+ let a = sim.start("a", &[], Start::Genesis);
+ let b = sim.start("b", &["a"], Start::Join);
+ let c = sim.start("c", &["a"], Start::Join);
+ let (a, b, c) = (&a, &b, &c);
+ sim.wait_ready(&[a, b, c], SOON).await;
+
+ for round in 0..10 {
+ write(a, &format!("a/{round}"), json!(round)).await;
+ write(b, &format!("b/{round}"), json!(round)).await;
+ write(c, &format!("c/{round}"), json!(round)).await;
+ }
+ sim.wait_converged(&[a, b, c], SOON).await;
+ for node in [a, b, c] {
+ for prefix in ["a", "b", "c"] {
+ assert_eq!(read(node, &format!("{prefix}/9")).await, Some(json!(9)));
+ }
+ }
+ assert!(sim.net.counts().duplicated > 0);
+}
+
+/// A peer that hears nothing for `dormant_after` is dormant; once messages flow again
+/// it is active, re-handshakes, and syncs what it missed.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn peer_goes_dormant_and_returns() {
+ let sim = Sim::new(3);
+ let a = sim.start("a", &[], Start::Genesis);
+ let b = sim.start("b", &["a"], Start::Join);
+ let (a, b) = (&a, &b);
+ sim.wait_ready(&[a, b], SOON).await;
+ // Only neighbours are judged, and a node joins the member list (which neighbours
+ // are computed from) within 5 s of becoming ready: until then the other doesn't
+ // count it as a neighbour.
+ sim.wait_until(
+ "a and b are each other's neighbours",
+ SOON,
+ move || async move { has_neighbour(a, "b") && has_neighbour(b, "a") },
+ )
+ .await;
+
+ sim.net.take_down("b");
+ sim.wait_until("a finds b dormant", SOON, move || is_dormant(a, "b"))
+ .await;
+ sim.wait_until("b finds a dormant", SOON, move || is_dormant(b, "a"))
+ .await;
+ let dormancies = peer(a, "b", |p| p.incidents.dormancies).await.unwrap();
+ assert!(dormancies >= 1);
+
+ // Written while apart.
+ write(a, "while/apart", json!(true)).await;
+ assert_ne!(heads(a).await, heads(b).await);
+
+ sim.net.bring_up("b");
+ sim.wait_until("a finds b active", SOON, move || async move {
+ !is_dormant(a, "b").await
+ })
+ .await;
+ sim.wait_until("b finds a active", SOON, move || async move {
+ !is_dormant(b, "a").await
+ })
+ .await;
+ sim.wait_until("b has what a wrote meanwhile", SOON, move || async move {
+ read(b, "while/apart").await == Some(json!(true))
+ })
+ .await;
+ sim.wait_converged(&[a, b], SOON).await;
+}
+
+async fn is_dormant(node: &AppState, other: &str) -> bool {
+ peer(node, other, |p| {
+ matches!(p.liveness, Liveness::Dormant { .. })
+ })
+ .await
+ == Some(true)
+}
+
+/// R17: a message sent before the peer's last epoch change and delivered late doesn't
+/// flip the epoch we recorded for it back; a new run of the peer starts its count over.
+/// The peer is a "ghost" whose messages are injected, so their order is the test's.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn late_epoch_does_not_flip_a_peers_epoch() {
+ let sim = Sim::new(4);
+ let a = sim.start("a", &[], Start::Genesis);
+ sim.wait_ready(&[&a], SOON).await;
+ let swarm_id = a.swarm_id().await.unwrap();
+
+ let epoch = |generation: u64| Epoch {
+ generation,
+ id: [generation as u8; 32],
+ };
+ let hello = |run: RunId, epoch: Epoch, epoch_seq: u32| {
+ ParsedMessage::Hello(HelloRequest {
+ swarm_id: Some(swarm_id.clone()),
+ epoch,
+ epoch_seq,
+ auth: Vec::new(),
+ ready: true,
+ run_id: run,
+ })
+ };
+ let ping = |epoch: Epoch, epoch_seq: u32| {
+ ParsedMessage::Ping(Keepalive {
+ heads: HeadsSummary::of(Vec::new()),
+ epoch,
+ epoch_seq,
+ ready: true,
+ syncs_with_you: false,
+ })
+ };
+ let a = &a;
+ let recorded = move || async move {
+ peer(a, "ghost", |p| (p.epoch, p.epoch_seq))
+ .await
+ .expect("a knows the ghost")
+ };
+
+ let first_run: RunId = [1; 16];
+ sim.net
+ .inject("ghost", "a", &hello(first_run, Epoch::GENESIS, 0))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(Epoch::GENESIS), Some(0)));
+
+ // The ghost switched twice; we hear of the second switch first.
+ sim.net
+ .inject("ghost", "a", &ping(epoch(2), 2))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(2)), Some(2)));
+
+ // The PING from between the switches arrives late: ignored.
+ sim.net
+ .inject("ghost", "a", &ping(epoch(1), 1))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(2)), Some(2)));
+ // So is one from before both.
+ sim.net
+ .inject("ghost", "a", &ping(Epoch::GENESIS, 0))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(2)), Some(2)));
+
+ // The ghost restarted: its new run counts from 0 again.
+ let second_run: RunId = [2; 16];
+ sim.net
+ .inject("ghost", "a", &hello(second_run, epoch(2), 0))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(2)), Some(0)));
+ assert_eq!(
+ peer(a, "ghost", |p| p.run_id).await.unwrap(),
+ Some(second_run)
+ );
+ sim.net
+ .inject("ghost", "a", &ping(epoch(3), 1))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(3)), Some(1)));
+
+ // One ahead peer isn't enough for a ready node to rejoin.
+ assert_eq!(a.local_epoch(), Epoch::GENESIS);
+ assert!(a.is_ready().await);
+}
diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs
index bd15a34..5e9ac06 100644
--- a/src/swarm/bootstrap.rs
+++ b/src/swarm/bootstrap.rs
@@ -351,8 +351,11 @@ async fn report_joining(state: &AppState, seeds: &[String], unresolved: &[String
peer_state.epoch,
));
}
+ // Awaited outside the macro: its arguments aren't Send, and a spawned bootstrap
+ // (the simulator's) must be.
+ let local_swarm = state.swarm_id().await;
warn!(
- local_swarm = ?state.swarm_id().await,
+ local_swarm = ?local_swarm,
local_epoch = %state.local_epoch(),
initial_sync_done = state.initial_sync_done.load(Ordering::Acquire),
unresolved_seeds = ?unresolved,
diff --git a/src/transport/mod.rs b/src/transport/mod.rs
index 564aada..c3db962 100644
--- a/src/transport/mod.rs
+++ b/src/transport/mod.rs
@@ -11,6 +11,8 @@ pub use crate::document::replica::Epoch;
pub mod firewall;
pub mod mycelium;
+#[cfg(test)]
+pub(crate) mod sim;
pub mod traffic;
/// Random identifier of one run of a node (from start to exit).
diff --git a/src/transport/mycelium.rs b/src/transport/mycelium.rs
index f519928..efab475 100644
--- a/src/transport/mycelium.rs
+++ b/src/transport/mycelium.rs
@@ -561,13 +561,7 @@ impl Transport for MyceliumTransport {
}
fn encode(&self, peer: &str, message: &ParsedMessage) -> Result<EncodedMessage> {
- let (outbound, payload_len) = format_message(peer, message)?;
- Ok(EncodedMessage {
- peer: peer.to_owned(),
- body: serde_json::to_vec(&outbound)?.into(),
- kind: message.kind(),
- payload_len,
- })
+ encode_message(peer, message)
}
async fn send_encoded(&self, message: &EncodedMessage) -> Result<()> {
@@ -712,6 +706,26 @@ impl Transport for MyceliumTransport {
}
}
+/// A message as the Mycelium REST API takes it (the JSON body of an outbound message).
+/// Also the simulator's wire format (`transport::sim`), so tests go through the same
+/// encoding.
+pub(crate) fn encode_message(peer: &str, message: &ParsedMessage) -> Result<EncodedMessage> {
+ let (outbound, payload_len) = format_message(peer, message)?;
+ Ok(EncodedMessage {
+ peer: peer.to_owned(),
+ body: serde_json::to_vec(&outbound)?.into(),
+ kind: message.kind(),
+ payload_len,
+ })
+}
+
+/// The message in a body made by [`encode_message`].
+#[cfg(test)]
+pub(crate) fn decode_message(body: &[u8]) -> Result<ParsedMessage> {
+ let outbound: OutboundMessage = serde_json::from_slice(body)?;
+ Ok(decode_payload(&outbound.topic, &outbound.payload)?)
+}
+
fn encode_topic(topic: &str) -> String {
BASE64.encode(topic.as_bytes())
}
diff --git a/src/transport/sim.rs b/src/transport/sim.rs
new file mode 100644
index 0000000..3e0163f
--- /dev/null
+++ b/src/transport/sim.rs
@@ -0,0 +1,444 @@
+//! A simulated network for tests: many nodes in one process, each with a
+//! [`SimTransport`] on a shared [`SimNet`].
+//!
+//! Messages go through the Mycelium wire encoding (`mycelium::encode_message`), so the
+//! codec is exercised as in production. The net delays, loses and duplicates them per
+//! link, from a seeded generator; nodes can be taken down (everything to and from them
+//! is lost) and brought up again; and a test can inject a message as if a node had sent
+//! it, e.g. one that arrives late.
+//!
+//! Time is tokio's real clock, not a paused one: a node's document work runs on the
+//! blocking pool (`spawn_blocking`), which a paused clock doesn't wait for, so timers
+//! would fire while the work is still running. Tests use a short keepalive instead.
+//! Delays and losses come from the seed; when a message's turn comes still depends on
+//! the scheduler, so a run is reproducible in its network decisions, not in its
+//! interleavings.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::{Arc, Mutex, MutexGuard};
+use std::time::Duration;
+
+use anyhow::{Result, bail};
+use async_trait::async_trait;
+use bytes::Bytes;
+use tokio::sync::{mpsc, oneshot};
+use tracing::{debug, warn};
+
+use super::mycelium::{decode_message, encode_message};
+use super::{EncodedMessage, MessageHandler, ParsedMessage, Transport};
+
+/// How one direction of a link behaves.
+#[derive(Clone, Debug)]
+pub(crate) struct LinkParams {
+ /// Probability that a message is lost.
+ pub(crate) loss: f64,
+ /// Probability that a message arrives twice (each copy with its own delay).
+ pub(crate) duplicate: f64,
+ /// Delay, uniform in this range. Overlapping delays reorder messages.
+ pub(crate) delay: (Duration, Duration),
+}
+
+impl Default for LinkParams {
+ /// A clean, fast link: nothing lost, a few milliseconds.
+ fn default() -> Self {
+ Self {
+ loss: 0.0,
+ duplicate: 0.0,
+ delay: (Duration::from_millis(1), Duration::from_millis(5)),
+ }
+ }
+}
+
+/// A message on its way into a node.
+struct Delivery {
+ from: String,
+ body: Bytes,
+ payload_len: usize,
+ /// Told once the node has handled the message (injections wait for it).
+ handled: Option<oneshot::Sender<()>>,
+}
+
+/// What the net counted, for assertions.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) struct NetCounts {
+ pub(crate) sent: u64,
+ pub(crate) delivered: u64,
+ pub(crate) lost: u64,
+ pub(crate) duplicated: u64,
+}
+
+struct NetInner {
+ rng: SplitMix64,
+ default_link: LinkParams,
+ links: HashMap<(String, String), LinkParams>,
+ down: HashSet<String>,
+ inboxes: HashMap<String, mpsc::UnboundedSender<Delivery>>,
+ counts: NetCounts,
+}
+
+/// The network all simulated nodes share. Cloning is cheap.
+#[derive(Clone)]
+pub(crate) struct SimNet {
+ inner: Arc<Mutex<NetInner>>,
+}
+
+impl SimNet {
+ pub(crate) fn new(seed: u64) -> Self {
+ Self {
+ inner: Arc::new(Mutex::new(NetInner {
+ rng: SplitMix64(seed),
+ default_link: LinkParams::default(),
+ links: HashMap::new(),
+ down: HashSet::new(),
+ inboxes: HashMap::new(),
+ counts: NetCounts::default(),
+ })),
+ }
+ }
+
+ fn lock(&self) -> MutexGuard<'_, NetInner> {
+ self.inner
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ /// A transport for a new node `id` on this net.
+ pub(crate) fn transport(&self, id: &str) -> Arc<SimTransport> {
+ let (tx, rx) = mpsc::unbounded_channel();
+ let previous = self.lock().inboxes.insert(id.to_owned(), tx);
+ assert!(previous.is_none(), "node {id} is already on the net");
+ Arc::new(SimTransport {
+ id: id.to_owned(),
+ net: self.clone(),
+ inbox: tokio::sync::Mutex::new(Some(rx)),
+ })
+ }
+
+ /// How links without their own parameters behave.
+ pub(crate) fn set_default_link(&self, params: LinkParams) {
+ self.lock().default_link = params;
+ }
+
+ /// How messages from `from` to `to` are carried (one direction).
+ pub(crate) fn set_link(&self, from: &str, to: &str, params: LinkParams) {
+ self.lock()
+ .links
+ .insert((from.to_owned(), to.to_owned()), params);
+ }
+
+ /// Everything to and from `id` is lost until [`bring_up`](Self::bring_up), including
+ /// messages already on their way. The node itself keeps running.
+ pub(crate) fn take_down(&self, id: &str) {
+ self.lock().down.insert(id.to_owned());
+ }
+
+ pub(crate) fn bring_up(&self, id: &str) {
+ self.lock().down.remove(id);
+ }
+
+ pub(crate) fn counts(&self) -> NetCounts {
+ self.lock().counts
+ }
+
+ /// Hands a message to the net: decides now whether and when it arrives.
+ fn send(&self, from: &str, message: &EncodedMessage) {
+ let delays = {
+ let mut guard = self.lock();
+ let inner = &mut *guard;
+ inner.counts.sent += 1;
+ if inner.down.contains(from) || inner.down.contains(&message.peer) {
+ inner.counts.lost += 1;
+ return;
+ }
+ let params = inner
+ .links
+ .get(&(from.to_owned(), message.peer.clone()))
+ .unwrap_or(&inner.default_link)
+ .clone();
+ if inner.rng.chance(params.loss) {
+ inner.counts.lost += 1;
+ return;
+ }
+ let copies = if inner.rng.chance(params.duplicate) {
+ inner.counts.duplicated += 1;
+ 2
+ } else {
+ 1
+ };
+ (0..copies)
+ .map(|_| inner.rng.duration_in(params.delay))
+ .collect::<Vec<_>>()
+ };
+ for delay in delays {
+ let net = self.clone();
+ let delivery = Delivery {
+ from: from.to_owned(),
+ body: message.body.clone(),
+ payload_len: message.payload_len,
+ handled: None,
+ };
+ let to = message.peer.clone();
+ tokio::spawn(async move {
+ tokio::time::sleep(delay).await;
+ net.arrive(&to, delivery);
+ });
+ }
+ }
+
+ /// The message reaches `to`, unless either end went down meanwhile.
+ fn arrive(&self, to: &str, delivery: Delivery) {
+ let mut guard = self.lock();
+ let inner = &mut *guard;
+ if inner.down.contains(to) || inner.down.contains(&delivery.from) {
+ inner.counts.lost += 1;
+ return;
+ }
+ // An unknown recipient (an address no node has) loses the message, as
+ // Mycelium would.
+ let Some(inbox) = inner.inboxes.get(to) else {
+ inner.counts.lost += 1;
+ return;
+ };
+ if inbox.send(delivery).is_ok() {
+ inner.counts.delivered += 1;
+ } else {
+ inner.counts.lost += 1;
+ }
+ }
+
+ /// Delivers `message` to `to` as if `from` had sent it, now, bypassing links and
+ /// `take_down`; returns once `to` has handled it. `from` needn't be a node on the
+ /// net. For messages a real node wouldn't send at that moment, e.g. one sent long
+ /// ago and delivered late.
+ pub(crate) async fn inject(&self, from: &str, to: &str, message: &ParsedMessage) -> Result<()> {
+ let encoded = encode_message(to, message)?;
+ let (handled_tx, handled_rx) = oneshot::channel();
+ let delivery = Delivery {
+ from: from.to_owned(),
+ body: encoded.body,
+ payload_len: encoded.payload_len,
+ handled: Some(handled_tx),
+ };
+ {
+ let inner = self.lock();
+ let Some(inbox) = inner.inboxes.get(to) else {
+ bail!("no node {to} on the net");
+ };
+ if inbox.send(delivery).is_err() {
+ bail!("node {to} has no consumer running");
+ }
+ }
+ handled_rx.await?;
+ Ok(())
+ }
+
+ fn is_known(&self, id: &str) -> bool {
+ self.lock().inboxes.contains_key(id)
+ }
+}
+
+/// One node's end of the [`SimNet`]. Its identity is the ID it was created with; seeds
+/// are node IDs.
+pub(crate) struct SimTransport {
+ id: String,
+ net: SimNet,
+ inbox: tokio::sync::Mutex<Option<mpsc::UnboundedReceiver<Delivery>>>,
+}
+
+#[async_trait]
+impl Transport for SimTransport {
+ fn local_id(&self) -> &str {
+ &self.id
+ }
+
+ /// A seed is a node ID; one no node has doesn't resolve (yet).
+ async fn resolve_seed(&self, seed: &str) -> Result<String> {
+ if self.net.is_known(seed) {
+ Ok(seed.to_owned())
+ } else {
+ bail!("no node {seed} on the net")
+ }
+ }
+
+ fn encode(&self, peer: &str, message: &ParsedMessage) -> Result<EncodedMessage> {
+ encode_message(peer, message)
+ }
+
+ /// Always accepted: the net loses messages silently, like Mycelium once it has
+ /// taken them.
+ async fn send_encoded(&self, message: &EncodedMessage) -> Result<()> {
+ self.net.send(&self.id, message);
+ Ok(())
+ }
+
+ /// Handles messages one at a time, in arrival order, as the Mycelium consumer does.
+ async fn start_consumer(&self, handler: MessageHandler) -> Result<()> {
+ let Some(mut inbox) = self.inbox.lock().await.take() else {
+ bail!("consumer of {} already started", self.id);
+ };
+ while let Some(delivery) = inbox.recv().await {
+ match decode_message(&delivery.body) {
+ Ok(message) => {
+ debug!(node = %self.id, from = %delivery.from, message = ?message, "Simulated delivery");
+ if let Err(error) = handler(delivery.from, message, delivery.payload_len).await
+ {
+ warn!(node = %self.id, error = %format!("{error:#}"), "Failed to process inbound message");
+ }
+ }
+ Err(error) => {
+ warn!(node = %self.id, error = %format!("{error:#}"), "Undecodable simulated message");
+ }
+ }
+ if let Some(handled) = delivery.handled {
+ let _ = handled.send(());
+ }
+ }
+ Ok(())
+ }
+
+ /// A simulated node has no daemon that could exit.
+ async fn wait_for_exit(&self) -> std::io::Result<std::process::ExitStatus> {
+ std::future::pending().await
+ }
+}
+
+/// A small, seedable generator (SplitMix64): enough for network decisions, and no
+/// dependency.
+struct SplitMix64(u64);
+
+impl SplitMix64 {
+ fn next_u64(&mut self) -> u64 {
+ self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
+ let mut z = self.0;
+ z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+ z ^ (z >> 31)
+ }
+
+ /// Uniform in [0, 1).
+ fn next_f64(&mut self) -> f64 {
+ (self.next_u64() >> 11) as f64 / (1_u64 << 53) as f64
+ }
+
+ fn chance(&mut self, probability: f64) -> bool {
+ probability > 0.0 && self.next_f64() < probability
+ }
+
+ fn duration_in(&mut self, (min, max): (Duration, Duration)) -> Duration {
+ if max <= min {
+ return min;
+ }
+ min + (max - min).mul_f64(self.next_f64())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::document::replica::Epoch;
+ use crate::transport::{HeadsSummary, Keepalive};
+
+ fn ping() -> ParsedMessage {
+ ParsedMessage::Ping(Keepalive {
+ heads: HeadsSummary::of(Vec::new()),
+ epoch: Epoch::GENESIS,
+ epoch_seq: 0,
+ ready: true,
+ syncs_with_you: false,
+ })
+ }
+
+ /// Collects what a node receives.
+ fn record(transport: Arc<SimTransport>) -> Arc<Mutex<Vec<String>>> {
+ let received = Arc::new(Mutex::new(Vec::new()));
+ let sink = Arc::clone(&received);
+ tokio::spawn(async move {
+ transport
+ .start_consumer(Box::new(move |from, _message, _len| {
+ sink.lock().unwrap().push(from);
+ Box::pin(async { Ok(()) })
+ }))
+ .await
+ });
+ received
+ }
+
+ #[tokio::test]
+ async fn delivers_decodes_and_loses_when_down() {
+ let net = SimNet::new(1);
+ let a = net.transport("a");
+ let received = record(net.transport("b"));
+
+ let settle = || tokio::time::sleep(Duration::from_millis(30));
+ a.send_message("b", &ping()).await.unwrap();
+ settle().await;
+ net.take_down("b");
+ a.send_message("b", &ping()).await.unwrap();
+ settle().await;
+ net.bring_up("b");
+ a.send_message("b", &ping()).await.unwrap();
+ settle().await;
+
+ assert_eq!(*received.lock().unwrap(), vec!["a", "a"]);
+ let counts = net.counts();
+ assert_eq!((counts.sent, counts.delivered, counts.lost), (3, 2, 1));
+ assert_eq!(a.resolve_seed("b").await.unwrap(), "b");
+ assert!(a.resolve_seed("c").await.is_err());
+ }
+
+ #[tokio::test]
+ async fn loss_and_duplication_follow_the_seed() {
+ let run = |seed| async move {
+ let net = SimNet::new(seed);
+ net.set_default_link(LinkParams {
+ loss: 0.3,
+ duplicate: 0.2,
+ ..LinkParams::default()
+ });
+ let a = net.transport("a");
+ let _b = net.transport("b");
+ for _ in 0..200 {
+ a.send_message("b", &ping()).await.unwrap();
+ }
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ net.counts()
+ };
+ let first = run(7).await;
+ assert_eq!(first, run(7).await);
+ assert!(first.lost > 30 && first.lost < 90, "{first:?}");
+ assert!(first.duplicated > 10, "{first:?}");
+ }
+
+ #[tokio::test]
+ async fn a_link_overrides_the_default_in_one_direction() {
+ let net = SimNet::new(1);
+ net.set_link(
+ "a",
+ "b",
+ LinkParams {
+ loss: 1.0,
+ ..LinkParams::default()
+ },
+ );
+ let a = net.transport("a");
+ let b = net.transport("b");
+ let at_a = record(Arc::clone(&a));
+ let at_b = record(Arc::clone(&b));
+
+ a.send_message("b", &ping()).await.unwrap();
+ b.send_message("a", &ping()).await.unwrap();
+ tokio::time::sleep(Duration::from_millis(30)).await;
+
+ assert!(at_b.lock().unwrap().is_empty());
+ assert_eq!(*at_a.lock().unwrap(), vec!["b"]);
+ }
+
+ #[tokio::test]
+ async fn inject_returns_once_handled() {
+ let net = SimNet::new(1);
+ let received = record(net.transport("b"));
+ net.inject("ghost", "b", &ping()).await.unwrap();
+ assert_eq!(*received.lock().unwrap(), vec!["ghost"]);
+ assert!(net.inject("ghost", "nobody", &ping()).await.is_err());
+ }
+}