Meet everybody, not just the one you were told about

A device pointed at one member talked to that member and nobody else. The
only candidates an agent had were the ones on its command line and
whatever the cache remembered, so the network was a star around whichever
peer happened to be typed — while the signed state sitting in front of it
listed every other member by name.

Two sources fix that, and both produce candidates rather than facts.

Every author of a signed record is somebody to try. Those records reach us
through anybody, so a member is known to exist, and by id, long before it
is ever spoken to; an id with no address is still dialable where the
endpoint's own discovery can resolve one.

And members tell each other where they have seen the others. A
`ControlMessage::Peers` carries each member with the addresses the sender
observes for it — including the sender's own, which is the one thing
nobody else can pass on — in the same `ip:`/`relay:` spelling the cache
already uses, so one decoder serves both and neither can drift. An agent
that only ever accepts has no candidates of its own and is exactly the one
everybody was pointed at, so the addresses come from the live sessions as
well as the candidate list.

None of it authenticates anything. An introduction is not a vouching: the
handshake decides membership as before, and a candidate from a member is
tried exactly like one from a bootstrap entry or the cache. It is also
deliberately the shape a distributed hash table lookup would return, so
that becomes another source beside these rather than a redesign.

With the mesh pairwise, the relay is what it was meant to be: the way to
the one peer that cannot be reached directly, not the way the network is
held together.

Covered by three agents where two are told only about the first: each ends
up with both of the others, and the one nobody mentioned arrives as an
introduction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-22 01:59:50 +01:00
co-authored by Claude Opus 5
parent 3581feb9b9
commit 83d3445b7e
6 changed files with 294 additions and 7 deletions
+60
View File
@@ -137,3 +137,63 @@ async fn forgetting_a_network_removes_it_from_the_state_store() {
drop(restarted);
drop(dir);
}
#[tokio::test]
async fn being_told_about_one_member_is_enough_to_meet_them_all() {
// A network is a mesh, and which member happened to be named on a
// command line must not decide who a device ends up talking to. The
// newcomer is given one address and nothing else; everybody else is
// learned from the members it meets.
let (name, secret) = network("introductions");
// Three that already know each other, and nothing that resolves by
// network: no shared discovery here, only what members pass on.
let first_dir = tempfile::TempDir::new().unwrap();
let first = Agent::spawn(local_config(first_dir.path())).await.unwrap();
let network_id = first.join_network(&name, &secret).await.unwrap();
let second_dir = tempfile::TempDir::new().unwrap();
let second = Agent::spawn(
local_config(second_dir.path())
.with_discovery(Arc::new(StaticBootstrap::new([first.local_addr()]))),
)
.await
.unwrap();
second.join_network(&name, &secret).await.unwrap();
wait_for_peers(&first, network_id, 1).await;
let third_dir = tempfile::TempDir::new().unwrap();
let third = Agent::spawn(
local_config(third_dir.path())
.with_discovery(Arc::new(StaticBootstrap::new([first.local_addr()]))),
)
.await
.unwrap();
third.join_network(&name, &secret).await.unwrap();
// Each of the two newcomers was given only the first one's address,
// and each ends up with both of the others.
wait_for_peers(&second, network_id, 2).await;
wait_for_peers(&third, network_id, 2).await;
wait_for_peers(&first, network_id, 2).await;
// And the one they were not told about is there as an introduction:
// a candidate like any other, still authenticated by the handshake.
let status = second.network_status(network_id).await.unwrap();
let introduced = status
.candidates
.iter()
.find(|candidate| candidate.endpoint_id == third.endpoint_id())
.map(|candidate| candidate.source);
assert!(
matches!(
introduced,
Some(CandidateSource::Introduced) | Some(CandidateSource::Member)
),
"the third should have arrived from the others: {introduced:?}"
);
second.shutdown().await;
third.shutdown().await;
first.shutdown().await;
}