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:
2026-06-01 00:08:33 -04:00
co-authored by Claude Opus 4.8
parent be818467dd
commit ffe5b43d8d
4 changed files with 156 additions and 12 deletions
+62
View File
@@ -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