fix: signal graceful leave at the transport so a disconnect doesn't show "reconnecting"
Field-testing the reconnect fix surfaced a follow-on bug: clicking Disconnect (or quitting) showed the peer as yellow "Reconnecting" on the other end instead of leaving cleanly. Logs showed the remote saw NeighborDown first and the gossip `Leave` arrived ~15s later (broadcast then router torn down ~450ms after, so NeighborDown — now meaning "reconnecting" — beats the slow, swarm-routed Leave). Fix: make graceful leave a prompt, reliable TRANSPORT signal instead of relying on gossip. On leave/quit, IrohTransport::leave() explicitly closes each live connection with a distinguished goodbye code (GOODBYE_CODE) and aborts all supervisors (so none linger redialing the about-to-close endpoint). The remote's supervisor inspects the close reason: an application close with our goodbye code => emit the new ConnEvent::Left (evict now); a timeout/reset/other code => transient drop, reconnect as before. core handles ConnEvent::Left exactly like a PeerLeft (cancel grace timer, disconnect, drop jitter, UI remove). ActiveSession now holds the transport and calls leave() before shutting the router down. Tracks the live connection per peer in Shared.live_conns (inserted on link up, removed on drop) so leave() can close them. The gossip Leave path stays as a harmless backup. New test dialer_reports_left_on_graceful_close: a close with the goodbye code reports Left and does NOT reconnect (mirror of the code-0 transient-drop test). clippy clean, 4 transport tests pass. NOT yet field-verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,7 @@ async fn await_reconnect(
|
||||
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||
Ok(Some(ConnEvent::Connecting(_))) => saw_connecting = true,
|
||||
Ok(Some(ConnEvent::Connected(_))) => return saw_connecting,
|
||||
Ok(Some(ConnEvent::Left(_))) => panic!("unexpected graceful Left during reconnect"),
|
||||
Ok(None) => panic!("conn events channel closed unexpectedly"),
|
||||
Err(_) => panic!("timed out waiting for Connected"),
|
||||
}
|
||||
@@ -319,6 +320,67 @@ async fn dialer_reconnects_after_link_drops() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A graceful close (our goodbye code `1`) must report `Left` and NOT reconnect —
|
||||
/// the opposite of `dialer_reconnects_after_link_drops`, which closes with code 0
|
||||
/// (a transient drop). This is what lets a clicked "disconnect" evict the peer
|
||||
/// promptly instead of leaving it stuck "reconnecting". Mirrors the reconnect
|
||||
/// test's harness but asserts the supervisor stops rather than re-dials.
|
||||
#[tokio::test]
|
||||
async fn dialer_reports_left_on_graceful_close() {
|
||||
let s1 = iroh::SecretKey::generate();
|
||||
let s2 = iroh::SecretKey::generate();
|
||||
let (dialer_secret, peer_secret) = if s1.public().to_string() < s2.public().to_string() {
|
||||
(s1, s2)
|
||||
} else {
|
||||
(s2, s1)
|
||||
};
|
||||
|
||||
let dialer = spawn_node_with_key(dialer_secret).await;
|
||||
let mut peer = spawn_capture_peer(peer_secret).await;
|
||||
|
||||
dialer.lookup.add_endpoint_info(peer.endpoint.addr());
|
||||
peer.lookup.add_endpoint_info(dialer.endpoint.addr());
|
||||
|
||||
let mut conn_events = dialer
|
||||
.transport
|
||||
.subscribe_conn_events()
|
||||
.await
|
||||
.expect("subscribe dialer conn events");
|
||||
dialer.transport.connect_peer(peer.endpoint.addr()).await;
|
||||
|
||||
let conn1 = tokio::time::timeout(Duration::from_secs(10), peer.conns_rx.recv())
|
||||
.await
|
||||
.expect("timed out awaiting initial connection")
|
||||
.expect("connection channel closed");
|
||||
assert!(
|
||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await,
|
||||
"initial link should report Connecting then Connected"
|
||||
);
|
||||
|
||||
// Close with the goodbye code (1) — an intentional leave, not a blip.
|
||||
conn1.close(1u32.into(), b"leave");
|
||||
drop(conn1);
|
||||
|
||||
// The supervisor must surface `Left` (and must NOT try to reconnect: any
|
||||
// Connecting/Connected here would be the bug). The peer also must not see a
|
||||
// re-dial.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
match tokio::time::timeout_at(deadline, conn_events.recv()).await {
|
||||
Ok(Some(ConnEvent::Left(id))) => {
|
||||
assert_eq!(id, peer.endpoint.id(), "Left should name the peer");
|
||||
}
|
||||
Ok(Some(ConnEvent::Connecting(_))) | Ok(Some(ConnEvent::Connected(_))) => {
|
||||
panic!("a graceful close must not trigger a reconnect");
|
||||
}
|
||||
Ok(None) => panic!("conn events channel closed unexpectedly"),
|
||||
Err(_) => panic!("timed out waiting for Left after graceful close"),
|
||||
}
|
||||
|
||||
// And no re-dial reaches the peer within a short window.
|
||||
let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await;
|
||||
assert!(redial.is_err(), "supervisor must not re-dial after a graceful leave");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dialer_connects_and_reconnects_without_an_address_lookup() {
|
||||
// Regression for the redial-loop bug: a transient drop that also triggers a
|
||||
|
||||
Reference in New Issue
Block a user