radicle-node: Prevent infinite connection loop on instant dial failure

When a connection drops and the node attempts to reconnect within the
same second, the `last_attempt` timestamp written to the database is
identitcal to the `last_success` timestamp due to the Unix time (seconds)
precision loss.

The `maintain_connections` logic filters eligible peers using the
condition `last_success >= last_attempt`. Because the truncated
timestamps are equal, this evaluates to true, causing the node to
bypass the `CONNECTION_RETRY_DELTA` backoff.

If the OS instantly rejects the dial attempt (e.g. `EHOSTUNREACH`),
the node synchronously queues another connection attempt. This traps
the reactor in an infinite `while` loop.

Changing the condition to `last_success > last_attempt` ensures that a
failed attempt in the same second correctly evaluates to false, forcing
the node to respect the backoff timer and breaking the loop.
This commit is contained in:
Adrian Duke 2026-06-05 18:27:16 +01:00
parent e4a16dc40a
commit a294e05730
2 changed files with 43 additions and 1 deletions

View File

@ -1401,6 +1401,48 @@ fn test_maintain_connections_failed_attempt() {
assert!(!alice.outbox().any(|o| matches!(o, Io::Connect(_, _)))); assert!(!alice.outbox().any(|o| matches!(o, Io::Connect(_, _))));
} }
#[test]
fn test_maintain_connections_same_second_loop() {
use std::io;
use std::sync::Arc;
let eve = Peer::new("eve", [9, 9, 9, 9]);
let mut alice = Peer::new("alice", [7, 7, 7, 7]);
let reason = DisconnectReason::Dial(Arc::new(io::Error::from(io::ErrorKind::HostUnreachable)));
alice.connect_to(&eve);
// Advance clock to make the connection stable.
// This triggers `idle_connections` which sets `last_success` in the DB to the current time (T).
alice.elapse(session::CONNECTION_STABLE_THRESHOLD);
// Eve disconnects.
// This triggers `maintain_connections`.
alice.disconnected(eve.id(), Link::Outbound, &reason);
let connects = alice
.outbox()
.filter(|o| matches!(o, Io::Connect(id, _) if id == &eve.id))
.count();
assert_eq!(connects, 1, "Alice should attempt to reconnect once");
// Simulate the dial failing instantly.
// We DO NOT advance the clock. We just call disconnected again.
// This triggers `maintain_connections` again.
// Now `last_success` is T, and `last_attempt` is T.
alice.disconnected(eve.id(), Link::Outbound, &reason);
// Check if Alice tries to connect again in the exact same second.
let immediate_retry = alice
.outbox()
.any(|o| matches!(o, Io::Connect(id, _) if id == eve.id));
assert!(
!immediate_retry,
"Alice immediately retried a connection when last_success == last_attempt"
);
}
#[test] #[test]
fn test_seed_repo_subscribe() { fn test_seed_repo_subscribe() {
let mut alice = Peer::new("alice", [7, 7, 7, 7]); let mut alice = Peer::new("alice", [7, 7, 7, 7]);

View File

@ -2579,7 +2579,7 @@ where
// If we succeeded the last time we tried, this is a good address. // If we succeeded the last time we tried, this is a good address.
// If it's been long enough that we failed to connect, we also try again. // If it's been long enough that we failed to connect, we also try again.
(Some(success), Some(attempt)) => { (Some(success), Some(attempt)) => {
success >= attempt || now - attempt >= CONNECTION_RETRY_DELTA success > attempt || now - attempt >= CONNECTION_RETRY_DELTA
} }
// If we haven't succeeded yet, and we waited long enough, we can try this address. // If we haven't succeeded yet, and we waited long enough, we can try this address.
(None, Some(attempt)) => now - attempt >= CONNECTION_RETRY_DELTA, (None, Some(attempt)) => now - attempt >= CONNECTION_RETRY_DELTA,