Files
ferrix/crates/server/src/collision.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 a772322d9e Unwind panics safely: say goodbye to the peer, keep the other sessions
A panic is a bug, and it should cost as little as it can. The server
already closed every session with a Cease and exited when a RIB or a
background task was lost. This fills in the rest:

- A panic inside a session is caught. The peer is sent Cease /
  Administrative Reset, the session's routes are removed in the usual
  order, and the dialer reconnects. Before, the peer was left with a TCP
  connection closing under it and no reason.
- The tables every session shares (the session registry and the collision
  table) no longer refuse to be locked after a panic. Lock poisoning would
  have made each remaining session panic in turn the next time it touched
  one, so one bug in one session took all of them down.
- The server's watcher reports a task that panicked with the panic's
  message, and is now tested for both a panicking task and a stopped RIB.
- The daemon sends panics to its log, with their location, instead of
  leaving them to the default report on stderr.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 23:24:00 +02:00

271 lines
8.6 KiB
Rust

//! Connection collision detection (RFC 4271 §6.8).
//!
//! Two speakers that dial each other can end up with two TCP connections.
//! Exactly one must survive and both ends must pick the same one: the
//! connection *initiated by the speaker with the higher BGP Identifier*.
//! The other is closed with Cease / Connection Collision Resolution.
//!
//! This cannot be a check local to one connection: the rival connection is
//! usually still mid-handshake (so it is not in the session registry yet),
//! and sometimes it is the rival, running in another task, that must close.
//! Hence one small shared map, keyed by the peer's BGP Identifier, holding
//! the single connection currently preferred for that peer and a way to
//! tell it to stop.
//!
//! A collision with an *Established* connection closes the new one, unless
//! `detect_established` is set (the RFC's optional
//! `CollisionDetectEstablishedState`), which lets a restarted peer replace a
//! session we have not yet noticed is dead. If both connections were opened
//! by the same side the newer wins: the initiator has abandoned the older.
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::oneshot;
use crate::metrics::COLLISIONS;
use crate::sync::IgnorePoison;
/// Which side opened the TCP connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// The peer connected to us.
Inbound,
/// We connected to the peer.
Outbound,
}
impl Direction {
pub fn as_str(&self) -> &'static str {
match self {
Direction::Inbound => "inbound",
Direction::Outbound => "outbound",
}
}
}
struct Entry {
id: u64,
direction: Direction,
established: bool,
kill: Option<oneshot::Sender<()>>,
}
/// True if the new connection is the one to close.
fn new_loses(
local_id: Ipv4Addr,
remote_id: Ipv4Addr,
existing: &Entry,
new: Direction,
detect_established: bool,
) -> bool {
if existing.established && !detect_established {
return true;
}
if existing.direction == new {
return false;
}
// Identifiers compare as unsigned 32-bit integers.
let keep = if u32::from(local_id) > u32::from(remote_id) {
Direction::Outbound
} else {
Direction::Inbound
};
new != keep
}
/// Shared by every connection of one server.
#[derive(Clone)]
pub struct CollisionTable {
entries: Arc<Mutex<HashMap<Ipv4Addr, Entry>>>,
next_id: Arc<AtomicU64>,
detect_established: bool,
}
impl CollisionTable {
pub fn new(detect_established: bool) -> Self {
Self {
entries: Default::default(),
next_id: Default::default(),
detect_established,
}
}
/// Call when a connection receives the peer's OPEN. `None` means this
/// connection lost and must be closed with Cease / Connection Collision
/// Resolution. Otherwise any rival has been told to close and this
/// connection holds the slot until the guard drops.
pub fn admit(
&self,
local_id: Ipv4Addr,
remote_id: Ipv4Addr,
direction: Direction,
) -> Option<ConnectionGuard> {
let mut entries = self.entries.locked();
if let Some(existing) = entries.get_mut(&remote_id) {
if new_loses(
local_id,
remote_id,
existing,
direction,
self.detect_established,
) {
COLLISIONS.with_label_values(&["new"]).inc();
return None;
}
if let Some(kill) = existing.kill.take() {
COLLISIONS.with_label_values(&["existing"]).inc();
let _ = kill.send(());
}
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (kill, killed) = oneshot::channel();
entries.insert(
remote_id,
Entry {
id,
direction,
established: false,
kill: Some(kill),
},
);
Some(ConnectionGuard {
table: self.clone(),
remote_id,
id,
killed,
})
}
}
/// Holds a connection's slot in the table; releases it on drop.
pub struct ConnectionGuard {
table: CollisionTable,
remote_id: Ipv4Addr,
id: u64,
/// Completes when a later connection won against this one; the owner
/// must then send Cease / Connection Collision Resolution and close.
pub killed: oneshot::Receiver<()>,
}
impl ConnectionGuard {
pub fn set_established(&self) {
let mut entries = self.table.entries.locked();
if let Some(e) = entries.get_mut(&self.remote_id).filter(|e| e.id == self.id) {
e.established = true;
}
}
}
impl Drop for ConnectionGuard {
fn drop(&mut self) {
// A connection that lost has already been replaced in the map; only
// remove the slot if it is still ours.
let mut entries = self.table.entries.locked();
if entries
.get(&self.remote_id)
.is_some_and(|e| e.id == self.id)
{
entries.remove(&self.remote_id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use Direction::*;
const LOW: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1);
const HIGH: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1);
fn loses(
local: Ipv4Addr,
remote: Ipv4Addr,
existing: Direction,
established: bool,
new: Direction,
detect: bool,
) -> bool {
let e = Entry {
id: 0,
direction: existing,
established,
kill: None,
};
new_loses(local, remote, &e, new, detect)
}
#[test]
fn higher_identifier_keeps_the_connection_it_initiated() {
// We are HIGH: our outbound connection survives.
assert!(loses(HIGH, LOW, Outbound, false, Inbound, false));
assert!(!loses(HIGH, LOW, Inbound, false, Outbound, false));
// We are LOW: the peer's connection (inbound to us) survives.
assert!(!loses(LOW, HIGH, Outbound, false, Inbound, false));
assert!(loses(LOW, HIGH, Inbound, false, Outbound, false));
}
#[test]
fn both_ends_agree() {
// A and B dial each other. Each end holds its own dial as the
// existing (Outbound) connection and sees the other's dial arrive as
// new (Inbound). Exactly one of the two dials may be closed, so the
// ends must give opposite answers to "close the other side's dial?".
for (a, b) in [(LOW, HIGH), (HIGH, LOW)] {
let a_closes_dial_from_b = loses(a, b, Outbound, false, Inbound, false);
let b_closes_dial_from_a = loses(b, a, Outbound, false, Inbound, false);
assert_ne!(
a_closes_dial_from_b, b_closes_dial_from_a,
"ends disagree for a={a} b={b}"
);
}
}
#[test]
fn established_wins_unless_enabled() {
for new in [Inbound, Outbound] {
for existing in [Inbound, Outbound] {
assert!(loses(LOW, HIGH, existing, true, new, false));
assert!(loses(HIGH, LOW, existing, true, new, false));
}
}
assert!(!loses(LOW, HIGH, Outbound, true, Inbound, true));
assert!(loses(HIGH, LOW, Outbound, true, Inbound, true));
}
#[test]
fn same_direction_newer_wins() {
assert!(!loses(LOW, HIGH, Inbound, false, Inbound, false));
assert!(!loses(HIGH, LOW, Inbound, false, Inbound, false));
assert!(loses(LOW, HIGH, Inbound, true, Inbound, false));
assert!(!loses(LOW, HIGH, Inbound, true, Inbound, true));
}
#[tokio::test]
async fn table_signals_loser_and_releases_slots() {
let table = CollisionTable::new(false);
// We are LOW: outbound first, then the peer's inbound wins.
let mut out = table.admit(LOW, HIGH, Outbound).unwrap();
let inb = table.admit(LOW, HIGH, Inbound).unwrap();
assert!(out.killed.try_recv().is_ok());
// The loser's guard dropping must not free the winner's slot.
drop(out);
inb.set_established();
assert!(table.admit(LOW, HIGH, Outbound).is_none());
// A different identifier never collides.
assert!(
table
.admit(LOW, Ipv4Addr::new(192, 0, 2, 2), Inbound)
.is_some()
);
// Once the survivor goes away the identifier is free again.
drop(inb);
assert!(table.admit(LOW, HIGH, Outbound).is_some());
}
}