You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by ma...@apache.org on 2026/10/01 12:13:16 UTC
(iggy) branch master updated: test(sdk): reproduce client lifecycle bugs (#4344)
This is an automated email from the ASF dual-hosted git repository.
mmodzelewski 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 a08d0ca8f test(sdk): reproduce client lifecycle bugs (#4344)
a08d0ca8f is described below
commit a08d0ca8fc5e2917650f3874d769c2ad7df3b900
Author: Tom Planche <to...@proton.me>
AuthorDate: Thu Oct 1 14:13:11 2026 +0200
test(sdk): reproduce client lifecycle bugs (#4344)
---
core/integration/tests/sdk/client_lifecycle.rs | 193 +++++++++++++++++++++++++
core/integration/tests/sdk/mod.rs | 1 +
2 files changed, 194 insertions(+)
diff --git a/core/integration/tests/sdk/client_lifecycle.rs b/core/integration/tests/sdk/client_lifecycle.rs
new file mode 100644
index 000000000..f55d150d6
--- /dev/null
+++ b/core/integration/tests/sdk/client_lifecycle.rs
@@ -0,0 +1,193 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! A client that is shut down stays shut down, and an explicit disconnect
+//! stays in effect until the caller connects again. These tests pin that
+//! contract and fail on the current SDK (#4287), so they stay ignored until the
+//! fixes land.
+
+use iggy::prelude::*;
+use integration::harness::TestHarness;
+use integration::iggy_harness;
+use std::time::Duration;
+use tokio::time::{sleep, timeout};
+
+/// Short, so that several heartbeats run within `HEARTBEATS_WINDOW`.
+const FAST_HEARTBEAT: &str = "100ms";
+/// Long enough for several heartbeats, any of which would reconnect the client.
+const HEARTBEATS_WINDOW: Duration = Duration::from_secs(1);
+/// Long, so that no heartbeat after the first one runs during a test.
+const SLOW_HEARTBEAT: &str = "1h";
+/// The heartbeat pings once right after `connect()`. Waiting this long lets that
+/// ping finish, so it cannot race with the `disconnect()` that follows.
+const FIRST_HEARTBEAT_SETTLE: Duration = Duration::from_millis(500);
+/// A request on a disconnected client must fail on the SDK side,
+/// with no dial and no implicit wait, so it has to fail well within this bound.
+const SDK_SIDE_FAILURE: Duration = Duration::from_millis(500);
+/// A disconnect closes local resources only, so it must return well within
+/// this bound. On QUIC, a heartbeat redial that lands inside the drain of the old
+/// connection blocks `disconnect()` in `endpoint.wait_idle()` for good, so the
+/// tests bound the call to fail instead of hang. That redial depends on timing,
+/// so it does not block every run.
+const DISCONNECT_BOUND: Duration = Duration::from_secs(5);
+
+#[iggy_harness]
+#[ignore = "fails until #4287 is fixed (WebSocket connects after shutdown); run this test explicitly with --ignored"]
+async fn given_a_shut_down_websocket_client_when_connecting_should_fail(harness: &TestHarness) {
+ // TODO: in `WebSocketClient::connect_inner`, return `ClientShutdown` in the
+ // `Shutdown` state before any dial, as `TcpClient` and `QuicClient` already
+ // do. The `set_state` guard of the next test only stops the state change,
+ // not the dial. The fix turns the strict `xfail` of
+ // `test_shutdown_client_cannot_connect_again` in
+ // `foreign/python/tests/test_connectivity.py` into an XPASS, so remove that
+ // mark with it, and update the known-issue note of `shutdown()` in
+ // `foreign/python/src/client.rs`.
+ let client = harness.websocket_new_client().await.unwrap();
+ client.shutdown().await.unwrap();
+
+ let connect = client.connect().await;
+
+ assert!(
+ matches!(connect, Err(IggyError::ClientShutdown)),
+ "a client that is shut down must not open a new connection, got {connect:?}"
+ );
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+#[ignore = "fails until #4287 is fixed (disconnect after shutdown makes the client reusable); run this test explicitly with --ignored"]
+async fn given_a_shut_down_client_when_disconnected_should_stay_shut_down(harness: &TestHarness) {
+ // TODO: add one guard in `set_state` of each transport, so that no write
+ // moves a client out of `Shutdown`. A guard in each `disconnect()` is not
+ // enough: other paths also write `Disconnected`, for example a timeout.
+ // With the guard, `connect_inner` returns `ClientShutdown` on TCP and QUIC.
+ // WebSocket also needs the `connect_inner` fix of the test above, because it
+ // dials in any state. Drop the known-issue note of `shutdown()` in
+ // `foreign/python/src/client.rs` with this fix.
+ let client = harness.new_client().await.unwrap();
+ client.shutdown().await.unwrap();
+ client.disconnect().await.unwrap();
+
+ let connect = client.connect().await;
+
+ assert!(
+ matches!(connect, Err(IggyError::ClientShutdown)),
+ "disconnect after shutdown must not make the client reusable, got {connect:?}"
+ );
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+#[ignore = "fails until #4287 is fixed (the heartbeat undoes an explicit disconnect); run this test explicitly with --ignored"]
+async fn given_an_auto_login_client_when_explicitly_disconnected_should_stay_disconnected(
+ harness: &TestHarness,
+) {
+ // TODO: stop the heartbeat task in `IggyClient::disconnect`, and let
+ // `connect` start it again. That alone is not enough: the heartbeat ping
+ // reconnects through the same path as a direct `ping()`, so this test also
+ // needs the caller-intent flag of the next test. The `IggyClient`, Python
+ // and C++ docs describe the current behavior, so they must change with the
+ // fix. On QUIC, the heartbeat redial also blocks `disconnect()` in
+ // `endpoint.wait_idle()`: stopping the heartbeat first removes that redial
+ // here, but `QuicClient::disconnect` must not wait on a connection that a
+ // redial opens.
+ let client = auto_login_client(harness, FAST_HEARTBEAT);
+ client.connect().await.unwrap();
+ client
+ .get_me()
+ .await
+ .expect("auto-login signs in on connect");
+
+ timeout(DISCONNECT_BOUND, client.disconnect())
+ .await
+ .expect("disconnect must return while the heartbeat runs")
+ .unwrap();
+ sleep(HEARTBEATS_WINDOW).await;
+
+ let me = client.get_me().await.map(|_| ());
+ assert!(
+ matches!(me, Err(IggyError::Disconnected)),
+ "a heartbeat after an explicit disconnect must not reconnect the client \
+ and replay its auto-login, got {me:?}"
+ );
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+#[ignore = "fails until #4287 is fixed (a request after disconnect reconnects an auto-login client); run this test explicitly with --ignored"]
+async fn given_a_disconnected_auto_login_client_when_sending_requests_should_fail_without_reconnecting(
+ harness: &TestHarness,
+) {
+ // TODO: set a caller-intent flag in the `Client` trait impl of `disconnect()`
+ // on each transport, and clear it in `connect()`. Only in the trait impl: on
+ // WebSocket and QUIC, `handle_leader_redirection` and the reconnect path call
+ // the inherent `disconnect()`, and a flag set there would block every later
+ // heal. While the flag is set, `send_raw_with_response` fails with
+ // `NotConnected` on the SDK side instead of reconnecting with the configured
+ // credentials. Do not read the intent from `get_state() == Disconnected`: a
+ // socket error or a timeout also leaves the client `Disconnected`, and that
+ // loss must still heal on its own.
+ let client = disconnected_auto_login_client(harness).await;
+
+ let ping = timeout(SDK_SIDE_FAILURE, client.ping()).await;
+ assert!(
+ matches!(ping, Ok(Err(IggyError::NotConnected))),
+ "ping after an explicit disconnect must fail at once, without a reconnect, got {ping:?}"
+ );
+
+ let stats = timeout(SDK_SIDE_FAILURE, client.get_stats())
+ .await
+ .map(|result| result.map(|_| ()));
+ assert!(
+ matches!(stats, Ok(Err(IggyError::NotConnected))),
+ "get_stats after a failed ping must fail at once too, without a reconnect, got {stats:?}"
+ );
+
+ let me = client.get_me().await.map(|_| ());
+ assert!(
+ matches!(me, Err(IggyError::Disconnected)),
+ "the failed requests must not reconnect the client, got {me:?}"
+ );
+}
+
+/// Builds a client whose auto-login credentials live in its configuration.
+/// After `disconnect()`, every transport reconnects only with these configured
+/// credentials, and TCP also drops a manual `login_user()` sign-in. The
+/// reconnect cooldown is zero, so a reconnect never hides behind a pause.
+fn auto_login_client(harness: &TestHarness, heartbeat_interval: &str) -> IggyClient {
+ let server = harness.server();
+ let transport = harness.transport().unwrap();
+ let (address, cooldown_key) = match transport {
+ TransportProtocol::Tcp => (server.tcp_addr(), "reestablish_after"),
+ TransportProtocol::WebSocket => (server.websocket_addr(), "reestablish_after"),
+ TransportProtocol::Quic => (server.quic_addr(), "reconnection_reestablish_after"),
+ TransportProtocol::Http => panic!("HTTP has no connection to disconnect"),
+ };
+ let connection_string = format!(
+ "iggy+{transport}://{DEFAULT_ROOT_USERNAME}:{DEFAULT_ROOT_PASSWORD}@{}\
+ ?heartbeat_interval={heartbeat_interval}&{cooldown_key}=0s",
+ address.unwrap()
+ );
+ IggyClient::from_connection_string(&connection_string).unwrap()
+}
+
+/// Connects and then disconnects an auto-login client, after its first
+/// heartbeat has finished.
+async fn disconnected_auto_login_client(harness: &TestHarness) -> IggyClient {
+ let client = auto_login_client(harness, SLOW_HEARTBEAT);
+ client.connect().await.unwrap();
+ sleep(FIRST_HEARTBEAT_SETTLE).await;
+ client.disconnect().await.unwrap();
+ client
+}
diff --git a/core/integration/tests/sdk/mod.rs b/core/integration/tests/sdk/mod.rs
index 0cec5f837..b9b329866 100644
--- a/core/integration/tests/sdk/mod.rs
+++ b/core/integration/tests/sdk/mod.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+mod client_lifecycle;
mod consumer_group;
mod consumer_group_membership;
mod consumer_offset;