From ead3031f3ce37ed82dc8782fb4904fdbf16a29a0 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Mon, 1 Jun 2026 15:46:15 -0400 Subject: [PATCH] test: cover graceful-leave instant-evict and rejoin-after-eviction edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reconnect-eviction edges were logically handled but had no explicit assertion: - A graceful ConnEvent::Left must emit PeerLeft *immediately* (not wait on the grace timer). New graceful_leave_evicts_immediately asserts the leave lands well inside the grace window. - After a grace-timer eviction scrubs the peer from seen_connected, a later rejoin's initial dial must be a fresh first-dial, not a reconnect that arms a new eviction. New rejoin_after_grace_eviction_dials_cleanly covers this (distinct from the existing graceful-leave scrub path). Also reorder the eviction task to scrub timers/seen_connected before emitting PeerConnectionFailed, so an observer (or a redial racing the eviction) always sees clean internal state — this also makes the new rejoin-after-eviction test deterministic. Co-Authored-By: Claude Opus 4.8 --- src/core/mod.rs | 6 +++- tests/reconnect_eviction.rs | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/core/mod.rs b/src/core/mod.rs index b82b248..79d104c 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -100,9 +100,13 @@ fn arm_grace_timer( crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id)); transport_evict.disconnect_peer(peer_id).await; jitter_evict.lock().await.remove(&peer_id); - let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; + // Scrub our internal state *before* announcing the eviction, so anything + // that observes `PeerConnectionFailed` (or a rejoin racing it) sees a clean + // slate — a later dial for this identity is then a fresh first-dial, not a + // reconnect. timers_evict.lock().unwrap().remove(&peer_id); seen_evict.lock().unwrap().remove(&peer_id); + let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; }); timers_guard.insert(peer_id, handle); } diff --git a/tests/reconnect_eviction.rs b/tests/reconnect_eviction.rs index 4010c72..c3dfaca 100644 --- a/tests/reconnect_eviction.rs +++ b/tests/reconnect_eviction.rs @@ -68,6 +68,24 @@ async fn evicted_within( } } +/// Drain UI events until a `PeerLeft` for `peer` arrives, or `within` elapses. +/// Returns whether the leave fired. +async fn left_within( + ui_rx: &mut mpsc::Receiver, + peer: EndpointId, + within: Duration, +) -> bool { + let deadline = tokio::time::Instant::now() + within; + loop { + match tokio::time::timeout_at(deadline, ui_rx.recv()).await { + Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true, + Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected + Ok(None) => return false, // channel closed + Err(_) => return false, // timed out — no leave + } + } +} + const GRACE: Duration = Duration::from_millis(200); /// The regression: connect, drop, reconnect, then drop again — the second outage @@ -139,3 +157,48 @@ async fn graceful_leave_lets_a_later_rejoin_dial_cleanly() { "a rejoin's initial dial after a graceful leave must not arm eviction" ); } + +/// A graceful leave evicts *immediately* — it emits `PeerLeft` right away rather +/// than leaving the peer "reconnecting" until the grace timer (or the slow gossip +/// Leave) catches up. +#[tokio::test] +async fn graceful_leave_evicts_immediately() { + let (ui_tx, mut ui_rx) = mpsc::channel(100); + let h = make_handler(ui_tx, make_transport().await, GRACE); + let peer = fake_peer(); + + h.handle(ConnEvent::Connected(peer)).await; + h.handle(ConnEvent::Left(peer)).await; + + // Well inside the grace window: the leave must not be gated on the timer. + assert!( + left_within(&mut ui_rx, peer, GRACE / 2).await, + "a graceful leave must emit PeerLeft immediately, not wait for the grace timer" + ); +} + +/// After a peer is evicted by the grace timer, its identity is scrubbed from +/// `seen_connected`, so a later rejoin's initial dial is a fresh first-dial and +/// must not be mistaken for a reconnect (which would arm a new eviction clock). +#[tokio::test] +async fn rejoin_after_grace_eviction_dials_cleanly() { + let (ui_tx, mut ui_rx) = mpsc::channel(100); + let h = make_handler(ui_tx, make_transport().await, GRACE); + let peer = fake_peer(); + + // Connect, drop, and let the grace timer fire (evicts + scrubs seen_connected). + h.handle(ConnEvent::Connected(peer)).await; + h.handle(ConnEvent::Connecting(peer)).await; + assert!( + evicted_within(&mut ui_rx, peer, GRACE * 4).await, + "the first outage should evict once the grace window elapses" + ); + + // A later rejoin's initial dial: scrubbed on eviction, so this is a first-ever + // dial again and must not arm a new eviction timer. + h.handle(ConnEvent::Connecting(peer)).await; + assert!( + !evicted_within(&mut ui_rx, peer, GRACE * 3).await, + "an initial dial after a grace eviction must not be treated as a reconnect" + ); +}