mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
Improve discovery protocol: bloom-guided tree routing with fallback
Replace discovery flooding with bloom-filter-guided tree routing: lookups sent only to tree peers (parent + children) whose bloom filter contains the target. If no tree peer matches, fall back to non-tree peers with bloom matches before dropping the request. This produces single-path forwarding through the spanning tree (90% traffic reduction) while recovering from dead ends caused by stale bloom filters, tree restructuring, or transit node failures. Remove visited bloom filter from LookupRequest wire format (-257 bytes per request). Tree routing is inherently loop-free; request_id dedup handles edge cases during tree restructuring. Add response- forwarded flag to prevent response routing loops from convergent request paths. Add originator-side exponential backoff (30s base, 300s cap) after lookup timeouts and bloom misses. Backoff resets on topology changes (parent switch, new peer, first RTT, reconnection). Add transit-side per-target rate limiting (2s minimum interval) for forwarded lookups as defense-in-depth. Add discovery retry within the timeout window (default: send at T=0, retry at T=5s, fail at T=10s) to compensate for single-path fragility. Lookups with zero eligible tree peers fail immediately. Improve discovery logging: promote key events to info (initiation, success, timeout with failure count). Add debug logging for dedup, pending packet retry, backoff suppression, forward rate limiting, and backoff reset. New config: discovery.backoff_base_secs, backoff_max_secs, forward_min_interval_secs, retry_interval_secs, max_attempts. New stats: req_backoff_suppressed, req_forward_rate_limited, req_bloom_miss, req_no_tree_peer, req_fallback_forwarded. Removed: req_already_visited, visited bloom filter.
This commit is contained in:
@@ -104,7 +104,9 @@ fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
helpers::kv_line("Deduplicated", &helpers::nested_u64(data, "discovery", "req_deduplicated")),
|
||||
helpers::kv_line("Target Is Us", &helpers::nested_u64(data, "discovery", "req_target_is_us")),
|
||||
helpers::kv_line("Duplicate", &helpers::nested_u64(data, "discovery", "req_duplicate")),
|
||||
helpers::kv_line("Already Visited", &helpers::nested_u64(data, "discovery", "req_already_visited")),
|
||||
helpers::kv_line("Bloom Miss", &helpers::nested_u64(data, "discovery", "req_bloom_miss")),
|
||||
helpers::kv_line("Backoff Suppressed", &helpers::nested_u64(data, "discovery", "req_backoff_suppressed")),
|
||||
helpers::kv_line("Fwd Rate Limited", &helpers::nested_u64(data, "discovery", "req_forward_rate_limited")),
|
||||
helpers::kv_line("TTL Exhausted", &helpers::nested_u64(data, "discovery", "req_ttl_exhausted")),
|
||||
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "req_decode_error")),
|
||||
Line::from(""),
|
||||
|
||||
@@ -166,6 +166,27 @@ pub struct DiscoveryConfig {
|
||||
/// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
|
||||
#[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
|
||||
pub recent_expiry_secs: u64,
|
||||
/// Base backoff after first lookup failure in seconds (`node.discovery.backoff_base_secs`).
|
||||
/// Doubles per consecutive failure up to `backoff_max_secs`.
|
||||
#[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
|
||||
pub backoff_base_secs: u64,
|
||||
/// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
|
||||
#[serde(default = "DiscoveryConfig::default_backoff_max_secs")]
|
||||
pub backoff_max_secs: u64,
|
||||
/// Minimum interval between forwarded lookups for the same target in seconds
|
||||
/// (`node.discovery.forward_min_interval_secs`).
|
||||
/// Defense-in-depth against misbehaving nodes.
|
||||
#[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
|
||||
pub forward_min_interval_secs: u64,
|
||||
/// Retry interval within the timeout window in seconds
|
||||
/// (`node.discovery.retry_interval_secs`).
|
||||
/// After this interval without a response, resend the lookup.
|
||||
#[serde(default = "DiscoveryConfig::default_retry_interval_secs")]
|
||||
pub retry_interval_secs: u64,
|
||||
/// Maximum attempts per lookup (`node.discovery.max_attempts`).
|
||||
/// 1 = no retry, 2 = one retry, etc.
|
||||
#[serde(default = "DiscoveryConfig::default_max_attempts")]
|
||||
pub max_attempts: u8,
|
||||
}
|
||||
|
||||
impl Default for DiscoveryConfig {
|
||||
@@ -174,6 +195,11 @@ impl Default for DiscoveryConfig {
|
||||
ttl: 64,
|
||||
timeout_secs: 10,
|
||||
recent_expiry_secs: 10,
|
||||
backoff_base_secs: 30,
|
||||
backoff_max_secs: 300,
|
||||
forward_min_interval_secs: 2,
|
||||
retry_interval_secs: 5,
|
||||
max_attempts: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +208,11 @@ impl DiscoveryConfig {
|
||||
fn default_ttl() -> u8 { 64 }
|
||||
fn default_timeout_secs() -> u64 { 10 }
|
||||
fn default_recent_expiry_secs() -> u64 { 10 }
|
||||
fn default_backoff_base_secs() -> u64 { 30 }
|
||||
fn default_backoff_max_secs() -> u64 { 300 }
|
||||
fn default_forward_min_interval_secs() -> u64 { 2 }
|
||||
fn default_retry_interval_secs() -> u64 { 5 }
|
||||
fn default_max_attempts() -> u8 { 2 }
|
||||
}
|
||||
|
||||
/// Spanning tree (`node.tree.*`).
|
||||
|
||||
378
src/node/discovery_rate_limit.rs
Normal file
378
src/node/discovery_rate_limit.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
//! Discovery protocol rate limiting and backoff.
|
||||
//!
|
||||
//! Two complementary mechanisms:
|
||||
//!
|
||||
//! - **`DiscoveryBackoff`** (originator-side): Exponential backoff for failed
|
||||
//! lookups. After a lookup times out, suppresses re-initiation with
|
||||
//! increasing delays (30s → 60s → 300s cap). Reset on topology changes
|
||||
//! (parent change, new peer, first RTT, reconnection).
|
||||
//!
|
||||
//! - **`DiscoveryForwardRateLimiter`** (transit-side): Per-target minimum
|
||||
//! interval for forwarded requests. Defense-in-depth against misbehaving
|
||||
//! nodes generating fresh request_ids at high rate.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ============================================================================
|
||||
// Originator-side: Discovery Backoff
|
||||
// ============================================================================
|
||||
|
||||
/// Default base backoff after first lookup failure.
|
||||
const DEFAULT_BACKOFF_BASE_SECS: u64 = 30;
|
||||
|
||||
/// Default maximum backoff cap.
|
||||
const DEFAULT_BACKOFF_MAX_SECS: u64 = 300;
|
||||
|
||||
/// Backoff multiplier per consecutive failure.
|
||||
const BACKOFF_MULTIPLIER: u64 = 2;
|
||||
|
||||
/// Exponential backoff for failed discovery lookups.
|
||||
///
|
||||
/// Tracks targets whose lookups have timed out and suppresses
|
||||
/// re-initiation with increasing delays. Cleared on topology changes.
|
||||
pub struct DiscoveryBackoff {
|
||||
/// Maps target → (suppress_until, consecutive_failures).
|
||||
entries: HashMap<NodeAddr, BackoffEntry>,
|
||||
/// Base backoff duration (first failure).
|
||||
base: Duration,
|
||||
/// Maximum backoff cap.
|
||||
max: Duration,
|
||||
}
|
||||
|
||||
struct BackoffEntry {
|
||||
/// Don't re-initiate until this instant.
|
||||
suppress_until: Instant,
|
||||
/// Consecutive failures (drives exponential backoff).
|
||||
failures: u32,
|
||||
}
|
||||
|
||||
impl DiscoveryBackoff {
|
||||
/// Create with default parameters (30s base, 300s cap).
|
||||
pub fn new() -> Self {
|
||||
Self::with_params(DEFAULT_BACKOFF_BASE_SECS, DEFAULT_BACKOFF_MAX_SECS)
|
||||
}
|
||||
|
||||
/// Create with custom base and max backoff in seconds.
|
||||
pub fn with_params(base_secs: u64, max_secs: u64) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
base: Duration::from_secs(base_secs),
|
||||
max: Duration::from_secs(max_secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a lookup for this target is suppressed.
|
||||
///
|
||||
/// Returns true if the target is in backoff and should not be
|
||||
/// looked up yet.
|
||||
pub fn is_suppressed(&self, target: &NodeAddr) -> bool {
|
||||
if let Some(entry) = self.entries.get(target) {
|
||||
Instant::now() < entry.suppress_until
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a lookup failure (timeout) for a target.
|
||||
///
|
||||
/// Increments the failure count and sets the next suppression
|
||||
/// window using exponential backoff.
|
||||
pub fn record_failure(&mut self, target: &NodeAddr) {
|
||||
let now = Instant::now();
|
||||
let failures = self
|
||||
.entries
|
||||
.get(target)
|
||||
.map_or(0, |e| e.failures)
|
||||
+ 1;
|
||||
|
||||
let backoff_secs = self
|
||||
.base
|
||||
.as_secs()
|
||||
.saturating_mul(BACKOFF_MULTIPLIER.saturating_pow(failures.saturating_sub(1)));
|
||||
let backoff = Duration::from_secs(backoff_secs.min(self.max.as_secs()));
|
||||
|
||||
self.entries.insert(
|
||||
*target,
|
||||
BackoffEntry {
|
||||
suppress_until: now + backoff,
|
||||
failures,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a successful lookup — remove backoff for this target.
|
||||
pub fn record_success(&mut self, target: &NodeAddr) {
|
||||
self.entries.remove(target);
|
||||
}
|
||||
|
||||
/// Clear all backoff entries.
|
||||
///
|
||||
/// Called on topology changes that might make previously-unreachable
|
||||
/// targets reachable (parent change, new peer, first RTT, reconnection).
|
||||
pub fn reset_all(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
/// Whether any entries exist.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Current number of entries.
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Get the failure count for a target (for logging).
|
||||
pub fn failure_count(&self, target: &NodeAddr) -> u32 {
|
||||
self.entries.get(target).map_or(0, |e| e.failures)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscoveryBackoff {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transit-side: Discovery Forward Rate Limiter
|
||||
// ============================================================================
|
||||
|
||||
/// Default minimum interval between forwarded lookups for the same target.
|
||||
const DEFAULT_FORWARD_MIN_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Maximum age of entries before cleanup.
|
||||
const FORWARD_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Rate limiter for forwarded discovery requests.
|
||||
///
|
||||
/// Tracks the last time a LookupRequest was forwarded for each target
|
||||
/// and enforces a minimum interval to prevent floods from misbehaving
|
||||
/// nodes generating fresh request_ids.
|
||||
pub struct DiscoveryForwardRateLimiter {
|
||||
last_forwarded: HashMap<NodeAddr, Instant>,
|
||||
min_interval: Duration,
|
||||
max_age: Duration,
|
||||
}
|
||||
|
||||
impl DiscoveryForwardRateLimiter {
|
||||
/// Create with default parameters (2s interval).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_forwarded: HashMap::new(),
|
||||
min_interval: DEFAULT_FORWARD_MIN_INTERVAL,
|
||||
max_age: FORWARD_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom minimum interval.
|
||||
pub fn with_interval(min_interval: Duration) -> Self {
|
||||
Self {
|
||||
last_forwarded: HashMap::new(),
|
||||
min_interval,
|
||||
max_age: FORWARD_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we should forward a lookup for this target.
|
||||
///
|
||||
/// Returns true if enough time has passed since the last forward
|
||||
/// for this target. Updates internal state when returning true.
|
||||
pub fn should_forward(&mut self, target: &NodeAddr) -> bool {
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(&last) = self.last_forwarded.get(target)
|
||||
&& now.duration_since(last) < self.min_interval
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.last_forwarded.insert(*target, now);
|
||||
self.cleanup(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the minimum interval (e.g., set to zero to disable).
|
||||
#[cfg(test)]
|
||||
pub fn set_interval(&mut self, interval: Duration) {
|
||||
self.min_interval = interval;
|
||||
}
|
||||
|
||||
/// Remove entries older than max_age.
|
||||
fn cleanup(&mut self, now: Instant) {
|
||||
self.last_forwarded
|
||||
.retain(|_, &mut last| now.duration_since(last) < self.max_age);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.last_forwarded.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscoveryForwardRateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
fn addr(val: u8) -> NodeAddr {
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[0] = val;
|
||||
NodeAddr::from_bytes(bytes)
|
||||
}
|
||||
|
||||
// --- DiscoveryBackoff tests ---
|
||||
|
||||
#[test]
|
||||
fn test_backoff_not_suppressed_initially() {
|
||||
let backoff = DiscoveryBackoff::new();
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_suppressed_after_failure() {
|
||||
let mut backoff = DiscoveryBackoff::new();
|
||||
backoff.record_failure(&addr(1));
|
||||
assert!(backoff.is_suppressed(&addr(1)));
|
||||
// Different target not affected
|
||||
assert!(!backoff.is_suppressed(&addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_cleared_on_success() {
|
||||
let mut backoff = DiscoveryBackoff::new();
|
||||
backoff.record_failure(&addr(1));
|
||||
assert!(backoff.is_suppressed(&addr(1)));
|
||||
|
||||
backoff.record_success(&addr(1));
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_reset_all() {
|
||||
let mut backoff = DiscoveryBackoff::new();
|
||||
backoff.record_failure(&addr(1));
|
||||
backoff.record_failure(&addr(2));
|
||||
assert_eq!(backoff.len(), 2);
|
||||
|
||||
backoff.reset_all();
|
||||
assert_eq!(backoff.len(), 0);
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_exponential() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(1, 300);
|
||||
|
||||
// First failure: 1s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 1);
|
||||
|
||||
// Second failure: 2s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 2);
|
||||
|
||||
// Third failure: 4s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_expires() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(0, 0);
|
||||
backoff.record_failure(&addr(1));
|
||||
// With 0s backoff, should not be suppressed
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_capped() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(1, 10);
|
||||
|
||||
// Record many failures
|
||||
for _ in 0..20 {
|
||||
backoff.record_failure(&addr(1));
|
||||
}
|
||||
|
||||
// Backoff should be capped at max (10s), not overflow
|
||||
let entry = backoff.entries.get(&addr(1)).unwrap();
|
||||
let remaining = entry.suppress_until.duration_since(Instant::now());
|
||||
assert!(remaining <= Duration::from_secs(11));
|
||||
}
|
||||
|
||||
// --- DiscoveryForwardRateLimiter tests ---
|
||||
|
||||
#[test]
|
||||
fn test_forward_first_allowed() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_rapid_rate_limited() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_different_targets_independent() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(limiter.should_forward(&addr(2)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_allowed_after_interval() {
|
||||
let mut limiter =
|
||||
DiscoveryForwardRateLimiter::with_interval(Duration::from_millis(100));
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
|
||||
thread::sleep(Duration::from_millis(110));
|
||||
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_cleanup_removes_old() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(limiter.should_forward(&addr(2)));
|
||||
assert_eq!(limiter.len(), 2);
|
||||
|
||||
let future = Instant::now() + Duration::from_secs(61);
|
||||
limiter.cleanup(future);
|
||||
assert_eq!(limiter.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_cleanup_preserves_recent() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert_eq!(limiter.len(), 1);
|
||||
|
||||
limiter.cleanup(Instant::now());
|
||||
assert_eq!(limiter.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
//! LookupRequest/LookupResponse discovery protocol handlers.
|
||||
//!
|
||||
//! Handles coordinate discovery requests: flood-based lookup with TTL,
|
||||
//! visited filter for loop prevention, and reverse-path forwarding for
|
||||
//! responses.
|
||||
//! Handles coordinate discovery via bloom-filter-guided tree routing.
|
||||
//! Requests are forwarded only to tree peers (parent + children) whose
|
||||
//! bloom filter contains the target. TTL and request_id dedup provide
|
||||
//! safety bounds.
|
||||
|
||||
use crate::node::{Node, RecentRequest};
|
||||
use crate::protocol::{LookupRequest, LookupResponse};
|
||||
use crate::{NodeAddr, PeerIdentity};
|
||||
use tracing::{debug, trace, warn};
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
impl Node {
|
||||
/// Handle an incoming LookupRequest from a peer.
|
||||
///
|
||||
/// Processing steps:
|
||||
/// 1. Decode and validate
|
||||
/// 2. Check request_id for duplicates (dedup)
|
||||
/// 2. Check request_id for duplicates (dedup / reverse-path routing)
|
||||
/// 3. Record request for reverse-path forwarding
|
||||
/// 4. Lazy purge expired entries
|
||||
/// 5. Check visited filter (loop prevention)
|
||||
/// 6. If we're the target, generate and send response
|
||||
/// 7. If TTL > 0, forward to peers not in visited filter
|
||||
/// 5. If we're the target, generate and send response
|
||||
/// 6. If TTL > 0, forward to tree peers whose bloom filter matches
|
||||
pub(in crate::node) async fn handle_lookup_request(
|
||||
&mut self,
|
||||
from: &NodeAddr,
|
||||
@@ -38,10 +38,12 @@ impl Node {
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
|
||||
// Dedup: drop if we've already seen this request_id
|
||||
// Dedup: drop if we've already seen this request_id.
|
||||
// Also serves as loop protection — tree routing is loop-free,
|
||||
// but request_id dedup catches edge cases during tree restructuring.
|
||||
if self.recent_requests.contains_key(&request.request_id) {
|
||||
self.stats_mut().discovery.req_duplicate += 1;
|
||||
trace!(
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
from = %self.peer_display_name(from),
|
||||
"Duplicate LookupRequest, dropping"
|
||||
@@ -58,17 +60,6 @@ impl Node {
|
||||
// Lazy purge expired entries
|
||||
self.purge_expired_requests(now_ms);
|
||||
|
||||
// Loop prevention: drop if we've already been visited
|
||||
if request.was_visited(self.node_addr()) {
|
||||
self.stats_mut().discovery.req_already_visited += 1;
|
||||
trace!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
"Already visited, dropping LookupRequest"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Are we the target?
|
||||
if request.target == *self.node_addr() {
|
||||
self.stats_mut().discovery.req_target_is_us += 1;
|
||||
@@ -83,14 +74,25 @@ impl Node {
|
||||
|
||||
// Forward if TTL permits
|
||||
if request.can_forward() {
|
||||
// Transit-side rate limit: collapse rapid-fire lookups for the
|
||||
// same target from misbehaving nodes generating fresh request_ids.
|
||||
if !self.discovery_forward_limiter.should_forward(&request.target) {
|
||||
self.stats_mut().discovery.req_forward_rate_limited += 1;
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
"Forward rate limited, suppressing LookupRequest"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.stats_mut().discovery.req_forwarded += 1;
|
||||
self.forward_lookup_request(request).await;
|
||||
} else {
|
||||
self.stats_mut().discovery.req_ttl_exhausted += 1;
|
||||
trace!(
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
"LookupRequest TTL exhausted, not forwarding"
|
||||
"LookupRequest TTL exhausted"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +123,19 @@ impl Node {
|
||||
let now_ms = Self::now_ms();
|
||||
|
||||
// Check if we forwarded this request (transit node) or originated it
|
||||
if let Some(recent) = self.recent_requests.get(&response.request_id) {
|
||||
if let Some(recent) = self.recent_requests.get_mut(&response.request_id) {
|
||||
// Already forwarded a response for this request — drop to
|
||||
// prevent response routing loops.
|
||||
if recent.response_forwarded {
|
||||
debug!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&response.target),
|
||||
"Response already forwarded for this request, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
recent.response_forwarded = true;
|
||||
|
||||
// Transit node: reverse-path forward
|
||||
let from_peer = recent.from_peer;
|
||||
self.stats_mut().discovery.resp_forwarded += 1;
|
||||
@@ -195,12 +209,15 @@ impl Node {
|
||||
|
||||
self.stats_mut().discovery.resp_accepted += 1;
|
||||
|
||||
debug!(
|
||||
// Clear backoff on success — target is reachable
|
||||
self.discovery_backoff.record_success(&target);
|
||||
|
||||
info!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&target),
|
||||
depth = response.target_coords.depth(),
|
||||
path_mtu = path_mtu,
|
||||
"Received LookupResponse, proof verified, caching route"
|
||||
"Discovery succeeded, proof verified, route cached"
|
||||
);
|
||||
|
||||
self.coord_cache.insert_with_path_mtu(
|
||||
@@ -214,9 +231,6 @@ impl Node {
|
||||
self.pending_lookups.remove(&target);
|
||||
|
||||
// If an established session exists, reset the warmup counter.
|
||||
// Discovery has completed and transit nodes along the response
|
||||
// path now have fresh coords. Reset warmup so the next N
|
||||
// data packets include COORDS_PRESENT to re-warm the forward path.
|
||||
if let Some(entry) = self.sessions.get_mut(&target)
|
||||
&& entry.is_established()
|
||||
{
|
||||
@@ -232,22 +246,18 @@ impl Node {
|
||||
// If we have pending TUN packets for this target, retry session
|
||||
// initiation. The coord_cache now has coords, so find_next_hop()
|
||||
// should succeed.
|
||||
if self.pending_tun_packets.contains_key(&target) {
|
||||
if let Some(packets) = self.pending_tun_packets.get(&target) {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&target),
|
||||
queued_packets = packets.len(),
|
||||
"Retrying queued packets after discovery"
|
||||
);
|
||||
self.retry_session_after_discovery(target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate and send a LookupResponse when we are the target.
|
||||
///
|
||||
/// Signs a proof using our identity and routes the response back
|
||||
/// toward the origin via reverse-path forwarding. The first hop
|
||||
/// uses the `recent_requests` entry (which records who sent us the
|
||||
/// request), ensuring the response follows the same path the
|
||||
/// request took. This is critical because greedy tree routing
|
||||
/// might send the response to a peer that never forwarded the
|
||||
/// request and thus has no `recent_requests` entry, causing the
|
||||
/// response to be discarded.
|
||||
async fn send_lookup_response(&mut self, request: &LookupRequest) {
|
||||
let our_coords = self.tree_state().my_coords().clone();
|
||||
|
||||
@@ -262,10 +272,7 @@ impl Node {
|
||||
proof,
|
||||
);
|
||||
|
||||
// Route toward origin via reverse path. The recent_requests entry
|
||||
// was recorded before we got here (line 49-51), so from_peer is
|
||||
// the node that forwarded the request to us — the correct first
|
||||
// hop for the response's reverse path.
|
||||
// Route toward origin via reverse path.
|
||||
let next_hop_addr = if let Some(recent) = self.recent_requests.get(&request.request_id) {
|
||||
recent.from_peer
|
||||
} else {
|
||||
@@ -299,37 +306,71 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a LookupRequest to peers not in the visited filter.
|
||||
/// Forward a LookupRequest to eligible peers.
|
||||
///
|
||||
/// Decrements TTL, adds self to visited, and sends to all eligible peers.
|
||||
/// Primary path: tree peers (parent + children) whose bloom filter
|
||||
/// contains the target. Restricting to tree peers follows the spanning
|
||||
/// tree partition, producing a single directed path.
|
||||
///
|
||||
/// Fallback: if no tree peer's bloom matches, try non-tree peers whose
|
||||
/// bloom contains the target. This recovers from dead ends caused by
|
||||
/// stale bloom filters, tree restructuring, or transit node failures.
|
||||
async fn forward_lookup_request(&mut self, mut request: LookupRequest) {
|
||||
if !request.forward(self.node_addr()) {
|
||||
if !request.forward() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect peers not in visited filter
|
||||
// Collect tree peers whose bloom filter contains the target
|
||||
let forward_to: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.keys()
|
||||
.filter(|addr| !request.was_visited(addr))
|
||||
.copied()
|
||||
.iter()
|
||||
.filter(|(addr, peer)| {
|
||||
self.is_tree_peer(addr) && peer.may_reach(&request.target)
|
||||
})
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
|
||||
if forward_to.is_empty() {
|
||||
trace!(
|
||||
request_id = request.request_id,
|
||||
"No eligible peers to forward LookupRequest"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Fallback: if no tree peer matches, try non-tree bloom-matching peers
|
||||
let (forward_to, used_fallback) = if forward_to.is_empty() {
|
||||
let fallback: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(addr, peer)| {
|
||||
!self.is_tree_peer(addr) && peer.may_reach(&request.target)
|
||||
})
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
if fallback.is_empty() {
|
||||
self.stats_mut().discovery.req_no_tree_peer += 1;
|
||||
trace!(
|
||||
request_id = request.request_id,
|
||||
"No eligible peers to forward LookupRequest"
|
||||
);
|
||||
return;
|
||||
}
|
||||
(fallback, true)
|
||||
} else {
|
||||
(forward_to, false)
|
||||
};
|
||||
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
ttl = request.ttl,
|
||||
peer_count = forward_to.len(),
|
||||
"Forwarding LookupRequest"
|
||||
);
|
||||
if used_fallback {
|
||||
self.stats_mut().discovery.req_fallback_forwarded += 1;
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
ttl = request.ttl,
|
||||
peer_count = forward_to.len(),
|
||||
"Forwarding LookupRequest via non-tree fallback"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
ttl = request.ttl,
|
||||
peer_count = forward_to.len(),
|
||||
"Forwarding LookupRequest"
|
||||
);
|
||||
}
|
||||
|
||||
let encoded = request.encode();
|
||||
|
||||
@@ -346,30 +387,42 @@ impl Node {
|
||||
|
||||
/// Initiate a discovery lookup for a target node.
|
||||
///
|
||||
/// Creates a LookupRequest and floods it to all peers. The originator
|
||||
/// does NOT record the request_id in recent_requests, so when the
|
||||
/// response arrives, it's recognized as "our request" and the
|
||||
/// target's coordinates are cached in coord_cache.
|
||||
pub(in crate::node) async fn initiate_lookup(&mut self, target: &NodeAddr, ttl: u8) {
|
||||
/// Creates a LookupRequest and sends it to tree peers whose bloom
|
||||
/// filters contain the target. Returns the number of peers sent to.
|
||||
/// The originator does NOT record the request_id in recent_requests,
|
||||
/// so when the response arrives, it's recognized as "our request".
|
||||
pub(in crate::node) async fn initiate_lookup(&mut self, target: &NodeAddr, ttl: u8) -> usize {
|
||||
self.stats_mut().discovery.req_initiated += 1;
|
||||
|
||||
let origin = *self.node_addr();
|
||||
let origin_coords = self.tree_state().my_coords().clone();
|
||||
let mut request = LookupRequest::generate(*target, origin, origin_coords, ttl, 0);
|
||||
let request = LookupRequest::generate(*target, origin, origin_coords, ttl, 0);
|
||||
|
||||
// Add ourselves to the visited filter so forwarding nodes
|
||||
// won't send the request back to us
|
||||
request.visited.insert(&origin);
|
||||
// Send only to tree peers whose bloom filter contains the target
|
||||
let peer_addrs: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(addr, peer)| {
|
||||
self.is_tree_peer(addr) && peer.may_reach(target)
|
||||
})
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
let peer_count = peer_addrs.len();
|
||||
|
||||
info!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(target),
|
||||
ttl = ttl,
|
||||
"Initiating LookupRequest"
|
||||
peer_count = peer_count,
|
||||
total_peers = self.peers.len(),
|
||||
"Discovery lookup initiated"
|
||||
);
|
||||
|
||||
// Send to all peers (flood)
|
||||
let peer_addrs: Vec<NodeAddr> = self.peers.keys().copied().collect();
|
||||
if peer_count == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let encoded = request.encode();
|
||||
|
||||
for peer_addr in peer_addrs {
|
||||
@@ -381,43 +434,136 @@ impl Node {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
peer_count
|
||||
}
|
||||
|
||||
/// Initiate a discovery lookup if one is not already pending for this target.
|
||||
///
|
||||
/// Deduplicates lookups using `pending_lookups` with a timeout. If a
|
||||
/// lookup was recently initiated and hasn't timed out, this is a no-op.
|
||||
/// Checks: pending dedup, backoff, bloom filter pre-check. If all pass,
|
||||
/// initiates the lookup. If no tree peers have the target in their bloom
|
||||
/// filter, the lookup is skipped (bloom miss) and recorded as a failure
|
||||
/// for backoff purposes.
|
||||
pub(in crate::node) async fn maybe_initiate_lookup(&mut self, dest: &NodeAddr) {
|
||||
let now_ms = Self::now_ms();
|
||||
let lookup_timeout_ms = self.config.node.discovery.timeout_secs * 1000;
|
||||
if let Some(&initiated_at) = self.pending_lookups.get(dest)
|
||||
&& now_ms.saturating_sub(initiated_at) < lookup_timeout_ms
|
||||
{
|
||||
self.stats_mut().discovery.req_deduplicated += 1;
|
||||
|
||||
// Check pending lookup dedup (in-flight)
|
||||
if let Some(entry) = self.pending_lookups.get(dest) {
|
||||
let age_ms = now_ms.saturating_sub(entry.initiated_ms);
|
||||
let attempt = entry.attempt;
|
||||
if age_ms < lookup_timeout_ms {
|
||||
self.stats_mut().discovery.req_deduplicated += 1;
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
age_ms = age_ms,
|
||||
attempt = attempt,
|
||||
"Discovery lookup deduplicated, already pending"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check backoff from previous failures
|
||||
if self.discovery_backoff.is_suppressed(dest) {
|
||||
self.stats_mut().discovery.req_backoff_suppressed += 1;
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
failures = self.discovery_backoff.failure_count(dest),
|
||||
"Discovery lookup suppressed by backoff"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.pending_lookups.insert(*dest, now_ms);
|
||||
|
||||
// Bloom filter pre-check: if no peer's filter contains the target,
|
||||
// it's not in the mesh — skip the lookup and record as failure.
|
||||
let reachable = self.peers.values().any(|peer| peer.may_reach(dest));
|
||||
if !reachable {
|
||||
self.stats_mut().discovery.req_bloom_miss += 1;
|
||||
self.discovery_backoff.record_failure(dest);
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
"Discovery skipped, target not in any peer bloom filter"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_lookups.insert(*dest, PendingLookup::new(now_ms));
|
||||
let ttl = self.config.node.discovery.ttl;
|
||||
self.initiate_lookup(dest, ttl).await;
|
||||
let sent = self.initiate_lookup(dest, ttl).await;
|
||||
|
||||
// If no tree peers had the target, fail immediately
|
||||
if sent == 0 {
|
||||
self.pending_lookups.remove(dest);
|
||||
self.discovery_backoff.record_failure(dest);
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
"Discovery failed, no tree peers with bloom match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove timed-out pending lookups and drain their queued packets.
|
||||
/// Check pending lookups for retry or timeout.
|
||||
///
|
||||
/// Called periodically from the tick handler. For each timed-out lookup,
|
||||
/// sends ICMPv6 Destination Unreachable for any queued TUN packets and
|
||||
/// removes them from the pending queue.
|
||||
pub(in crate::node) fn purge_stale_lookups(&mut self, now_ms: u64) {
|
||||
let timed_out: Vec<NodeAddr> = self
|
||||
.pending_lookups
|
||||
.iter()
|
||||
.filter(|&(_, &ts)| now_ms.saturating_sub(ts) >= self.config.node.discovery.timeout_secs * 1000)
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
/// Called periodically from the tick handler. For each pending lookup:
|
||||
/// - If retry interval elapsed and attempts remain: resend
|
||||
/// - If total timeout elapsed: fail, record backoff, send ICMP unreachable
|
||||
pub(in crate::node) async fn check_pending_lookups(&mut self, now_ms: u64) {
|
||||
let timeout_ms = self.config.node.discovery.timeout_secs * 1000;
|
||||
let retry_ms = self.config.node.discovery.retry_interval_secs * 1000;
|
||||
|
||||
for addr in timed_out {
|
||||
// Collect targets needing action
|
||||
let mut to_retry: Vec<NodeAddr> = Vec::new();
|
||||
let mut to_timeout: Vec<NodeAddr> = Vec::new();
|
||||
|
||||
for (&target, entry) in &self.pending_lookups {
|
||||
let age = now_ms.saturating_sub(entry.initiated_ms);
|
||||
if age >= timeout_ms {
|
||||
to_timeout.push(target);
|
||||
} else if entry.attempt < self.config.node.discovery.max_attempts
|
||||
&& now_ms.saturating_sub(entry.last_sent_ms) >= retry_ms
|
||||
{
|
||||
to_retry.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
// Process retries
|
||||
for target in to_retry {
|
||||
if let Some(entry) = self.pending_lookups.get_mut(&target) {
|
||||
entry.attempt += 1;
|
||||
entry.last_sent_ms = now_ms;
|
||||
let attempt = entry.attempt;
|
||||
|
||||
let ttl = self.config.node.discovery.ttl;
|
||||
let sent = self.initiate_lookup(&target, ttl).await;
|
||||
if sent > 0 {
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(&target),
|
||||
attempt = attempt,
|
||||
"Discovery retry sent"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process timeouts
|
||||
for addr in to_timeout {
|
||||
self.stats_mut().discovery.resp_timed_out += 1;
|
||||
self.pending_lookups.remove(&addr);
|
||||
if let Some(packets) = self.pending_tun_packets.remove(&addr) {
|
||||
|
||||
// Record failure for backoff
|
||||
self.discovery_backoff.record_failure(&addr);
|
||||
let failures = self.discovery_backoff.failure_count(&addr);
|
||||
|
||||
let queued = self.pending_tun_packets.remove(&addr);
|
||||
let pkt_count = queued.as_ref().map_or(0, |p| p.len());
|
||||
info!(
|
||||
target_node = %self.peer_display_name(&addr),
|
||||
queued_packets = pkt_count,
|
||||
failures = failures,
|
||||
"Discovery lookup timed out, destination unreachable"
|
||||
);
|
||||
if let Some(packets) = queued {
|
||||
for pkt in &packets {
|
||||
self.send_icmpv6_dest_unreachable(pkt);
|
||||
}
|
||||
@@ -425,11 +571,41 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset discovery backoff on topology changes.
|
||||
pub(in crate::node) fn reset_discovery_backoff(&mut self) {
|
||||
if !self.discovery_backoff.is_empty() {
|
||||
debug!(
|
||||
entries = self.discovery_backoff.entry_count(),
|
||||
"Resetting discovery backoff on topology change"
|
||||
);
|
||||
self.discovery_backoff.reset_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove expired entries from the recent_requests cache.
|
||||
fn purge_expired_requests(&mut self, current_time_ms: u64) {
|
||||
let expiry_ms = self.config.node.discovery.recent_expiry_secs * 1000;
|
||||
self.recent_requests
|
||||
.retain(|_, entry| !entry.is_expired(current_time_ms, expiry_ms));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Tracks a pending discovery lookup with retry state.
|
||||
pub(crate) struct PendingLookup {
|
||||
/// When the lookup was first initiated.
|
||||
pub initiated_ms: u64,
|
||||
/// When the last attempt was sent.
|
||||
pub last_sent_ms: u64,
|
||||
/// Current attempt number (1 = initial, 2 = first retry, ...).
|
||||
pub attempt: u8,
|
||||
}
|
||||
|
||||
impl PendingLookup {
|
||||
pub fn new(now_ms: u64) -> Self {
|
||||
Self {
|
||||
initiated_ms: now_ms,
|
||||
last_sent_ms: now_ms,
|
||||
attempt: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +432,7 @@ impl Node {
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(node_addr);
|
||||
self.reset_discovery_backoff();
|
||||
}
|
||||
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
|
||||
// Store msg2 on peer for resend on duplicate msg1
|
||||
@@ -459,6 +460,7 @@ impl Node {
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(node_addr);
|
||||
self.reset_discovery_backoff();
|
||||
}
|
||||
PromotionResult::CrossConnectionLost { winner_link_id } => {
|
||||
// Close the losing TCP connection (no-op for connectionless)
|
||||
@@ -765,6 +767,7 @@ impl Node {
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(peer_node_addr);
|
||||
self.reset_discovery_backoff();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -786,6 +789,7 @@ impl Node {
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(node_addr);
|
||||
self.reset_discovery_backoff();
|
||||
}
|
||||
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
|
||||
// Close the losing TCP connection (no-op for connectionless)
|
||||
@@ -814,6 +818,7 @@ impl Node {
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(node_addr);
|
||||
self.reset_discovery_backoff();
|
||||
}
|
||||
PromotionResult::CrossConnectionLost { winner_link_id } => {
|
||||
// Close the losing TCP connection (no-op for connectionless)
|
||||
|
||||
@@ -145,6 +145,7 @@ impl Node {
|
||||
}
|
||||
self.tree_state.recompute_coords();
|
||||
self.coord_cache.clear();
|
||||
self.reset_discovery_backoff();
|
||||
self.stats_mut().tree.parent_switched += 1;
|
||||
self.stats_mut().tree.parent_switches += 1;
|
||||
info!(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! RX event loop and message handlers.
|
||||
|
||||
mod discovery;
|
||||
pub(crate) mod discovery;
|
||||
mod dispatch;
|
||||
mod encrypted;
|
||||
mod forwarding;
|
||||
|
||||
@@ -129,7 +129,7 @@ impl Node {
|
||||
self.check_link_heartbeats().await;
|
||||
self.check_rekey().await;
|
||||
self.check_session_rekey().await;
|
||||
self.purge_stale_lookups(now_ms);
|
||||
self.check_pending_lookups(now_ms).await;
|
||||
self.poll_transport_discovery().await;
|
||||
self.sample_transport_congestion();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod bloom;
|
||||
mod handlers;
|
||||
mod lifecycle;
|
||||
mod retry;
|
||||
mod discovery_rate_limit;
|
||||
mod rate_limit;
|
||||
mod routing_error_rate_limit;
|
||||
pub(crate) mod session;
|
||||
@@ -23,6 +24,7 @@ use crate::cache::CoordCache;
|
||||
use crate::utils::index::IndexAllocator;
|
||||
use crate::node::session::SessionEntry;
|
||||
use crate::peer::{ActivePeer, PeerConnection};
|
||||
use self::discovery_rate_limit::{DiscoveryBackoff, DiscoveryForwardRateLimiter};
|
||||
use self::rate_limit::HandshakeRateLimiter;
|
||||
use self::routing_error_rate_limit::RoutingErrorRateLimiter;
|
||||
use crate::transport::{
|
||||
@@ -175,12 +177,17 @@ impl fmt::Display for NodeState {
|
||||
/// When a LookupRequest is forwarded through a node, the node stores the
|
||||
/// request_id and which peer sent it. When the corresponding LookupResponse
|
||||
/// arrives, it's forwarded back to that peer (reverse-path forwarding).
|
||||
/// The `response_forwarded` flag prevents response routing loops.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RecentRequest {
|
||||
/// The peer who sent this request to us.
|
||||
pub(crate) from_peer: NodeAddr,
|
||||
/// When we received this request (Unix milliseconds).
|
||||
pub(crate) timestamp_ms: u64,
|
||||
/// Whether we've already forwarded a response for this request.
|
||||
/// Prevents response routing loops when convergent request paths
|
||||
/// create bidirectional entries in recent_requests.
|
||||
pub(crate) response_forwarded: bool,
|
||||
}
|
||||
|
||||
impl RecentRequest {
|
||||
@@ -188,6 +195,7 @@ impl RecentRequest {
|
||||
Self {
|
||||
from_peer,
|
||||
timestamp_ms,
|
||||
response_forwarded: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +331,7 @@ pub struct Node {
|
||||
// === Pending Discovery Lookups ===
|
||||
/// Tracks in-flight discovery lookups. Maps target NodeAddr to the
|
||||
/// initiation timestamp (Unix ms). Prevents duplicate flood queries.
|
||||
pending_lookups: HashMap<NodeAddr, u64>,
|
||||
pending_lookups: HashMap<NodeAddr, handlers::discovery::PendingLookup>,
|
||||
|
||||
// === Resource Limits ===
|
||||
/// Maximum connections (0 = unlimited).
|
||||
@@ -382,6 +390,10 @@ pub struct Node {
|
||||
routing_error_rate_limiter: RoutingErrorRateLimiter,
|
||||
/// Rate limiter for source-side CoordsRequired/PathBroken responses.
|
||||
coords_response_rate_limiter: RoutingErrorRateLimiter,
|
||||
/// Backoff for failed discovery lookups (originator-side).
|
||||
discovery_backoff: DiscoveryBackoff,
|
||||
/// Rate limiter for forwarded discovery requests (transit-side).
|
||||
discovery_forward_limiter: DiscoveryForwardRateLimiter,
|
||||
|
||||
// === Pending Transport Connects ===
|
||||
/// Links waiting for transport-level connection establishment before
|
||||
@@ -472,6 +484,9 @@ impl Node {
|
||||
let max_peers = config.node.limits.max_peers;
|
||||
let max_links = config.node.limits.max_links;
|
||||
let coords_response_interval_ms = config.node.session.coords_response_interval_ms;
|
||||
let backoff_base_secs = config.node.discovery.backoff_base_secs;
|
||||
let backoff_max_secs = config.node.discovery.backoff_max_secs;
|
||||
let forward_min_interval_secs = config.node.discovery.forward_min_interval_secs;
|
||||
|
||||
let mut host_map = HostMap::from_peer_configs(config.peers());
|
||||
let hosts_file = HostMap::load_hosts_file(std::path::Path::new(
|
||||
@@ -526,6 +541,13 @@ impl Node {
|
||||
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval(
|
||||
std::time::Duration::from_millis(coords_response_interval_ms),
|
||||
),
|
||||
discovery_backoff: DiscoveryBackoff::with_params(
|
||||
backoff_base_secs,
|
||||
backoff_max_secs,
|
||||
),
|
||||
discovery_forward_limiter: DiscoveryForwardRateLimiter::with_interval(
|
||||
std::time::Duration::from_secs(forward_min_interval_secs),
|
||||
),
|
||||
pending_connects: Vec::new(),
|
||||
retry_pending: HashMap::new(),
|
||||
last_parent_reeval: None,
|
||||
@@ -629,6 +651,8 @@ impl Node {
|
||||
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval(
|
||||
std::time::Duration::from_millis(coords_response_interval_ms),
|
||||
),
|
||||
discovery_backoff: DiscoveryBackoff::new(),
|
||||
discovery_forward_limiter: DiscoveryForwardRateLimiter::new(),
|
||||
pending_connects: Vec::new(),
|
||||
retry_pending: HashMap::new(),
|
||||
last_parent_reeval: None,
|
||||
@@ -1222,6 +1246,13 @@ impl Node {
|
||||
// === End-to-End Sessions ===
|
||||
|
||||
/// Get a session by remote NodeAddr.
|
||||
/// Disable the discovery forward rate limiter (for tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn disable_discovery_forward_rate_limit(&mut self) {
|
||||
self.discovery_forward_limiter
|
||||
.set_interval(std::time::Duration::ZERO);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn get_session(&self, remote: &NodeAddr) -> Option<&SessionEntry> {
|
||||
self.sessions.get(remote)
|
||||
|
||||
@@ -107,12 +107,16 @@ pub struct DiscoveryStats {
|
||||
pub req_received: u64,
|
||||
pub req_decode_error: u64,
|
||||
pub req_duplicate: u64,
|
||||
pub req_already_visited: u64,
|
||||
pub req_target_is_us: u64,
|
||||
pub req_forwarded: u64,
|
||||
pub req_ttl_exhausted: u64,
|
||||
pub req_initiated: u64,
|
||||
pub req_deduplicated: u64,
|
||||
pub req_backoff_suppressed: u64,
|
||||
pub req_forward_rate_limited: u64,
|
||||
pub req_bloom_miss: u64,
|
||||
pub req_no_tree_peer: u64,
|
||||
pub req_fallback_forwarded: u64,
|
||||
// Response counters
|
||||
pub resp_received: u64,
|
||||
pub resp_decode_error: u64,
|
||||
@@ -129,12 +133,16 @@ impl DiscoveryStats {
|
||||
req_received: self.req_received,
|
||||
req_decode_error: self.req_decode_error,
|
||||
req_duplicate: self.req_duplicate,
|
||||
req_already_visited: self.req_already_visited,
|
||||
req_target_is_us: self.req_target_is_us,
|
||||
req_forwarded: self.req_forwarded,
|
||||
req_ttl_exhausted: self.req_ttl_exhausted,
|
||||
req_initiated: self.req_initiated,
|
||||
req_deduplicated: self.req_deduplicated,
|
||||
req_backoff_suppressed: self.req_backoff_suppressed,
|
||||
req_forward_rate_limited: self.req_forward_rate_limited,
|
||||
req_bloom_miss: self.req_bloom_miss,
|
||||
req_no_tree_peer: self.req_no_tree_peer,
|
||||
req_fallback_forwarded: self.req_fallback_forwarded,
|
||||
resp_received: self.resp_received,
|
||||
resp_decode_error: self.resp_decode_error,
|
||||
resp_forwarded: self.resp_forwarded,
|
||||
@@ -342,12 +350,16 @@ pub struct DiscoveryStatsSnapshot {
|
||||
pub req_received: u64,
|
||||
pub req_decode_error: u64,
|
||||
pub req_duplicate: u64,
|
||||
pub req_already_visited: u64,
|
||||
pub req_target_is_us: u64,
|
||||
pub req_forwarded: u64,
|
||||
pub req_ttl_exhausted: u64,
|
||||
pub req_initiated: u64,
|
||||
pub req_deduplicated: u64,
|
||||
pub req_backoff_suppressed: u64,
|
||||
pub req_forward_rate_limited: u64,
|
||||
pub req_bloom_miss: u64,
|
||||
pub req_no_tree_peer: u64,
|
||||
pub req_fallback_forwarded: u64,
|
||||
pub resp_received: u64,
|
||||
pub resp_decode_error: u64,
|
||||
pub resp_forwarded: u64,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Discovery protocol tests: LookupRequest and LookupResponse.
|
||||
//!
|
||||
//! Unit tests for handler logic (dedup, visited filter, TTL, response
|
||||
//! caching) and integration tests for multi-node forwarding and
|
||||
//! reverse-path response routing.
|
||||
//! Unit tests for handler logic (dedup, TTL, response caching) and
|
||||
//! integration tests for multi-node forwarding and reverse-path
|
||||
//! response routing.
|
||||
|
||||
use super::*;
|
||||
use crate::node::RecentRequest;
|
||||
@@ -46,26 +46,6 @@ async fn test_request_dedup() {
|
||||
assert_eq!(node.recent_requests.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_visited_filter_self() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let target = make_node_addr(0xBB);
|
||||
let origin = make_node_addr(0xCC);
|
||||
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
|
||||
|
||||
let mut request = LookupRequest::new(888, target, origin, coords, 5, 0);
|
||||
// Mark ourselves as already visited
|
||||
request.visited.insert(node.node_addr());
|
||||
|
||||
let payload = &request.encode()[1..];
|
||||
node.handle_lookup_request(&from, payload).await;
|
||||
|
||||
// Request was recorded (dedup happens before visited check)
|
||||
// but the handler should have stopped after detecting self in visited filter
|
||||
assert!(node.recent_requests.contains_key(&888));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_target_is_self() {
|
||||
let mut node = make_node();
|
||||
@@ -379,13 +359,13 @@ async fn test_recent_request_expiry() {
|
||||
#[tokio::test]
|
||||
async fn test_request_forwarding_two_node() {
|
||||
// Set up a two-node topology: node0 — node1
|
||||
// Send a LookupRequest from node0 targeting some unknown node.
|
||||
// Send a LookupRequest from node0 targeting node1's address.
|
||||
// Node1 should receive the forwarded request.
|
||||
let edges = vec![(0, 1)];
|
||||
let mut nodes = run_tree_test(2, &edges, false).await;
|
||||
|
||||
let node0_addr = *nodes[0].node.node_addr();
|
||||
let target = make_node_addr(0xEE); // unknown node
|
||||
let target = *nodes[1].node.node_addr(); // target node1 (in bloom filters)
|
||||
let root = make_node_addr(0);
|
||||
|
||||
let coords = TreeCoordinate::from_addrs(vec![node0_addr, root]).unwrap();
|
||||
@@ -499,20 +479,22 @@ async fn test_request_three_node_chain() {
|
||||
#[tokio::test]
|
||||
async fn test_request_dedup_convergent_paths() {
|
||||
// Topology: triangle (node0 — node1, node0 — node2, node1 — node2)
|
||||
// A request from node0 reaches node2 via two paths: 0→1→2 and 0→2.
|
||||
// The second arrival at node2 should be deduped.
|
||||
// A request from node0 targeting node2 may reach it via two paths
|
||||
// depending on bloom filter state. If both paths deliver the request,
|
||||
// the second arrival at node2 should be deduped.
|
||||
let edges = vec![(0, 1), (0, 2), (1, 2)];
|
||||
let mut nodes = run_tree_test(3, &edges, false).await;
|
||||
|
||||
let node0_addr = *nodes[0].node.node_addr();
|
||||
let target = make_node_addr(0xEE);
|
||||
let target = *nodes[2].node.node_addr(); // target node2 (in bloom filters)
|
||||
let root = make_node_addr(0);
|
||||
|
||||
let coords = TreeCoordinate::from_addrs(vec![node0_addr, root]).unwrap();
|
||||
let request = LookupRequest::new(300, target, node0_addr, coords, 5, 0);
|
||||
let payload = &request.encode()[1..];
|
||||
|
||||
// Node0 handles the request (forwards to both node1 and node2)
|
||||
// Node0 handles the request (forwards to peers whose bloom filter
|
||||
// contains node2 — bloom-guided, not flooding)
|
||||
nodes[0]
|
||||
.node
|
||||
.handle_lookup_request(&node0_addr, payload)
|
||||
@@ -524,12 +506,16 @@ async fn test_request_dedup_convergent_paths() {
|
||||
process_available_packets(&mut nodes).await;
|
||||
}
|
||||
|
||||
// Both node1 and node2 should have recorded the request
|
||||
assert!(nodes[1].node.recent_requests.contains_key(&300));
|
||||
assert!(nodes[2].node.recent_requests.contains_key(&300));
|
||||
// Node2 (the target) must have received the request
|
||||
assert!(
|
||||
nodes[2].node.recent_requests.contains_key(&300),
|
||||
"Node 2 (target) should have received the request"
|
||||
);
|
||||
|
||||
// The request should appear exactly once in each node's recent_requests
|
||||
// (dedup prevents duplicate processing via convergent paths)
|
||||
// If node1 also received and forwarded it, node2 would have seen a
|
||||
// duplicate — verify dedup counter reflects convergent arrivals.
|
||||
// With bloom-guided routing, node1 may or may not receive the request
|
||||
// depending on filter state, so we only assert the target received it.
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
@@ -547,11 +533,18 @@ async fn test_discovery_100_nodes() {
|
||||
const NUM_NODES: usize = 100;
|
||||
const TARGET_EDGES: usize = 250;
|
||||
const SEED: u64 = 42;
|
||||
const TTL: u8 = 15; // generous TTL for network diameter
|
||||
const TTL: u8 = 20; // must exceed tree diameter (can reach 17+ hops)
|
||||
let edges = generate_random_edges(NUM_NODES, TARGET_EDGES, SEED);
|
||||
let mut nodes = run_tree_test(NUM_NODES, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
|
||||
// Disable forward rate limiting: in this test all 100 nodes look up
|
||||
// the same 10 targets in <1s wall time. The 2s per-target rate limit
|
||||
// would suppress nearly all transit forwarding.
|
||||
for tn in nodes.iter_mut() {
|
||||
tn.node.disable_discovery_forward_rate_limit();
|
||||
}
|
||||
|
||||
// Collect all node addresses and public keys for lookup targets
|
||||
let all_addrs: Vec<NodeAddr> = nodes
|
||||
.iter()
|
||||
@@ -588,9 +581,8 @@ async fn test_discovery_100_nodes() {
|
||||
let total_lookups = lookup_pairs.len();
|
||||
|
||||
// Process one source node at a time. Each node initiates ~10 lookups,
|
||||
// which flood through the network. We drain until quiescent before
|
||||
// moving to the next node. This avoids overwhelming UDP buffers
|
||||
// while still testing concurrent lookups from the same origin.
|
||||
// which route through the tree via bloom filters. We drain until
|
||||
// quiescent before moving to the next node.
|
||||
for src in 0..NUM_NODES {
|
||||
// Initiate all lookups for this source node
|
||||
let mut initiated = false;
|
||||
@@ -607,14 +599,17 @@ async fn test_discovery_100_nodes() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drain packets until quiescent
|
||||
// Drain packets until quiescent. With single-path tree routing,
|
||||
// a packet forwarded by node X may land in node Y's queue where
|
||||
// Y < X in iteration order, causing a zero-count round even though
|
||||
// packets are in flight. Use a higher idle threshold to handle this.
|
||||
let mut idle_rounds = 0;
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
for _ in 0..80 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
let count = process_available_packets(&mut nodes).await;
|
||||
if count == 0 {
|
||||
idle_rounds += 1;
|
||||
if idle_rounds >= 2 {
|
||||
if idle_rounds >= 5 {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -232,6 +232,7 @@ impl Node {
|
||||
}
|
||||
self.tree_state.recompute_coords();
|
||||
self.coord_cache.clear();
|
||||
self.reset_discovery_backoff();
|
||||
|
||||
self.stats_mut().tree.parent_switched += 1;
|
||||
self.stats_mut().tree.parent_switches += 1;
|
||||
@@ -275,6 +276,7 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
self.coord_cache.clear();
|
||||
self.reset_discovery_backoff();
|
||||
self.send_tree_announce_to_all().await;
|
||||
}
|
||||
return;
|
||||
@@ -299,6 +301,7 @@ impl Node {
|
||||
}
|
||||
self.tree_state.recompute_coords();
|
||||
self.coord_cache.clear();
|
||||
self.reset_discovery_backoff();
|
||||
|
||||
let new_root = *self.tree_state.root();
|
||||
let new_depth = self.tree_state.my_coords().depth();
|
||||
@@ -376,6 +379,7 @@ impl Node {
|
||||
}
|
||||
self.tree_state.recompute_coords();
|
||||
self.coord_cache.clear();
|
||||
self.reset_discovery_backoff();
|
||||
|
||||
self.stats_mut().tree.parent_switched += 1;
|
||||
self.stats_mut().tree.parent_switches += 1;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Discovery messages: LookupRequest and LookupResponse.
|
||||
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::protocol::error::ProtocolError;
|
||||
use crate::protocol::session::{decode_coords, encode_coords};
|
||||
use crate::tree::TreeCoordinate;
|
||||
@@ -9,8 +8,9 @@ use secp256k1::schnorr::Signature;
|
||||
|
||||
/// Request to discover a node's coordinates.
|
||||
///
|
||||
/// Flooded through the network with TTL limiting scope. The visited
|
||||
/// filter prevents routing loops.
|
||||
/// Routed through the spanning tree via bloom-filter-guided forwarding.
|
||||
/// Each transit node forwards only to tree peers whose bloom filter
|
||||
/// contains the target. TTL limits propagation depth.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LookupRequest {
|
||||
/// Unique request identifier.
|
||||
@@ -26,8 +26,6 @@ pub struct LookupRequest {
|
||||
/// Minimum transport MTU the origin requires for a viable route.
|
||||
/// 0 means no requirement.
|
||||
pub min_mtu: u16,
|
||||
/// Visited nodes filter (loop prevention).
|
||||
pub visited: BloomFilter,
|
||||
}
|
||||
|
||||
impl LookupRequest {
|
||||
@@ -40,8 +38,6 @@ impl LookupRequest {
|
||||
ttl: u8,
|
||||
min_mtu: u16,
|
||||
) -> Self {
|
||||
// Small filter for visited tracking
|
||||
let visited = BloomFilter::with_params(256 * 8, 5).expect("valid params");
|
||||
Self {
|
||||
request_id,
|
||||
target,
|
||||
@@ -49,7 +45,6 @@ impl LookupRequest {
|
||||
origin_coords,
|
||||
ttl,
|
||||
min_mtu,
|
||||
visited,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +61,14 @@ impl LookupRequest {
|
||||
Self::new(request_id, target, origin, origin_coords, ttl, min_mtu)
|
||||
}
|
||||
|
||||
/// Decrement TTL and add self to visited.
|
||||
/// Decrement TTL for forwarding.
|
||||
///
|
||||
/// Returns false if TTL was already 0.
|
||||
pub fn forward(&mut self, my_node_addr: &NodeAddr) -> bool {
|
||||
pub fn forward(&mut self) -> bool {
|
||||
if self.ttl == 0 {
|
||||
return false;
|
||||
}
|
||||
self.ttl -= 1;
|
||||
self.visited.insert(my_node_addr);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -83,19 +77,12 @@ impl LookupRequest {
|
||||
self.ttl > 0
|
||||
}
|
||||
|
||||
/// Check if a node was already visited.
|
||||
pub fn was_visited(&self, node_addr: &NodeAddr) -> bool {
|
||||
self.visited.contains(node_addr)
|
||||
}
|
||||
|
||||
/// Encode as wire format (includes msg_type byte).
|
||||
///
|
||||
/// Format: `[0x30][request_id:8][target:16][origin:16][ttl:1][min_mtu:2]`
|
||||
/// `[origin_coords_cnt:2][origin_coords:16×n]`
|
||||
/// `[visited_hash_cnt:1][visited_bits:256]`
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let visited_bytes = self.visited.as_bytes();
|
||||
let mut buf = Vec::with_capacity(46 + self.origin_coords.depth() * 16 + 1 + visited_bytes.len());
|
||||
let mut buf = Vec::with_capacity(46 + self.origin_coords.depth() * 16);
|
||||
|
||||
buf.push(0x30); // msg_type
|
||||
buf.extend_from_slice(&self.request_id.to_le_bytes());
|
||||
@@ -104,8 +91,6 @@ impl LookupRequest {
|
||||
buf.push(self.ttl);
|
||||
buf.extend_from_slice(&self.min_mtu.to_le_bytes());
|
||||
encode_coords(&self.origin_coords, &mut buf);
|
||||
buf.push(self.visited.hash_count());
|
||||
buf.extend_from_slice(visited_bytes);
|
||||
|
||||
buf
|
||||
}
|
||||
@@ -113,10 +98,10 @@ impl LookupRequest {
|
||||
/// Decode from wire format (after msg_type byte has been consumed).
|
||||
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
|
||||
// Minimum: request_id(8) + target(16) + origin(16) + ttl(1) + min_mtu(2)
|
||||
// + coords_count(2) + hash_count(1) = 46 bytes
|
||||
if payload.len() < 46 {
|
||||
// + coords_count(2) = 45 bytes
|
||||
if payload.len() < 45 {
|
||||
return Err(ProtocolError::MessageTooShort {
|
||||
expected: 46,
|
||||
expected: 45,
|
||||
got: payload.len(),
|
||||
});
|
||||
}
|
||||
@@ -150,25 +135,7 @@ impl LookupRequest {
|
||||
);
|
||||
pos += 2;
|
||||
|
||||
let (origin_coords, consumed) = decode_coords(&payload[pos..])?;
|
||||
pos += consumed;
|
||||
|
||||
if payload.len() < pos + 1 {
|
||||
return Err(ProtocolError::MessageTooShort {
|
||||
expected: pos + 1,
|
||||
got: payload.len(),
|
||||
});
|
||||
}
|
||||
let hash_count = payload[pos];
|
||||
pos += 1;
|
||||
|
||||
let filter_bytes = &payload[pos..];
|
||||
if filter_bytes.is_empty() {
|
||||
return Err(ProtocolError::Malformed("visited filter missing".into()));
|
||||
}
|
||||
|
||||
let visited = BloomFilter::from_slice(filter_bytes, hash_count)
|
||||
.map_err(|e| ProtocolError::Malformed(format!("bad visited filter: {e}")))?;
|
||||
let (origin_coords, _consumed) = decode_coords(&payload[pos..])?;
|
||||
|
||||
Ok(Self {
|
||||
request_id,
|
||||
@@ -177,7 +144,6 @@ impl LookupRequest {
|
||||
origin_coords,
|
||||
ttl,
|
||||
min_mtu,
|
||||
visited,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -323,17 +289,12 @@ mod tests {
|
||||
let target = make_node_addr(1);
|
||||
let origin = make_node_addr(2);
|
||||
let coords = make_coords(&[2, 0]);
|
||||
let forwarder = make_node_addr(3);
|
||||
|
||||
let mut request = LookupRequest::new(123, target, origin, coords, 5, 0);
|
||||
|
||||
assert!(request.can_forward());
|
||||
assert!(!request.was_visited(&forwarder));
|
||||
|
||||
assert!(request.forward(&forwarder));
|
||||
|
||||
assert!(request.forward());
|
||||
assert_eq!(request.ttl, 4);
|
||||
assert!(request.was_visited(&forwarder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -344,9 +305,9 @@ mod tests {
|
||||
|
||||
let mut request = LookupRequest::new(123, target, origin, coords, 1, 0);
|
||||
|
||||
assert!(request.forward(&make_node_addr(3)));
|
||||
assert!(request.forward());
|
||||
assert!(!request.can_forward());
|
||||
assert!(!request.forward(&make_node_addr(4)));
|
||||
assert!(!request.forward());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,8 +345,8 @@ mod tests {
|
||||
let origin = make_node_addr(20);
|
||||
let coords = make_coords(&[20, 0]);
|
||||
|
||||
let mut request = LookupRequest::new(12345, target, origin, coords.clone(), 8, 1386);
|
||||
request.forward(&make_node_addr(30));
|
||||
let mut request = LookupRequest::new(12345, target, origin, coords, 8, 1386);
|
||||
request.forward();
|
||||
|
||||
let encoded = request.encode();
|
||||
assert_eq!(encoded[0], 0x30);
|
||||
@@ -396,7 +357,6 @@ mod tests {
|
||||
assert_eq!(decoded.origin, origin);
|
||||
assert_eq!(decoded.ttl, 7); // decremented by forward()
|
||||
assert_eq!(decoded.min_mtu, 1386);
|
||||
assert!(decoded.was_visited(&make_node_addr(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -438,7 +398,7 @@ mod tests {
|
||||
let digest: [u8; 32] = sha2::Sha256::digest(&proof_data).into();
|
||||
let sig = secp.sign_schnorr(&digest, &keypair);
|
||||
|
||||
let response = LookupResponse::new(999, target, coords.clone(), sig);
|
||||
let response = LookupResponse::new(999, target, coords, sig);
|
||||
|
||||
// Default path_mtu should be u16::MAX
|
||||
assert_eq!(response.path_mtu, u16::MAX);
|
||||
|
||||
@@ -55,6 +55,7 @@ class AnalysisResult:
|
||||
discovery_timeout: list[tuple[str, str]] = field(default_factory=list)
|
||||
discovery_retry: list[tuple[str, str]] = field(default_factory=list)
|
||||
discovery_no_tree_peer: list[tuple[str, str]] = field(default_factory=list)
|
||||
discovery_fallback: list[tuple[str, str]] = field(default_factory=list)
|
||||
discovery_trigger: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
def summary(self) -> str:
|
||||
@@ -85,6 +86,7 @@ class AnalysisResult:
|
||||
f"Backoff suppressed: {len(self.discovery_backoff)}",
|
||||
f"Deduplicated: {len(self.discovery_dedup)}",
|
||||
f"No tree peer: {len(self.discovery_no_tree_peer)}",
|
||||
f"Non-tree fallback: {len(self.discovery_fallback)}",
|
||||
f"Timed out: {len(self.discovery_timeout)}",
|
||||
]
|
||||
|
||||
@@ -172,7 +174,7 @@ def _analyze_lines(result: AnalysisResult, source: str, log_text: str):
|
||||
if "Rekey cutover complete" in line or "FSP rekey cutover complete" in line:
|
||||
result.rekey_cutovers.append((source, line))
|
||||
# Discovery
|
||||
if "Initiating LookupRequest" in line:
|
||||
if "Initiating LookupRequest" in line or "Discovery lookup initiated" in line:
|
||||
result.discovery_initiated.append((source, line))
|
||||
if "proof verified, caching route" in line:
|
||||
result.discovery_succeeded.append((source, line))
|
||||
@@ -186,8 +188,10 @@ def _analyze_lines(result: AnalysisResult, source: str, log_text: str):
|
||||
result.discovery_timeout.append((source, line))
|
||||
if "Discovery retry sent" in line:
|
||||
result.discovery_retry.append((source, line))
|
||||
if "no tree peers with bloom match" in line:
|
||||
if "no tree peers with bloom match" in line or "No eligible peers to forward" in line:
|
||||
result.discovery_no_tree_peer.append((source, line))
|
||||
if "non-tree fallback" in line:
|
||||
result.discovery_fallback.append((source, line))
|
||||
if "Failed to initiate session, trying discovery" in line:
|
||||
result.discovery_trigger.append((source, line))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user