Say when there is nobody to contact

Two freshly wiped devices joined the same network, each got an address,
and nothing happened. The report said "none known yet; nobody else has
joined", which reads as patience — as if somebody were expected and had
not arrived. What had actually happened is that neither agent had anywhere
to look: this project publishes nothing about who is in which network, so
a first meeting needs one of them to be told the other's endpoint id, and
the hints an agent remembers from earlier sessions had gone with the wipe.

Candidates are now in the report, and having none of them with nobody
connected is said plainly, with both ways out of it and this device's own
id ready to hand to the other side. Having some and no sessions yet is the
ordinary state of a network coming up, and stays ungraded.

The local control protocol is 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-22 00:51:39 +01:00
co-authored by Claude Opus 5
parent 9b240672e6
commit 3044e592bd
4 changed files with 88 additions and 10 deletions
+7
View File
@@ -95,6 +95,13 @@ and join the same network with the secret that was printed:
./target/release/tsunagi join --network lab --secret tsn1...
```
**`--peer` is how the first meeting happens.** Two agents that have never
met have nothing to go on: this project publishes nothing about who is in
which network, by design. One of them has to be told the other's endpoint
id — after that each remembers the other and finds it again by itself, so
it is needed once. An agent with nobody to contact says so in `status`
rather than sitting there looking patient.
Within a few seconds both print something like:
```text
+73 -9
View File
@@ -2529,15 +2529,40 @@ fn network_section(
let online = rows.iter().filter(|row| row.online()).count();
if rows.is_empty() {
// Why there is nobody, rather than just that there is nobody: the
// two reasons want different actions.
section.push(Row::new(
Health::Info,
"members",
match (&network.range, &network.range_conflict) {
(None, Some(_)) => "none: this network has no range to allocate from",
_ => "none known yet; nobody else has joined",
},
));
// three reasons want different actions, and the third one used to
// read as the first.
match (&network.range, &network.range_conflict, network.candidates) {
(None, Some(_), _) => section.push(Row::new(
Health::Info,
"members",
"none: this network has no range to allocate from",
)),
// Nobody to contact and nowhere to look. An agent finds a peer
// by being told about one, or from what it remembers of an
// earlier session — with neither it waits for ever, and the
// report should say so rather than imply patience.
(_, _, 0) => section.push(
Row::new(
Health::Degraded,
"members",
"none, and nobody to contact: no candidates in this network",
)
.with_note(format!(
"somebody has to make the introduction. Start this agent with \
`--peer <their-endpoint-id>`, or have them start theirs with \
`--peer {}`. Once they have met, each remembers the other.",
short(own_id, 12)
)),
),
_ => section.push(Row::new(
Health::Info,
"members",
format!(
"none yet · {} candidate(s) being tried",
network.candidates
),
)),
}
} else {
section.push(Row::new(
Health::Info,
@@ -3504,6 +3529,7 @@ async fn build_report(
name: network.name.to_string(),
network_id: network.network_id.to_string(),
active: matches!(network.state, tsunagi::agent::NetworkState::Active),
candidates: network.candidates.len() as u32,
peers: network
.peers
.iter()
@@ -3798,6 +3824,7 @@ mod status_tests {
name: "LAB".into(),
network_id: "xa7gyz".into(),
active: true,
candidates: 1,
peers: vec![PeerReport {
endpoint_id: ONLINE.into(),
hostname: Some("music".into()),
@@ -3847,6 +3874,43 @@ mod status_tests {
assert!(text.contains("mistyped secret"), "{text}");
}
#[test]
fn an_agent_with_nobody_to_contact_says_so_instead_of_waiting_quietly() {
// The wiped-and-restarted case: both devices join the same network
// and sit there. An agent finds a peer by being told about one or
// by remembering an earlier session, and with neither it will wait
// for ever — which "nobody else has joined" reads as patience.
let mut network = network_after_a_peer_returned();
network.peers.clear();
network.members.clear();
network.overlay = None;
network.candidates = 0;
let mut out = report::Report::new();
out.push(network_section(&network, OWN, false));
let text = out.render(false);
assert!(text.contains("nobody to contact"), "{text}");
assert!(text.contains("--peer"), "the fix is named: {text}");
assert!(text.contains(OWN), "with this device's own id: {text}");
assert_eq!(out.worst(), Health::Degraded, "{text}");
}
#[test]
fn candidates_with_nobody_connected_yet_is_patience_rather_than_a_fault() {
// Something to try is the ordinary state of a network coming up.
let mut network = network_after_a_peer_returned();
network.peers.clear();
network.members.clear();
network.overlay = None;
network.candidates = 2;
let mut out = report::Report::new();
out.push(network_section(&network, OWN, false));
let text = out.render(false);
assert!(text.contains("2 candidate(s) being tried"), "{text}");
assert_ne!(out.worst(), Health::Degraded, "{text}");
}
#[test]
fn a_network_with_no_range_says_that_is_why_it_is_empty() {
// Rather than "nobody else has joined", which points at the wrong
+7
View File
@@ -267,6 +267,13 @@ pub struct NetworkReport {
/// left to report is a dial-failure counter — which describes the symptom
/// and not the cause.
pub members: Vec<MemberReport>,
/// Unverified candidates this network currently knows of.
///
/// Not peers: somewhere to try. Zero of them, with nobody connected,
/// is the difference between "nobody has joined yet" and "this agent
/// has no way to reach anybody" — which look identical in a report
/// that counts only members.
pub candidates: u32,
/// Outbound dials that failed.
pub dial_failures: u64,
/// Handshakes rejected in either direction.
+1 -1
View File
@@ -367,7 +367,7 @@ pub async fn set_dns(
///
/// Bump it whenever [`Request`], [`Response`] or anything they contain
/// changes shape.
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 11]);
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 12]);
async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> Result<()> {
let encoded = postcard::to_stdvec(value)