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>
163 lines
5.9 KiB
Rust
163 lines
5.9 KiB
Rust
//! Two real iroh Endpoints connected over a shared in-memory DHT.
|
|
//!
|
|
//! Validates the full stack end-to-end — `Builder::build` → `initial_join`
|
|
//! → WriteLoop publish → HealLoop read → iroh-gossip dial → `NeighborUp`
|
|
//! event — without touching the real Mainline DHT.
|
|
//!
|
|
//! Requires the `test-support` feature to inject the custom DHT backend.
|
|
|
|
#![cfg(feature = "test-support")]
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use iroh_gossip_rendezvous::sim::InMemoryDht;
|
|
use iroh_gossip_rendezvous::{Builder, DhtStatus};
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn two_nodes_publish_and_discover_via_dht() {
|
|
// Shared InMemoryDht — both nodes inject it via the `test-support`
|
|
// Builder::dht_backend hook, bypassing the real Mainline client.
|
|
//
|
|
// This test validates the **rendezvous** layer (DHT publish + read),
|
|
// not the gossip-dial handshake. iroh-gossip dialing requires N0
|
|
// discovery for network addresses and is subject to its own timing,
|
|
// so the stricter "neighbors see each other" claim lives in an
|
|
// `#[ignore]`d test below that's run explicitly.
|
|
let dht = Arc::new(InMemoryDht::new());
|
|
|
|
let rendezvous_a = Builder::default()
|
|
.passphrase("integration-test")
|
|
.app_salt("iroh-gossip-rendezvous/tests/two-node")
|
|
.heal_period(Duration::from_millis(200))
|
|
.write_period(Duration::from_secs(1))
|
|
.jitter(0.1)
|
|
.dht_backend(dht.clone())
|
|
.build()
|
|
.await
|
|
.expect("node A builds");
|
|
|
|
let rendezvous_b = Builder::default()
|
|
.passphrase("integration-test")
|
|
.app_salt("iroh-gossip-rendezvous/tests/two-node")
|
|
.heal_period(Duration::from_millis(200))
|
|
.write_period(Duration::from_secs(1))
|
|
.jitter(0.1)
|
|
.dht_backend(dht.clone())
|
|
.build()
|
|
.await
|
|
.expect("node B builds");
|
|
|
|
// Both nodes share the same passphrase + app_salt → same topic_id.
|
|
assert_eq!(rendezvous_a.topic_id(), rendezvous_b.topic_id());
|
|
|
|
// Give the write loop a couple of ticks to publish. Both nodes'
|
|
// `DhtStatus` should flip from Bootstrapping to Ready once the first
|
|
// write succeeds.
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
let a = rendezvous_a.state();
|
|
let b = rendezvous_b.state();
|
|
if a.dht_status == DhtStatus::Ready && b.dht_status == DhtStatus::Ready {
|
|
break;
|
|
}
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"DHT publishes didn't complete in 5s: A={:?} B={:?}",
|
|
a.dht_status,
|
|
b.dht_status
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
|
|
// Graceful shutdown.
|
|
rendezvous_a.shutdown().await;
|
|
rendezvous_b.shutdown().await;
|
|
}
|
|
|
|
/// End-to-end convergence over real iroh-gossip. Ignored by default
|
|
/// because iroh's N0 discovery + QUIC handshake is several-seconds-slow
|
|
/// on cold start and network-sensitive. Run with:
|
|
///
|
|
/// ```text
|
|
/// cargo test --test two_node_bootstrap --features test-support -- --ignored
|
|
/// ```
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[ignore = "slow: depends on iroh-gossip cold-start dialing"]
|
|
async fn two_nodes_converge_end_to_end() {
|
|
let dht = Arc::new(InMemoryDht::new());
|
|
let a = Builder::default()
|
|
.passphrase("integration-test")
|
|
.app_salt("iroh-gossip-rendezvous/tests/e2e")
|
|
.heal_period(Duration::from_millis(500))
|
|
.write_period(Duration::from_secs(1))
|
|
.dht_backend(dht.clone())
|
|
.build()
|
|
.await
|
|
.unwrap();
|
|
let b = Builder::default()
|
|
.passphrase("integration-test")
|
|
.app_salt("iroh-gossip-rendezvous/tests/e2e")
|
|
.heal_period(Duration::from_millis(500))
|
|
.write_period(Duration::from_secs(1))
|
|
.dht_backend(dht.clone())
|
|
.build()
|
|
.await
|
|
.unwrap();
|
|
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(60);
|
|
loop {
|
|
if a.state().neighbor_count > 0 && b.state().neighbor_count > 0 {
|
|
break;
|
|
}
|
|
assert!(std::time::Instant::now() < deadline, "never converged");
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
a.shutdown().await;
|
|
b.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn drop_cancels_background_tasks() {
|
|
let dht = Arc::new(InMemoryDht::new());
|
|
{
|
|
let _rendezvous = Builder::default()
|
|
.passphrase("integration-test")
|
|
.app_salt("iroh-gossip-rendezvous/tests/drop")
|
|
.heal_period(Duration::from_millis(100))
|
|
.write_period(Duration::from_millis(500))
|
|
.dht_backend(dht.clone())
|
|
.build()
|
|
.await
|
|
.expect("node builds");
|
|
// Let the background loops start and do at least one publish.
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
// Dropping the rendezvous triggers the cancellation token and the
|
|
// task JoinSets are aborted when dropped.
|
|
}
|
|
|
|
// After drop, wait a bit and verify no panics (tokio would surface them
|
|
// via the test harness). Successful drop = implicit pass.
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn multiple_subscribers_receive_events() {
|
|
let dht = Arc::new(InMemoryDht::new());
|
|
let rendezvous = Builder::default()
|
|
.passphrase("integration-test")
|
|
.app_salt("iroh-gossip-rendezvous/tests/multi-sub")
|
|
.dht_backend(dht.clone())
|
|
.build()
|
|
.await
|
|
.expect("build");
|
|
|
|
// Two subscribers, both should register.
|
|
let sub1 = rendezvous.subscribe();
|
|
let sub2 = rendezvous.subscribe();
|
|
assert_eq!(sub1.len(), sub2.len(), "both fresh subs start empty");
|
|
|
|
rendezvous.shutdown().await;
|
|
}
|