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>
271 lines
8.6 KiB
Rust
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());
|
|
}
|
|
}
|