This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.4.1 — 2026-09-02
|
||||||
|
|
||||||
|
- `federation-net 0.3.1`: recover timed-out or failed Mainline-DHT rendezvous clients and expose network health to applications.
|
||||||
|
- `music-dht 0.4.1`: retain temporarily unreachable contacts and serialize duplicate dial attempts with bounded backoff.
|
||||||
Generated
+2
-2
@@ -837,7 +837,7 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -2073,7 +2073,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@ rust-version = "1.97"
|
|||||||
repository = "https://gt.hexor.cy/ab/frid"
|
repository = "https://gt.hexor.cy/ab/frid"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
federation-net = { path = "crates/federation-net", version = "0.3.0" }
|
federation-net = { path = "crates/federation-net", version = "0.3.1" }
|
||||||
music-dht = { path = "crates/music-dht", version = "0.4.0" }
|
music-dht = { path = "crates/music-dht", version = "0.4.1" }
|
||||||
iroh = "1"
|
iroh = "1"
|
||||||
iroh-base = "1"
|
iroh-base = "1"
|
||||||
iroh-tickets = "1"
|
iroh-tickets = "1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
description = "Generic peer-to-peer networking engine built on Iroh"
|
description = "Generic peer-to-peer networking engine built on Iroh"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
documentation = "https://docs.rs/federation-net"
|
documentation = "https://docs.rs/federation-net"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::collections::HashMap;
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
|
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt, presets};
|
use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt, presets};
|
||||||
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
|
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
|
||||||
@@ -19,6 +19,7 @@ use tracing::{debug, info, warn};
|
|||||||
use crate::config::NetworkConfig;
|
use crate::config::NetworkConfig;
|
||||||
use crate::error::{NetworkError, Result};
|
use crate::error::{NetworkError, Result};
|
||||||
use crate::event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
|
use crate::event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
|
||||||
|
use crate::health::{NetworkHealthSnapshot, NetworkHealthState};
|
||||||
use crate::identity;
|
use crate::identity;
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
ALPN, Handshake, HandshakeAck, HandshakeErrorCode, MAX_HANDSHAKE_FRAME_SIZE,
|
ALPN, Handshake, HandshakeAck, HandshakeErrorCode, MAX_HANDSHAKE_FRAME_SIZE,
|
||||||
@@ -50,6 +51,12 @@ const REJECT_LINGER: Duration = Duration::from_secs(3);
|
|||||||
const SHUTDOWN_TASK_GRACE: Duration = Duration::from_secs(5);
|
const SHUTDOWN_TASK_GRACE: Duration = Duration::from_secs(5);
|
||||||
/// Upper bound of the rendezvous round delay while no peer is connected yet.
|
/// Upper bound of the rendezvous round delay while no peer is connected yet.
|
||||||
const RENDEZVOUS_LONELY_INTERVAL: Duration = Duration::from_secs(15);
|
const RENDEZVOUS_LONELY_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
|
/// A stuck Mainline-DHT operation must not stall discovery forever.
|
||||||
|
const RENDEZVOUS_ROUND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
/// Recreate the Mainline-DHT client after this many consecutive failures.
|
||||||
|
const RENDEZVOUS_REBUILD_AFTER_FAILURES: u32 = 2;
|
||||||
|
/// Recommend an application-owned full restart after sustained discovery failure.
|
||||||
|
const RENDEZVOUS_RESTART_AFTER_FAILURES: u32 = 4;
|
||||||
/// Capacity of the queue of accepted-but-not-yet-consumed incoming byte
|
/// Capacity of the queue of accepted-but-not-yet-consumed incoming byte
|
||||||
/// streams, per stream protocol. A full queue delays the handshake ack of
|
/// streams, per stream protocol. A full queue delays the handshake ack of
|
||||||
/// further incoming streams (natural backpressure).
|
/// further incoming streams (natural backpressure).
|
||||||
@@ -260,6 +267,27 @@ struct StreamAcceptorSlot {
|
|||||||
receiver: Option<mpsc::Receiver<ByteStream>>,
|
receiver: Option<mpsc::Receiver<ByteStream>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct RendezvousHealth {
|
||||||
|
last_success: Option<Instant>,
|
||||||
|
consecutive_failures: u32,
|
||||||
|
consecutive_peer_dial_failures: u32,
|
||||||
|
client_restarts: u64,
|
||||||
|
last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RendezvousHealth {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
last_success: None,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
consecutive_peer_dial_failures: 0,
|
||||||
|
client_restarts: 0,
|
||||||
|
last_error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn alpn_display(alpn: &[u8]) -> String {
|
fn alpn_display(alpn: &[u8]) -> String {
|
||||||
String::from_utf8_lossy(alpn).into_owned()
|
String::from_utf8_lossy(alpn).into_owned()
|
||||||
}
|
}
|
||||||
@@ -276,6 +304,7 @@ struct Shared<M> {
|
|||||||
tasks: Mutex<JoinSet<()>>,
|
tasks: Mutex<JoinSet<()>>,
|
||||||
next_generation: AtomicU64,
|
next_generation: AtomicU64,
|
||||||
shutting_down: AtomicBool,
|
shutting_down: AtomicBool,
|
||||||
|
rendezvous_health: Mutex<Option<RendezvousHealth>>,
|
||||||
/// Broadcasts the start of the shutdown to long-running background
|
/// Broadcasts the start of the shutdown to long-running background
|
||||||
/// loops so they can stop promptly instead of being aborted.
|
/// loops so they can stop promptly instead of being aborted.
|
||||||
shutdown_signal: watch::Sender<bool>,
|
shutdown_signal: watch::Sender<bool>,
|
||||||
@@ -294,6 +323,97 @@ impl<M: Message> Shared<M> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_rendezvous_success(&self) {
|
||||||
|
if let Some(health) = lock(&self.rendezvous_health).as_mut() {
|
||||||
|
health.last_success = Some(Instant::now());
|
||||||
|
health.consecutive_failures = 0;
|
||||||
|
health.last_error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_rendezvous_failure(&self, error: String) -> u32 {
|
||||||
|
let mut guard = lock(&self.rendezvous_health);
|
||||||
|
let Some(health) = guard.as_mut() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
health.consecutive_failures = health.consecutive_failures.saturating_add(1);
|
||||||
|
health.last_error = Some(error);
|
||||||
|
health.consecutive_failures
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_rendezvous_restart(&self) {
|
||||||
|
if let Some(health) = lock(&self.rendezvous_health).as_mut() {
|
||||||
|
health.client_restarts = health.client_restarts.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_peer_connection_success(&self) {
|
||||||
|
if let Some(health) = lock(&self.rendezvous_health).as_mut() {
|
||||||
|
health.consecutive_peer_dial_failures = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_rendezvous_peer_dials(&self, candidates: usize, attempts: usize, successes: usize) {
|
||||||
|
let mut guard = lock(&self.rendezvous_health);
|
||||||
|
let Some(health) = guard.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if candidates == 0 || successes > 0 {
|
||||||
|
health.consecutive_peer_dial_failures = 0;
|
||||||
|
} else if attempts > 0 {
|
||||||
|
health.consecutive_peer_dial_failures =
|
||||||
|
health.consecutive_peer_dial_failures.saturating_add(1);
|
||||||
|
health.last_error = Some(format!(
|
||||||
|
"failed to connect to all {attempts} peer(s) discovered by rendezvous"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health(&self) -> NetworkHealthSnapshot {
|
||||||
|
let connected_peers = lock(&self.peers).len();
|
||||||
|
let guard = lock(&self.rendezvous_health);
|
||||||
|
let Some(health) = guard.as_ref() else {
|
||||||
|
return NetworkHealthSnapshot {
|
||||||
|
state: if connected_peers == 0 {
|
||||||
|
NetworkHealthState::Discovering
|
||||||
|
} else {
|
||||||
|
NetworkHealthState::Healthy
|
||||||
|
},
|
||||||
|
connected_peers,
|
||||||
|
rendezvous_enabled: false,
|
||||||
|
consecutive_rendezvous_failures: 0,
|
||||||
|
consecutive_peer_dial_failures: 0,
|
||||||
|
rendezvous_restarts: 0,
|
||||||
|
last_rendezvous_success_ago: None,
|
||||||
|
last_rendezvous_error: None,
|
||||||
|
restart_recommended: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let restart_recommended = connected_peers == 0
|
||||||
|
&& (health.consecutive_failures >= RENDEZVOUS_RESTART_AFTER_FAILURES
|
||||||
|
|| health.consecutive_peer_dial_failures >= RENDEZVOUS_RESTART_AFTER_FAILURES);
|
||||||
|
let state = if restart_recommended {
|
||||||
|
NetworkHealthState::Degraded
|
||||||
|
} else if health.consecutive_failures > 0 || health.consecutive_peer_dial_failures > 0 {
|
||||||
|
NetworkHealthState::Recovering
|
||||||
|
} else if health.last_success.is_some() {
|
||||||
|
NetworkHealthState::Healthy
|
||||||
|
} else {
|
||||||
|
NetworkHealthState::Discovering
|
||||||
|
};
|
||||||
|
NetworkHealthSnapshot {
|
||||||
|
state,
|
||||||
|
connected_peers,
|
||||||
|
rendezvous_enabled: true,
|
||||||
|
consecutive_rendezvous_failures: health.consecutive_failures,
|
||||||
|
consecutive_peer_dial_failures: health.consecutive_peer_dial_failures,
|
||||||
|
rendezvous_restarts: health.client_restarts,
|
||||||
|
last_rendezvous_success_ago: health.last_success.map(|at| at.elapsed()),
|
||||||
|
last_rendezvous_error: health.last_error.clone(),
|
||||||
|
restart_recommended,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Delivers an event to the application.
|
/// Delivers an event to the application.
|
||||||
///
|
///
|
||||||
/// The channel is bounded; if it is full this awaits until the
|
/// The channel is bounded; if it is full this awaits until the
|
||||||
@@ -335,6 +455,7 @@ impl<M: Message> Shared<M> {
|
|||||||
generation,
|
generation,
|
||||||
};
|
};
|
||||||
let replaced = lock(&self.peers).insert(peer_id, state);
|
let replaced = lock(&self.peers).insert(peer_id, state);
|
||||||
|
self.record_peer_connection_success();
|
||||||
if let Some(old) = replaced {
|
if let Some(old) = replaced {
|
||||||
debug!(peer = %peer_id, "replacing existing connection");
|
debug!(peer = %peer_id, "replacing existing connection");
|
||||||
old.connection
|
old.connection
|
||||||
@@ -786,39 +907,92 @@ impl<M: Message> Shared<M> {
|
|||||||
/// Failures of a single round or dial are logged and retried on the next
|
/// Failures of a single round or dial are logged and retried on the next
|
||||||
/// round; the loop only ends when the engine shuts down (the task is
|
/// round; the loop only ends when the engine shuts down (the task is
|
||||||
/// aborted).
|
/// aborted).
|
||||||
async fn rendezvous_loop<M: Message>(
|
async fn rendezvous_loop<M: Message>(shared: Arc<Shared<M>>, config: RendezvousConfig) {
|
||||||
shared: Arc<Shared<M>>,
|
|
||||||
client: RendezvousClient,
|
|
||||||
config: RendezvousConfig,
|
|
||||||
) {
|
|
||||||
let mut shutdown = shared.shutdown_signal.subscribe();
|
let mut shutdown = shared.shutdown_signal.subscribe();
|
||||||
// Give the endpoint a moment to learn its relay and direct addresses so
|
// Give the endpoint a moment to learn its relay and direct addresses so
|
||||||
// the very first published record is already dialable.
|
// the very first published record is already dialable.
|
||||||
let _ = timeout(shared.config.request_timeout, shared.endpoint.online()).await;
|
let _ = timeout(shared.config.request_timeout, shared.endpoint.online()).await;
|
||||||
let self_id = shared.endpoint.id();
|
let self_id = shared.endpoint.id();
|
||||||
|
let mut client = None;
|
||||||
loop {
|
loop {
|
||||||
if shared.is_shutting_down() {
|
if shared.is_shutting_down() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if client.is_none() {
|
||||||
|
match RendezvousClient::new(shared.config.network_id, &config) {
|
||||||
|
Ok(new_client) => client = Some(new_client),
|
||||||
|
Err(err) => {
|
||||||
|
let failures = shared.record_rendezvous_failure(err.to_string());
|
||||||
|
warn!(error = %err, failures, "failed to start peer rendezvous; retrying");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let round = async {
|
let round = async {
|
||||||
|
let Some(active_client) = client.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let addr = shared.endpoint.addr();
|
let addr = shared.endpoint.addr();
|
||||||
let self_addr = (!addr.is_empty()).then_some(addr);
|
let self_addr = (!addr.is_empty()).then_some(addr);
|
||||||
match client.round(self_addr, now_ms()).await {
|
if self_addr.is_none() {
|
||||||
Ok(peers) => {
|
let failures = shared.record_rendezvous_failure(
|
||||||
for peer_addr in peers {
|
"local endpoint has no dialable address".to_string(),
|
||||||
|
);
|
||||||
|
debug!(failures, "rendezvous waiting for a dialable local address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match timeout(
|
||||||
|
RENDEZVOUS_ROUND_TIMEOUT,
|
||||||
|
active_client.round(self_addr, now_ms()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(round)) => {
|
||||||
|
let candidates = round.peers.len();
|
||||||
|
let mut attempts = 0;
|
||||||
|
let mut successes = 0;
|
||||||
|
for peer_addr in round.peers {
|
||||||
let peer = peer_addr.id;
|
let peer = peer_addr.id;
|
||||||
if peer == self_id || lock(&shared.peers).contains_key(&peer) {
|
if peer == self_id || lock(&shared.peers).contains_key(&peer) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
debug!(peer = %peer, "rendezvous discovered a peer; connecting");
|
debug!(peer = %peer, "rendezvous discovered a peer; connecting");
|
||||||
if let Err(err) = shared.connect_to_addr(peer_addr).await {
|
attempts += 1;
|
||||||
// Stale entries (peers that left) fail here; they
|
match shared.connect_to_addr(peer_addr).await {
|
||||||
// age out of the record by TTL.
|
Ok(_) => successes += 1,
|
||||||
debug!(peer = %peer, error = %err, "rendezvous connect attempt failed");
|
Err(err) => {
|
||||||
|
// Stale entries (peers that left) fail here;
|
||||||
|
// they age out of the record by TTL.
|
||||||
|
debug!(peer = %peer, error = %err, "rendezvous connect attempt failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(error) = round.publish_error {
|
||||||
|
let failures = shared.record_rendezvous_failure(error.clone());
|
||||||
|
debug!(%error, failures, "rendezvous publish failed");
|
||||||
|
} else {
|
||||||
|
shared.record_rendezvous_success();
|
||||||
|
}
|
||||||
|
shared.record_rendezvous_peer_dials(candidates, attempts, successes);
|
||||||
}
|
}
|
||||||
Err(err) => debug!(error = %err, "rendezvous round failed"),
|
Ok(Err(err)) => {
|
||||||
|
let failures = shared.record_rendezvous_failure(err.to_string());
|
||||||
|
debug!(error = %err, failures, "rendezvous round failed");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let failures = shared.record_rendezvous_failure(format!(
|
||||||
|
"rendezvous round timed out after {}s",
|
||||||
|
RENDEZVOUS_ROUND_TIMEOUT.as_secs()
|
||||||
|
));
|
||||||
|
warn!(failures, "rendezvous round timed out");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let failures = lock(&shared.rendezvous_health)
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |health| health.consecutive_failures);
|
||||||
|
if failures >= RENDEZVOUS_REBUILD_AFTER_FAILURES {
|
||||||
|
client = None;
|
||||||
|
shared.record_rendezvous_restart();
|
||||||
|
info!(failures, "rebuilding the rendezvous client");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -960,6 +1134,7 @@ impl<M: Message> NetworkEngine<M> {
|
|||||||
config: NetworkConfig,
|
config: NetworkConfig,
|
||||||
secret_key: SecretKey,
|
secret_key: SecretKey,
|
||||||
) -> Result<(Self, NetworkEventReceiver<M>)> {
|
) -> Result<(Self, NetworkEventReceiver<M>)> {
|
||||||
|
let rendezvous_enabled = config.rendezvous.is_some();
|
||||||
let endpoint = Endpoint::builder(presets::N0)
|
let endpoint = Endpoint::builder(presets::N0)
|
||||||
.secret_key(secret_key)
|
.secret_key(secret_key)
|
||||||
.bind()
|
.bind()
|
||||||
@@ -992,6 +1167,7 @@ impl<M: Message> NetworkEngine<M> {
|
|||||||
tasks: Mutex::new(JoinSet::new()),
|
tasks: Mutex::new(JoinSet::new()),
|
||||||
next_generation: AtomicU64::new(0),
|
next_generation: AtomicU64::new(0),
|
||||||
shutting_down: AtomicBool::new(false),
|
shutting_down: AtomicBool::new(false),
|
||||||
|
rendezvous_health: Mutex::new(rendezvous_enabled.then(RendezvousHealth::new)),
|
||||||
shutdown_signal: watch::Sender::new(false),
|
shutdown_signal: watch::Sender::new(false),
|
||||||
});
|
});
|
||||||
let handler = FederationProtocol {
|
let handler = FederationProtocol {
|
||||||
@@ -1015,17 +1191,10 @@ impl<M: Message> NetworkEngine<M> {
|
|||||||
let router = router_builder.spawn();
|
let router = router_builder.spawn();
|
||||||
*lock(&shared.router) = Some(router);
|
*lock(&shared.router) = Some(router);
|
||||||
if let Some(rendezvous) = shared.config.rendezvous.clone() {
|
if let Some(rendezvous) = shared.config.rendezvous.clone() {
|
||||||
match RendezvousClient::new(shared.config.network_id, &rendezvous) {
|
let loop_shared = shared.clone();
|
||||||
Ok(client) => {
|
shared.spawn_task(async move {
|
||||||
let loop_shared = shared.clone();
|
rendezvous_loop(loop_shared, rendezvous).await;
|
||||||
shared.spawn_task(async move {
|
});
|
||||||
rendezvous_loop(loop_shared, client, rendezvous).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Rendezvous is a convenience; the engine stays usable via
|
|
||||||
// tickets even when the DHT client cannot start.
|
|
||||||
Err(err) => warn!(error = %err, "peer rendezvous disabled"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
info!(
|
info!(
|
||||||
endpoint_id = %endpoint_id,
|
endpoint_id = %endpoint_id,
|
||||||
@@ -1257,6 +1426,11 @@ impl<M: Message> NetworkEngine<M> {
|
|||||||
lock(&self.shared.peers).keys().copied().collect()
|
lock(&self.shared.peers).keys().copied().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a point-in-time snapshot of transport and rendezvous health.
|
||||||
|
pub fn health(&self) -> NetworkHealthSnapshot {
|
||||||
|
self.shared.health()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if there is an active connection to `peer`.
|
/// Returns `true` if there is an active connection to `peer`.
|
||||||
pub fn is_connected(&self, peer: EndpointId) -> bool {
|
pub fn is_connected(&self, peer: EndpointId) -> bool {
|
||||||
lock(&self.shared.peers).contains_key(&peer)
|
lock(&self.shared.peers).contains_key(&peer)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//! Point-in-time health information for the transport and rendezvous loop.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Coarse health state of a running network engine.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NetworkHealthState {
|
||||||
|
/// The engine is starting or has no discovery mechanism configured.
|
||||||
|
Discovering,
|
||||||
|
/// Transport and rendezvous maintenance are operating normally.
|
||||||
|
Healthy,
|
||||||
|
/// A recent rendezvous operation failed and is being retried.
|
||||||
|
Recovering,
|
||||||
|
/// Repeated rendezvous failures indicate that the engine should be restarted.
|
||||||
|
Degraded,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkHealthState {
|
||||||
|
/// Returns a compact stable label for logs and user interfaces.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Discovering => "discovering",
|
||||||
|
Self::Healthy => "healthy",
|
||||||
|
Self::Recovering => "recovering",
|
||||||
|
Self::Degraded => "degraded",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for NetworkHealthState {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point-in-time health snapshot of a running network engine.
|
||||||
|
///
|
||||||
|
/// Zero connected peers is not itself an error: a network may legitimately
|
||||||
|
/// contain only one online node. [`Self::restart_recommended`] is therefore
|
||||||
|
/// based on repeated discovery failures as well as the absence of peers.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NetworkHealthSnapshot {
|
||||||
|
/// Coarse state suitable for logs and user interfaces.
|
||||||
|
pub state: NetworkHealthState,
|
||||||
|
/// Number of currently connected peers.
|
||||||
|
pub connected_peers: usize,
|
||||||
|
/// Whether Mainline-DHT rendezvous discovery is enabled.
|
||||||
|
pub rendezvous_enabled: bool,
|
||||||
|
/// Consecutive failed or timed-out rendezvous rounds.
|
||||||
|
pub consecutive_rendezvous_failures: u32,
|
||||||
|
/// Consecutive rendezvous rounds in which every discovered peer dial failed.
|
||||||
|
pub consecutive_peer_dial_failures: u32,
|
||||||
|
/// Number of times the rendezvous client was rebuilt after failures.
|
||||||
|
pub rendezvous_restarts: u64,
|
||||||
|
/// Time elapsed since the most recent successful rendezvous round.
|
||||||
|
pub last_rendezvous_success_ago: Option<Duration>,
|
||||||
|
/// Most recent rendezvous error, if a later successful round has not cleared it.
|
||||||
|
pub last_rendezvous_error: Option<String>,
|
||||||
|
/// Whether an application-owned full service restart is advisable.
|
||||||
|
pub restart_recommended: bool,
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ mod config;
|
|||||||
mod engine;
|
mod engine;
|
||||||
mod error;
|
mod error;
|
||||||
mod event;
|
mod event;
|
||||||
|
mod health;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
mod rendezvous;
|
mod rendezvous;
|
||||||
@@ -71,6 +72,7 @@ pub use engine::{
|
|||||||
};
|
};
|
||||||
pub use error::{NetworkError, Result};
|
pub use error::{NetworkError, Result};
|
||||||
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
|
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
|
||||||
|
pub use health::{NetworkHealthSnapshot, NetworkHealthState};
|
||||||
pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId};
|
pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId};
|
||||||
pub use rendezvous::RENDEZVOUS_RECORD_VERSION;
|
pub use rendezvous::RENDEZVOUS_RECORD_VERSION;
|
||||||
pub use rendezvous::{DEFAULT_RENDEZVOUS_ENTRY_TTL, DEFAULT_RENDEZVOUS_INTERVAL, RendezvousConfig};
|
pub use rendezvous::{DEFAULT_RENDEZVOUS_ENTRY_TTL, DEFAULT_RENDEZVOUS_INTERVAL, RendezvousConfig};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use iroh::{EndpointAddr, EndpointId};
|
|||||||
use mainline::async_dht::AsyncDht;
|
use mainline::async_dht::AsyncDht;
|
||||||
use mainline::{Dht, MutableItem, SigningKey};
|
use mainline::{Dht, MutableItem, SigningKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, warn};
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::error::{NetworkError, Result};
|
use crate::error::{NetworkError, Result};
|
||||||
use crate::protocol::NetworkId;
|
use crate::protocol::NetworkId;
|
||||||
@@ -171,6 +171,12 @@ pub(crate) struct RendezvousClient {
|
|||||||
entry_ttl: Duration,
|
entry_ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of one read/merge/publish cycle.
|
||||||
|
pub(crate) struct RendezvousRound {
|
||||||
|
pub(crate) peers: Vec<EndpointAddr>,
|
||||||
|
pub(crate) publish_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl RendezvousClient {
|
impl RendezvousClient {
|
||||||
/// Binds a mainline DHT client for the rendezvous record of `network_id`.
|
/// Binds a mainline DHT client for the rendezvous record of `network_id`.
|
||||||
pub(crate) fn new(network_id: NetworkId, config: &RendezvousConfig) -> Result<Self> {
|
pub(crate) fn new(network_id: NetworkId, config: &RendezvousConfig) -> Result<Self> {
|
||||||
@@ -201,7 +207,7 @@ impl RendezvousClient {
|
|||||||
&self,
|
&self,
|
||||||
self_addr: Option<EndpointAddr>,
|
self_addr: Option<EndpointAddr>,
|
||||||
now_ms: u64,
|
now_ms: u64,
|
||||||
) -> Result<Vec<EndpointAddr>> {
|
) -> Result<RendezvousRound> {
|
||||||
let public_key = self.key.verifying_key().to_bytes();
|
let public_key = self.key.verifying_key().to_bytes();
|
||||||
let mut items = self.dht.get_mutable(&public_key, None, None);
|
let mut items = self.dht.get_mutable(&public_key, None, None);
|
||||||
let mut seen = Vec::new();
|
let mut seen = Vec::new();
|
||||||
@@ -222,21 +228,28 @@ impl RendezvousClient {
|
|||||||
let publish = self_entry.is_some();
|
let publish = self_entry.is_some();
|
||||||
let merged = merge_entries(seen, self_entry, now_ms, self.entry_ttl);
|
let merged = merge_entries(seen, self_entry, now_ms, self.entry_ttl);
|
||||||
|
|
||||||
if publish {
|
let publish_error = if publish {
|
||||||
let encoded = encode_record_capped(merged.clone())?;
|
let encoded = encode_record_capped(merged.clone())?;
|
||||||
// Strictly newer than every instance seen this round; concurrent
|
// Strictly newer than every instance seen this round; concurrent
|
||||||
// writers race, but merging on read makes lost updates benign.
|
// writers race, but merging on read makes lost updates benign.
|
||||||
let item = MutableItem::new(self.key.clone(), &encoded, max_seq + 1, None);
|
let item = MutableItem::new(self.key.clone(), &encoded, max_seq + 1, None);
|
||||||
if let Err(err) = self.dht.put_mutable(item, None).await {
|
self.dht
|
||||||
warn!(error = %err, "failed to publish the rendezvous record");
|
.put_mutable(item, None)
|
||||||
}
|
.await
|
||||||
}
|
.err()
|
||||||
|
.map(|err| format!("failed to publish the rendezvous record: {err}"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
Ok(merged
|
Ok(RendezvousRound {
|
||||||
.into_iter()
|
peers: merged
|
||||||
.map(|entry| entry.addr)
|
.into_iter()
|
||||||
.filter(|addr| Some(addr.id) != self_id)
|
.map(|entry| entry.addr)
|
||||||
.collect())
|
.filter(|addr| Some(addr.id) != self_id)
|
||||||
|
.collect(),
|
||||||
|
publish_error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
description = "Distributed music library search: a Kademlia-style DHT on top of federation-net"
|
description = "Distributed music library search: a Kademlia-style DHT on top of federation-net"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
documentation = "https://docs.rs/music-dht"
|
documentation = "https://docs.rs/music-dht"
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub use service::{
|
|||||||
|
|
||||||
// Re-exported types from the transport layer that appear in this API.
|
// Re-exported types from the transport layer that appear in this API.
|
||||||
pub use federation_net::{
|
pub use federation_net::{
|
||||||
ByteStream, ByteStreamConnectionStats, ConnectionPathKind, EndpointAddr, EndpointId, NetworkId,
|
ByteStream, ByteStreamConnectionStats, ConnectionPathKind, EndpointAddr, EndpointId,
|
||||||
PeerTicket, RecvStream, RendezvousConfig, SecretKey, SendStream, StreamAcceptor,
|
NetworkHealthSnapshot, NetworkHealthState, NetworkId, PeerTicket, RecvStream, RendezvousConfig,
|
||||||
|
SecretKey, SendStream, StreamAcceptor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
|||||||
const DIAL_BACKOFF_BASE: Duration = Duration::from_secs(30);
|
const DIAL_BACKOFF_BASE: Duration = Duration::from_secs(30);
|
||||||
/// Upper bound of the dial backoff.
|
/// Upper bound of the dial backoff.
|
||||||
const DIAL_BACKOFF_MAX: Duration = Duration::from_secs(10 * 60);
|
const DIAL_BACKOFF_MAX: Duration = Duration::from_secs(10 * 60);
|
||||||
/// Consecutive failed dials after which a contact is evicted entirely.
|
/// Consecutive failed dials after which a contact is considered cold.
|
||||||
const DIAL_FAILURES_BEFORE_EVICT: u32 = 5;
|
const DIAL_FAILURES_BEFORE_COLD: u32 = 5;
|
||||||
|
|
||||||
/// Dial-failure state of one currently unreachable contact.
|
/// Dial-failure state of one currently unreachable contact.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -97,6 +97,8 @@ pub(crate) struct Node {
|
|||||||
exchange_sent: Mutex<HashSet<EndpointId>>,
|
exchange_sent: Mutex<HashSet<EndpointId>>,
|
||||||
/// Contacts that recently failed to dial, with their backoff state.
|
/// Contacts that recently failed to dial, with their backoff state.
|
||||||
dial_failures: Mutex<HashMap<EndpointId, DialFailure>>,
|
dial_failures: Mutex<HashMap<EndpointId, DialFailure>>,
|
||||||
|
/// Serializes on-demand connection attempts to the same contact.
|
||||||
|
dial_locks: Mutex<HashMap<EndpointId, Arc<tokio::sync::Mutex<()>>>>,
|
||||||
events: Mutex<Option<mpsc::Sender<MusicDhtEvent>>>,
|
events: Mutex<Option<mpsc::Sender<MusicDhtEvent>>>,
|
||||||
/// Set once the post-startup republish has been triggered.
|
/// Set once the post-startup republish has been triggered.
|
||||||
initial_republish_done: AtomicBool,
|
initial_republish_done: AtomicBool,
|
||||||
@@ -123,6 +125,7 @@ impl Node {
|
|||||||
hello_sent: Mutex::new(HashSet::new()),
|
hello_sent: Mutex::new(HashSet::new()),
|
||||||
exchange_sent: Mutex::new(HashSet::new()),
|
exchange_sent: Mutex::new(HashSet::new()),
|
||||||
dial_failures: Mutex::new(HashMap::new()),
|
dial_failures: Mutex::new(HashMap::new()),
|
||||||
|
dial_locks: Mutex::new(HashMap::new()),
|
||||||
events: Mutex::new(Some(events)),
|
events: Mutex::new(Some(events)),
|
||||||
initial_republish_done: AtomicBool::new(false),
|
initial_republish_done: AtomicBool::new(false),
|
||||||
shutting_down: AtomicBool::new(false),
|
shutting_down: AtomicBool::new(false),
|
||||||
@@ -478,6 +481,18 @@ impl Node {
|
|||||||
if self.engine.is_connected(contact.peer_id) {
|
if self.engine.is_connected(contact.peer_id) {
|
||||||
return Ok(contact.peer_id);
|
return Ok(contact.peer_id);
|
||||||
}
|
}
|
||||||
|
let dial_lock = lock(&self.dial_locks)
|
||||||
|
.entry(contact.peer_id)
|
||||||
|
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||||
|
.clone();
|
||||||
|
let _dial_guard = dial_lock.lock().await;
|
||||||
|
self.ensure_running()?;
|
||||||
|
if self.engine.is_connected(contact.peer_id) {
|
||||||
|
return Ok(contact.peer_id);
|
||||||
|
}
|
||||||
|
if self.dial_backoff_active(&contact.peer_id, now_ms()) {
|
||||||
|
return Err(MusicDhtError::Timeout);
|
||||||
|
}
|
||||||
let ticket: PeerTicket = contact
|
let ticket: PeerTicket = contact
|
||||||
.ticket
|
.ticket
|
||||||
.parse()
|
.parse()
|
||||||
@@ -493,7 +508,7 @@ impl Node {
|
|||||||
Ok(peer)
|
Ok(peer)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
self.note_dial_failure(contact).await;
|
self.note_dial_failure(contact);
|
||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,35 +534,36 @@ impl Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records a failed dial; after [`DIAL_FAILURES_BEFORE_EVICT`] failures
|
/// Records a failed dial and increases its bounded retry backoff.
|
||||||
/// in a row the contact is dropped from the routing table and the
|
///
|
||||||
/// database (gossip re-adds it with a clean slate if it comes back).
|
/// The contact remains in memory and durable storage: temporary network
|
||||||
async fn note_dial_failure(&self, contact: &NodeContact) {
|
/// loss or system sleep must not erase the only route back to a peer.
|
||||||
|
fn note_dial_failure(&self, contact: &NodeContact) {
|
||||||
let consecutive = {
|
let consecutive = {
|
||||||
let mut failures = lock(&self.dial_failures);
|
let mut failures = lock(&self.dial_failures);
|
||||||
let failure = failures.entry(contact.peer_id).or_insert(DialFailure {
|
let failure = failures.entry(contact.peer_id).or_insert(DialFailure {
|
||||||
consecutive: 0,
|
consecutive: 0,
|
||||||
last_attempt_ms: 0,
|
last_attempt_ms: 0,
|
||||||
});
|
});
|
||||||
failure.consecutive += 1;
|
failure.consecutive = failure.consecutive.saturating_add(1);
|
||||||
failure.last_attempt_ms = now_ms();
|
failure.last_attempt_ms = now_ms();
|
||||||
failure.consecutive
|
failure.consecutive
|
||||||
};
|
};
|
||||||
if consecutive < DIAL_FAILURES_BEFORE_EVICT {
|
if consecutive == DIAL_FAILURES_BEFORE_COLD {
|
||||||
|
info!(
|
||||||
|
peer = %contact.peer_id,
|
||||||
|
consecutive,
|
||||||
|
backoff_s = dial_backoff(consecutive).as_secs(),
|
||||||
|
"DHT contact remains unavailable; retaining it for later recovery"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
debug!(
|
debug!(
|
||||||
peer = %contact.peer_id,
|
peer = %contact.peer_id,
|
||||||
consecutive,
|
consecutive,
|
||||||
backoff_s = dial_backoff(consecutive).as_secs(),
|
backoff_s = dial_backoff(consecutive).as_secs(),
|
||||||
"dial failed; backing off"
|
"dial failed; backing off"
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
lock(&self.dial_failures).remove(&contact.peer_id);
|
|
||||||
lock(&self.routing).remove(&contact.peer_id);
|
|
||||||
if let Err(err) = self.db.delete_known_peer(contact.peer_id).await {
|
|
||||||
warn!(error = %err, "failed to delete evicted peer from the database");
|
|
||||||
}
|
|
||||||
info!(peer = %contact.peer_id, "evicted unreachable DHT contact");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends one request and awaits its response, cleaning up the pending
|
/// Sends one request and awaits its response, cleaning up the pending
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use federation_net::{
|
use federation_net::{
|
||||||
ByteStream, EndpointAddr, EndpointId, NetworkConfig, NetworkEngine, NetworkId, PeerTicket,
|
ByteStream, EndpointAddr, EndpointId, NetworkConfig, NetworkEngine, NetworkHealthSnapshot,
|
||||||
SchemaId, SecretKey, Signature, StreamAcceptor,
|
NetworkId, PeerTicket, SchemaId, SecretKey, Signature, StreamAcceptor,
|
||||||
};
|
};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
@@ -375,6 +375,11 @@ impl MusicDhtService {
|
|||||||
self.node.engine.connected_peers()
|
self.node.engine.connected_peers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a point-in-time snapshot of transport and rendezvous health.
|
||||||
|
pub fn network_health(&self) -> NetworkHealthSnapshot {
|
||||||
|
self.node.engine.health()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if a transport connection to `peer` is open.
|
/// Returns `true` if a transport connection to `peer` is open.
|
||||||
pub fn is_connected(&self, peer: EndpointId) -> bool {
|
pub fn is_connected(&self, peer: EndpointId) -> bool {
|
||||||
self.node.engine.is_connected(peer)
|
self.node.engine.is_connected(peer)
|
||||||
|
|||||||
Reference in New Issue
Block a user