fix federation recovery after network suspension
CI / check (push) Successful in 1m13s

This commit is contained in:
Ultradesu
2026-09-02 15:09:16 +01:00
parent 02ef0095f5
commit 95bcc9bac4
12 changed files with 342 additions and 63 deletions
+6
View File
@@ -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
View File
@@ -837,7 +837,7 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "federation-net"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"blake3",
@@ -2073,7 +2073,7 @@ dependencies = [
[[package]]
name = "music-dht"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"anyhow",
"async-trait",
+2 -2
View File
@@ -13,8 +13,8 @@ rust-version = "1.97"
repository = "https://gt.hexor.cy/ab/frid"
[workspace.dependencies]
federation-net = { path = "crates/federation-net", version = "0.3.0" }
music-dht = { path = "crates/music-dht", version = "0.4.0" }
federation-net = { path = "crates/federation-net", version = "0.3.1" }
music-dht = { path = "crates/music-dht", version = "0.4.1" }
iroh = "1"
iroh-base = "1"
iroh-tickets = "1"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "federation-net"
version = "0.3.0"
version = "0.3.1"
description = "Generic peer-to-peer networking engine built on Iroh"
readme = "README.md"
documentation = "https://docs.rs/federation-net"
+195 -21
View File
@@ -4,7 +4,7 @@ use std::collections::HashMap;
use std::fmt;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
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::protocol::{AcceptError, ProtocolHandler, Router};
@@ -19,6 +19,7 @@ use tracing::{debug, info, warn};
use crate::config::NetworkConfig;
use crate::error::{NetworkError, Result};
use crate::event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
use crate::health::{NetworkHealthSnapshot, NetworkHealthState};
use crate::identity;
use crate::protocol::{
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);
/// Upper bound of the rendezvous round delay while no peer is connected yet.
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
/// streams, per stream protocol. A full queue delays the handshake ack of
/// further incoming streams (natural backpressure).
@@ -260,6 +267,27 @@ struct StreamAcceptorSlot {
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 {
String::from_utf8_lossy(alpn).into_owned()
}
@@ -276,6 +304,7 @@ struct Shared<M> {
tasks: Mutex<JoinSet<()>>,
next_generation: AtomicU64,
shutting_down: AtomicBool,
rendezvous_health: Mutex<Option<RendezvousHealth>>,
/// Broadcasts the start of the shutdown to long-running background
/// loops so they can stop promptly instead of being aborted.
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.
///
/// The channel is bounded; if it is full this awaits until the
@@ -335,6 +455,7 @@ impl<M: Message> Shared<M> {
generation,
};
let replaced = lock(&self.peers).insert(peer_id, state);
self.record_peer_connection_success();
if let Some(old) = replaced {
debug!(peer = %peer_id, "replacing existing 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
/// round; the loop only ends when the engine shuts down (the task is
/// aborted).
async fn rendezvous_loop<M: Message>(
shared: Arc<Shared<M>>,
client: RendezvousClient,
config: RendezvousConfig,
) {
async fn rendezvous_loop<M: Message>(shared: Arc<Shared<M>>, config: RendezvousConfig) {
let mut shutdown = shared.shutdown_signal.subscribe();
// Give the endpoint a moment to learn its relay and direct addresses so
// the very first published record is already dialable.
let _ = timeout(shared.config.request_timeout, shared.endpoint.online()).await;
let self_id = shared.endpoint.id();
let mut client = None;
loop {
if shared.is_shutting_down() {
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 Some(active_client) = client.as_ref() else {
return;
};
let addr = shared.endpoint.addr();
let self_addr = (!addr.is_empty()).then_some(addr);
match client.round(self_addr, now_ms()).await {
Ok(peers) => {
for peer_addr in peers {
if self_addr.is_none() {
let failures = shared.record_rendezvous_failure(
"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;
if peer == self_id || lock(&shared.peers).contains_key(&peer) {
continue;
}
debug!(peer = %peer, "rendezvous discovered a peer; connecting");
if let Err(err) = shared.connect_to_addr(peer_addr).await {
// Stale entries (peers that left) fail here; they
// age out of the record by TTL.
attempts += 1;
match shared.connect_to_addr(peer_addr).await {
Ok(_) => successes += 1,
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");
}
}
}
Err(err) => debug!(error = %err, "rendezvous round 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);
}
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! {
@@ -960,6 +1134,7 @@ impl<M: Message> NetworkEngine<M> {
config: NetworkConfig,
secret_key: SecretKey,
) -> Result<(Self, NetworkEventReceiver<M>)> {
let rendezvous_enabled = config.rendezvous.is_some();
let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key)
.bind()
@@ -992,6 +1167,7 @@ impl<M: Message> NetworkEngine<M> {
tasks: Mutex::new(JoinSet::new()),
next_generation: AtomicU64::new(0),
shutting_down: AtomicBool::new(false),
rendezvous_health: Mutex::new(rendezvous_enabled.then(RendezvousHealth::new)),
shutdown_signal: watch::Sender::new(false),
});
let handler = FederationProtocol {
@@ -1015,18 +1191,11 @@ impl<M: Message> NetworkEngine<M> {
let router = router_builder.spawn();
*lock(&shared.router) = Some(router);
if let Some(rendezvous) = shared.config.rendezvous.clone() {
match RendezvousClient::new(shared.config.network_id, &rendezvous) {
Ok(client) => {
let loop_shared = shared.clone();
shared.spawn_task(async move {
rendezvous_loop(loop_shared, client, rendezvous).await;
rendezvous_loop(loop_shared, 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!(
endpoint_id = %endpoint_id,
network_id = %shared.config.network_id,
@@ -1257,6 +1426,11 @@ impl<M: Message> NetworkEngine<M> {
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`.
pub fn is_connected(&self, peer: EndpointId) -> bool {
lock(&self.shared.peers).contains_key(&peer)
+62
View File
@@ -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,
}
+2
View File
@@ -55,6 +55,7 @@ mod config;
mod engine;
mod error;
mod event;
mod health;
mod identity;
mod protocol;
mod rendezvous;
@@ -71,6 +72,7 @@ pub use engine::{
};
pub use error::{NetworkError, Result};
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
pub use health::{NetworkHealthSnapshot, NetworkHealthState};
pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId};
pub use rendezvous::RENDEZVOUS_RECORD_VERSION;
pub use rendezvous::{DEFAULT_RENDEZVOUS_ENTRY_TTL, DEFAULT_RENDEZVOUS_INTERVAL, RendezvousConfig};
+22 -9
View File
@@ -27,7 +27,7 @@ use iroh::{EndpointAddr, EndpointId};
use mainline::async_dht::AsyncDht;
use mainline::{Dht, MutableItem, SigningKey};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use tracing::debug;
use crate::error::{NetworkError, Result};
use crate::protocol::NetworkId;
@@ -171,6 +171,12 @@ pub(crate) struct RendezvousClient {
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 {
/// Binds a mainline DHT client for the rendezvous record of `network_id`.
pub(crate) fn new(network_id: NetworkId, config: &RendezvousConfig) -> Result<Self> {
@@ -201,7 +207,7 @@ impl RendezvousClient {
&self,
self_addr: Option<EndpointAddr>,
now_ms: u64,
) -> Result<Vec<EndpointAddr>> {
) -> Result<RendezvousRound> {
let public_key = self.key.verifying_key().to_bytes();
let mut items = self.dht.get_mutable(&public_key, None, None);
let mut seen = Vec::new();
@@ -222,21 +228,28 @@ impl RendezvousClient {
let publish = self_entry.is_some();
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())?;
// Strictly newer than every instance seen this round; concurrent
// writers race, but merging on read makes lost updates benign.
let item = MutableItem::new(self.key.clone(), &encoded, max_seq + 1, None);
if let Err(err) = self.dht.put_mutable(item, None).await {
warn!(error = %err, "failed to publish the rendezvous record");
}
}
self.dht
.put_mutable(item, None)
.await
.err()
.map(|err| format!("failed to publish the rendezvous record: {err}"))
} else {
None
};
Ok(merged
Ok(RendezvousRound {
peers: merged
.into_iter()
.map(|entry| entry.addr)
.filter(|addr| Some(addr.id) != self_id)
.collect())
.collect(),
publish_error,
})
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
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"
readme = "README.md"
documentation = "https://docs.rs/music-dht"
+3 -2
View File
@@ -133,6 +133,7 @@ pub use service::{
// Re-exported types from the transport layer that appear in this API.
pub use federation_net::{
ByteStream, ByteStreamConnectionStats, ConnectionPathKind, EndpointAddr, EndpointId, NetworkId,
PeerTicket, RecvStream, RendezvousConfig, SecretKey, SendStream, StreamAcceptor,
ByteStream, ByteStreamConnectionStats, ConnectionPathKind, EndpointAddr, EndpointId,
NetworkHealthSnapshot, NetworkHealthState, NetworkId, PeerTicket, RecvStream, RendezvousConfig,
SecretKey, SendStream, StreamAcceptor,
};
+32 -16
View File
@@ -36,8 +36,8 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
const DIAL_BACKOFF_BASE: Duration = Duration::from_secs(30);
/// Upper bound of the dial backoff.
const DIAL_BACKOFF_MAX: Duration = Duration::from_secs(10 * 60);
/// Consecutive failed dials after which a contact is evicted entirely.
const DIAL_FAILURES_BEFORE_EVICT: u32 = 5;
/// Consecutive failed dials after which a contact is considered cold.
const DIAL_FAILURES_BEFORE_COLD: u32 = 5;
/// Dial-failure state of one currently unreachable contact.
#[derive(Debug, Clone, Copy)]
@@ -97,6 +97,8 @@ pub(crate) struct Node {
exchange_sent: Mutex<HashSet<EndpointId>>,
/// Contacts that recently failed to dial, with their backoff state.
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>>>,
/// Set once the post-startup republish has been triggered.
initial_republish_done: AtomicBool,
@@ -123,6 +125,7 @@ impl Node {
hello_sent: Mutex::new(HashSet::new()),
exchange_sent: Mutex::new(HashSet::new()),
dial_failures: Mutex::new(HashMap::new()),
dial_locks: Mutex::new(HashMap::new()),
events: Mutex::new(Some(events)),
initial_republish_done: AtomicBool::new(false),
shutting_down: AtomicBool::new(false),
@@ -478,6 +481,18 @@ impl Node {
if self.engine.is_connected(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
.ticket
.parse()
@@ -493,7 +508,7 @@ impl Node {
Ok(peer)
}
Err(err) => {
self.note_dial_failure(contact).await;
self.note_dial_failure(contact);
Err(err)
}
}
@@ -519,35 +534,36 @@ impl Node {
}
}
/// Records a failed dial; after [`DIAL_FAILURES_BEFORE_EVICT`] failures
/// 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).
async fn note_dial_failure(&self, contact: &NodeContact) {
/// Records a failed dial and increases its bounded retry backoff.
///
/// The contact remains in memory and durable storage: temporary network
/// loss or system sleep must not erase the only route back to a peer.
fn note_dial_failure(&self, contact: &NodeContact) {
let consecutive = {
let mut failures = lock(&self.dial_failures);
let failure = failures.entry(contact.peer_id).or_insert(DialFailure {
consecutive: 0,
last_attempt_ms: 0,
});
failure.consecutive += 1;
failure.consecutive = failure.consecutive.saturating_add(1);
failure.last_attempt_ms = now_ms();
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!(
peer = %contact.peer_id,
consecutive,
backoff_s = dial_backoff(consecutive).as_secs(),
"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
+7 -2
View File
@@ -5,8 +5,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use federation_net::{
ByteStream, EndpointAddr, EndpointId, NetworkConfig, NetworkEngine, NetworkId, PeerTicket,
SchemaId, SecretKey, Signature, StreamAcceptor,
ByteStream, EndpointAddr, EndpointId, NetworkConfig, NetworkEngine, NetworkHealthSnapshot,
NetworkId, PeerTicket, SchemaId, SecretKey, Signature, StreamAcceptor,
};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
@@ -375,6 +375,11 @@ impl MusicDhtService {
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.
pub fn is_connected(&self, peer: EndpointId) -> bool {
self.node.engine.is_connected(peer)