This is an automated email from the ASF dual-hosted git repository.

spetz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new b1166a590 fix(sdk): stop a reconnect sweep when the client shuts down 
(#4507)
b1166a590 is described below

commit b1166a590ad0a3f0d809bf13ad095412c1dbf60d
Author: Tom Planche <[email protected]>
AuthorDate: Wed Oct 14 23:22:41 2026 +0200

    fix(sdk): stop a reconnect sweep when the client shuts down (#4507)
    
    `shutdown()` must be final, also for a reconnect that runs at the same time.
    
    Relates to #4287
---
 core/sdk/src/quic/quic_client.rs           | 167 ++++++++++++++++++++++++++---
 core/sdk/src/tcp/tcp_client.rs             | 144 ++++++++++++++++++++++---
 core/sdk/src/websocket/websocket_client.rs | 156 ++++++++++++++++++++++++---
 3 files changed, 428 insertions(+), 39 deletions(-)

diff --git a/core/sdk/src/quic/quic_client.rs b/core/sdk/src/quic/quic_client.rs
index 037e0a008..7e23a3a22 100644
--- a/core/sdk/src/quic/quic_client.rs
+++ b/core/sdk/src/quic/quic_client.rs
@@ -110,6 +110,11 @@ pub struct QuicClient {
     /// connection that drops without a `disconnect()` call leaves it clear, so
     /// that loss still heals.
     disconnected_by_caller: AtomicBool,
+    /// Set when `shutdown()` finishes its cleanup. The state turns `Shutdown`
+    /// before that cleanup and stays there, so it cannot tell a finished
+    /// shutdown from a cancelled one: a cancelled `shutdown()` leaves the
+    /// cleanup to the next call.
+    shutdown_complete: AtomicBool,
     /// Serializes leader checks and roster walks after refused requests, so
     /// concurrent QUIC streams cannot tear down each other's new connection.
     routing_lock: Mutex<()>,
@@ -679,6 +684,7 @@ impl QuicClient {
             roster_endpoints: Mutex::new(Vec::new()),
             roster_learned: AtomicBool::new(false),
             disconnected_by_caller: AtomicBool::new(false),
+            shutdown_complete: AtomicBool::new(false),
             routing_lock: Mutex::new(()),
             connect_coordinator: ConnectCoordinator::new(),
             consumer_group_state: 
Arc::new(iggy_common::ConsumerGroupClientState::new()),
@@ -739,6 +745,27 @@ impl QuicClient {
         self.disconnected_by_caller.load(Ordering::SeqCst)
     }
 
+    /// A reconnect sweep checks this before each dial and after it installs
+    /// the link: once the client is shut down or the caller disconnects,
+    /// nothing in the sweep may bring the client back.
+    async fn sweep_interrupted(&self) -> Option<IggyError> {
+        if self.get_state().await == ClientState::Shutdown {
+            return Some(IggyError::ClientShutdown);
+        }
+        self.caller_disconnected()
+            .then_some(IggyError::NotConnected)
+    }
+
+    async fn fail_connect(&self) {
+        // The state stays `Shutdown`, and a `Disconnected` event would
+        // contradict the `Shutdown` one.
+        if self.get_state().await == ClientState::Shutdown {
+            return;
+        }
+        self.set_state(ClientState::Disconnected).await;
+        self.publish_event(DiagnosticEvent::Disconnected).await;
+    }
+
     async fn connect_inner(&self, context: ConnectOwnerContext) -> Result<(), 
IggyError> {
         let settle_off_leader = context.settle_off_leader();
         let single_attempt = context.single_attempt();
@@ -786,10 +813,9 @@ impl QuicClient {
             let connection;
             let remote_address;
             loop {
-                if self.caller_disconnected() {
-                    self.set_state(ClientState::Disconnected).await;
-                    self.publish_event(DiagnosticEvent::Disconnected).await;
-                    return Err(IggyError::NotConnected);
+                if let Some(error) = self.sweep_interrupted().await {
+                    self.fail_connect().await;
+                    return Err(error);
                 }
                 let server_address_str = 
self.current_server_address.lock().await.clone();
                 let server_address = 
tokio::net::lookup_host(&server_address_str)
@@ -868,18 +894,22 @@ impl QuicClient {
                 break;
             }
 
-            if self.caller_disconnected() {
-                connection.close(0u32.into(), b"");
-                self.set_state(ClientState::Disconnected).await;
-                self.publish_event(DiagnosticEvent::Disconnected).await;
-                return Err(IggyError::NotConnected);
+            let link = QuicLink::new(connection, 
self.config.response_buffer_size as usize);
+            self.link.lock().await.replace(link);
+            // Checked once the link is installed, not before: `shutdown()` 
sets
+            // `Shutdown` before it takes the link, so a shutdown during the
+            // dial either takes this link or is seen here.
+            if let Some(error) = self.sweep_interrupted().await {
+                if let Some(link) = self.link.lock().await.take() {
+                    link.close();
+                }
+                self.fail_connect().await;
+                return Err(error);
             }
 
             let now = IggyTimestamp::now();
             info!("{NAME} client has connected to server: {remote_address} at 
{now}",);
             self.set_state(ClientState::Connected).await;
-            let link = QuicLink::new(connection, 
self.config.response_buffer_size as usize);
-            self.link.lock().await.replace(link);
             self.connected_at.lock().await.replace(now);
             self.publish_event(DiagnosticEvent::Connected).await;
 
@@ -1043,11 +1073,14 @@ impl QuicClient {
     }
 
     async fn shutdown(&self) -> Result<(), IggyError> {
-        if self.get_state().await == ClientState::Shutdown {
+        if self.shutdown_complete.load(Ordering::SeqCst) {
             return Ok(());
         }
 
         info!("Shutting down the {NAME} QUIC client.");
+        // Set before the link is taken: a reconnect sweep that installs a link
+        // after this point sees `Shutdown` and closes it.
+        self.set_state(ClientState::Shutdown).await;
         let link = self.link.lock().await.take();
         if let Some(link) = link {
             link.close();
@@ -1055,8 +1088,8 @@ impl QuicClient {
 
         self.endpoint.wait_idle().await;
         self.reset_vsr_session().await?;
-        self.set_state(ClientState::Shutdown).await;
         self.publish_event(DiagnosticEvent::Shutdown).await;
+        self.shutdown_complete.store(true, Ordering::SeqCst);
         info!("{NAME} QUIC client has been shutdown.");
         Ok(())
     }
@@ -2396,4 +2429,112 @@ mod tests {
             "a publish waited for room that an unread receiver never frees"
         );
     }
+
+    #[tokio::test]
+    async fn a_shutdown_during_a_reconnect_sweep_ends_the_sweep() {
+        let _ = rustls::crypto::ring::default_provider().install_default();
+        let certified = 
rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
+        let config = quinn::ServerConfig::with_single_cert(
+            vec![certified.cert.der().clone()],
+            
rustls::pki_types::PrivatePkcs8KeyDer::from(certified.signing_key.serialize_der())
+                .into(),
+        )
+        .unwrap();
+        let endpoint = quinn::Endpoint::server(config, 
"127.0.0.1:0".parse().unwrap()).unwrap();
+        let client = Arc::new(
+            QuicClient::create(Arc::new(QuicClientConfig {
+                server_address: endpoint.local_addr().unwrap().to_string(),
+                reconnection: QuicClientReconnectionConfig {
+                    reestablish_after: 
IggyDuration::from_str("500ms").unwrap(),
+                    ..Default::default()
+                },
+                ..Default::default()
+            }))
+            .unwrap(),
+        );
+        // A connection a moment ago: the sweep first waits out
+        // `reestablish_after`, and the shutdown lands during that wait.
+        client
+            .connected_at
+            .lock()
+            .await
+            .replace(IggyTimestamp::now());
+        let sweep = tokio::spawn({
+            let client = Arc::clone(&client);
+            async move { QuicClient::connect(&client).await }
+        });
+        while client.get_state().await != ClientState::Connecting {
+            sleep(Duration::from_millis(10)).await;
+        }
+
+        Client::shutdown(&*client).await.unwrap();
+        let result = tokio::time::timeout(Duration::from_secs(2), sweep)
+            .await
+            .expect("the sweep must end after the shutdown")
+            .expect("the sweep task must not panic");
+
+        assert!(
+            matches!(result, Err(IggyError::ClientShutdown)),
+            "got {result:?}"
+        );
+        assert_eq!(client.get_state().await, ClientState::Shutdown);
+        assert!(
+            client.link.lock().await.is_none(),
+            "no link may be installed after the shutdown"
+        );
+        assert!(
+            tokio::time::timeout(Duration::from_millis(200), endpoint.accept())
+                .await
+                .is_err(),
+            "the sweep must not dial after the shutdown"
+        );
+    }
+
+    #[tokio::test]
+    async fn a_cancelled_shutdown_is_finished_by_the_next_call() {
+        let _ = rustls::crypto::ring::default_provider().install_default();
+        let certified = 
rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
+        let config = quinn::ServerConfig::with_single_cert(
+            vec![certified.cert.der().clone()],
+            
rustls::pki_types::PrivatePkcs8KeyDer::from(certified.signing_key.serialize_der())
+                .into(),
+        )
+        .unwrap();
+        let endpoint = quinn::Endpoint::server(config, 
"127.0.0.1:0".parse().unwrap()).unwrap();
+        let client = QuicClient::create(Arc::new(QuicClientConfig {
+            server_address: endpoint.local_addr().unwrap().to_string(),
+            ..Default::default()
+        }))
+        .unwrap();
+        let (peer, connected) = tokio::join!(
+            async { endpoint.accept().await.unwrap().await.unwrap() },
+            Client::connect(&client)
+        );
+        connected.unwrap();
+        let mut events = client.subscribe_events().await;
+        {
+            let _transport = client.link.lock().await;
+            // The shutdown waits for the transport lock, and its caller gives 
up.
+            assert!(
+                tokio::time::timeout(Duration::from_millis(100), 
Client::shutdown(&client))
+                    .await
+                    .is_err()
+            );
+        }
+
+        Client::shutdown(&client).await.unwrap();
+
+        assert_eq!(client.get_state().await, ClientState::Shutdown);
+        assert!(client.link.lock().await.is_none());
+        tokio::time::timeout(Duration::from_secs(1), peer.closed())
+            .await
+            .expect("the live connection must be closed");
+        let mut shutdowns = 0;
+        while let Ok(event) = events.try_recv() {
+            if event == DiagnosticEvent::Shutdown {
+                shutdowns += 1;
+            }
+        }
+        assert_eq!(shutdowns, 1);
+    }
 }
diff --git a/core/sdk/src/tcp/tcp_client.rs b/core/sdk/src/tcp/tcp_client.rs
index bf89a3efa..c9837c902 100644
--- a/core/sdk/src/tcp/tcp_client.rs
+++ b/core/sdk/src/tcp/tcp_client.rs
@@ -115,6 +115,11 @@ pub struct TcpClient {
     /// connection that drops without a `disconnect()` call leaves it clear, so
     /// that loss still heals.
     disconnected_by_caller: AtomicBool,
+    /// Set when `shutdown()` finishes its cleanup. The state turns `Shutdown`
+    /// before that cleanup and stays there, so it cannot tell a finished
+    /// shutdown from a cancelled one: a cancelled `shutdown()` leaves the
+    /// cleanup to the next call.
+    shutdown_complete: AtomicBool,
     /// The session and the sign-in, shared with the poll router and the
     /// poll clients it dials.
     session_owner: Arc<SessionOwner>,
@@ -542,6 +547,7 @@ impl TcpClient {
             roster_endpoints: Mutex::new(Vec::new()),
             roster_learned: AtomicBool::new(false),
             disconnected_by_caller: AtomicBool::new(false),
+            shutdown_complete: AtomicBool::new(false),
             session_owner,
             skip_auto_login_once: Mutex::new(false),
             routing_lock: Mutex::new(()),
@@ -664,19 +670,13 @@ impl TcpClient {
                     sleep(remaining.get_duration()).await;
                 }
 
-                if self.caller_disconnected() {
+                if let Some(error) = self.sweep_interrupted().await {
                     self.fail_connect().await;
-                    return Err(IggyError::NotConnected);
+                    return Err(error);
                 }
 
                 info!("{NAME} client is connecting to server: 
{server_address}...");
                 match self.establish_bounded(&server_address, 
&candidates).await {
-                    // Dropped before it is installed: the caller disconnected
-                    // while it was being dialed.
-                    Ok(_) if self.caller_disconnected() => {
-                        self.fail_connect().await;
-                        return Err(IggyError::NotConnected);
-                    }
                     Ok(connection) => {
                         let dialed = server_address.clone();
                         // The endpoint that answered is where this client now
@@ -694,6 +694,15 @@ impl TcpClient {
                             connection.remote_address,
                         );
                         self.link.lock().await.replace(connection.link);
+                        // Checked once the link is installed, not before:
+                        // `shutdown()` sets `Shutdown` before it takes the
+                        // link, so a shutdown during the dial either takes
+                        // this link or is seen here.
+                        if let Some(error) = self.sweep_interrupted().await {
+                            self.link.lock().await.take();
+                            self.fail_connect().await;
+                            return Err(error);
+                        }
                         self.set_state(ClientState::Connected).await;
                         self.connected_at.lock().await.replace(now);
                         self.publish_event(DiagnosticEvent::Connected).await;
@@ -742,9 +751,9 @@ impl TcpClient {
                 }
                 candidate = 0;
 
-                if self.caller_disconnected() {
+                if let Some(error) = self.sweep_interrupted().await {
                     self.fail_connect().await;
-                    return Err(IggyError::NotConnected);
+                    return Err(error);
                 }
 
                 // An unreadable CA file, a domain that will not parse: no
@@ -1211,6 +1220,11 @@ impl TcpClient {
     /// left at `Connecting`, the next `connect()` returns ok at the top
     /// without ever dialing.
     async fn fail_connect(&self) {
+        // The state stays `Shutdown`, and a `Disconnected` event would
+        // contradict the `Shutdown` one.
+        if self.get_state().await == ClientState::Shutdown {
+            return;
+        }
         self.set_state(ClientState::Disconnected).await;
         self.publish_event(DiagnosticEvent::Disconnected).await;
     }
@@ -1222,6 +1236,17 @@ impl TcpClient {
         self.disconnected_by_caller.load(Ordering::SeqCst)
     }
 
+    /// A reconnect sweep checks this before each dial, after it installs a
+    /// link and before each retry pause: once the client is shut down or the
+    /// caller disconnects, nothing in the sweep may bring the client back.
+    async fn sweep_interrupted(&self) -> Option<IggyError> {
+        if self.get_state().await == ClientState::Shutdown {
+            return Some(IggyError::ClientShutdown);
+        }
+        self.caller_disconnected()
+            .then_some(IggyError::NotConnected)
+    }
+
     /// [`Self::establish`], bounded while other endpoints are queued behind
     /// this one (see `FAILOVER_DIAL_TIMEOUT`). The bound covers the handshake
     /// as well as the connect: a peer that accepts TCP and then never answers
@@ -1299,19 +1324,22 @@ impl TcpClient {
     }
 
     async fn shutdown(&self) -> Result<(), IggyError> {
-        if self.get_state().await == ClientState::Shutdown {
+        if self.shutdown_complete.load(Ordering::SeqCst) {
             return Ok(());
         }
 
         let client_address = self.get_client_address_value().await;
         info!("Shutting down the {NAME} TCP client: {client_address}");
+        // Set before the link is taken: a reconnect sweep that installs a link
+        // after this point sees `Shutdown` and drops it.
+        self.set_state(ClientState::Shutdown).await;
         let link = self.link.lock().await.take();
         if let Some(link) = link {
             link.shutdown().await;
         }
         self.reset_vsr_session().await?;
-        self.set_state(ClientState::Shutdown).await;
         self.publish_event(DiagnosticEvent::Shutdown).await;
+        self.shutdown_complete.store(true, Ordering::SeqCst);
         info!("{NAME} TCP client: {client_address} has been shutdown.");
         Ok(())
     }
@@ -3912,4 +3940,96 @@ mod tests {
             "a publish waited for room that an unread receiver never frees"
         );
     }
+
+    #[tokio::test]
+    async fn a_shutdown_during_a_reconnect_sweep_ends_the_sweep() {
+        let address = dead_endpoint().await;
+        let client = Arc::new(
+            TcpClient::create(Arc::new(TcpClientConfig {
+                server_address: address.clone(),
+                reconnection: TcpClientReconnectionConfig {
+                    enabled: true,
+                    max_retries: None,
+                    interval: 
NonZeroIggyDuration::from_str("500ms").expect("duration"),
+                    reestablish_after: IggyDuration::new(Duration::ZERO),
+                },
+                ..TcpClientConfig::default()
+            }))
+            .expect("create the client"),
+        );
+        let sweep = tokio::spawn({
+            let client = Arc::clone(&client);
+            async move { TcpClient::connect(&client).await }
+        });
+        while client.get_state().await != ClientState::Connecting {
+            sleep(Duration::from_millis(10)).await;
+        }
+        // The first dial is refused at once, so the sweep now waits out its
+        // retry interval.
+        sleep(Duration::from_millis(100)).await;
+
+        Client::shutdown(&*client).await.unwrap();
+        // The server comes back before the next dial.
+        let listener = TcpListener::bind(&address).await.expect("bind again");
+        let result = tokio::time::timeout(Duration::from_secs(2), sweep)
+            .await
+            .expect("the sweep must end after the shutdown")
+            .expect("the sweep task must not panic");
+
+        assert!(
+            matches!(result, Err(IggyError::ClientShutdown)),
+            "got {result:?}"
+        );
+        assert_eq!(client.get_state().await, ClientState::Shutdown);
+        assert!(
+            client.link.lock().await.is_none(),
+            "no link may be installed after the shutdown"
+        );
+        assert!(
+            tokio::time::timeout(Duration::from_millis(200), listener.accept())
+                .await
+                .is_err(),
+            "the sweep must not dial after the shutdown"
+        );
+    }
+
+    #[tokio::test]
+    async fn a_cancelled_shutdown_is_finished_by_the_next_call() {
+        let (listener, address) = live_endpoint().await;
+        let client = TcpClient::create(Arc::new(TcpClientConfig {
+            server_address: address,
+            ..TcpClientConfig::default()
+        }))
+        .expect("create the client");
+        let (accepted, connected) = tokio::join!(listener.accept(), 
TcpClient::connect(&client));
+        connected.expect("connect");
+        let (mut peer, _) = accepted.expect("accept");
+        let mut events = client.subscribe_events().await;
+        {
+            let _transport = client.link.lock().await;
+            // The shutdown waits for the transport lock, and its caller gives 
up.
+            assert!(
+                tokio::time::timeout(Duration::from_millis(100), 
Client::shutdown(&client))
+                    .await
+                    .is_err()
+            );
+        }
+
+        Client::shutdown(&client).await.unwrap();
+
+        assert_eq!(client.get_state().await, ClientState::Shutdown);
+        assert!(client.link.lock().await.is_none());
+        let mut byte = [0u8; 1];
+        let read = tokio::time::timeout(Duration::from_secs(1), peer.read(&mut 
byte))
+            .await
+            .expect("the peer must see the connection close");
+        assert!(matches!(read, Ok(0) | Err(_)), "got {read:?}");
+        let mut shutdowns = 0;
+        while let Ok(event) = events.try_recv() {
+            if event == DiagnosticEvent::Shutdown {
+                shutdowns += 1;
+            }
+        }
+        assert_eq!(shutdowns, 1);
+    }
 }
diff --git a/core/sdk/src/websocket/websocket_client.rs 
b/core/sdk/src/websocket/websocket_client.rs
index 51b3b54c4..082f53386 100644
--- a/core/sdk/src/websocket/websocket_client.rs
+++ b/core/sdk/src/websocket/websocket_client.rs
@@ -104,6 +104,11 @@ pub struct WebSocketClient {
     /// connection that drops without a `disconnect()` call leaves it clear, so
     /// that loss still heals.
     disconnected_by_caller: AtomicBool,
+    /// Set when `shutdown()` finishes its cleanup. The state turns `Shutdown`
+    /// before that cleanup and stays there, so it cannot tell a finished
+    /// shutdown from a cancelled one: a cancelled `shutdown()` leaves the
+    /// cleanup to the next call.
+    shutdown_complete: AtomicBool,
     /// Serializes leader checks and roster walks after refused requests, so
     /// concurrent callers cannot tear down each other's new connection.
     routing_lock: Mutex<()>,
@@ -624,6 +629,7 @@ impl WebSocketClient {
             roster_endpoints: Mutex::new(Vec::new()),
             roster_learned: AtomicBool::new(false),
             disconnected_by_caller: AtomicBool::new(false),
+            shutdown_complete: AtomicBool::new(false),
             routing_lock: Mutex::new(()),
             connect_coordinator: ConnectCoordinator::new(),
             consumer_group_state: 
Arc::new(iggy_common::ConsumerGroupClientState::new()),
@@ -689,6 +695,27 @@ impl WebSocketClient {
         self.disconnected_by_caller.load(Ordering::SeqCst)
     }
 
+    /// A reconnect sweep checks this before each dial and after it installs
+    /// the link: once the client is shut down or the caller disconnects,
+    /// nothing in the sweep may bring the client back.
+    async fn sweep_interrupted(&self) -> Option<IggyError> {
+        if self.get_state().await == ClientState::Shutdown {
+            return Some(IggyError::ClientShutdown);
+        }
+        self.caller_disconnected()
+            .then_some(IggyError::NotConnected)
+    }
+
+    async fn fail_connect(&self) {
+        // The state stays `Shutdown`, and a `Disconnected` event would
+        // contradict the `Shutdown` one.
+        if self.get_state().await == ClientState::Shutdown {
+            return;
+        }
+        self.set_state(ClientState::Disconnected).await;
+        self.publish_event(DiagnosticEvent::Disconnected).await;
+    }
+
     async fn connect_inner(&self, context: ConnectOwnerContext) -> Result<(), 
IggyError> {
         let settle_off_leader = context.settle_off_leader();
         let single_attempt = context.single_attempt();
@@ -743,10 +770,9 @@ impl WebSocketClient {
                     }
                 }
 
-                if self.caller_disconnected() {
-                    self.set_state(ClientState::Disconnected).await;
-                    self.publish_event(DiagnosticEvent::Disconnected).await;
-                    return Err(IggyError::NotConnected);
+                if let Some(error) = self.sweep_interrupted().await {
+                    self.fail_connect().await;
+                    return Err(error);
                 }
 
                 let server_addr = tokio::net::lookup_host(&*current_address)
@@ -798,14 +824,15 @@ impl WebSocketClient {
                     Err(error) => return Err(error),
                 };
 
-                if self.caller_disconnected() {
-                    drop(link);
-                    self.set_state(ClientState::Disconnected).await;
-                    self.publish_event(DiagnosticEvent::Disconnected).await;
-                    return Err(IggyError::NotConnected);
-                }
-
                 *self.link.lock().await = Some(link);
+                // Checked once the link is installed, not before: `shutdown()`
+                // sets `Shutdown` before it takes the link, so a shutdown
+                // during the dial either takes this link or is seen here.
+                if let Some(error) = self.sweep_interrupted().await {
+                    self.link.lock().await.take();
+                    self.fail_connect().await;
+                    return Err(error);
+                }
                 *self.client_address.lock().await = Some(server_addr);
                 self.set_state(ClientState::Connected).await;
                 *self.connected_at.lock().await = Some(IggyTimestamp::now());
@@ -1182,14 +1209,16 @@ impl WebSocketClient {
     }
 
     async fn shutdown(&self) -> Result<(), IggyError> {
-        if self.get_state().await == ClientState::Shutdown {
+        if self.shutdown_complete.load(Ordering::SeqCst) {
             return Ok(());
         }
 
         let client_address = self.get_client_address_value().await;
         info!("Shutting down the {NAME} client: {client_address}");
 
-        self.set_state(ClientState::Disconnected).await;
+        // Set before the link is taken: a reconnect sweep that installs a link
+        // after this point sees `Shutdown` and drops it.
+        self.set_state(ClientState::Shutdown).await;
 
         let link = self.link.lock().await.take();
         if let Some(link) = link {
@@ -1197,8 +1226,8 @@ impl WebSocketClient {
         }
 
         self.reset_vsr_session().await?;
-        self.set_state(ClientState::Shutdown).await;
         self.publish_event(DiagnosticEvent::Shutdown).await;
+        self.shutdown_complete.store(true, Ordering::SeqCst);
         info!("{NAME} client: {client_address} has been shutdown.");
         Ok(())
     }
@@ -2461,4 +2490,103 @@ mod tests {
             "a publish waited for room that an unread receiver never frees"
         );
     }
+
+    #[tokio::test]
+    async fn a_shutdown_during_a_reconnect_sweep_ends_the_sweep() {
+        let dead_listener = 
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let address = dead_listener.local_addr().unwrap().to_string();
+        drop(dead_listener);
+        let client = Arc::new(
+            WebSocketClient::create(Arc::new(WebSocketClientConfig {
+                server_address: address.clone(),
+                reconnection: WebSocketClientReconnectionConfig {
+                    enabled: true,
+                    max_retries: None,
+                    interval: NonZeroIggyDuration::from_str("500ms").unwrap(),
+                    reestablish_after: IggyDuration::from_str("0s").unwrap(),
+                },
+                ..Default::default()
+            }))
+            .unwrap(),
+        );
+        let sweep = tokio::spawn({
+            let client = Arc::clone(&client);
+            async move { WebSocketClient::connect(&client).await }
+        });
+        // The first dial is refused at once, so the sweep now waits out its
+        // retry interval.
+        sleep(Duration::from_millis(100)).await;
+
+        Client::shutdown(&*client).await.unwrap();
+        // The server comes back before the next dial.
+        let listener = tokio::net::TcpListener::bind(&address).await.unwrap();
+        let result = tokio::time::timeout(Duration::from_secs(2), sweep)
+            .await
+            .expect("the sweep must end after the shutdown")
+            .expect("the sweep task must not panic");
+
+        assert!(
+            matches!(result, Err(IggyError::ClientShutdown)),
+            "got {result:?}"
+        );
+        assert_eq!(client.get_state().await, ClientState::Shutdown);
+        assert!(
+            client.link.lock().await.is_none(),
+            "no link may be installed after the shutdown"
+        );
+        assert!(
+            tokio::time::timeout(Duration::from_millis(200), listener.accept())
+                .await
+                .is_err(),
+            "the sweep must not dial after the shutdown"
+        );
+    }
+
+    #[tokio::test]
+    async fn a_cancelled_shutdown_is_finished_by_the_next_call() {
+        let listener = 
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let client = WebSocketClient::create(Arc::new(WebSocketClientConfig {
+            server_address: listener.local_addr().unwrap().to_string(),
+            ..Default::default()
+        }))
+        .unwrap();
+        let (mut peer, connected) = tokio::join!(
+            async {
+                accept_async(listener.accept().await.unwrap().0)
+                    .await
+                    .unwrap()
+            },
+            Client::connect(&client)
+        );
+        connected.unwrap();
+        let mut events = client.subscribe_events().await;
+        {
+            let _transport = client.link.lock().await;
+            // The shutdown waits for the transport lock, and its caller gives 
up.
+            assert!(
+                tokio::time::timeout(Duration::from_millis(100), 
Client::shutdown(&client))
+                    .await
+                    .is_err()
+            );
+        }
+
+        Client::shutdown(&client).await.unwrap();
+
+        assert_eq!(client.get_state().await, ClientState::Shutdown);
+        assert!(client.link.lock().await.is_none());
+        let next = tokio::time::timeout(Duration::from_secs(1), peer.next())
+            .await
+            .expect("the peer must see the connection close");
+        assert!(
+            matches!(next, None | Some(Ok(Message::Close(_))) | Some(Err(_))),
+            "got {next:?}"
+        );
+        let mut shutdowns = 0;
+        while let Ok(event) = events.try_recv() {
+            if event == DiagnosticEvent::Shutdown {
+                shutdowns += 1;
+            }
+        }
+        assert_eq!(shutdowns, 1);
+    }
 }

Reply via email to