use std::sync::Arc;
use iroh::Watcher;
use mcpmesh_local_api::{RelayInfo, SelfNetwork};
use crate::util::epoch_now_i64;
use super::MeshState;
pub(crate) fn project(
relays: impl IntoIterator<Item = (String, bool)>,
direct_addrs: Vec<String>,
last_change_epoch: Option<i64>,
identity_conflict_epoch: Option<i64>,
) -> SelfNetwork {
let relays: Vec<RelayInfo> = relays
.into_iter()
.map(|(url, connected)| RelayInfo { url, connected })
.collect();
let online = relays.iter().any(|r| r.connected);
let home_relay = relays.iter().find(|r| r.connected).map(|r| r.url.clone());
SelfNetwork {
online,
home_relay,
relays,
direct_addrs,
last_change_epoch,
identity_conflict_epoch,
}
}
pub(crate) fn read_current(mesh: &MeshState, last_change_epoch: Option<i64>) -> SelfNetwork {
let relays = mesh
.endpoint
.home_relay_status()
.get()
.into_iter()
.map(|s| (super::reach::sanitize_relay_url(s.url()), s.is_connected()));
let direct_addrs = mesh
.endpoint
.addr()
.addrs
.iter()
.filter_map(|a| match a {
iroh::TransportAddr::Ip(sock) => Some(sock.to_string()),
_ => None,
})
.collect();
project(
relays,
direct_addrs,
last_change_epoch,
mesh.identity_conflict_epoch(),
)
}
pub fn spawn_self_net_watch(mesh: Arc<MeshState>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut watcher = mesh.endpoint.home_relay_status();
let mut previous = project(std::iter::empty(), Vec::new(), None, None);
loop {
let current = read_current(&mesh, None);
if signature(¤t) != signature(&previous) {
let stamp = epoch_now_i64();
*mesh
.self_net_change
.lock()
.expect("self_net_change lock not poisoned") = Some(stamp);
let frame = SelfNetwork {
last_change_epoch: Some(stamp),
..current.clone()
};
let _ = mesh.self_net_bcast.send(frame);
previous = current;
}
if watcher.updated().await.is_err() {
return;
}
}
})
}
type Posture<'a> = (bool, Option<&'a str>, Vec<(&'a str, bool)>, Option<i64>);
fn signature(net: &SelfNetwork) -> Posture<'_> {
(
net.online,
net.home_relay.as_deref(),
net.relays
.iter()
.map(|r| (r.url.as_str(), r.connected))
.collect(),
net.identity_conflict_epoch,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_selects_the_first_connected_relay_as_home() {
let net = project(
[
("https://a.example:443".to_string(), false),
("https://b.example:443".to_string(), true),
("https://c.example:443".to_string(), true),
],
vec!["192.168.1.2:4444".into()],
None,
None,
);
assert!(net.online);
assert_eq!(net.home_relay.as_deref(), Some("https://b.example:443"));
assert_eq!(net.relays.len(), 3);
let net = project(
[("https://a.example:443".to_string(), false)],
Vec::new(),
None,
None,
);
assert!(!net.online, "a known-but-disconnected relay is not online");
assert_eq!(net.home_relay, None);
let net = project(std::iter::empty::<(String, bool)>(), Vec::new(), None, None);
assert!(!net.online, "no relays configured (relay_mode=disabled)");
assert!(net.relays.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn an_observation_reaches_status_through_the_shared_cell() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
std::fs::write(&cfg, "").unwrap();
let mesh = crate::daemon::testutil::hermetic_mesh(cfg).await;
assert_eq!(
read_current(&mesh, None).identity_conflict_epoch,
None,
"with no cell adopted the field is absent"
);
let shared = std::sync::Arc::new(crate::diag::IdentityConflict::default());
mesh.adopt_identity_conflict(shared.clone());
assert_eq!(
read_current(&mesh, None).identity_conflict_epoch,
None,
"adopting a cell is not itself an observation"
);
shared.observe(1_753_900_000);
assert_eq!(
read_current(&mesh, None).identity_conflict_epoch,
Some(1_753_900_000),
"an observation recorded by the HOST's layer must reach this node's status — if these \
are different cells the feature is silently disconnected, which is what shipped"
);
}
#[test]
fn a_duplicate_identity_observation_is_a_transition() {
let with = |conflict| {
project(
[("https://a.example:443".to_string(), true)],
vec!["10.0.0.1:1".into()],
None,
conflict,
)
};
let clean = with(None);
assert_eq!(
clean.identity_conflict_epoch, None,
"a node with a unique identity reports nothing"
);
assert_ne!(
signature(&clean),
signature(&with(Some(1_753_000_000))),
"the first observation must emit — otherwise the fact exists only for a poller"
);
assert_ne!(
signature(&with(Some(1_753_000_000))),
signature(&with(Some(1_753_000_900))),
"a LATER report is news too: it says the duplicate is still out there"
);
assert_eq!(
signature(&with(Some(1_753_000_000))),
signature(&with(Some(1_753_000_000))),
"an unchanged stamp must not emit on every tick — the stamp is sticky, so this is \
what stops one observation becoming a frame per loop iteration forever"
);
}
#[test]
fn only_online_home_relay_or_relay_state_count_as_a_transition() {
let base = project(
[("https://a.example:443".to_string(), true)],
vec!["10.0.0.1:1".into()],
None,
None,
);
let addr_churn = project(
[("https://a.example:443".to_string(), true)],
vec!["10.0.0.2:2".into()],
None,
None,
);
assert_eq!(
signature(&base),
signature(&addr_churn),
"address churn alone must not emit"
);
let relay_down = project(
[("https://a.example:443".to_string(), false)],
vec!["10.0.0.1:1".into()],
None,
None,
);
assert_ne!(
signature(&base),
signature(&relay_down),
"a relay losing its connection is a transition"
);
let two_up = project(
[
("https://a.example:443".to_string(), true),
("https://b.example:443".to_string(), true),
],
vec!["10.0.0.1:1".into()],
None,
None,
);
let secondary_down = project(
[
("https://a.example:443".to_string(), true),
("https://b.example:443".to_string(), false),
],
vec!["10.0.0.1:1".into()],
None,
None,
);
assert_ne!(
signature(&two_up),
signature(&secondary_down),
"a secondary relay's connection state is a transition even while the home stays up — losing a fallback relay is exactly the pre-outage warning #90 exists to give"
);
let stamp_only = SelfNetwork {
last_change_epoch: Some(42),
..base.clone()
};
assert_eq!(
signature(&base),
signature(&stamp_only),
"the stamp itself must not count, or every emission differs from its successor"
);
}
}