From b3cbfa463fb0f8d445f9381fd5cc15377d535620 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:03:07 +0000 Subject: [PATCH] fix(mesh): a peer send sweeps every shared network, not one slot's guess The tech's KVM Site door opened a browser tab onto a tunnel that never carried a byte - and fleet KVMs have shown the same "online, wired up, nothing connects" ghost before. Root cause, finally: peer_networks is a single slot per peer, overwritten by EVERY inbound frame, and a KVM is multi-homed by design - fleet mesh, local-claim, CEC help mesh at once, broadcasting presence on all of them. The slot therefore names whichever mesh delivered the last advert, which (hub topologies especially) is not necessarily a mesh that carries OUR frames back. send_control then made exactly one attempt on that guess: a route offer into the void, so the site tunnel never activated. Remote control survived only because a live session's inbound frame firehose keeps re-pinning the slot to the session's mesh - the quiet site plane had no such luck. The KVM's Go bridge already ships the cure for its own replies (sendSiteFrame: "we broadcast across our networks... the correct network's send reaches them and others are harmless no-ops"). Mirror it on the node side: - send_control now walks ordered_send_candidates - the slot's network (last proven), then the primary, then every other joined network - and returns on the first daemon-CONFIRMED delivery. - The confirming network is written back to the slot (note_peer_network), so the media frames that follow a route offer (site tunnel bytes, input) ride the mesh that provably reaches the peer, not the last one a presence advert happened in on. - The order lives in a pure fn with a test, like seed_peer_networks. Every send_control caller inherits the sweep: site route offers, the display/input offers, claim, ownership, fleet-key handoff, upgrade asks. cargo test --lib: 155 passed. clippy -D warnings: clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YYdqmManehKSKxwzcfeeU4 --- node/src/mesh.rs | 141 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 123 insertions(+), 18 deletions(-) diff --git a/node/src/mesh.rs b/node/src/mesh.rs index e67fbee..26f0192 100644 --- a/node/src/mesh.rs +++ b/node/src/mesh.rs @@ -10333,6 +10333,37 @@ impl Mesh { Ok(delivered) } + /// Ordered networks to try when sending to `peer`: the last network the + /// slot proved (an inbound frame, or a prior confirmed send), then the + /// primary network, then every other joined network. A multi-homed peer — + /// a KVM sits on its fleet mesh, the local-claim mesh and the CEC help + /// mesh at once, broadcasting presence on all of them — keeps overwriting + /// the single `peer_networks` slot with whichever mesh delivered its last + /// advert, and that mesh is not necessarily one that carries OUR frames + /// back (hub topologies relay a beacon without giving us a direct lane). + /// One slot, one attempt was the lottery behind "shows up online, the + /// site opens, but nothing connects." + fn peer_network_candidates(&self, peer: &str) -> Vec { + let st = self.state.lock(); + ordered_send_candidates( + st.peer_networks.get(pubkey_part(peer)), + st.network.as_ref(), + &st.networks, + ) + } + + /// Record that a send to `peer` was daemon-confirmed on `network`, so the + /// tunnel traffic that follows (site/input frames ride the slot) sticks to + /// a mesh that provably reaches the peer — until the next inbound frame or + /// confirmed send updates it again. + fn note_peer_network(&self, peer: &str, network: &str) { + let mut st = self.state.lock(); + let key = pubkey_part(peer).to_string(); + if st.peer_networks.get(&key).map(String::as_str) != Some(network) { + st.peer_networks.insert(key, network.to_string()); + } + } + /// Send a control message to one peer, reporting whether the daemon /// actually dispatched it. The daemon's peer set is keyed by the *bare /// pubkey* (what signaling announces), while AllMyStuff mostly holds @@ -10340,28 +10371,43 @@ impl Mesh { /// — so the id is canonicalised here, at the daemon boundary. Addressing /// the display form made every send come back "peer not found", an error /// this used to swallow: a claim showed "asking…" and then nothing. + /// + /// Tries every shared network until the daemon confirms one (the KVM's + /// bridge sweeps its networks the same way — "the correct network's send + /// reaches them and others are harmless no-ops"), then pins the peer's + /// slot to the network that actually delivered, so the media frames that + /// follow a route offer ride the proven mesh instead of the last one a + /// presence advert happened to arrive on. async fn send_control(&self, peer: &str, message: &ControlMessage) -> Result<(), String> { - let Some(network) = self.network_for_peer(peer) else { + let candidates = self.peer_network_candidates(peer); + if candidates.is_empty() { return Err(format!("no shared network with {peer}")); - }; + } let payload = serde_json::to_value(message).map_err(|e| e.to_string())?; - let resp = self - .client - .request(&Request::ChannelSendTo { - network, - channel: CHANNEL_CONTROL.to_string(), - peer: pubkey_part(peer).to_string(), - payload, - }) - .await - .map_err(|e| e.to_string())?; - if resp.ok { - Ok(()) - } else { - let err = resp.error.unwrap_or_else(|| "channel send failed".into()); - tracing::warn!("control send to {peer} failed: {err}"); - Err(err) + let mut last_err = String::new(); + for network in candidates { + let resp = self + .client + .request(&Request::ChannelSendTo { + network: network.clone(), + channel: CHANNEL_CONTROL.to_string(), + peer: pubkey_part(peer).to_string(), + payload: payload.clone(), + }) + .await; + match resp { + Ok(r) if r.ok => { + self.note_peer_network(peer, &network); + return Ok(()); + } + Ok(r) => { + last_err = r.error.unwrap_or_else(|| "channel send failed".into()); + } + Err(e) => last_err = e.to_string(), + } } + tracing::warn!("control send to {peer} failed on every shared network: {last_err}"); + Err(last_err) } fn emit_snapshot(&self) { @@ -10854,6 +10900,25 @@ fn seed_peer_networks(map: &mut HashMap, peers: &[Value], networ } } +/// The try-order for sending to one peer: its slot (last proven network) +/// first, then the primary, then every other joined network, deduped in that +/// priority. Pure, so the order — the part that decides whether a +/// multi-homed peer's tunnel finds its live mesh — is testable on its own; +/// [`Mesh::peer_network_candidates`] feeds it the live state. +fn ordered_send_candidates( + slot: Option<&String>, + primary: Option<&String>, + joined: &[String], +) -> Vec { + let mut out: Vec = Vec::new(); + for n in slot.into_iter().chain(primary).chain(joined) { + if !out.contains(n) { + out.push(n.clone()); + } + } + out +} + /// One peer row's link class off the daemon's `selected_pair` — the /// daemon's own LAN/STUN/TURN rule (host↔host = LAN, which already folds /// in its private-address override), reduced to the two classes the video @@ -11352,6 +11417,46 @@ mod tests { assert!(!is_terminal_route(&display)); } + #[test] + fn ordered_send_candidates_tries_slot_then_primary_then_the_rest() { + let slot = "cec-help".to_string(); + let primary = "joining".to_string(); + let joined = vec![ + "joining".to_string(), + "allmystuff-local-claim-v1".to_string(), + "cec-help".to_string(), + "fleet-mesh".to_string(), + ]; + // Slot first (last proven), then primary, then the remaining joined + // networks — each exactly once. This order is what lets a send to a + // multi-homed peer (a KVM on fleet + local-claim + help mesh at once) + // fall through to the mesh that actually carries our frames. + assert_eq!( + ordered_send_candidates(Some(&slot), Some(&primary), &joined), + vec![ + "cec-help".to_string(), + "joining".to_string(), + "allmystuff-local-claim-v1".to_string(), + "fleet-mesh".to_string(), + ] + ); + // No slot yet (never heard from the peer): primary leads. + assert_eq!( + ordered_send_candidates(None, Some(&primary), &joined), + vec![ + "joining".to_string(), + "allmystuff-local-claim-v1".to_string(), + "cec-help".to_string(), + "fleet-mesh".to_string(), + ] + ); + // Nothing known at all: nothing to try. + assert_eq!( + ordered_send_candidates(None, None, &[]), + Vec::::new() + ); + } + #[test] fn seed_peer_networks_fills_gaps_for_reachable_peers_only() { use serde_json::json;