Initial release: v0.1.0
Passphrase-based peer rendezvous for iroh-gossip swarms over the public Mainline DHT. Two nodes that share a passphrase + application salt find each other and join the same gossip topic — no bootstrap server, no prior knowledge of peer addresses. The algorithm is a clock-free variant of HyParView-style partition healing on BEP 44 mutable items, with K-sharded slots, logical aging with random-tie eviction, vouching of up to V neighbors at age 1, and a two-layer AEAD record envelope. Full spec in PROTOCOL.md. Ships with: * 119 tests (unit / property / integration / doctest) all green * 100% mutation-testing catch rate (cargo-mutants) * cargo-fuzz harnesses for the parse surface * Kani proofs of no-UB / no-panic on crypto::epoch_of and wire::decode_entries across their full symbolic input spaces * Stateright distributed-system model: safety invariants S1 + S2 proved exhaustively for N ≤ 6, K ≤ 3; liveness witness under weak fairness * Miri green on the synchronous modules * cargo-deny + cargo-audit clean * criterion benches * Full-matrix CI (Linux + macOS + Windows × stable + MSRV 1.93.0) * Dedicated nightly lane for Miri / mutation / fuzz / Kani / Stateright Pinned iroh 0.98.x. License MIT. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
54 files changed
+16897
No files matched your search
@@ -0,0 +1,120 @@
|
||||
//! Minimal end-to-end demo: join a swarm, broadcast a message every few
|
||||
//! seconds, print everything received. Run two instances with the same
|
||||
//! passphrase + app_salt on two machines and they'll find each other.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --example quickstart -- --passphrase my-secret --app my-app/v1
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use iroh_gossip_rendezvous::Rendezvous;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
/// Shared passphrase. All instances with the same passphrase+app salt meet.
|
||||
#[arg(long, default_value = "quickstart-demo")]
|
||||
passphrase: String,
|
||||
|
||||
/// Application salt — opaque label identifying your app + version.
|
||||
#[arg(long, default_value = "iroh-gossip-rendezvous/quickstart/v1")]
|
||||
app: String,
|
||||
|
||||
/// Broadcast a message every N seconds.
|
||||
#[arg(long, default_value_t = 5)]
|
||||
broadcast_interval_secs: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
println!(
|
||||
"joining rendezvous (passphrase=\"{}\", app=\"{}\")",
|
||||
args.passphrase, args.app
|
||||
);
|
||||
let rendezvous = Rendezvous::join(&args.passphrase, &args.app).await?;
|
||||
println!("node id: {}", rendezvous.node_id().fmt_short());
|
||||
println!(
|
||||
"topic: {}",
|
||||
hex::encode(&rendezvous.topic_id().as_bytes()[..8])
|
||||
);
|
||||
|
||||
// Subscriber for incoming events.
|
||||
let mut rx = rendezvous.subscribe();
|
||||
|
||||
// Broadcaster loop.
|
||||
let sender = rendezvous.sender().clone();
|
||||
let my_id = rendezvous.node_id().fmt_short();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(args.broadcast_interval_secs));
|
||||
let mut counter: u64 = 0;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
counter += 1;
|
||||
let msg = format!("hello from {my_id} (#{counter})");
|
||||
if let Err(e) = sender.broadcast(bytes::Bytes::from(msg)).await {
|
||||
eprintln!("broadcast error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Periodic state print.
|
||||
let state_handle = {
|
||||
let r = &rendezvous;
|
||||
async {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||
let s = r.state();
|
||||
println!(
|
||||
"[state] neighbors={} dht={} shards={}",
|
||||
s.neighbor_count, s.dht_status, s.active_shards,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Receive loop.
|
||||
let recv_handle = async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||
let delivered = String::from_utf8_lossy(&msg.content);
|
||||
println!("[recv] from={} {delivered}", msg.delivered_from.fmt_short());
|
||||
}
|
||||
Ok(iroh_gossip::api::Event::NeighborUp(id)) => {
|
||||
println!("[neighbor up] {}", id.fmt_short());
|
||||
}
|
||||
Ok(iroh_gossip::api::Event::NeighborDown(id)) => {
|
||||
println!("[neighbor down] {}", id.fmt_short());
|
||||
}
|
||||
Ok(iroh_gossip::api::Event::Lagged) => {
|
||||
eprintln!("[warn] event stream lagged");
|
||||
}
|
||||
Err(RecvError::Lagged(n)) => {
|
||||
eprintln!("[warn] subscription lagged; dropped {n}");
|
||||
}
|
||||
Err(RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
() = recv_handle => {}
|
||||
() = state_handle => {}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
println!("\nshutting down...");
|
||||
}
|
||||
}
|
||||
|
||||
rendezvous.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Discrete-event simulator for the rendezvous algorithm.
|
||||
//!
|
||||
//! Runs the protocol (Merge + heal-read) directly against an in-memory DHT —
|
||||
//! no iroh-gossip, no network. Drives the partition-healing and cold-start
|
||||
//! scenarios from §8 of the design doc.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release --example simulator --features sim -- \
|
||||
//! --scenario cold-start --nodes 10 --runs 50
|
||||
//! cargo run --release --example simulator --features sim -- \
|
||||
//! --scenario partition --nodes 10 --runs 50
|
||||
//! cargo run --release --example simulator --features sim -- \
|
||||
//! --scenario asymmetric --minority 1 --majority 50 --runs 50
|
||||
//! ```
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, ValueEnum};
|
||||
use iroh_gossip_rendezvous::sim::{self, Outcome, Scenario, SimConfig};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "iroh-gossip-rendezvous discrete-event simulator")]
|
||||
struct Args {
|
||||
/// Scenario to run.
|
||||
#[arg(long, value_enum, default_value_t = ScenarioArg::ColdStart)]
|
||||
scenario: ScenarioArg,
|
||||
|
||||
/// Number of runs.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
runs: usize,
|
||||
|
||||
/// Max rounds per run before declaring it unhealed.
|
||||
#[arg(long, default_value_t = 30)]
|
||||
max_rounds: usize,
|
||||
|
||||
/// Total nodes (cold-start) or per-partition (partition).
|
||||
#[arg(long, default_value_t = 6)]
|
||||
nodes: usize,
|
||||
|
||||
/// Minority size (asymmetric only).
|
||||
#[arg(long, default_value_t = 1)]
|
||||
minority: usize,
|
||||
|
||||
/// Majority size (asymmetric only).
|
||||
#[arg(long, default_value_t = 20)]
|
||||
majority: usize,
|
||||
|
||||
/// Shard count K.
|
||||
#[arg(long, default_value_t = 3)]
|
||||
shards: usize,
|
||||
|
||||
/// Base passphrase. Per-run seeding appends the run index.
|
||||
#[arg(long, default_value = "simulator")]
|
||||
passphrase: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
enum ScenarioArg {
|
||||
ColdStart,
|
||||
Partition,
|
||||
Asymmetric,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
rt.block_on(run(args))
|
||||
}
|
||||
|
||||
async fn run(args: Args) -> Result<()> {
|
||||
let cfg = SimConfig {
|
||||
shards: args.shards,
|
||||
max_rounds: args.max_rounds,
|
||||
..SimConfig::default()
|
||||
};
|
||||
|
||||
let scenario = match args.scenario {
|
||||
ScenarioArg::ColdStart => Scenario::ColdStart { nodes: args.nodes },
|
||||
ScenarioArg::Partition => Scenario::Partition {
|
||||
a: args.nodes / 2,
|
||||
b: args.nodes - args.nodes / 2,
|
||||
},
|
||||
ScenarioArg::Asymmetric => Scenario::Asymmetric {
|
||||
minority: args.minority,
|
||||
majority: args.majority,
|
||||
},
|
||||
};
|
||||
|
||||
let mut outcomes: Vec<Outcome> = Vec::with_capacity(args.runs);
|
||||
for run_idx in 0..args.runs {
|
||||
let pass = format!("{}-{run_idx}", args.passphrase);
|
||||
let outcome = sim::run(&pass, scenario, &cfg).await;
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
report(&args, scenario, &outcomes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report(args: &Args, scenario: Scenario, outcomes: &[Outcome]) {
|
||||
let total = outcomes.len();
|
||||
let healed: Vec<usize> = outcomes.iter().filter_map(|o| o.healed_in).collect();
|
||||
let unhealed = total - healed.len();
|
||||
let pct = healed.len() as f64 / total as f64 * 100.0;
|
||||
|
||||
println!("scenario: {scenario:?}");
|
||||
println!("shards (K): {}", args.shards);
|
||||
println!("max rounds: {}", args.max_rounds);
|
||||
println!("runs: {total}");
|
||||
println!("healed: {} ({:.1}%)", healed.len(), pct);
|
||||
println!("unhealed: {unhealed}");
|
||||
if !healed.is_empty() {
|
||||
let mut sorted = healed.clone();
|
||||
sorted.sort_unstable();
|
||||
let min = sorted[0];
|
||||
let max = sorted[sorted.len() - 1];
|
||||
let p50 = sorted[sorted.len() / 2];
|
||||
let p95_idx = ((sorted.len() * 95) / 100)
|
||||
.saturating_sub(1)
|
||||
.min(sorted.len() - 1);
|
||||
let p95 = sorted[p95_idx];
|
||||
let mean = sorted.iter().sum::<usize>() as f64 / sorted.len() as f64;
|
||||
println!("rounds: min={min} p50={p50} p95={p95} max={max} mean={mean:.1}");
|
||||
}
|
||||
let avg_reads = outcomes.iter().map(|o| o.dht_reads as f64).sum::<f64>() / total as f64;
|
||||
let avg_writes = outcomes.iter().map(|o| o.dht_writes as f64).sum::<f64>() / total as f64;
|
||||
println!("dht ops: avg reads {avg_reads:.1}, avg writes {avg_writes:.1}");
|
||||
}
|
||||
Reference in new issue
Block a user