diff --git a/README.md b/README.md index 005b426..b86fa03 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index c2e8dd5..6933855 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -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 `, 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 diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index 6a7241a..264c6e4 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -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, + /// 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. diff --git a/crates/tsunagi/src/ipc/unix.rs b/crates/tsunagi/src/ipc/unix.rs index c5daa0b..de664e5 100644 --- a/crates/tsunagi/src/ipc/unix.rs +++ b/crates/tsunagi/src/ipc/unix.rs @@ -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(stream: &mut UnixStream, value: &T) -> Result<()> { let encoded = postcard::to_stdvec(value)