Closes the remaining insider resource-exhaustion vector the Codex Tier C
audit flagged: the active-roster cap bounds the live peer map, but a member
could join (<=32), drop the link without a signed Leave, let the grace timer
expire, and repeat with a fresh identity. Each abandoned identity grew two
unbounded structures and kept doing periodic work forever:
- known_peers[topic] (the retained rejoin/recovery dial table) was only
pruned on a signed PeerLeft, so grace-evicted ghosts accumulated.
- the recovery coordinator's active set + entries map had no identity cap
and no terminal retry budget — backoff saturated at 60s and re-dialed a
never-returning peer indefinitely.
Two non-breaking, dependency-free bounds (no wire/protocol change):
- MAX_RETAINED_PEERS=64 per topic via pure admit_retained() — refreshing a
tracked peer always succeeds, a brand-new identity is rejected when full.
Set above MAX_ACTIVE_PEERS=32 so legitimate rooms never hit it.
- RECOVERY_TERMINAL_ATTEMPTS=12 (~7 min) via pure recovery_is_terminal():
the coordinator gives up, frees the active slot, and signals a new
terminal channel; a small drain task forgets the retained address (so the
table self-drains), scrubs seen-connected state, and emits
PeerConnectionFailed.
Giving up never blocks a legitimate reconnect: a peer returning after a long
outage still rejoins on its own via a gossip announce — terminal eviction only
stops us from dialing a peer that is not coming back, which was a latent leak
even absent an attacker.
+2 pure-seam unit tests (admit_retained, recovery_is_terminal); 418 lib tests
green, clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
325 lines
12 KiB
Rust
325 lines
12 KiB
Rust
use crate::network::{RoomState, gossip::IrohGossipState};
|
|
use iroh::{EndpointAddr, EndpointId};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
use tokio::sync::mpsc;
|
|
use tokio::task::JoinHandle;
|
|
use tokio::time::Instant;
|
|
|
|
const RECOVERY_COMMAND_CAPACITY: usize = 64;
|
|
const RECOVERY_DELAYS: [Duration; 7] = [
|
|
Duration::from_secs(1),
|
|
Duration::from_secs(2),
|
|
Duration::from_secs(4),
|
|
Duration::from_secs(8),
|
|
Duration::from_secs(15),
|
|
Duration::from_secs(30),
|
|
Duration::from_secs(60),
|
|
];
|
|
|
|
fn recovery_delay(attempt: usize) -> Duration {
|
|
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
|
|
}
|
|
|
|
/// Terminal retry budget for background recovery. After this many failed attempts
|
|
/// the coordinator gives up: it drops the entry, frees the active slot, and signals
|
|
/// the event task to forget the retained address (Tier C recovery-identity cap).
|
|
///
|
|
/// With the [`RECOVERY_DELAYS`] backoff this is roughly seven minutes of dialing
|
|
/// (1+2+4+8+15+30+60s, then 60s steps), far beyond any normal transient outage. A
|
|
/// genuine peer returning after a longer outage still rejoins on its own via a
|
|
/// gossip announce, so giving up only stops us from dialing a peer that is not
|
|
/// coming back — it does not break legitimate reconnect-after-outage.
|
|
const RECOVERY_TERMINAL_ATTEMPTS: usize = 12;
|
|
|
|
/// Capacity of the terminal-eviction notification channel. Bounded; on the rare
|
|
/// event of saturation the entry is still removed (the dial work stops) and only
|
|
/// the retained-address forget is skipped, which the per-topic retain cap bounds.
|
|
const RECOVERY_TERMINAL_CAPACITY: usize = 64;
|
|
|
|
/// Whether `attempt` completed recoveries have exhausted the terminal budget.
|
|
fn recovery_is_terminal(attempt: usize, max_attempts: usize) -> bool {
|
|
attempt >= max_attempts
|
|
}
|
|
|
|
enum RecoveryCommand {
|
|
Start {
|
|
peer_id: EndpointId,
|
|
addr: EndpointAddr,
|
|
},
|
|
Cancel(EndpointId),
|
|
}
|
|
|
|
struct RecoveryEntry {
|
|
addr: EndpointAddr,
|
|
attempt: usize,
|
|
next_attempt: Instant,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
trait RecoveryRoom: Send + Sync {
|
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String>;
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl RecoveryRoom for IrohGossipState {
|
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
|
|
RoomState::rebootstrap_peers(self, peers)
|
|
.await
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
}
|
|
|
|
/// Cloneable command side of the single per-session recovery coordinator.
|
|
/// `active` is shared with transport/event handlers so cancellation is visible
|
|
/// immediately even while the coordinator is awaiting an in-flight gossip call.
|
|
#[derive(Clone)]
|
|
pub(super) struct RecoveryCoordinator {
|
|
tx: mpsc::Sender<RecoveryCommand>,
|
|
active: Arc<Mutex<HashSet<EndpointId>>>,
|
|
}
|
|
|
|
impl RecoveryCoordinator {
|
|
pub(super) fn spawn(
|
|
room_state: Arc<IrohGossipState>,
|
|
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
|
|
Self::spawn_inner(room_state)
|
|
}
|
|
|
|
fn spawn_inner(
|
|
room_state: Arc<dyn RecoveryRoom>,
|
|
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
|
|
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
|
|
let (terminal_tx, terminal_rx) = mpsc::channel(RECOVERY_TERMINAL_CAPACITY);
|
|
let active = Arc::new(Mutex::new(HashSet::new()));
|
|
let handle = Self {
|
|
tx,
|
|
active: active.clone(),
|
|
};
|
|
let task = tokio::spawn(run_coordinator(room_state, active, rx, terminal_tx));
|
|
(handle, task, terminal_rx)
|
|
}
|
|
|
|
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
|
/// false when the peer is already recovering, preventing duplicate work.
|
|
pub(super) fn begin(&self, peer_id: EndpointId) -> bool {
|
|
self.active.lock().unwrap().insert(peer_id)
|
|
}
|
|
|
|
/// Activate the reserved slot with its retained authenticated address.
|
|
/// Uses a bounded non-blocking send while holding the active-set lock so a
|
|
/// concurrent cancellation is ordered before or after this command.
|
|
pub(super) fn activate(&self, peer_id: EndpointId, addr: EndpointAddr) -> Result<bool, ()> {
|
|
let mut active = self.active.lock().unwrap();
|
|
if !active.contains(&peer_id) {
|
|
return Ok(false);
|
|
}
|
|
if self
|
|
.tx
|
|
.try_send(RecoveryCommand::Start { peer_id, addr })
|
|
.is_err()
|
|
{
|
|
active.remove(&peer_id);
|
|
return Err(());
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
pub(super) fn cancel(&self, peer_id: EndpointId) {
|
|
self.active.lock().unwrap().remove(&peer_id);
|
|
// Cancellation is governed by the shared active set, so it remains
|
|
// immediate even if the bounded command queue is temporarily full.
|
|
let _ = self.tx.try_send(RecoveryCommand::Cancel(peer_id));
|
|
}
|
|
|
|
pub(super) fn is_active(&self, peer_id: &EndpointId) -> bool {
|
|
self.active.lock().unwrap().contains(peer_id)
|
|
}
|
|
}
|
|
|
|
async fn run_coordinator(
|
|
room_state: Arc<dyn RecoveryRoom>,
|
|
active: Arc<Mutex<HashSet<EndpointId>>>,
|
|
mut rx: mpsc::Receiver<RecoveryCommand>,
|
|
terminal_tx: mpsc::Sender<EndpointId>,
|
|
) {
|
|
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
|
|
|
|
loop {
|
|
// The shared active set is the authoritative cancellation gate. Prune
|
|
// here as well as on Cancel commands so a saturated command queue cannot
|
|
// leave an inactive, past-due entry spinning the timer loop.
|
|
let active_snapshot = active.lock().unwrap().clone();
|
|
entries.retain(|peer_id, _| active_snapshot.contains(peer_id));
|
|
let next_deadline = entries.values().map(|entry| entry.next_attempt).min();
|
|
let command = match next_deadline {
|
|
Some(deadline) => {
|
|
tokio::select! {
|
|
command = rx.recv() => command,
|
|
_ = tokio::time::sleep_until(deadline) => {
|
|
let now = Instant::now();
|
|
let active_snapshot = active.lock().unwrap().clone();
|
|
let due: Vec<(EndpointId, EndpointAddr)> = entries
|
|
.iter()
|
|
.filter(|(id, entry)| {
|
|
entry.next_attempt <= now && active_snapshot.contains(*id)
|
|
})
|
|
.map(|(id, entry)| (*id, entry.addr.clone()))
|
|
.collect();
|
|
|
|
if !due.is_empty() {
|
|
let addrs = due.iter().map(|(_, addr)| addr.clone()).collect();
|
|
if let Err(error) = room_state.rebootstrap_peers(addrs).await {
|
|
crate::log_msg(&format!(
|
|
"Background peer recovery attempt failed: {error}"
|
|
));
|
|
}
|
|
|
|
let scheduled_at = Instant::now();
|
|
for (peer_id, _) in due {
|
|
if !active.lock().unwrap().contains(&peer_id) {
|
|
entries.remove(&peer_id);
|
|
continue;
|
|
}
|
|
// Advance the backoff, then check the terminal budget.
|
|
// `attempt` counts completed attempts, so the delay
|
|
// uses the current value before it is incremented.
|
|
let terminal = if let Some(entry) = entries.get_mut(&peer_id) {
|
|
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
|
|
entry.attempt = entry.attempt.saturating_add(1);
|
|
recovery_is_terminal(entry.attempt, RECOVERY_TERMINAL_ATTEMPTS)
|
|
} else {
|
|
false
|
|
};
|
|
if terminal {
|
|
// Give up on a peer that has not returned within the
|
|
// budget: drop its entry, free the active slot, and
|
|
// signal the event task to forget its retained
|
|
// address so the per-topic retain table drains.
|
|
entries.remove(&peer_id);
|
|
active.lock().unwrap().remove(&peer_id);
|
|
let _ = terminal_tx.try_send(peer_id);
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
None => rx.recv().await,
|
|
};
|
|
|
|
match command {
|
|
Some(RecoveryCommand::Start { peer_id, addr }) => {
|
|
if active.lock().unwrap().contains(&peer_id) {
|
|
entries.entry(peer_id).or_insert(RecoveryEntry {
|
|
addr,
|
|
attempt: 0,
|
|
next_attempt: Instant::now(),
|
|
});
|
|
}
|
|
}
|
|
Some(RecoveryCommand::Cancel(peer_id)) => {
|
|
entries.remove(&peer_id);
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use iroh::SecretKey;
|
|
|
|
struct RecordingRoom {
|
|
attempts: mpsc::UnboundedSender<Vec<EndpointAddr>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl RecoveryRoom for RecordingRoom {
|
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
|
|
self.attempts.send(peers).map_err(|error| error.to_string())
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn retry_backoff_reaches_and_stays_at_sixty_seconds() {
|
|
let actual: Vec<u64> = (0..10)
|
|
.map(|attempt| recovery_delay(attempt).as_secs())
|
|
.collect();
|
|
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_budget_is_terminal_only_at_or_past_the_cap() {
|
|
assert!(!recovery_is_terminal(0, RECOVERY_TERMINAL_ATTEMPTS));
|
|
assert!(!recovery_is_terminal(
|
|
RECOVERY_TERMINAL_ATTEMPTS - 1,
|
|
RECOVERY_TERMINAL_ATTEMPTS
|
|
));
|
|
assert!(recovery_is_terminal(
|
|
RECOVERY_TERMINAL_ATTEMPTS,
|
|
RECOVERY_TERMINAL_ATTEMPTS
|
|
));
|
|
assert!(recovery_is_terminal(
|
|
RECOVERY_TERMINAL_ATTEMPTS + 5,
|
|
RECOVERY_TERMINAL_ATTEMPTS
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
|
|
let (tx, mut rx) = mpsc::channel(4);
|
|
let coordinator = RecoveryCoordinator {
|
|
tx,
|
|
active: Arc::new(Mutex::new(HashSet::new())),
|
|
};
|
|
let peer_id = SecretKey::generate().public();
|
|
|
|
assert!(coordinator.begin(peer_id));
|
|
assert!(
|
|
!coordinator.begin(peer_id),
|
|
"a peer gets only one recovery slot"
|
|
);
|
|
assert_eq!(
|
|
coordinator.activate(peer_id, EndpointAddr::from(peer_id)),
|
|
Ok(true)
|
|
);
|
|
assert!(matches!(
|
|
rx.try_recv(),
|
|
Ok(RecoveryCommand::Start { peer_id: id, .. }) if id == peer_id
|
|
));
|
|
|
|
coordinator.cancel(peer_id);
|
|
assert!(!coordinator.is_active(&peer_id));
|
|
assert!(matches!(
|
|
rx.try_recv(),
|
|
Ok(RecoveryCommand::Cancel(id)) if id == peer_id
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn coordinator_attempts_rebootstrap_immediately() {
|
|
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
|
let (coordinator, task, _terminal_rx) =
|
|
RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
|
attempts: attempts_tx,
|
|
}));
|
|
let peer_id = SecretKey::generate().public();
|
|
let addr = EndpointAddr::from(peer_id);
|
|
|
|
assert!(coordinator.begin(peer_id));
|
|
assert_eq!(coordinator.activate(peer_id, addr.clone()), Ok(true));
|
|
let attempted = tokio::time::timeout(Duration::from_secs(1), attempts_rx.recv())
|
|
.await
|
|
.expect("first recovery attempt should be immediate")
|
|
.expect("recording room remains subscribed");
|
|
assert_eq!(attempted, vec![addr]);
|
|
|
|
coordinator.cancel(peer_id);
|
|
task.abort();
|
|
}
|
|
}
|