Working library with real iroh connections, not an interface sketch: - persistent device identity in state.sqlite, stable across restarts - deterministic network space derived from name + secret via HKDF-SHA256, with frozen labels and unambiguous length-prefixed encoding - replaceable discovery returning unverified candidates only; static bootstrap, in-memory test backend and a composite - real iroh connections plus an explicit mutual membership proof: HMAC-SHA256 over a role-separated transcript bound to the TLS exporter, the network id and both endpoint identities - small versioned control protocol: handshake, announcement, ping/pong - multiple networks per agent with enforced isolation - automatic reconnect with bounded backoff and jitter - mandatory state vs disposable cache, with a real directory ownership lock - status snapshots, event stream and honest diagnostics 47 integration and unit tests cover the required scenarios offline on loopback. Snapshots, revocations and WireGuard are designed for and documented, not implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
346 lines
11 KiB
Rust
346 lines
11 KiB
Rust
//! Scenarios 8 and 10: unreachable participants, bounded retries, and the
|
|
//! agent lifecycle including state directory ownership.
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
mod common;
|
|
|
|
use std::net::SocketAddr;
|
|
use std::time::Duration;
|
|
|
|
use common::{TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers};
|
|
use iroh::{EndpointAddr, SecretKey};
|
|
use tsunagi::agent::Event;
|
|
use tsunagi::config::ReconnectPolicy;
|
|
use tsunagi::discovery::SharedMemoryDiscovery;
|
|
use tsunagi::identity::NetworkKeys;
|
|
use tsunagi::proto::ControlMessage;
|
|
use tsunagi::{Agent, Error};
|
|
|
|
/// An endpoint id nobody is listening for, at an address nothing answers on.
|
|
fn dead_candidate() -> EndpointAddr {
|
|
let unreachable: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
|
EndpointAddr::new(SecretKey::generate().public()).with_ip_addr(unreachable)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_dead_candidate_does_not_hold_up_the_reachable_ones() {
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
let (name, secret) = network("dead-candidate");
|
|
let keys = NetworkKeys::derive(&name, &secret);
|
|
|
|
// Poison the rendezvous table before anybody real shows up.
|
|
let dead = dead_candidate();
|
|
discovery.insert_raw(keys.discovery_key(), dead.clone());
|
|
|
|
let a = TestAgent::spawn(&discovery).await.unwrap();
|
|
let b = TestAgent::spawn(&discovery).await.unwrap();
|
|
let mut events = a.agent.subscribe();
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
// The reachable peer still connects.
|
|
let peers = wait_for_peers(&a.agent, network_id, 1).await;
|
|
assert_eq!(peers, vec![b.agent.endpoint_id()]);
|
|
|
|
// And the dead candidate is reported as a failure, not silently forgotten.
|
|
wait_event(&mut events, |event| match event {
|
|
Event::DialFailed { peer, .. } if *peer == dead.id => Some(()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
let status = a.agent.network_status(network_id).await.unwrap();
|
|
assert!(
|
|
status
|
|
.candidates
|
|
.iter()
|
|
.any(|candidate| candidate.endpoint_id == dead.id
|
|
&& candidate.consecutive_failures > 0),
|
|
"a failing candidate must stay visible as an unverified candidate"
|
|
);
|
|
assert!(
|
|
status.peers.iter().all(|peer| peer.endpoint_id != dead.id),
|
|
"a candidate must never be reported as a peer"
|
|
);
|
|
|
|
a.agent.shutdown().await;
|
|
b.agent.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_vanished_peer_is_retried_with_backoff_and_others_keep_working() {
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
let (name, secret) = network("vanishing-peer");
|
|
|
|
let watcher = TestAgent::spawn_with(
|
|
|cfg| {
|
|
cfg.with_reconnect(ReconnectPolicy {
|
|
initial_delay: Duration::from_millis(50),
|
|
max_delay: Duration::from_millis(200),
|
|
factor: 1.5,
|
|
jitter: 0.2,
|
|
max_consecutive_failures: None,
|
|
})
|
|
},
|
|
&discovery,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let stayer = TestAgent::spawn(&discovery).await.unwrap();
|
|
let leaver = TestAgent::spawn(&discovery).await.unwrap();
|
|
|
|
let network_id = watcher.agent.join_network(&name, &secret).await.unwrap();
|
|
stayer.agent.join_network(&name, &secret).await.unwrap();
|
|
leaver.agent.join_network(&name, &secret).await.unwrap();
|
|
wait_for_peers(&watcher.agent, network_id, 2).await;
|
|
|
|
let leaver_id = leaver.agent.endpoint_id();
|
|
let mut events = watcher.agent.subscribe();
|
|
leaver.agent.shutdown().await;
|
|
drop(leaver);
|
|
|
|
wait_event(&mut events, |event| match event {
|
|
Event::PeerDisconnected { peer, .. } if *peer == leaver_id => Some(()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
// The other peer is untouched and still answers.
|
|
watcher
|
|
.agent
|
|
.send(
|
|
network_id,
|
|
stayer.agent.endpoint_id(),
|
|
ControlMessage::Ping {
|
|
seq: 3,
|
|
payload: b"still here".to_vec(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
wait_event(&mut events, |event| match event {
|
|
Event::MessageReceived {
|
|
peer,
|
|
message: ControlMessage::Pong { seq: 3, .. },
|
|
..
|
|
} if *peer == stayer.agent.endpoint_id() => Some(()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
// The watcher does retry the peer that went away.
|
|
wait_event(&mut events, |event| match event {
|
|
Event::DialFailed { peer, .. } if *peer == leaver_id => Some(()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
// Retries are bounded by the backoff rather than spinning.
|
|
settle().await;
|
|
let status = watcher.agent.network_status(network_id).await.unwrap();
|
|
let failures = status
|
|
.candidates
|
|
.iter()
|
|
.find(|candidate| candidate.endpoint_id == leaver_id)
|
|
.map(|candidate| candidate.consecutive_failures)
|
|
.unwrap_or(0);
|
|
assert!(
|
|
(1..=40).contains(&failures),
|
|
"expected bounded backed-off retries, got {failures}"
|
|
);
|
|
assert_eq!(
|
|
status.connected_peers(),
|
|
vec![stayer.agent.endpoint_id()],
|
|
"the surviving peer keeps its session"
|
|
);
|
|
|
|
watcher.agent.shutdown().await;
|
|
stayer.agent.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retries_stop_when_the_network_is_deactivated() {
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
let (name, secret) = network("stop-retrying");
|
|
let keys = NetworkKeys::derive(&name, &secret);
|
|
discovery.insert_raw(keys.discovery_key(), dead_candidate());
|
|
|
|
let agent = TestAgent::spawn_with(
|
|
|cfg| {
|
|
cfg.with_discovery_interval(Duration::from_millis(80))
|
|
.with_reconnect(ReconnectPolicy {
|
|
initial_delay: Duration::from_millis(20),
|
|
max_delay: Duration::from_millis(60),
|
|
factor: 1.2,
|
|
jitter: 0.1,
|
|
max_consecutive_failures: None,
|
|
})
|
|
},
|
|
&discovery,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut events = agent.agent.subscribe();
|
|
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
// Retries are definitely happening.
|
|
wait_event(&mut events, |event| match event {
|
|
Event::DialFailed { .. } => Some(()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
agent.agent.deactivate_network(network_id).await.unwrap();
|
|
|
|
// Drain whatever was already queued, then require silence.
|
|
while events.try_recv().is_ok() {}
|
|
settle().await;
|
|
let mut stragglers = 0;
|
|
while let Ok(event) = events.try_recv() {
|
|
if matches!(event, Event::DialFailed { .. }) {
|
|
stragglers += 1;
|
|
}
|
|
}
|
|
assert_eq!(
|
|
stragglers, 0,
|
|
"a deactivated network must stop dialling entirely"
|
|
);
|
|
|
|
agent.agent.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_second_agent_on_the_same_state_directory_is_refused() {
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
let first = Agent::spawn(config_with(dir.path(), &discovery))
|
|
.await
|
|
.unwrap();
|
|
|
|
let second = Agent::spawn(config_with(dir.path(), &discovery)).await;
|
|
match second {
|
|
Err(Error::StateLocked { path }) => {
|
|
assert!(path.starts_with(dir.path()));
|
|
}
|
|
Err(other) => panic!("expected StateLocked, got {other:?}"),
|
|
Ok(agent) => {
|
|
agent.shutdown().await;
|
|
panic!("two live agents must not share one state directory");
|
|
}
|
|
}
|
|
|
|
// After a clean stop the directory is immediately claimable again.
|
|
let device_id = first.endpoint_id();
|
|
first.shutdown().await;
|
|
drop(first);
|
|
|
|
let third = Agent::spawn(config_with(dir.path(), &discovery))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(third.endpoint_id(), device_id);
|
|
third.shutdown().await;
|
|
drop(third);
|
|
drop(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_releases_resources_and_rejects_further_work() {
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
let (name, secret) = network("clean-stop");
|
|
|
|
let a = TestAgent::spawn(&discovery).await.unwrap();
|
|
let b = TestAgent::spawn(&discovery).await.unwrap();
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
wait_for_peers(&a.agent, network_id, 1).await;
|
|
|
|
a.agent.shutdown().await;
|
|
|
|
// The endpoint is closed and the networks are gone.
|
|
assert!(a.agent.endpoint().is_closed());
|
|
assert!(matches!(
|
|
a.agent.network_status(network_id).await,
|
|
Err(Error::NetworkNotActive(_))
|
|
));
|
|
assert!(
|
|
a.agent
|
|
.send(
|
|
network_id,
|
|
b.agent.endpoint_id(),
|
|
ControlMessage::Ping {
|
|
seq: 1,
|
|
payload: Vec::new()
|
|
}
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
|
|
// Shutting down twice is harmless.
|
|
a.agent.shutdown().await;
|
|
|
|
// The peer notices and carries on.
|
|
let status = b.agent.network_status(network_id).await.unwrap();
|
|
assert_eq!(status.state, tsunagi::agent::NetworkState::Active);
|
|
|
|
b.agent.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn several_independent_agents_coexist_in_one_process() {
|
|
// No global state: two completely separate rendezvous tables, two networks
|
|
// with the same name but different secrets, four agents, one process.
|
|
let left = SharedMemoryDiscovery::new();
|
|
let right = SharedMemoryDiscovery::new();
|
|
let (name, left_secret) = network("same-name-different-world");
|
|
let (_, right_secret) = network("ignored");
|
|
|
|
let l1 = TestAgent::spawn(&left).await.unwrap();
|
|
let l2 = TestAgent::spawn(&left).await.unwrap();
|
|
let r1 = TestAgent::spawn(&right).await.unwrap();
|
|
let r2 = TestAgent::spawn(&right).await.unwrap();
|
|
|
|
let left_id = l1.agent.join_network(&name, &left_secret).await.unwrap();
|
|
l2.agent.join_network(&name, &left_secret).await.unwrap();
|
|
let right_id = r1.agent.join_network(&name, &right_secret).await.unwrap();
|
|
r2.agent.join_network(&name, &right_secret).await.unwrap();
|
|
assert_ne!(left_id, right_id);
|
|
|
|
wait_for_peers(&l1.agent, left_id, 1).await;
|
|
wait_for_peers(&r1.agent, right_id, 1).await;
|
|
assert_eq!(
|
|
l1.agent
|
|
.network_status(left_id)
|
|
.await
|
|
.unwrap()
|
|
.connected_peers(),
|
|
vec![l2.agent.endpoint_id()]
|
|
);
|
|
|
|
for agent in [l1, l2, r1, r2] {
|
|
agent.agent.shutdown().await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_agent_without_discovery_still_starts_and_serves_status() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let agent = Agent::spawn(local_config(dir.path())).await.unwrap();
|
|
let (name, secret) = network("no-discovery");
|
|
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
let status = agent.network_status(network_id).await.unwrap();
|
|
assert!(status.peers.is_empty());
|
|
assert!(status.candidates.is_empty());
|
|
agent.recheck().await;
|
|
assert!(agent.recheck_network(network_id).await.is_ok());
|
|
|
|
agent.shutdown().await;
|
|
drop(agent);
|
|
drop(dir);
|
|
}
|