This is an automated email from the ASF dual-hosted git repository.
hubcio 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 8ded54789 fix(sdk): keep an explicit disconnect until the next connect
(#4388)
8ded54789 is described below
commit 8ded54789a0ac08414de4f73d7311789856ba2c6
Author: Tom Planche <[email protected]>
AuthorDate: Mon Oct 12 15:47:33 2026 +0200
fix(sdk): keep an explicit disconnect until the next connect (#4388)
---
core/common/src/traits/client.rs | 9 +-
core/integration/tests/sdk/client_lifecycle.rs | 28 +---
core/sdk/src/client_wrappers/binary_client.rs | 27 +++-
.../src/clients/binary_personal_access_tokens.rs | 4 +-
core/sdk/src/clients/binary_users.rs | 4 +-
core/sdk/src/clients/client.rs | 63 +++++---
core/sdk/src/quic/quic_client.rs | 101 +++++++++++-
core/sdk/src/tcp/tcp_client.rs | 175 +++++++++++++++++++--
core/sdk/src/websocket/websocket_client.rs | 72 ++++++++-
foreign/cpp/include/iggy.hpp | 6 +-
foreign/python/apache_iggy.pyi | 10 +-
foreign/python/src/client.rs | 10 +-
foreign/python/tests/test_connectivity.py | 13 +-
13 files changed, 431 insertions(+), 91 deletions(-)
diff --git a/core/common/src/traits/client.rs b/core/common/src/traits/client.rs
index 8df424215..be7afbcbc 100644
--- a/core/common/src/traits/client.rs
+++ b/core/common/src/traits/client.rs
@@ -48,7 +48,14 @@ pub trait Client:
/// If the client is already connected, it will do nothing.
async fn connect(&self) -> Result<(), IggyError>;
- /// Disconnect from the server. If the client is not connected, it will do
nothing.
+ /// Disconnect from the server. Repeated calls are safe.
+ ///
+ /// On TCP, QUIC and WebSocket, the disconnect holds until the next
`connect()`,
+ /// also on a client that was not connected: nothing reconnects the client
on
+ /// its own, even with auto-login credentials. Until then, a request that
needs
+ /// a session fails with `IggyError::Disconnected`, and any other request
with
+ /// `IggyError::NotConnected`. A connection that drops without this call
can
+ /// still reconnect on its own. Over HTTP this call does nothing.
async fn disconnect(&self) -> Result<(), IggyError>;
/// Shut down the client and release all the resources. Repeated calls are
safe.
diff --git a/core/integration/tests/sdk/client_lifecycle.rs
b/core/integration/tests/sdk/client_lifecycle.rs
index 801efd0f0..571795477 100644
--- a/core/integration/tests/sdk/client_lifecycle.rs
+++ b/core/integration/tests/sdk/client_lifecycle.rs
@@ -17,8 +17,7 @@
//! 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. The ignored ones still fail on the current SDK (#4287) and stay
-//! ignored until their fixes land.
+//! contract.
use iggy::prelude::*;
use integration::harness::TestHarness;
@@ -39,10 +38,11 @@ const FIRST_HEARTBEAT_SETTLE: Duration =
Duration::from_millis(500);
/// 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
+/// this bound. On QUIC, a 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.
+/// and a connect already past the caller-intent check can still make it
+/// (problem 6 of #4287).
const DISCONNECT_BOUND: Duration = Duration::from_secs(5);
#[iggy_harness]
@@ -73,19 +73,9 @@ async fn
given_a_shut_down_client_when_disconnected_should_stay_shut_down(harnes
}
#[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
@@ -108,19 +98,9 @@ async fn
given_an_auto_login_client_when_explicitly_disconnected_should_stay_dis
}
#[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;
diff --git a/core/sdk/src/client_wrappers/binary_client.rs
b/core/sdk/src/client_wrappers/binary_client.rs
index e8b9dfcda..f1cbe7225 100644
--- a/core/sdk/src/client_wrappers/binary_client.rs
+++ b/core/sdk/src/client_wrappers/binary_client.rs
@@ -25,11 +25,13 @@ use iggy_common::{DiagnosticEvent, IggyError};
impl Client for ClientWrapper {
async fn connect(&self) -> Result<(), IggyError> {
match self {
- ClientWrapper::Iggy(client) => client.connect().await,
- ClientWrapper::Http(client) => client.connect().await,
- ClientWrapper::Tcp(client) => client.connect().await,
- ClientWrapper::Quic(client) => client.connect().await,
- ClientWrapper::WebSocket(client) => client.connect().await,
+ // The trait `connect` is the caller's: it clears a held
disconnect,
+ // where each transport's own `connect` keeps it.
+ ClientWrapper::Iggy(client) => Client::connect(client).await,
+ ClientWrapper::Http(client) => Client::connect(client).await,
+ ClientWrapper::Tcp(client) => Client::connect(client).await,
+ ClientWrapper::Quic(client) => Client::connect(client).await,
+ ClientWrapper::WebSocket(client) => Client::connect(client).await,
}
}
@@ -63,3 +65,18 @@ impl Client for ClientWrapper {
}
}
}
+
+impl ClientWrapper {
+ /// Connects again through the transport's own `connect`, which leaves a
+ /// caller's `disconnect()` in force: a reconnect the client starts on its
+ /// own, such as after a leader redirect, is refused while it holds.
+ pub(crate) async fn reconnect_keeping_intent(&self) -> Result<(),
IggyError> {
+ match self {
+ ClientWrapper::Tcp(client) => client.connect().await,
+ ClientWrapper::Quic(client) => client.connect().await,
+ ClientWrapper::WebSocket(client) => client.connect().await,
+ ClientWrapper::Iggy(client) => Client::connect(client).await,
+ ClientWrapper::Http(client) => Client::connect(client).await,
+ }
+ }
+}
diff --git a/core/sdk/src/clients/binary_personal_access_tokens.rs
b/core/sdk/src/clients/binary_personal_access_tokens.rs
index cfbe169d5..c0456b441 100644
--- a/core/sdk/src/clients/binary_personal_access_tokens.rs
+++ b/core/sdk/src/clients/binary_personal_access_tokens.rs
@@ -18,8 +18,8 @@
use crate::clients::redirect_login_settled;
use crate::prelude::{ClientWrapper, IggyClient};
use async_trait::async_trait;
+use iggy_common::PersonalAccessTokenClient;
use iggy_common::locking::IggyRwLockFn;
-use iggy_common::{Client, PersonalAccessTokenClient};
use iggy_common::{
IdentityInfo, IggyError, PersonalAccessTokenExpiry,
PersonalAccessTokenInfo,
RawPersonalAccessToken,
@@ -77,7 +77,7 @@ impl PersonalAccessTokenClient for IggyClient {
if should_redirect {
info!("Redirected to leader, reconnecting and re-authenticating");
- self.connect().await?;
+ self.reconnect_after_redirect().await?;
// The reconnect signs in with the credentials this very call just
// remembered, so on a client without a configured `AutoLogin` the
// session is already this user's: signing in again would cost a
diff --git a/core/sdk/src/clients/binary_users.rs
b/core/sdk/src/clients/binary_users.rs
index 606e11e44..e014d4dd6 100644
--- a/core/sdk/src/clients/binary_users.rs
+++ b/core/sdk/src/clients/binary_users.rs
@@ -19,9 +19,9 @@ use crate::client_wrappers::client_wrapper::ClientWrapper;
use crate::clients::redirect_login_settled;
use crate::prelude::IggyClient;
use async_trait::async_trait;
+use iggy_common::UserClient;
use iggy_common::UserUpdateOptions;
use iggy_common::locking::IggyRwLockFn;
-use iggy_common::{Client, UserClient};
use iggy_common::{
Identifier, IdentityInfo, IggyError, Permissions, UserInfo,
UserInfoDetails, UserStatus,
};
@@ -116,7 +116,7 @@ impl UserClient for IggyClient {
if should_redirect {
info!("Redirected to leader, reconnecting and re-authenticating");
- self.connect().await?;
+ self.reconnect_after_redirect().await?;
// The reconnect signs in with the credentials this very call just
// remembered, so on a client without a configured `AutoLogin` the
// session is already this user's: signing in again would cost a
diff --git a/core/sdk/src/clients/client.rs b/core/sdk/src/clients/client.rs
index 64328d14a..992b6ecd4 100644
--- a/core/sdk/src/clients/client.rs
+++ b/core/sdk/src/clients/client.rs
@@ -123,9 +123,11 @@ const SESSION_CONTROL_CODES: [u32; 5] = [
/// [`IggyProducer`] so that _background_ producers flush the latest state.
Finally,
/// call [`shutdown()`] on the [`IggyClient`] which closes the connection.
/// Use [`disconnect()`] rather than [`shutdown()`] to close the connection
but keep the client usable, as a
-/// client that has been shut down cannot reconnect. Explicit disconnect
clears the remembered sign-in.
-/// Configured auto-login still authenticates on the next connection;
otherwise call
-/// [`connect()`] and [`login_user()`] again before issuing requests.
+/// client that has been shut down cannot reconnect. The disconnect holds
until the next
+/// [`connect()`]: requests fail and nothing reconnects in the background,
even with `auto-login`.
+/// Explicit disconnect clears the remembered sign-in. Configured
auto-login still authenticates
+/// on the next connection; otherwise call [`connect()`] and
[`login_user()`] again before
+/// issuing requests.
///
/// # Examples
///
@@ -954,27 +956,24 @@ impl IggyClient {
impl Drop for IggyClient {
fn drop(&mut self) {
- let heartbeat_handle = self
- .heartbeat_handle
- .get_mut()
- .unwrap_or_else(|error| error.into_inner())
- .take();
- if let Some(handle) = heartbeat_handle {
+ if let Some(handle) = self.take_heartbeat() {
handle.abort();
}
}
}
-#[async_trait]
-impl Client for IggyClient {
- async fn connect(&self) -> Result<(), IggyError> {
- let heartbeat_interval;
- {
- let client = self.client.read().await;
- client.connect().await?;
- heartbeat_interval = client.heartbeat_interval().await;
- }
+impl IggyClient {
+ /// Connects again after a leader redirect without clearing the caller's
+ /// `disconnect()`: one that lands during the redirect holds, and the
+ /// transport refuses the reconnect.
+ pub(crate) async fn reconnect_after_redirect(&self) -> Result<(),
IggyError> {
+ self.client.read().await.reconnect_keeping_intent().await?;
+ self.start_heartbeat().await;
+ Ok(())
+ }
+ async fn start_heartbeat(&self) {
+ let heartbeat_interval =
self.client.read().await.heartbeat_interval().await;
let mut heartbeat_handle = self
.heartbeat_handle
.lock()
@@ -983,7 +982,7 @@ impl Client for IggyClient {
.as_ref()
.is_some_and(|handle| !handle.is_finished())
{
- return Ok(());
+ return;
}
drop(heartbeat_handle.take());
@@ -1010,10 +1009,36 @@ impl Client for IggyClient {
sleep(heartbeat_interval.get_duration()).await
}
}));
+ }
+
+ fn take_heartbeat(&self) -> Option<JoinHandle<()>> {
+ self.heartbeat_handle
+ .lock()
+ .unwrap_or_else(|error| error.into_inner())
+ .take()
+ }
+}
+
+#[async_trait]
+impl Client for IggyClient {
+ async fn connect(&self) -> Result<(), IggyError> {
+ self.client.read().await.connect().await?;
+ self.start_heartbeat().await;
Ok(())
}
async fn disconnect(&self) -> Result<(), IggyError> {
+ // HTTP keeps no connection, so its disconnect changes nothing and the
+ // heartbeat keeps running.
+ let is_http = matches!(&*self.client.read().await,
ClientWrapper::Http(_));
+ // A heartbeat ping on a disconnected client only logs errors, and on
+ // QUIC a redial it starts during the disconnect blocks the call. The
+ // aborted task is awaited, so a reconnect it owned is over before the
+ // transport tears down. `connect` starts the heartbeat again.
+ if !is_http && let Some(handle) = self.take_heartbeat() {
+ handle.abort();
+ let _ = handle.await;
+ }
self.client.read().await.disconnect().await
}
diff --git a/core/sdk/src/quic/quic_client.rs b/core/sdk/src/quic/quic_client.rs
index 3292d13e7..61399ae93 100644
--- a/core/sdk/src/quic/quic_client.rs
+++ b/core/sdk/src/quic/quic_client.rs
@@ -101,6 +101,11 @@ pub struct QuicClient {
/// admit (its replica of the target partition group is not the primary).
roster_endpoints: Mutex<Vec<String>>,
roster_learned: AtomicBool,
+ /// Set by an explicit `Client::disconnect` and cleared by
`Client::connect`.
+ /// While it is set, no request reconnects the client on its own. A
+ /// connection that drops without a `disconnect()` call leaves it clear, so
+ /// that loss still heals.
+ disconnected_by_caller: 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<()>,
@@ -120,10 +125,12 @@ impl Default for QuicClient {
#[async_trait]
impl Client for QuicClient {
async fn connect(&self) -> Result<(), IggyError> {
+ self.disconnected_by_caller.store(false, Ordering::SeqCst);
QuicClient::connect(self).await
}
async fn disconnect(&self) -> Result<(), IggyError> {
+ self.disconnected_by_caller.store(true, Ordering::SeqCst);
self.forget_session_credentials().await;
QuicClient::disconnect_transport(self).await?;
self.reset_vsr_session().await
@@ -183,6 +190,12 @@ impl BinaryTransport for QuicClient {
payload: Bytes,
context: PartitionContext,
) -> Result<Bytes, IggyError> {
+ if self.caller_disconnected() {
+ return Err(match self.get_state().await {
+ ClientState::Shutdown => IggyError::ClientShutdown,
+ _ => IggyError::NotConnected,
+ });
+ }
if is_poll_routing_code(code) {
return self
.send_poll_request_with_context(code, payload, context)
@@ -656,6 +669,7 @@ impl QuicClient {
skip_auto_login_once: Mutex::new(false),
roster_endpoints: Mutex::new(Vec::new()),
roster_learned: AtomicBool::new(false),
+ disconnected_by_caller: AtomicBool::new(false),
routing_lock: Mutex::new(()),
connect_coordinator: ConnectCoordinator::new(),
consumer_group_state:
Arc::new(iggy_common::ConsumerGroupClientState::new()),
@@ -702,7 +716,7 @@ impl QuicClient {
crate::vsr::decode_response(Bytes::from(buffer))
}
- async fn connect(&self) -> Result<(), IggyError> {
+ pub(crate) async fn connect(&self) -> Result<(), IggyError> {
self.connect_with_settlement(false).await
}
@@ -730,6 +744,13 @@ impl QuicClient {
.await
}
+ /// A reconnect sweep checks this before each dial and before it installs
+ /// the connection: once the caller disconnects, nothing in the sweep may
+ /// bring the client back.
+ fn caller_disconnected(&self) -> bool {
+ self.disconnected_by_caller.load(Ordering::SeqCst)
+ }
+
async fn connect_inner(&self, context: ConnectOwnerContext) -> Result<(),
IggyError> {
let settle_off_leader = context.settle_off_leader();
let single_attempt = context.single_attempt();
@@ -750,6 +771,10 @@ impl QuicClient {
trace!("Client is already connecting.");
return Ok(());
}
+ _ if self.caller_disconnected() => {
+ trace!("Cannot connect. The caller disconnected the
client.");
+ return Err(IggyError::NotConnected);
+ }
_ => {}
}
@@ -773,6 +798,11 @@ 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);
+ }
let server_address_str =
self.current_server_address.lock().await.clone();
let server_address =
tokio::net::lookup_host(&server_address_str)
.await
@@ -850,6 +880,13 @@ 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 now = IggyTimestamp::now();
info!("{NAME} client has connected to server: {remote_address} at
{now}",);
self.set_state(ClientState::Connected).await;
@@ -911,6 +948,12 @@ impl QuicClient {
.map(|_| ()),
};
if let Err(error) = result {
+ // An explicit disconnect landed during the sign-in. The
+ // non-retryable arm below would keep the connection up.
+ if self.caller_disconnected() {
+ self.disconnect_transport().await?;
+ return Err(IggyError::NotConnected);
+ }
if crate::vsr::resume_error_is_retryable(&error) {
self.disconnect_transport().await?;
} else {
@@ -1030,7 +1073,10 @@ impl QuicClient {
}
async fn disconnect_transport(&self) -> Result<(), IggyError> {
- if self.get_state().await == ClientState::Disconnected {
+ // Taken before the state is read: a teardown aborted after it set
+ // `Disconnected` can leave the connection installed.
+ let connection = self.connection.lock().await.take();
+ if connection.is_none() && self.get_state().await ==
ClientState::Disconnected {
return Ok(());
}
@@ -1039,7 +1085,7 @@ impl QuicClient {
self.config.client_address
);
self.set_state(ClientState::Disconnected).await;
- self.connection.lock().await.take();
+ drop(connection);
self.endpoint.wait_idle().await;
self.publish_event(DiagnosticEvent::Disconnected).await;
let now = IggyTimestamp::now();
@@ -2114,4 +2160,53 @@ mod tests {
let client = QuicClient::create(config);
assert!(client.is_err());
}
+
+ #[tokio::test]
+ async fn a_reconnect_after_an_explicit_disconnect_is_refused() {
+ let client = QuicClient::default();
+ Client::disconnect(&client).await.unwrap();
+
+ // The recovery path of a request that was in flight during the
+ // disconnect calls the inherent `connect`, not the trait one.
+ let reconnect = tokio::time::timeout(Duration::from_secs(1),
client.connect())
+ .await
+ .expect("a refused reconnect must not dial");
+
+ assert!(
+ matches!(reconnect, Err(IggyError::NotConnected)),
+ "got {reconnect:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn
a_teardown_cut_short_after_the_state_change_is_finished_by_the_next_one() {
+ 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();
+ // What an abort leaves between the state change and the cleanup.
+ client.set_state(ClientState::Disconnected).await;
+
+ client.disconnect_transport().await.unwrap();
+
+ assert!(client.connection.lock().await.is_none());
+ tokio::time::timeout(Duration::from_secs(1), peer.closed())
+ .await
+ .expect("the live connection must be closed");
+ }
}
diff --git a/core/sdk/src/tcp/tcp_client.rs b/core/sdk/src/tcp/tcp_client.rs
index 9907a9529..ba9517d32 100644
--- a/core/sdk/src/tcp/tcp_client.rs
+++ b/core/sdk/src/tcp/tcp_client.rs
@@ -111,6 +111,11 @@ pub struct TcpClient {
/// Set once a sign-in on this client has gone looking for the roster, so
/// that read happens once (see [`TcpClient::learn_roster_once`]).
roster_learned: AtomicBool,
+ /// Set by an explicit `Client::disconnect` and cleared by
`Client::connect`.
+ /// While it is set, no request reconnects the client on its own. A
+ /// connection that drops without a `disconnect()` call leaves it clear, so
+ /// that loss still heals.
+ disconnected_by_caller: AtomicBool,
/// Credentials a sign-in on this client succeeded with, so a reconnect --
/// onto this node or, after a failover, another one -- can re-establish
/// the session instead of surfacing `Unauthenticated`. Cleared on logout.
@@ -169,10 +174,12 @@ impl Default for TcpClient {
#[async_trait]
impl Client for TcpClient {
async fn connect(&self) -> Result<(), IggyError> {
+ self.disconnected_by_caller.store(false, Ordering::SeqCst);
TcpClient::connect(self).await
}
async fn disconnect(&self) -> Result<(), IggyError> {
+ self.disconnected_by_caller.store(true, Ordering::SeqCst);
// An explicit disconnect is caller intent, like a logout: the session
// it ends must not be resurrected by the next reconnect, so the
// remembered sign-in goes with it. Involuntary drops (a dead socket,
@@ -236,6 +243,12 @@ impl BinaryTransport for TcpClient {
payload: Bytes,
context: PartitionContext,
) -> Result<Bytes, IggyError> {
+ if self.caller_disconnected() {
+ return Err(match self.get_state().await {
+ ClientState::Shutdown => IggyError::ClientShutdown,
+ _ => IggyError::NotConnected,
+ });
+ }
if is_poll_routing_code(code) {
return self
.send_poll_request_with_context(code, payload, context)
@@ -619,6 +632,7 @@ impl TcpClient {
current_server_address: Mutex::new(server_address),
roster_endpoints: Mutex::new(Vec::new()),
roster_learned: AtomicBool::new(false),
+ disconnected_by_caller: AtomicBool::new(false),
configured_password: Mutex::new(None),
configured_username: Mutex::new(None),
session_credentials: Mutex::new(None),
@@ -630,7 +644,7 @@ impl TcpClient {
})
}
- async fn connect(&self) -> Result<(), IggyError> {
+ pub(crate) async fn connect(&self) -> Result<(), IggyError> {
self.connect_with_settlement(false).await
}
@@ -682,6 +696,10 @@ impl TcpClient {
trace!("Client is already connecting.");
return Ok(());
}
+ _ if self.caller_disconnected() => {
+ trace!("Cannot connect. The caller disconnected the
client.");
+ return Err(IggyError::NotConnected);
+ }
_ => *state = ClientState::Connecting,
}
}
@@ -725,8 +743,19 @@ impl TcpClient {
sleep(remaining.get_duration()).await;
}
+ if self.caller_disconnected() {
+ self.fail_connect().await;
+ return Err(IggyError::NotConnected);
+ }
+
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
@@ -785,6 +814,11 @@ impl TcpClient {
}
candidate = 0;
+ if self.caller_disconnected() {
+ self.fail_connect().await;
+ return Err(IggyError::NotConnected);
+ }
+
// An unreadable CA file, a domain that will not parse: no
// endpoint answered and at least one said why in a way that a
// retry cannot change, so the caller gets that reason instead
@@ -1293,6 +1327,13 @@ impl TcpClient {
self.publish_event(DiagnosticEvent::Disconnected).await;
}
+ /// A reconnect sweep checks this before each dial, before each retry pause
+ /// and before it signs in: once the caller disconnects, nothing in the
+ /// sweep may bring the client back.
+ fn caller_disconnected(&self) -> bool {
+ self.disconnected_by_caller.load(Ordering::SeqCst)
+ }
+
/// [`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
@@ -1344,20 +1385,25 @@ impl TcpClient {
/// failover unauthenticated. The public [`Client::disconnect`] wraps this
/// and forgets them first.
async fn disconnect_transport(&self) -> Result<(), IggyError> {
- match self.get_state().await {
- ClientState::Disconnected => return Ok(()),
- // Preserve the stream a concurrent reconnect is installing.
- ClientState::Connecting => {
- trace!("Not disconnecting; a connect is already in flight.");
- return Ok(());
- }
- _ => {}
+ // Preserve the stream a concurrent reconnect is installing.
+ // A `Connecting` that no sweep owns, such as one left by an aborted
+ // heartbeat, is torn down like any other state.
+ if self.get_state().await == ClientState::Connecting &&
self.connect_coordinator.is_active()
+ {
+ trace!("Not disconnecting; a connect is already in flight.");
+ return Ok(());
+ }
+ // Taken before the state is read: a teardown aborted after it set
+ // `Disconnected` can leave the stream installed.
+ let stream = self.stream.lock().await.take();
+ if stream.is_none() && self.get_state().await ==
ClientState::Disconnected {
+ return Ok(());
}
let client_address = self.get_client_address_value().await;
info!("{NAME} client: {client_address} is disconnecting from
server...");
self.set_state(ClientState::Disconnected).await;
- self.stream.lock().await.take();
+ drop(stream);
self.publish_event(DiagnosticEvent::Disconnected).await;
let now = IggyTimestamp::now();
info!("{NAME} client: {client_address} has disconnected from server
at: {now}.");
@@ -1817,7 +1863,7 @@ mod tests {
use super::*;
use crate::vsr::replay_after_session_reset_is_safe;
use iggy_binary_protocol::codes::{
- GET_ME_CODE, JOIN_CONSUMER_GROUP_CODE, LOGOUT_USER_CODE,
SEND_MESSAGES_CODE,
+ GET_ME_CODE, JOIN_CONSUMER_GROUP_CODE, LOGOUT_USER_CODE, PING_CODE,
SEND_MESSAGES_CODE,
};
use iggy_binary_protocol::consensus::{REJECTION_SECTION_LEN,
write_rejection_section};
use iggy_binary_protocol::requests::system::BindSessionRequest;
@@ -1827,6 +1873,7 @@ mod tests {
WireDecode, WireEncode, WireName,
};
use std::sync::atomic::AtomicUsize;
+ use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const SESSION_USER_ID: u32 = 7;
@@ -3793,4 +3840,110 @@ mod tests {
IggyDuration::from_str("5s").unwrap()
);
}
+
+ #[tokio::test]
+ async fn a_reconnect_after_an_explicit_disconnect_is_refused() {
+ let client = TcpClient::default();
+ Client::disconnect(&client).await.unwrap();
+
+ // The recovery path of a request that was in flight during the
+ // disconnect calls the inherent `connect`, not the trait one.
+ let reconnect = tokio::time::timeout(Duration::from_secs(1),
client.connect())
+ .await
+ .expect("a refused reconnect must not dial");
+
+ assert!(
+ matches!(reconnect, Err(IggyError::NotConnected)),
+ "got {reconnect:?}"
+ );
+ }
+
+ fn auto_login_client(server_address: String) -> TcpClient {
+ TcpClient::create(Arc::new(TcpClientConfig {
+ server_address,
+ auto_login: AutoLogin::Enabled(Credentials::UsernamePassword(
+ "iggy".to_string(),
+ "iggy".into(),
+ )),
+ reconnection: TcpClientReconnectionConfig {
+ enabled: true,
+ max_retries: None,
+ interval:
NonZeroIggyDuration::from_str("50ms").expect("duration"),
+ // The sweep checks the disconnect between pauses, so a zero
+ // pause keeps this test about the check, not the pacing.
+ reestablish_after: IggyDuration::new(Duration::ZERO),
+ },
+ ..TcpClientConfig::default()
+ }))
+ .expect("create the client")
+ }
+
+ #[tokio::test]
+ async fn a_disconnect_during_a_reconnect_sweep_ends_the_sweep() {
+ // Every sign-in fails on the socket, so with unlimited retries the
+ // sweep only ends on a disconnect.
+ let (address, accepts) = counted_endpoint_that_hangs_up().await;
+ let client = Arc::new(auto_login_client(address));
+ let sweep = tokio::spawn({
+ let client = Arc::clone(&client);
+ async move { TcpClient::connect(&client).await }
+ });
+ while accepts.load(Ordering::SeqCst) == 0 {
+ sleep(Duration::from_millis(10)).await;
+ }
+
+ Client::disconnect(&*client).await.unwrap();
+ let result = tokio::time::timeout(Duration::from_secs(2), sweep)
+ .await
+ .expect("the sweep must end after the disconnect")
+ .expect("the sweep task must not panic");
+ let accepts_after_end = accepts.load(Ordering::SeqCst);
+ sleep(Duration::from_millis(300)).await;
+
+ assert!(
+ matches!(result, Err(IggyError::NotConnected)),
+ "got {result:?}"
+ );
+ assert_eq!(
+ accepts.load(Ordering::SeqCst),
+ accepts_after_end,
+ "nothing may redial after the sweep ends"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_request_after_an_explicit_disconnect_fails_without_a_dial() {
+ let (listener, address) = live_endpoint().await;
+ let client = auto_login_client(address);
+ Client::disconnect(&client).await.unwrap();
+
+ let ping = tokio::time::timeout(
+ Duration::from_secs(1),
+ client.send_raw_with_response(PING_CODE, Bytes::new()),
+ )
+ .await
+ .expect("a refused request must not wait");
+
+ assert!(matches!(ping, Err(IggyError::NotConnected)), "got {ping:?}");
+ assert!(
+ tokio::time::timeout(Duration::from_millis(200), listener.accept())
+ .await
+ .is_err(),
+ "the request must not dial"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_disconnect_after_a_shutdown_keeps_reporting_the_shutdown() {
+ let client = auto_login_client("127.0.0.1:8090".to_string());
+ Client::shutdown(&client).await.unwrap();
+ Client::disconnect(&client).await.unwrap();
+
+ let ping = client.send_raw_with_response(PING_CODE,
Bytes::new()).await;
+
+ assert!(
+ matches!(ping, Err(IggyError::ClientShutdown)),
+ "got {ping:?}"
+ );
+ }
}
diff --git a/core/sdk/src/websocket/websocket_client.rs
b/core/sdk/src/websocket/websocket_client.rs
index 46deacf7d..dbc482caa 100644
--- a/core/sdk/src/websocket/websocket_client.rs
+++ b/core/sdk/src/websocket/websocket_client.rs
@@ -98,6 +98,11 @@ pub struct WebSocketClient {
/// admit (its replica of the target partition group is not the primary).
roster_endpoints: Mutex<Vec<String>>,
roster_learned: AtomicBool,
+ /// Set by an explicit `Client::disconnect` and cleared by
`Client::connect`.
+ /// While it is set, no request reconnects the client on its own. A
+ /// connection that drops without a `disconnect()` call leaves it clear, so
+ /// that loss still heals.
+ disconnected_by_caller: AtomicBool,
/// Serializes leader checks and roster walks after refused requests, so
/// concurrent callers cannot tear down each other's new connection.
routing_lock: Mutex<()>,
@@ -114,10 +119,12 @@ impl Default for WebSocketClient {
#[async_trait]
impl Client for WebSocketClient {
async fn connect(&self) -> Result<(), IggyError> {
+ self.disconnected_by_caller.store(false, Ordering::SeqCst);
WebSocketClient::connect(self).await
}
async fn disconnect(&self) -> Result<(), IggyError> {
+ self.disconnected_by_caller.store(true, Ordering::SeqCst);
self.forget_session_credentials().await;
WebSocketClient::disconnect_transport(self).await?;
self.reset_vsr_session().await
@@ -177,6 +184,12 @@ impl BinaryTransport for WebSocketClient {
payload: Bytes,
context: PartitionContext,
) -> Result<Bytes, IggyError> {
+ if self.caller_disconnected() {
+ return Err(match self.get_state().await {
+ ClientState::Shutdown => IggyError::ClientShutdown,
+ _ => IggyError::NotConnected,
+ });
+ }
if is_poll_routing_code(code) {
return self
.send_poll_request_with_context(code, payload, context)
@@ -605,6 +618,7 @@ impl WebSocketClient {
skip_auto_login_once: Mutex::new(false),
roster_endpoints: Mutex::new(Vec::new()),
roster_learned: AtomicBool::new(false),
+ disconnected_by_caller: AtomicBool::new(false),
routing_lock: Mutex::new(()),
connect_coordinator: ConnectCoordinator::new(),
consumer_group_state:
Arc::new(iggy_common::ConsumerGroupClientState::new()),
@@ -627,7 +641,7 @@ impl WebSocketClient {
}
}
- async fn connect(&self) -> Result<(), IggyError> {
+ pub(crate) async fn connect(&self) -> Result<(), IggyError> {
self.connect_with_settlement(false).await
}
@@ -655,6 +669,13 @@ impl WebSocketClient {
.await
}
+ /// A reconnect sweep checks this before each dial and before it installs
+ /// the stream: once the caller disconnects, nothing in the sweep may bring
+ /// the client back.
+ fn caller_disconnected(&self) -> bool {
+ self.disconnected_by_caller.load(Ordering::SeqCst)
+ }
+
async fn connect_inner(&self, context: ConnectOwnerContext) -> Result<(),
IggyError> {
let settle_off_leader = context.settle_off_leader();
let single_attempt = context.single_attempt();
@@ -668,6 +689,10 @@ impl WebSocketClient {
ClientState::Connected
| ClientState::Authenticating
| ClientState::Authenticated => return Ok(()),
+ _ if self.caller_disconnected() => {
+ trace!("Cannot connect. The caller disconnected the
client.");
+ return Err(IggyError::NotConnected);
+ }
_ => {}
}
@@ -705,6 +730,12 @@ impl WebSocketClient {
}
}
+ if self.caller_disconnected() {
+ self.set_state(ClientState::Disconnected).await;
+ self.publish_event(DiagnosticEvent::Disconnected).await;
+ return Err(IggyError::NotConnected);
+ }
+
let server_addr = tokio::net::lookup_host(&*current_address)
.await
.map_err(|e| {
@@ -754,6 +785,13 @@ impl WebSocketClient {
Err(error) => return Err(error),
};
+ if self.caller_disconnected() {
+ drop(connection_stream);
+ self.set_state(ClientState::Disconnected).await;
+ self.publish_event(DiagnosticEvent::Disconnected).await;
+ return Err(IggyError::NotConnected);
+ }
+
*self.stream.lock().await = Some(connection_stream);
*self.client_address.lock().await = Some(server_addr);
self.set_state(ClientState::Connected).await;
@@ -1098,6 +1136,12 @@ impl WebSocketClient {
.map(|_| ()),
};
if let Err(error) = result {
+ // An explicit disconnect landed during the sign-in. The
+ // non-retryable arm below would keep the stream up.
+ if self.caller_disconnected() {
+ self.disconnect_transport().await?;
+ return Err(IggyError::NotConnected);
+ }
if crate::vsr::resume_error_is_retryable(&error) {
self.disconnect_transport().await?;
} else {
@@ -1109,15 +1153,17 @@ impl WebSocketClient {
}
async fn disconnect_transport(&self) -> Result<(), IggyError> {
- if self.get_state().await == ClientState::Disconnected {
+ // Taken before the state is read: a teardown aborted after it set
+ // `Disconnected` can leave the stream installed.
+ let stream = self.stream.lock().await.take();
+ if stream.is_none() && self.get_state().await ==
ClientState::Disconnected {
return Ok(());
}
let client_address = self.get_client_address_value().await;
info!("{NAME} client: {client_address} is disconnecting from
server...");
self.set_state(ClientState::Disconnected).await;
-
- self.stream.lock().await.take();
+ drop(stream);
self.publish_event(DiagnosticEvent::Disconnected).await;
let now = IggyTimestamp::now();
@@ -1429,6 +1475,7 @@ const fn is_login_register_code(code: u32) -> bool {
mod tests {
use super::*;
use std::str::FromStr;
+ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use iggy_binary_protocol::codes::{JOIN_CONSUMER_GROUP_CODE,
SEND_MESSAGES_CODE};
@@ -2073,4 +2120,21 @@ mod tests {
let client = client.unwrap();
assert_eq!(client.config.server_address, "localhost:8092");
}
+
+ #[tokio::test]
+ async fn a_reconnect_after_an_explicit_disconnect_is_refused() {
+ let client = WebSocketClient::default();
+ Client::disconnect(&client).await.unwrap();
+
+ // The recovery path of a request that was in flight during the
+ // disconnect calls the inherent `connect`, not the trait one.
+ let reconnect = tokio::time::timeout(Duration::from_secs(1),
client.connect())
+ .await
+ .expect("a refused reconnect must not dial");
+
+ assert!(
+ matches!(reconnect, Err(IggyError::NotConnected)),
+ "got {reconnect:?}"
+ );
+ }
}
diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp
index 685952408..20430666d 100644
--- a/foreign/cpp/include/iggy.hpp
+++ b/foreign/cpp/include/iggy.hpp
@@ -3458,9 +3458,9 @@ class IggyBlockingClient final {
* Call Connect() to establish a new connection. Configured automatic login
* is applied when reconnecting.
*
- * @note Disconnect() does not stop the existing heartbeat task. With
- * automatic login configured, a heartbeat may reconnect and
- * authenticate the client in the background.
+ * @note The disconnect holds until the next Connect(): requests fail and
+ * nothing reconnects the client in the background, even with
+ * automatic login configured.
* @note The HTTP transport is stateless and treats this operation as a
* no-op.
* @throws IggyException if the client cannot disconnect cleanly.
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index ba7b247cb..7e32341a2 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -1377,12 +1377,10 @@ class IggyClient:
for the rest of `reestablish_after` (5 s by default). The sign-in made
with `login_user` is dropped, so it must be repeated after
reconnecting.
A client configured with auto-login credentials signs in again on
- `connect`. Over HTTP there is no connection to close and this call does
- nothing.
-
- Known issue: unless reconnection is disabled, the heartbeat of a client
- with auto-login credentials connects it again and signs in within one
- heartbeat interval. See https://github.com/apache/iggy/issues/4287.
+ `connect`. Until then, requests fail with `RuntimeError`, and nothing
+ connects the client again in the background, even with auto-login
+ credentials. Over HTTP there is no connection to close and this call
+ does nothing.
Raises:
RuntimeError: If the connection cannot be closed.
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index c237e7f45..7f35fb29b 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -522,12 +522,10 @@ impl IggyClient {
/// for the rest of `reestablish_after` (5 s by default). The sign-in made
/// with `login_user` is dropped, so it must be repeated after
reconnecting.
/// A client configured with auto-login credentials signs in again on
- /// `connect`. Over HTTP there is no connection to close and this call does
- /// nothing.
- ///
- /// Known issue: unless reconnection is disabled, the heartbeat of a client
- /// with auto-login credentials connects it again and signs in within one
- /// heartbeat interval. See https://github.com/apache/iggy/issues/4287.
+ /// `connect`. Until then, requests fail with `RuntimeError`, and nothing
+ /// connects the client again in the background, even with auto-login
+ /// credentials. Over HTTP there is no connection to close and this call
+ /// does nothing.
///
/// Raises:
/// RuntimeError: If the connection cannot be closed.
diff --git a/foreign/python/tests/test_connectivity.py
b/foreign/python/tests/test_connectivity.py
index c17111f5a..3b77d36f7 100644
--- a/foreign/python/tests/test_connectivity.py
+++ b/foreign/python/tests/test_connectivity.py
@@ -43,8 +43,8 @@ from .utils import (
def binary_transport_configs(auto_login: AutoLogin | None = None) -> list:
"""Return a config for every transport that holds a connection.
- Auto-login is disabled by default: with credentials to replay, a ping sent
- while disconnected reconnects on its own instead of failing.
+ Auto-login is disabled by default, so a test opts in only when it checks
+ the auto-login behavior.
TCP and QUIC wait out `reestablish_after` before they connect again, so the
configs set it to zero to keep the reconnect cases fast.
@@ -275,17 +275,20 @@ class TestLifecycle:
binary_transport_configs(AutoLogin.username_password("iggy", "iggy")),
)
@pytest.mark.asyncio
- async def test_disconnected_client_with_auto_login_signs_in_on_reconnect(
+ async def test_auto_login_client_stays_disconnected_until_connect(
self, config: TcpConfig | WebSocketConfig | QuicConfig
):
- """Test a client with auto-login credentials signs in again on
connect."""
+ """Test an auto-login client stays disconnected, then signs in on
connect."""
client = IggyClient(config)
await client.connect()
await wait_for_ping(client)
await client.disconnect()
- await client.connect()
+ with pytest.raises(RuntimeError):
+ await client.ping()
+
+ await client.connect()
await client.get_streams()
@pytest.mark.parametrize("config", binary_transport_configs())