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

lizhimins pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-clients.git


The following commit(s) were added to refs/heads/master by this push:
     new d0f3cd13 Fix/rust telemetry session defects (#1404)
d0f3cd13 is described below

commit d0f3cd13a1b815a7da6b1cfd02ad6bb4701c6af7
Author: zhaohai <[email protected]>
AuthorDate: Sat Oct 24 04:18:35 2026 +0800

    Fix/rust telemetry session defects (#1404)
    
    * fix(rust): stop rebuilding telemetry streams on shadow sessions and keep 
the session map bounded
    
    The telemetry stream was rebuilt on throw-away shadow sessions during the 
periodic
    settings sync. Because a shadow session is dropped as soon as the sync 
cycle ends, the
    rebuilt stream was dropped with it: the registered session kept its dead 
handle, so every
    cycle produced a fresh one-off stream (a few milliseconds each) instead of 
restoring the
    long-lived connection. Combined with a `session_map` that only ever grew, 
dead endpoints
    accumulated and failed on every settings report cycle, which showed up as 
recurring
    errors and gaps in message consumption.
    
    Changes, following the Java client as the reference:
    
    * Sync settings on the registered sessions 
(`SessionManager::sync_settings`) so a rebuilt
      stream actually sticks, mirroring `ClientImpl#syncSettings` which reuses 
one
      `ClientSession` instance. Sessions whose stream cannot be restored are 
evicted, mirroring
      `ClientImpl#removeClientSession`.
    * Evict a session when its heartbeat RPC fails outright (`evict_session`), 
so the next
      access rebuilds it against a fresh connection.
    * Reap dead and idle sessions periodically 
(`clear_dead_and_idle_sessions`), mirroring
      `ClientManagerImpl#clearIdleRpcClients`; activity is recorded on every 
request and
      shared with shadow sessions through an atomic counter.
    * Enable HTTP/2 keep-alive on the channel (300s interval, 30s timeout, ping 
while idle),
      matching `RpcClientImpl`. Without it a rolling deployment left a 
half-open connection
      that was never detected.
    * Fix the receive deadline, which was set to exactly `long_polling_timeout` 
while the
      server is allowed to hold the response for that long. It now adds the 
request timeout on
      top, as documented on `ClientOption::set_long_polling_timeout` and as the 
Java client
      does (`awaitDuration.plus(tolerance)`). This is why every poll failed once
      `long_polling_timeout` was set below a few seconds.
    * Apply the settings pushed by the server (`PushConsumer::apply_settings`), 
which were
      previously discarded: long polling timeout, receive batch size and fifo 
now follow the
      server, mirroring `PushSubscriptionSettings#sync`.
    
    Tests: six new unit tests cover session eviction, idle/dead session 
reaping, settings sync
    acting on the registered session, eviction of unrecoverable sessions, and 
the receive
    deadline contract. `cargo test` (95 lib + 3 integration + doc tests) and
    `cargo clippy --all-features -- -D warnings` are green.
    
    * fix(rust): propagate session errors instead of panicking when the server 
is unreachable
    
    `send_message`, `recall_message`, `receive_message`, `ack_message` and
    `change_invisible_duration` acquired their session with
    `get_session_with_endpoints(...).await.unwrap()`. Whenever the session 
could not be
    obtained, the client panicked instead of reporting a failure.
    
    That is reachable whenever the session is not cached yet and the peer is 
down, which is
    exactly what happens around a rolling deployment: the first call after the 
server went
    away took the whole process down, and the caller never got a chance to 
retry. It also
    amplified the consumption gaps seen with the telemetry defects, since a 
dead endpoint
    turned a transient outage into a crash.
    
    The session error is now returned to the caller with `?`, so callers can 
retry or fall
    back. Verified against a local RocketMQ 5.5.1 cluster: with the proxy 
killed mid-run, the
    producer now reports the connection failure and recovers on its own once 
the proxy is back,
    instead of panicking.
    
    * fix(rust): recover the telemetry stream proactively instead of waiting 
for periodic syncs
    
    A rolling deployment or failover of the server used to interrupt
    consumption for 42-70 seconds, because the client only noticed the dead
    telemetry stream at the next 30s settings sync, kept pushing settings
    into the fire-and-forget channel of the dying stream, and never
    re-registered when the new server instance answered unary RPCs with
    `cannot find client` / `settings is null`.
    
    Mirroring the Java client (`ClientSessionImpl#renewRequestObserver`,
    `ClientImpl#recoverTransport`), this introduces an error-driven
    recovery path:
    
    * the telemetry stream task notifies the client layer through a
      stream-closed channel the moment the server closes the stream;
    * the client schedules a reconnect that retries on the registered
      session every second (deliberately not exponential: a growing
      back-off keeps missing the moment the new instance becomes ready);
    * `SessionManager#reconnect_session` re-establishes the stream on the
      registered session, keeping it in the map on failure so that an idle
      client keeps a driver for recovery; rebuilding the stream also
      re-registers the client with the new instance;
    * a heartbeat business error now triggers the same reconnect (the
      heartbeat interval drops from 30s to 10s, aligned with
      `HEART_BEAT_PERIOD`), and a session is only evicted after 2
      consecutive transport failures (`HEART_BEAT_FAILURE_THRESHOLD`);
    * the long-polling timeout moved behind a shared handle: settings
      pushed by the server now take effect on the receive deadline and on
      the request body of the simple consumer too, instead of being
      discarded (`SimpleSubscriptionSettings#sync`).
    
    Verified against a live cluster: after `kill -9` of the proxy and a
    restart, the telemetry stream is re-established within one retry cycle
    of the new instance becoming ready; the remaining ~20s of the measured
    interruption window is the restarted proxy rebuilding its topic routes
    from broker heartbeats, which no client can skip.
    
    * test(rust): tolerate the client recovering faster than the readiness probe
    
    The client can re-establish its stream before the external driver has
    detected that the proxy is up and written the marker file, so poll for
    the marker briefly instead of failing outright.
    
    * fix(rust): bound the reconnect loop, rebuild evicted sessions, fix MSRV 
pins and keep-alive interval
    
    Follow-up on the proactive reconnect, addressing real-cluster findings:
    
    * A session that was evicted or reaped meanwhile made `reconnect_session`
      return `Ok(())`, which the reconnect retry loop treated as "stream
      re-established" and exited without any stream. A missing session is now
      rebuilt from the access endpoints instead (`Ok(bool)` tells the loop
      whether the stream was really restored). The lock is released before that
      rebuild: `get_or_create_session` takes the same map lock, and holding it
      across the rebuild dead-locked the tokio mutex.
    * The retry loop is bounded: the delay doubles from 1s up to a 30s cap and
      the loop gives up after 8 attempts (~2 minutes), leaving a permanently
      offline endpoint to the periodic settings sync, the heartbeat and the next
      business traffic instead of hammering it once a second forever. Every
      attempt is logged with its index and next delay.
    * The periodic topic-route sync drops from 30s to 10s: when a broker
      endpoint drifts, the stale-route window (measured 13-29s) is decided by
      that timer, not by the telemetry reconnect.
    * The HTTP/2 keep-alive interval drops from 300s to 60s: the proxy permits
      client pings every 30s by default (`ProxyConfig#
      grpcServerPermitKeepAliveTimeMillis = 30000`, overriding the bare 
grpc-java
      5-minute default), so a dead peer is now detected within ~90s instead of
      ~330s. The Java client does not configure keep-alive at all, so the
      previous "aligned with Java" comment was wrong.
    * MSRV 1.74 broke on fresh dependency resolution (as the CI build does every
      run): pin `hyper-util <0.1.16` (edition 2024), `powerfmt <0.2.1`
      (rustc 1.79) and `tokio <1.47` (rustc 1.85).
    
    Verified on a live cluster: after `kill -9` of the proxy the reconnect loop
    logged attempts 1/8 (1s), 2/8 (2s), 3/8 (4s) ... and re-established the
    stream on attempt 5; the first successful receive happened 79ms after the
    new instance became ready.
    
    * fix(rust): iterate the session map through values() to satisfy clippy 1.99
    
    The current stable clippy promotes `for_kv_map` to a style error, so
    `cargo clippy --all-features -- -D warnings` fails on the two session-map
    loops that destructure an unused key. Iterating with `values()` /
    `values_mut()` expresses the same intent without the discarded binding.
    
    * fix(rust): reconnect the requested peer instead of the access endpoint
    
    `reconnect_session` rebuilt a missing session from `option.access_url()` 
while
    reporting `Ok(true)` for the peer that was asked for. When the two differ
    (broker endpoint drift), the reconnect retry loop logged "telemetry stream 
for
    peer ... re-established" and exited although that peer had no stream at all.
    Session-map keys always come from `Endpoints::endpoint_url`, so the peer url
    round-trips back into a valid endpoint and the rebuild can target it 
directly.
    
    The new test took no part in the `MTX` serialization that the other
    `MockSession::new_context()` tests use, so mockall's process-wide context 
was
    swapped under concurrently running tests and the suite failed at random
    (`MockSession::new: No matching expectation found`). `client_get_session` 
had
    the same gap. Both now take the lock.
---
 rust/Cargo.toml                  |   9 +-
 rust/src/client.rs               | 686 ++++++++++++++++++++++++++++++++++++---
 rust/src/conf.rs                 |  15 +
 rust/src/push_consumer.rs        |  39 +++
 rust/src/session.rs              | 197 ++++++++++-
 rust/src/simple_consumer.rs      |  43 ++-
 rust/tests/rolling_restart_it.rs | 210 ++++++++++++
 7 files changed, 1151 insertions(+), 48 deletions(-)

diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 14968994..3f1e105d 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -32,7 +32,8 @@ description = "Rust client for Apache RocketMQ"
 keywords = ["rocketmq", "api", "client", "sdk", "grpc"]
 
 [dependencies]
-tokio = { version = "1", features = ["full"] }
+# Pin tokio to versions compatible with Rust 1.74; 1.47+ raised the MSRV 
beyond it.
+tokio = { version = ">=1.20, <1.47", features = ["full"] }
 tokio-rustls = { version = "0.24.0", features = ["default", 
"dangerous_configuration"] }
 tokio-stream = "0.1.12"
 async-trait = "0.1.68"
@@ -71,6 +72,12 @@ tokio-util = { version = "=0.7.10", features = ["rt"] }
 indexmap = ">=2.0, <2.8"
 # Explicitly pin hashbrown to versions compatible with Rust 1.74
 hashbrown = ">=0.12, <0.16"
+# Pin hyper-util (pulled in via tonic -> hyper-timeout) to versions that still 
build with
+# Rust 1.74; 0.1.16+ switched to edition 2024 and broke the MSRV build.
+hyper-util = ">=0.1.2, <0.1.16"
+# Pin powerfmt (pulled in via time 0.3 -> deranged/time-macros) to versions 
compatible
+# with Rust 1.74; 0.2.1+ requires rustc 1.79.
+powerfmt = ">=0.2.0, <0.2.1"
 
 [build-dependencies]
 tonic-build = "0.10.2"
diff --git a/rust/src/client.rs b/rust/src/client.rs
index 8243d75e..93820223 100644
--- a/rust/src/client.rs
+++ b/rust/src/client.rs
@@ -22,7 +22,7 @@ use std::{collections::HashMap, sync::atomic::AtomicUsize, 
sync::Arc};
 use mockall::automock;
 use mockall_double::double;
 use once_cell::sync::Lazy;
-use parking_lot::Mutex;
+use parking_lot::{Mutex, RwLock};
 use prost_types::Duration;
 use tokio::select;
 use tokio::sync::{mpsc, oneshot};
@@ -42,9 +42,9 @@ use crate::pb::{
     RecallMessageResponse, ReceiveMessageRequest, Resource, 
SendMessageRequest, Status,
     TelemetryCommand,
 };
-use crate::session::RPCClient;
 #[double]
 use crate::session::Session;
+use crate::session::{RPCClient, SharedLongPollingTimeout};
 use crate::util::{handle_receive_message_status, handle_response_status, 
select_message_queue};
 
 #[derive(Debug)]
@@ -57,6 +57,10 @@ pub(crate) struct Client {
     settings: TelemetryCommand,
     telemetry_command_tx: Option<mpsc::Sender<pb::telemetry_command::Command>>,
     shutdown_tx: Option<oneshot::Sender<()>>,
+    /// Long-polling timeout, shared with every session so that a 
server-pushed settings
+    /// update takes effect on the receive deadline and request body without 
recreating
+    /// anything.
+    long_polling_timeout: SharedLongPollingTimeout,
 }
 
 #[derive(Debug, Clone)]
@@ -78,6 +82,53 @@ const OPERATION_SEND_MESSAGE: &str = "client.send_message";
 const OPERATION_RECEIVE_MESSAGE: &str = "client.receive_message";
 const OPERATION_ACK_MESSAGE: &str = "client.ack_message";
 
+/// Maximum duration a session may stay idle before it is dropped from the 
session map.
+///
+/// A session is re-created on demand, so reaping idle ones only costs a 
re-connect for
+/// endpoints that are used again. Mirrors 
`ClientManagerImpl#RPC_CLIENT_MAX_IDLE_DURATION`
+/// of the Java client.
+const SESSION_MAX_IDLE_DURATION: std::time::Duration = 
std::time::Duration::from_secs(30 * 60);
+
+/// Interval of the housekeeping task that drops dead and idle sessions.
+///
+/// It is deliberately shorter than the settings/heartbeat intervals so that a 
dead endpoint
+/// is removed before the next report cycle tries to use it again.
+const SESSION_CLEANUP_INTERVAL: std::time::Duration = 
std::time::Duration::from_secs(30);
+
+/// Period between heartbeats. Kept in line with the Java client
+/// (`ClientImpl#HEART_BEAT_PERIOD`).
+const HEART_BEAT_INTERVAL: std::time::Duration = 
std::time::Duration::from_secs(10);
+
+/// Number of consecutive heartbeat transport failures before the session is 
evicted.
+/// Kept in line with the Java client 
(`ClientImpl#HEART_BEAT_FAILURE_THRESHOLD`), which
+/// tolerates a transient blip instead of tearing the connection down at the 
first failure.
+const HEART_BEAT_FAILURE_THRESHOLD: u32 = 2;
+
+/// Initial delay before the first reconnect attempt after the telemetry 
stream was
+/// observed dead. Kept in line with the Java client
+/// (`ClientSessionImpl#REQUEST_OBSERVER_RENEW_BACKOFF_DELAY`).
+const RECONNECT_BACKOFF_DELAY: std::time::Duration = 
std::time::Duration::from_secs(1);
+
+/// Upper bound of the delay between reconnect attempts. The delay doubles on 
every
+/// failure, so a permanently offline endpoint is probed at most every 30s 
instead of being
+/// hammered once a second, while a peer that comes back is still picked up 
quickly.
+const RECONNECT_RETRY_MAX_DELAY: std::time::Duration = 
std::time::Duration::from_secs(30);
+
+/// Number of reconnect attempts after a stream-close notification before 
giving up on the
+/// peer (about two minutes in total with the doubling back-off). After that 
the endpoint is
+/// considered gone for now: recovery is left to the periodic settings sync, 
the heartbeat
+/// and the next business traffic, so a permanently offline endpoint is not 
probed forever.
+const RECONNECT_MAX_ATTEMPTS: u32 = 8;
+
+/// Interval of the periodic topic-route sync.
+///
+/// Deliberately shorter than the 30s the Java client uses: when a broker 
endpoint drifts
+/// (failover, migration), the client keeps sending to the stale, dead 
endpoints until the
+/// route data is refreshed, and this timer — not the telemetry reconnect — 
decides how long
+/// that window lasts. 10s halves-to-thirds the measured 13-29s interruption 
of the 30s
+/// variant.
+const ROUTE_SYNC_INTERVAL: std::time::Duration = 
std::time::Duration::from_secs(10);
+
 #[automock]
 impl Client {
     pub(crate) fn new(
@@ -87,7 +138,9 @@ impl Client {
         let id = Self::generate_client_id();
         let endpoints = Endpoints::from_url(option.access_url())
             .map_err(|e| e.with_operation(OPERATION_CLIENT_NEW))?;
-        let session_manager = SessionManager::new(id.clone(), &option);
+        let long_polling_timeout = 
Arc::new(RwLock::new(*option.long_polling_timeout()));
+        let session_manager =
+            SessionManager::new(id.clone(), &option, 
Arc::clone(&long_polling_timeout));
         let route_manager =
             TopicRouteManager::new(option.get_namespace().to_string(), 
endpoints.clone());
         Ok(Client {
@@ -99,6 +152,7 @@ impl Client {
             settings,
             telemetry_command_tx: None,
             shutdown_tx: None,
+            long_polling_timeout,
         })
     }
 
@@ -173,6 +227,7 @@ impl Client {
             settings: new_settings,
             telemetry_command_tx: None, // Will be set during start()
             shutdown_tx: None,          // Intentionally None to avoid 
duplicate shutdown
+            long_polling_timeout: Arc::clone(&self.long_polling_timeout),
         }
     }
 
@@ -189,7 +244,16 @@ impl Client {
         let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
         self.shutdown_tx = Some(shutdown_tx);
 
-        self.telemetry_command_tx = Some(telemetry_command_tx);
+        self.telemetry_command_tx = Some(telemetry_command_tx.clone());
+
+        // Channel the sessions notify through when the server closes a 
telemetry stream.
+        // Registered before the first session is created, so that the stream 
task captures
+        // the sender while it is spawned.
+        let (stream_closed_tx, mut stream_closed_rx) = 
mpsc::channel::<String>(16);
+        session_manager.set_stream_closed_channel(stream_closed_tx);
+        // Also captured so that reconnect_session can rebuild a session that 
was evicted
+        // or reaped meanwhile.
+        session_manager.set_telemetry_command_channel(telemetry_command_tx);
 
         let mut rpc_client = self
             .get_session()
@@ -201,11 +265,19 @@ impl Client {
         let settings = self.settings.clone();
         tokio::spawn(async move {
             rpc_client.is_started();
+            let mut heartbeat_interval = 
tokio::time::interval(HEART_BEAT_INTERVAL);
             let seconds_30 = std::time::Duration::from_secs(30);
-            let mut heartbeat_interval = tokio::time::interval(seconds_30);
             let mut sync_settings_interval =
                 tokio::time::interval_at(Instant::now() + seconds_30, 
seconds_30);
-            let mut sync_route_timer = tokio::time::interval(seconds_30);
+            let mut sync_route_timer = 
tokio::time::interval(ROUTE_SYNC_INTERVAL);
+            let mut session_cleanup_timer = tokio::time::interval_at(
+                Instant::now() + SESSION_CLEANUP_INTERVAL,
+                SESSION_CLEANUP_INTERVAL,
+            );
+            // Consecutive heartbeat transport failures per peer; a session is 
only evicted
+            // after the failure threshold is reached, so a transient blip 
does not tear a
+            // working connection down.
+            let mut heartbeat_failures: HashMap<String, u32> = HashMap::new();
             loop {
                 select! {
                     _ = heartbeat_interval.tick() => {
@@ -226,6 +298,16 @@ impl Client {
                                     "send heartbeat failed: failed to send 
heartbeat rpc: {}",
                                     e
                                 );
+                                // The endpoint did not answer at all. Evict 
it only after
+                                // the failure threshold is reached so that 
the next access
+                                // rebuilds it against a fresh connection, 
instead of
+                                // keeping a dead endpoint around forever.
+                                let failures = 
heartbeat_failures.entry(peer.clone()).or_insert(0);
+                                *failures += 1;
+                                if *failures >= HEART_BEAT_FAILURE_THRESHOLD {
+                                    session_manager.evict_session(&peer).await;
+                                    heartbeat_failures.remove(&peer);
+                                }
                                 continue;
                             }
                             let result =
@@ -235,27 +317,99 @@ impl Client {
                                     "send heartbeat failed: server return 
error: {}",
                                     e
                                 );
+                                heartbeat_failures.remove(&peer);
+                                // A business error such as `cannot find 
client` means the
+                                // server no longer knows this client, which 
happens when a
+                                // failover moved the traffic to another 
instance. Rebuild
+                                // the telemetry stream: the telemetry RPC 
itself
+                                // re-registers the client with the new 
instance.
+                                match session_manager.reconnect_session(&peer, 
&settings).await {
+                                    Ok(_) => {}
+                                    Err(re) => {
+                                        error!(
+                                            "reconnect session after heartbeat 
error failed, peer={}, error={}",
+                                            peer, re
+                                        );
+                                    }
+                                }
                                 continue;
                             }
+                            heartbeat_failures.remove(&peer);
                             debug!("send heartbeat to server success, 
peer={}",peer);
                         }
                     },
                     _ = sync_settings_interval.tick() => {
-                        let sessions = 
session_manager.get_all_sessions().await;
-                        if let Err(e) = sessions {
-                            error!("sync settings failed: failed to get 
sessions: {}", e);
-                            continue;
-                        }
-                        for mut session in sessions.unwrap() {
-                            let peer = session.peer().to_string();
-                            let result = 
session.update_settings(settings.clone()).await;
-                            if let Err(e) = result {
-                                error!("sync settings failed: failed to call 
rpc: {}", e);
-                                continue;
+                        // Sync on the registered sessions, not on shadow 
copies: rebuilding a
+                        // stream on a throw-away copy loses it immediately, 
which is what left
+                        // the client with a new one-off stream on every cycle.
+                        session_manager.sync_settings(&settings).await;
+                    },
+                    _ = session_cleanup_timer.tick() => {
+                        // Keep the session map from growing without bound: 
drop sessions whose
+                        // long-lived connection is gone, or that nobody used 
for a long time.
+                        session_manager.clear_dead_and_idle_sessions().await;
+                    },
+                    peer = stream_closed_rx.recv() => {
+                        match peer {
+                            Some(peer) => {
+                                warn!(
+                                    "telemetry stream for peer {} was closed, 
scheduling reconnect",
+                                    peer
+                                );
+                                // Retry in the background: the first attempt 
follows the
+                                // initial back-off delay, then the delay 
doubles up to a
+                                // cap, so a permanently offline endpoint is 
not hammered
+                                // once a second. After the attempt budget is 
exhausted the
+                                // endpoint is left to the periodic settings 
sync, the
+                                // heartbeat and the next business traffic.
+                                let session_manager = session_manager.clone();
+                                let settings = settings.clone();
+                                tokio::spawn(async move {
+                                    let mut delay = RECONNECT_BACKOFF_DELAY;
+                                    for attempt in 1..=RECONNECT_MAX_ATTEMPTS {
+                                        tokio::time::sleep(delay).await;
+                                        match session_manager
+                                            .reconnect_session(&peer, 
&settings)
+                                            .await
+                                        {
+                                            Ok(true) => {
+                                                info!(
+                                                    "telemetry stream for peer 
{} re-established after {} attempt(s)",
+                                                    peer, attempt
+                                                );
+                                                return;
+                                            }
+                                            Ok(false) => {
+                                                // Nothing could be driven (no 
access
+                                                // endpoints configured); 
retrying without
+                                                // a session to rebuild on is 
pointless.
+                                                warn!(
+                                                    "session for peer {} could 
not be rebuilt, it will be recreated on the next access",
+                                                    peer
+                                                );
+                                                return;
+                                            }
+                                            Err(e) => {
+                                                error!(
+                                                    "reconnect to peer {} 
failed (attempt {}/{}): {}, next attempt in {:?}",
+                                                    peer, attempt, 
RECONNECT_MAX_ATTEMPTS, e, delay
+                                                );
+                                                delay = (delay * 
2).min(RECONNECT_RETRY_MAX_DELAY);
+                                            }
+                                        }
+                                    }
+                                    error!(
+                                        "gave up reconnecting to peer {} after 
{} attempts; it will be rebuilt by the periodic settings sync or the next 
traffic",
+                                        peer, RECONNECT_MAX_ATTEMPTS
+                                    );
+                                });
+                            }
+                            None => {
+                                // Unreachable while the session manager is 
alive: it holds
+                                // a sender of this channel.
+                                error!("stream closed channel was closed 
unexpectedly");
                             }
-                            debug!("sync settings success, peer = {}", peer);
                         }
-
                     },
                     _ = sync_route_timer.tick() => {
                         let result = route_manager.sync_route_data(&mut 
rpc_client).await;
@@ -325,6 +479,12 @@ impl Client {
         &self.id
     }
 
+    /// Handle of the shared long-polling timeout, so that consumers can apply 
a
+    /// server-pushed settings update to it from their telemetry loop.
+    pub(crate) fn long_polling_timeout_handle(&self) -> 
SharedLongPollingTimeout {
+        Arc::clone(&self.long_polling_timeout)
+    }
+
     /// Get the client type
     #[allow(dead_code)]
     pub(crate) fn get_client_type(&self) -> ClientType {
@@ -473,11 +633,8 @@ impl Client {
         endpoints: &Endpoints,
         messages: Vec<Message>,
     ) -> Result<Vec<SendReceipt>, ClientError> {
-        self.send_message_inner(
-            self.get_session_with_endpoints(endpoints).await.unwrap(),
-            messages,
-        )
-        .await
+        let rpc_client = self.get_session_with_endpoints(endpoints).await?;
+        self.send_message_inner(rpc_client, messages).await
     }
 
     pub(crate) async fn recall_message(
@@ -485,11 +642,8 @@ impl Client {
         endpoints: &Endpoints,
         request: RecallMessageRequest,
     ) -> Result<RecallMessageResponse, ClientError> {
-        self.recall_message_inner(
-            self.get_session_with_endpoints(endpoints).await.unwrap(),
-            request,
-        )
-        .await
+        let rpc_client = self.get_session_with_endpoints(endpoints).await?;
+        self.recall_message_inner(rpc_client, request).await
     }
 
     pub(crate) async fn send_message_inner<T: RPCClient + 'static>(
@@ -525,8 +679,9 @@ impl Client {
         batch_size: i32,
         invisible_duration: Duration,
     ) -> Result<Vec<Message>, ClientError> {
+        let rpc_client = self.get_session_with_endpoints(endpoints).await?;
         self.receive_message_inner(
-            self.get_session_with_endpoints(endpoints).await.unwrap(),
+            rpc_client,
             message_queue,
             expression,
             batch_size,
@@ -553,8 +708,10 @@ impl Client {
             batch_size,
             invisible_duration: Some(invisible_duration),
             auto_renew: false,
+            // Read from the shared handle: the server may have pushed a new 
value through
+            // the telemetry stream since this client was created.
             long_polling_timeout: Some(
-                
Duration::try_from(*self.option.long_polling_timeout()).unwrap(),
+                Duration::try_from(*self.long_polling_timeout.read()).unwrap(),
             ),
             attempt_id: None,
         };
@@ -596,11 +753,12 @@ impl Client {
         &self,
         ack_entry: &T,
     ) -> Result<AckMessageResultEntry, ClientError> {
+        let rpc_client = self
+            .get_session_with_endpoints(ack_entry.endpoints())
+            .await?;
         let result = self
             .ack_message_inner(
-                self.get_session_with_endpoints(ack_entry.endpoints())
-                    .await
-                    .unwrap(),
+                rpc_client,
                 ack_entry.topic(),
                 vec![pb::AckMessageEntry {
                     message_id: ack_entry.message_id(),
@@ -639,11 +797,12 @@ impl Client {
         ack_entry: &T,
         invisible_duration: Duration,
     ) -> Result<String, ClientError> {
+        let rpc_client = self
+            .get_session_with_endpoints(ack_entry.endpoints())
+            .await?;
         let result = self
             .change_invisible_duration_inner(
-                self.get_session_with_endpoints(ack_entry.endpoints())
-                    .await
-                    .unwrap(),
+                rpc_client,
                 ack_entry.topic(),
                 ack_entry.receipt_handle(),
                 invisible_duration,
@@ -867,19 +1026,50 @@ pub(crate) struct SessionManager {
     client_id: String,
     option: ClientOption,
     session_map: tokio::sync::Mutex<HashMap<String, Session>>,
+    /// Long-polling timeout shared with every session, see 
[`SharedLongPollingTimeout`].
+    long_polling_timeout: SharedLongPollingTimeout,
+    /// Sender of the channel stream-end notifications are sent through; 
handed to every
+    /// session when it is created.
+    stream_closed_tx: Mutex<Option<mpsc::Sender<String>>>,
+    /// Sender of the client's telemetry command channel, captured on start so 
that
+    /// `reconnect_session` can rebuild a missing session from scratch.
+    telemetry_command_tx: 
Mutex<Option<mpsc::Sender<pb::telemetry_command::Command>>>,
 }
 
 #[automock]
 impl SessionManager {
-    pub(crate) fn new(client_id: String, option: &ClientOption) -> Self {
+    pub(crate) fn new(
+        client_id: String,
+        option: &ClientOption,
+        long_polling_timeout: SharedLongPollingTimeout,
+    ) -> Self {
         let session_map = tokio::sync::Mutex::new(HashMap::new());
         SessionManager {
             client_id,
             option: option.clone(),
             session_map,
+            long_polling_timeout,
+            stream_closed_tx: Mutex::new(None),
+            telemetry_command_tx: Mutex::new(None),
         }
     }
 
+    /// Register the channel telemetry stream-end notifications are sent 
through.
+    ///
+    /// Must be called before the first session is created.
+    pub(crate) fn set_stream_closed_channel(&self, tx: mpsc::Sender<String>) {
+        *self.stream_closed_tx.lock() = Some(tx);
+    }
+
+    /// Capture the client's telemetry command channel so that 
`reconnect_session` can
+    /// rebuild a missing session from scratch. Called by `Client::start`.
+    pub(crate) fn set_telemetry_command_channel(
+        &self,
+        tx: mpsc::Sender<pb::telemetry_command::Command>,
+    ) {
+        *self.telemetry_command_tx.lock() = Some(tx);
+    }
+
     pub(crate) async fn get_or_create_session(
         &self,
         endpoints: &Endpoints,
@@ -891,7 +1081,16 @@ impl SessionManager {
         if session_map.contains_key(&endpoint_url) {
             Ok(session_map.get(&endpoint_url).unwrap().shadow_session())
         } else {
-            let mut session = Session::new(endpoints, self.client_id.clone(), 
&self.option).await?;
+            let mut session = Session::new(
+                endpoints,
+                self.client_id.clone(),
+                &self.option,
+                Arc::clone(&self.long_polling_timeout),
+            )
+            .await?;
+            if let Some(tx) = self.stream_closed_tx.lock().clone() {
+                session.set_stream_closed_tx(tx);
+            }
             session.start(settings, telemetry_command_tx).await?;
             let shadow_session = session.shadow_session();
             session_map.insert(endpoint_url.clone(), session);
@@ -901,15 +1100,168 @@ impl SessionManager {
     pub(crate) async fn get_all_sessions(&self) -> Result<Vec<Session>, 
ClientError> {
         let session_map = self.session_map.lock().await;
         let mut sessions = Vec::new();
-        for (_, session) in session_map.iter() {
+        for session in session_map.values() {
             sessions.push(session.shadow_session());
         }
         Ok(sessions)
     }
 
+    /// Drop the session bound to `endpoint_url`, if it is still the one 
registered under
+    /// that key.
+    ///
+    /// A session removed here is torn down and a later call to
+    /// [`Self::get_or_create_session`] rebuilds it from scratch, which is the 
only way to
+    /// recover from a server that closed the connection for good.
+    ///
+    /// Mirrors `ClientImpl#removeClientSession` of the Java client.
+    pub(crate) async fn evict_session(&self, endpoint_url: &str) -> bool {
+        let mut session_map = self.session_map.lock().await;
+        match session_map.remove(endpoint_url) {
+            Some(mut session) => {
+                session.shutdown();
+                info!("evicted session for endpoint {}", endpoint_url);
+                true
+            }
+            None => false,
+        }
+    }
+
+    /// Re-establish the telemetry stream of the session bound to 
`endpoint_url`.
+    ///
+    /// Rebuilding the stream also re-registers the client with the server 
instance that
+    /// answers, which is what recovers from a failover or rolling deployment 
where the
+    /// server no longer knows this client (`cannot find client`, `settings is 
null`).
+    ///
+    /// The session is taken out of the map for the duration of the RPC so 
that other
+    /// callers are not blocked on the network round trip. On failure it is 
put back so
+    /// that the reconnect retry loop keeps driving recovery on the same 
session even when
+    /// the client is idle.
+    ///
+    /// Returns `Ok(true)` when the stream of `endpoint_url` was 
re-established. A session
+    /// that is missing from the map (evicted or reaped meanwhile) is rebuilt 
against that
+    /// same endpoint, so an idle client keeps a driver for recovery even when 
no traffic
+    /// refers to the endpoint anymore. `Ok(false)` means nothing could be 
driven (no
+    /// telemetry command channel captured yet).
+    pub(crate) async fn reconnect_session(
+        &self,
+        endpoint_url: &str,
+        settings: &TelemetryCommand,
+    ) -> Result<bool, ClientError> {
+        // Take the session out of the map and release the lock before any 
await: the
+        // rebuild path below calls get_or_create_session, which locks the 
same map again
+        // (tokio::sync::Mutex is not reentrant).
+        let session_opt = {
+            let mut session_map = self.session_map.lock().await;
+            session_map.remove(endpoint_url)
+        };
+        let Some(mut session) = session_opt else {
+            // The session was evicted or reaped meanwhile. Rebuild it against 
the very
+            // endpoint that was asked for: rebuilding the access session 
instead would
+            // leave this peer without a stream while still reporting 
`Ok(true)`, which
+            // makes the reconnect retry loop log "re-established" and stop. 
Every key of
+            // `session_map` comes from `Endpoints::endpoint_url`, so it 
round-trips back
+            // into a valid endpoint here.
+            let telemetry_command_tx = 
self.telemetry_command_tx.lock().clone();
+            let Some(telemetry_command_tx) = telemetry_command_tx else {
+                return Ok(false);
+            };
+            let endpoints = Endpoints::from_url(endpoint_url)?;
+            return self
+                .get_or_create_session(&endpoints, settings.clone(), 
telemetry_command_tx)
+                .await
+                .map(|_| true);
+        };
+        match session.reconnect(settings.clone()).await {
+            Ok(()) => {
+                self.session_map
+                    .lock()
+                    .await
+                    .insert(endpoint_url.to_string(), session);
+                Ok(true)
+            }
+            Err(e) => {
+                // Keep the session registered so that the reconnect retry 
loop keeps
+                // retrying on it; dropping it here would leave an idle client 
without any
+                // driver to re-establish the stream until the next traffic 
arrives.
+                self.session_map
+                    .lock()
+                    .await
+                    .insert(endpoint_url.to_string(), session);
+                Err(e)
+            }
+        }
+    }
+
+    /// Push the client settings to every session that currently holds a 
telemetry stream.
+    ///
+    /// This deliberately operates on the *registered* sessions rather than on 
throw-away
+    /// shadow copies: a shadow that rebuilds its telemetry stream would drop 
that stream
+    /// again as soon as it goes out of scope, which used to leave the 
registered session
+    /// permanently without a stream (one short-lived stream per sync 
interval, and a dead
+    /// endpoint that fails on every cycle). Mirrors `ClientImpl#syncSettings` 
of the Java
+    /// client, where the same `ClientSession` instance is reused.
+    ///
+    /// Sessions whose stream cannot be restored are evicted so that 
`session_map` shrinks
+    /// instead of accumulating dead endpoints.
+    pub(crate) async fn sync_settings(&self, settings: &TelemetryCommand) {
+        let mut session_map = self.session_map.lock().await;
+        let mut broken: Vec<String> = Vec::new();
+        for (endpoint_url, session) in session_map.iter_mut() {
+            match session.update_settings(settings.clone()).await {
+                Ok(()) => {
+                    debug!("sync settings success, peer = {}", endpoint_url);
+                }
+                Err(e) => {
+                    error!(
+                        "sync settings failed, peer = {}, error = {}",
+                        endpoint_url, e
+                    );
+                    broken.push(endpoint_url.clone());
+                }
+            }
+        }
+        for endpoint_url in broken {
+            if let Some(mut session) = session_map.remove(&endpoint_url) {
+                session.shutdown();
+                warn!(
+                    "evicted session after settings sync failure, peer = {}",
+                    endpoint_url
+                );
+            }
+        }
+    }
+
+    /// Forget the sessions that are no longer usable.
+    ///
+    /// Two kinds of session are dropped, both of which used to accumulate 
forever because
+    /// `session_map` only ever grew:
+    ///
+    /// * sessions whose telemetry stream is gone, i.e. the long-lived 
connection was lost
+    ///   (for example after a rolling deployment of the server);
+    /// * sessions that have been idle for longer than 
[`SESSION_MAX_IDLE_DURATION`],
+    ///   mirroring `ClientManagerImpl#clearIdleRpcClients` of the Java client.
+    ///
+    /// Every dropped session is torn down so that the next access rebuilds it.
+    pub(crate) async fn clear_dead_and_idle_sessions(&self) {
+        let mut session_map = self.session_map.lock().await;
+        let stale: Vec<String> = session_map
+            .iter()
+            .filter(|(_, session)| {
+                !session.is_healthy() || session.idle_duration() > 
SESSION_MAX_IDLE_DURATION
+            })
+            .map(|(url, _)| url.clone())
+            .collect();
+        for url in stale {
+            if let Some(mut session) = session_map.remove(&url) {
+                session.shutdown();
+                info!("evicted stale session for endpoint {}", url);
+            }
+        }
+    }
+
     pub(crate) async fn shutdown(&self) {
         let mut session_map = self.session_map.lock().await;
-        for (_, session) in session_map.iter_mut() {
+        for session in session_map.values_mut() {
             session.shutdown();
         }
         session_map.clear();
@@ -957,9 +1309,21 @@ pub(crate) mod tests {
                 group: Some("group".to_string()),
                 ..Default::default()
             },
+            Arc::new(RwLock::new(std::time::Duration::from_secs(40))),
         )
     }
 
+    fn mock_session(started: bool) -> Session {
+        let mut session = MockSession::default();
+        session.expect_is_started().return_const(started);
+        session.expect_is_healthy().return_const(started);
+        session
+            .expect_idle_duration()
+            .return_const(std::time::Duration::ZERO);
+        session.expect_shutdown().returning(|| ());
+        session
+    }
+
     fn new_client_for_test() -> Client {
         Client {
             option: ClientOption {
@@ -973,6 +1337,7 @@ pub(crate) mod tests {
             settings: TelemetryCommand::default(),
             telemetry_command_tx: None,
             shutdown_tx: None,
+            long_polling_timeout: 
Arc::new(RwLock::new(std::time::Duration::from_secs(40))),
         }
     }
 
@@ -987,6 +1352,7 @@ pub(crate) mod tests {
             settings: TelemetryCommand::default(),
             telemetry_command_tx: Some(tx),
             shutdown_tx: None,
+            long_polling_timeout: 
Arc::new(RwLock::new(std::time::Duration::from_secs(40))),
         }
     }
 
@@ -1013,9 +1379,10 @@ pub(crate) mod tests {
     async fn client_get_session() {
         let _m = MTX.lock();
         let context = MockSession::new_context();
-        context.expect().returning(|_, _, _| {
+        context.expect().returning(|_, _, _, _| {
             let mut session = MockSession::default();
             session.expect_start().returning(|_, _| Ok(()));
+            session.expect_set_stream_closed_tx().returning(|_| ());
             session
                 .expect_shadow_session()
                 .returning(|| MockSession::default());
@@ -1466,9 +1833,10 @@ pub(crate) mod tests {
         let _m = MTX.lock();
 
         let context = MockSession::new_context();
-        context.expect().returning(|_, _, _| {
+        context.expect().returning(|_, _, _, _| {
             let mut session = MockSession::default();
             session.expect_start().returning(|_, _| Ok(()));
+            session.expect_set_stream_closed_tx().returning(|_| ());
             session
                 .expect_shadow_session()
                 .returning(|| MockSession::default());
@@ -1502,9 +1870,10 @@ pub(crate) mod tests {
         let _m = MTX.lock();
 
         let context = MockSession::new_context();
-        context.expect().returning(|_, _, _| {
+        context.expect().returning(|_, _, _, _| {
             let mut session = MockSession::default();
             session.expect_start().returning(|_, _| Ok(()));
+            session.expect_set_stream_closed_tx().returning(|_| ());
             session
                 .expect_shadow_session()
                 .returning(|| MockSession::default());
@@ -1548,4 +1917,233 @@ pub(crate) mod tests {
         let cloned_client = original_client.clone_for_lite_consumer();
         assert!(cloned_client.is_lite_consumer());
     }
+
+    #[tokio::test]
+    async fn test_evict_session() {
+        let session_manager = new_session_manager();
+        session_manager
+            .session_map
+            .lock()
+            .await
+            .insert("endpoint_a".to_string(), mock_session(true));
+
+        assert!(session_manager.evict_session("endpoint_a").await);
+        assert!(session_manager.session_map.lock().await.is_empty());
+
+        // Evicting an unknown endpoint is a no-op.
+        assert!(!session_manager.evict_session("endpoint_a").await);
+    }
+
+    #[tokio::test]
+    async fn test_clear_dead_and_idle_sessions() {
+        let session_manager = new_session_manager();
+        {
+            let mut session_map = session_manager.session_map.lock().await;
+            // Healthy session: kept.
+            session_map.insert("healthy".to_string(), mock_session(true));
+            // Lost its telemetry stream: dropped.
+            session_map.insert("dead".to_string(), mock_session(false));
+        }
+
+        session_manager.clear_dead_and_idle_sessions().await;
+
+        let session_map = session_manager.session_map.lock().await;
+        assert_eq!(session_map.len(), 1);
+        assert!(session_map.contains_key("healthy"));
+    }
+
+    #[tokio::test]
+    async fn test_clear_idle_sessions() {
+        let session_manager = new_session_manager();
+        {
+            let mut session_map = session_manager.session_map.lock().await;
+            let mut session = MockSession::default();
+            session.expect_is_started().return_const(true);
+            session.expect_is_healthy().return_const(true);
+            session
+                .expect_idle_duration()
+                .return_const(SESSION_MAX_IDLE_DURATION + 
std::time::Duration::from_secs(1));
+            session.expect_shutdown().returning(|| ());
+            session_map.insert("idle".to_string(), session);
+        }
+
+        session_manager.clear_dead_and_idle_sessions().await;
+
+        assert!(session_manager.session_map.lock().await.is_empty());
+    }
+
+    #[tokio::test]
+    async fn test_sync_settings_rebuilds_stream_on_registered_session() {
+        let session_manager = new_session_manager();
+        let rebuilt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+        {
+            let mut session_map = session_manager.session_map.lock().await;
+            let mut session = MockSession::default();
+            // The stream is gone, so the settings sync must re-establish it...
+            session.expect_is_started().return_const(false);
+            session.expect_is_healthy().return_const(false);
+            let counter = Arc::clone(&rebuilt);
+            session.expect_update_settings().returning(move |_| {
+                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+                Ok(())
+            });
+            session.expect_shutdown().returning(|| ());
+            session_map.insert("endpoint_a".to_string(), session);
+        }
+
+        session_manager
+            .sync_settings(&TelemetryCommand::default())
+            .await;
+
+        // ... and it must have been rebuilt exactly once, on the registered 
session rather
+        // than on a throw-away shadow copy.
+        assert_eq!(
+            rebuilt.load(std::sync::atomic::Ordering::SeqCst),
+            1,
+            "settings sync should rebuild the stream of the registered session 
once"
+        );
+        // A session whose stream was restored successfully stays registered.
+        assert_eq!(session_manager.session_map.lock().await.len(), 1);
+    }
+
+    #[tokio::test]
+    async fn test_sync_settings_evicts_unrecoverable_session() {
+        let session_manager = new_session_manager();
+        {
+            let mut session_map = session_manager.session_map.lock().await;
+            let mut session = MockSession::default();
+            session.expect_is_started().return_const(false);
+            session.expect_is_healthy().return_const(false);
+            session.expect_update_settings().returning(|_| {
+                Err(ClientError::new(
+                    ErrorKind::ServerUnavailable,
+                    "server unavailable",
+                    "test.sync_settings",
+                ))
+            });
+            session.expect_shutdown().returning(|| ());
+            session_map.insert("endpoint_a".to_string(), session);
+        }
+
+        session_manager
+            .sync_settings(&TelemetryCommand::default())
+            .await;
+
+        // A dead endpoint must not be kept around: it would fail again on 
every cycle.
+        assert!(session_manager.session_map.lock().await.is_empty());
+    }
+
+    #[tokio::test]
+    async fn test_reconnect_session_rebuilds_stream_on_registered_session() {
+        let session_manager = new_session_manager();
+        let reconnected = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+        {
+            let mut session_map = session_manager.session_map.lock().await;
+            let mut session = MockSession::default();
+            let counter = Arc::clone(&reconnected);
+            session.expect_reconnect().returning(move |_| {
+                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+                Ok(())
+            });
+            session_map.insert("endpoint_a".to_string(), session);
+        }
+
+        session_manager
+            .reconnect_session("endpoint_a", &TelemetryCommand::default())
+            .await
+            .unwrap();
+
+        // The stream was rebuilt on the registered session, which stayed in 
the map.
+        assert_eq!(
+            reconnected.load(std::sync::atomic::Ordering::SeqCst),
+            1,
+            "reconnect should re-establish the stream of the registered 
session"
+        );
+        assert_eq!(session_manager.session_map.lock().await.len(), 1);
+        assert!(
+            session_manager
+                .reconnect_session("endpoint_a", &TelemetryCommand::default())
+                .await
+                .unwrap(),
+            "a re-established stream must be reported as restored"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_reconnect_session_keeps_session_that_cannot_be_restored() {
+        let session_manager = new_session_manager();
+        {
+            let mut session_map = session_manager.session_map.lock().await;
+            let mut session = MockSession::default();
+            session.expect_reconnect().returning(|_| {
+                Err(ClientError::new(
+                    ErrorKind::ServerUnavailable,
+                    "server unavailable",
+                    "test.reconnect_session",
+                ))
+            });
+            session_map.insert("endpoint_a".to_string(), session);
+        }
+
+        assert!(session_manager
+            .reconnect_session("endpoint_a", &TelemetryCommand::default())
+            .await
+            .is_err());
+
+        // The session must stay registered: the reconnect retry loop keeps 
driving
+        // recovery on it, which is the only driver an idle client has while 
the server is
+        // unavailable.
+        assert_eq!(session_manager.session_map.lock().await.len(), 1);
+    }
+
+    #[tokio::test]
+    async fn test_reconnect_session_rebuilds_missing_session() {
+        let _m = MTX.lock();
+
+        let session_manager = new_session_manager();
+        let (tx, _rx) = mpsc::channel(16);
+        session_manager.set_telemetry_command_channel(tx);
+        let created: Arc<parking_lot::Mutex<Vec<String>>> =
+            Arc::new(parking_lot::Mutex::new(Vec::new()));
+        let context = MockSession::new_context();
+        let recorded = Arc::clone(&created);
+        context.expect().returning(move |endpoints, _, _, _| {
+            recorded.lock().push(endpoints.endpoint_url().to_string());
+            let mut session = MockSession::default();
+            session.expect_start().returning(|_, _| Ok(()));
+            session.expect_set_stream_closed_tx().returning(|_| ());
+            session
+                .expect_shadow_session()
+                .returning(|| MockSession::default());
+            Ok(session)
+        });
+
+        // A session that was evicted or reaped meanwhile must be rebuilt 
against the peer
+        // that is being reconnected, not against the access endpoint. 
Rebuilding the access
+        // session used to report Ok(true) for a peer that had no stream left, 
so the
+        // reconnect retry loop stopped as if it had restored the connection. 
The peer here
+        // deliberately differs from the default access_url (localhost:8081).
+        let peer = "127.0.0.1:9999";
+        let restored = session_manager
+            .reconnect_session(peer, &TelemetryCommand::default())
+            .await
+            .unwrap();
+        assert!(
+            restored,
+            "the rebuilt peer stream must be reported as restored"
+        );
+        assert_eq!(created.lock().as_slice(), &[peer.to_string()]);
+        let keys: Vec<String> = session_manager
+            .session_map
+            .lock()
+            .await
+            .keys()
+            .cloned()
+            .collect();
+        assert_eq!(
+            keys,
+            vec![peer.to_string()],
+            "the session must be registered under the reconnected peer"
+        );
+    }
 }
diff --git a/rust/src/conf.rs b/rust/src/conf.rs
index 697f41fc..78463575 100644
--- a/rust/src/conf.rs
+++ b/rust/src/conf.rs
@@ -312,6 +312,14 @@ impl PushConsumerOption {
         &self.long_polling_timeout
     }
 
+    /// Set the await duration during long polling.
+    ///
+    /// Also used to apply the value pushed by the server, so that a client 
configured with
+    /// a short `long_polling_timeout` follows the server after the first 
settings sync.
+    pub(crate) fn set_long_polling_timeout(&mut self, long_polling_timeout: 
Duration) {
+        self.long_polling_timeout = long_polling_timeout;
+    }
+
     pub fn subscription_expressions(&self) -> &HashMap<String, 
FilterExpression> {
         &self.subscription_expressions
     }
@@ -355,6 +363,13 @@ impl PushConsumerOption {
         self.batch_size
     }
 
+    /// Set the reception batch size.
+    ///
+    /// Also used to apply the value pushed by the server.
+    pub(crate) fn set_batch_size(&mut self, batch_size: i32) {
+        self.batch_size = batch_size;
+    }
+
     pub fn consumer_worker_count_each_queue(&self) -> usize {
         self.consumer_worker_count_each_queue
     }
diff --git a/rust/src/push_consumer.rs b/rust/src/push_consumer.rs
index d16c8d88..33ed6ee9 100644
--- a/rust/src/push_consumer.rs
+++ b/rust/src/push_consumer.rs
@@ -209,6 +209,12 @@ impl PushConsumer {
                 select! {
                     command = telemetry_command_rx.recv() => {
                         if let Some(command) = command {
+                            if let 
pb::telemetry_command::Command::Settings(ref settings) = command {
+                                // Apply what the server pushed, otherwise 
these settings are
+                                // silently dropped and the client keeps 
polling with the
+                                // configured values.
+                                Self::apply_settings(settings, &option);
+                            }
                             let remote_backoff_policy = 
BackOffRetryPolicy::try_from(command);
                             if let Ok(remote_backoff_policy) = 
remote_backoff_policy {
                                 
retry_policy.lock().replace(remote_backoff_policy);
@@ -322,6 +328,9 @@ impl PushConsumer {
                 select! {
                     command = command_rx.recv() => {
                         if let Some(command) = command {
+                            if let 
pb::telemetry_command::Command::Settings(ref settings) = command {
+                                Self::apply_settings(settings, &option);
+                            }
                             let remote_backoff_policy = 
BackOffRetryPolicy::try_from(command);
                             if let Ok(remote_backoff_policy) = 
remote_backoff_policy {
                                 
retry_policy.lock().replace(remote_backoff_policy);
@@ -397,6 +406,36 @@ impl PushConsumer {
         Ok(())
     }
 
+    /// Apply the settings pushed by the server.
+    ///
+    /// Only subscription settings are applied; the back-off policy is handled 
separately by
+    /// [`BackOffRetryPolicy::try_from`]. Mirrors 
`PushSubscriptionSettings#sync` of the Java
+    /// client, where the same values are also taken from the server-pushed 
settings.
+    fn apply_settings(settings: &pb::Settings, option: 
&Arc<RwLock<PushConsumerOption>>) {
+        let Some(pb::settings::PubSub::Subscription(ref subscription)) = 
settings.pub_sub else {
+            warn!(
+                "received settings that do not match the client type, 
pub_sub={:?}",
+                settings.pub_sub
+            );
+            return;
+        };
+
+        let mut option = option.write();
+        if subscription.receive_batch_size.unwrap_or_default() > 0 {
+            option.set_batch_size(subscription.receive_batch_size.unwrap());
+        }
+        if let Some(ref long_polling_timeout) = 
subscription.long_polling_timeout {
+            let seconds = long_polling_timeout.seconds;
+            let nanos = long_polling_timeout.nanos;
+            if seconds > 0 || nanos > 0 {
+                option.set_long_polling_timeout(Duration::new(seconds as u64, 
nanos as u32));
+            }
+        }
+        if let Some(fifo) = subscription.fifo {
+            option.set_fifo(fifo);
+        }
+    }
+
     async fn process_assignments(
         rpc_client: &Session,
         option: &PushConsumerOption,
diff --git a/rust/src/session.rs b/rust/src/session.rs
index 55e9d4a1..73163859 100644
--- a/rust/src/session.rs
+++ b/rust/src/session.rs
@@ -15,6 +15,9 @@
  * limitations under the License.
  */
 
+use std::sync::Arc;
+use std::time::Instant;
+
 use async_trait::async_trait;
 use mockall::{automock, mock};
 use ring::hmac;
@@ -47,6 +50,14 @@ use tracing::{debug, error, info};
 const OPERATION_START: &str = "session.start";
 const OPERATION_UPDATE_SETTINGS: &str = "session.update_settings";
 
+/// The long-polling timeout, shared between the client layer and every 
session.
+///
+/// The server can push a new value at any time through the telemetry stream, 
so it has to
+/// live behind a lock instead of being baked into the immutable 
[`ClientOption`] clone each
+/// session keeps. Both the gRPC deadline of a receive request and the request 
body itself
+/// read from here.
+pub(crate) type SharedLongPollingTimeout = 
Arc<parking_lot::RwLock<std::time::Duration>>;
+
 const OPERATION_QUERY_ROUTE: &str = "rpc.query_route";
 const OPERATION_HEARTBEAT: &str = "rpc.heartbeat";
 const OPERATION_SEND_MESSAGE: &str = "rpc.send_message";
@@ -59,6 +70,20 @@ const OPERATION_NOTIFY_CLIENT_TERMINATION: &str = 
"rpc.notify_client_termination
 const OPERATION_QUERY_ASSIGNMENT: &str = "rpc.query_assignment";
 const OPERATION_FORWARD_TO_DEADLETTER_QUEUE: &str = 
"rpc.forward_to_deadletter_queue";
 
+/// Interval between HTTP/2 keep-alive pings.
+///
+/// The RocketMQ proxy allows client pings every 30 seconds by default
+/// (`ProxyConfig#grpcServerPermitKeepAliveTimeMillis = 30000`; the bare 
grpc-java default is
+/// 5 minutes, but the proxy overrides it), so the interval must stay above 
that or the
+/// server answers with `GOAWAY(ENHANCE_YOUR_CALM)`. 60s doubles the permitted 
minimum,
+/// which detects a dead peer within ~90s instead of the ~330s a 300s interval 
would take.
+const KEEP_ALIVE_INTERVAL: std::time::Duration = 
std::time::Duration::from_secs(60);
+
+/// How long to wait for the keep-alive ping acknowledgement before 
considering the
+/// connection dead and reconnecting.
+/// Kept in line with the Java client 
(`RpcClientImpl.KEEP_ALIVE_TIMEOUT_MILLIS`).
+const KEEP_ALIVE_TIMEOUT: std::time::Duration = 
std::time::Duration::from_secs(30);
+
 #[async_trait]
 #[automock]
 pub(crate) trait RPCClient {
@@ -121,6 +146,26 @@ pub(crate) struct Session {
     telemetry_tx: Option<mpsc::Sender<TelemetryCommand>>,
     telemetry_command_tx: Option<mpsc::Sender<Command>>,
     shutdown_tx: Option<oneshot::Sender<()>>,
+    /// Milliseconds elapsed since [`Instant`] `SESSION_ACTIVITY_ORIGIN` at 
the time of the
+    /// last request on this session, shared with every shadow session so that 
activity is
+    /// recorded on the registered session as well.
+    last_activity_millis: Arc<std::sync::atomic::AtomicU64>,
+    /// Long-polling timeout shared with the client layer, see
+    /// [`SharedLongPollingTimeout`].
+    long_polling_timeout: SharedLongPollingTimeout,
+    /// Notifies the client layer when the telemetry stream ends, so that it 
can schedule a
+    /// proactive reconnect instead of waiting for the next periodic settings 
sync. Mirrors
+    /// `ClientSessionImpl#renewRequestObserver` of the Java client, which 
renews the stream
+    /// as soon as it is observed dead.
+    stream_closed_tx: Option<mpsc::Sender<String>>,
+}
+
+/// Origin the session activity timestamps are measured from.
+static SESSION_ACTIVITY_ORIGIN: once_cell::sync::Lazy<Instant> =
+    once_cell::sync::Lazy::new(Instant::now);
+
+fn now_millis() -> u64 {
+    SESSION_ACTIVITY_ORIGIN.elapsed().as_millis() as u64
 }
 
 impl Session {
@@ -138,6 +183,9 @@ impl Session {
             telemetry_tx: self.telemetry_tx.clone(),
             telemetry_command_tx: self.telemetry_command_tx.clone(),
             shutdown_tx: None,
+            last_activity_millis: Arc::clone(&self.last_activity_millis),
+            long_polling_timeout: Arc::clone(&self.long_polling_timeout),
+            stream_closed_tx: self.stream_closed_tx.clone(),
         }
     }
 
@@ -145,6 +193,7 @@ impl Session {
         endpoints: &Endpoints,
         client_id: String,
         option: &ClientOption,
+        long_polling_timeout: SharedLongPollingTimeout,
     ) -> Result<Self, ClientError> {
         let peer = endpoints.endpoint_url().to_owned();
 
@@ -188,6 +237,9 @@ impl Session {
             telemetry_tx: None,
             telemetry_command_tx: None,
             shutdown_tx: None,
+            last_activity_millis: 
Arc::new(std::sync::atomic::AtomicU64::new(now_millis())),
+            long_polling_timeout,
+            stream_closed_tx: None,
         })
     }
 
@@ -223,7 +275,21 @@ impl Session {
             //     .with_context("peer", &peer_addr)
             // })?
             .connect_timeout(option.timeout)
-            .tcp_nodelay(true);
+            .tcp_nodelay(true)
+            // Keep the long-lived HTTP/2 connection alive so that a rolling 
deployment of the
+            // server does not silently break the telemetry stream, and so 
that a dead peer is
+            // detected within `KEEP_ALIVE_TIMEOUT` instead of leaving a 
half-open connection.
+            //
+            // NOTE: `KEEP_ALIVE_INTERVAL` must not fall below the minimum 
ping interval the
+            // server permits. The RocketMQ proxy defaults to 30s
+            // (`ProxyConfig#grpcServerPermitKeepAliveTimeMillis`), so 60s 
keeps a safety
+            // margin; lowering it further makes the server answer with
+            // GOAWAY(ENHANCE_YOUR_CALM) and drop the connection.
+            .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL)
+            .keep_alive_timeout(KEEP_ALIVE_TIMEOUT)
+            // Ping even while no RPC is in flight: the telemetry stream is 
long-lived but is
+            // idle between settings syncs, so without this the connection is 
never probed.
+            .keep_alive_while_idle(true);
         Ok(endpoint)
     }
 
@@ -232,6 +298,8 @@ impl Session {
     }
 
     fn sign<T>(&self, message: T) -> tonic::Request<T> {
+        // Any request proves the session is in use, even when it goes through 
a shadow copy.
+        self.touch();
         let mut request = tonic::Request::new(message);
         let metadata = request.metadata_mut();
         self.sign_without_timeout(metadata);
@@ -340,6 +408,8 @@ impl Session {
         self.telemetry_tx = Some(tx);
 
         let telemetry_command_tx = 
self.telemetry_command_tx.as_ref().unwrap().clone();
+        let stream_closed_tx = self.stream_closed_tx.clone();
+        let peer = self.peer().to_string();
         tokio::spawn(async move {
             let mut stream = response.into_inner();
             loop {
@@ -354,10 +424,19 @@ impl Session {
                             }
                             Ok(None) => {
                                 info!("telemetry command stream closed by 
server");
+                                if let Some(tx) = stream_closed_tx {
+                                    // Tell the client layer so that it 
re-establishes the
+                                    // stream right away instead of waiting 
for the next
+                                    // periodic settings sync.
+                                    let _ = tx.send(peer.clone()).await;
+                                }
                                 break;
                             }
                             Err(e) => {
                                 error!("telemetry response error: {:?}", e);
+                                if let Some(tx) = stream_closed_tx {
+                                    let _ = tx.send(peer.clone()).await;
+                                }
                                 break;
                             }
                         }
@@ -381,10 +460,72 @@ impl Session {
         }
     }
 
+    /// Register the channel the stream-end notification is sent through.
+    ///
+    /// Must be called before [`Self::start`], because the telemetry stream 
task captures
+    /// the sender when it is spawned.
+    pub(crate) fn set_stream_closed_tx(&mut self, tx: mpsc::Sender<String>) {
+        self.stream_closed_tx = Some(tx);
+    }
+
+    /// Unconditionally re-establish the telemetry stream on this session.
+    ///
+    /// Unlike [`Self::update_settings`], which only rebuilds the stream when 
it observes a
+    /// closed sender, this is called by the client layer when the server 
closed the stream
+    /// (or when a heartbeat error reveals the server no longer knows this 
client):
+    /// re-establishing the stream also re-registers the client with the 
(possibly new)
+    /// server instance.
+    pub(crate) async fn reconnect(
+        &mut self,
+        settings: TelemetryCommand,
+    ) -> Result<(), ClientError> {
+        info!("re-establishing telemetry stream, peer={}", self.peer());
+        self.establish_telemetry_stream(settings).await
+    }
+
     pub(crate) fn is_started(&self) -> bool {
         self.telemetry_tx.is_some() && 
!self.telemetry_tx.as_ref().unwrap().is_closed()
     }
 
+    /// Whether the session still owns a live telemetry stream.
+    ///
+    /// A session whose telemetry stream is gone can no longer receive 
server-push
+    /// commands (settings, reconnect, unsubscribe, ...), so it must not be 
kept in the
+    /// session map: keeping it makes every settings sync fail on a dead 
endpoint and
+    /// blocks the long-lived connection from ever being re-established.
+    pub(crate) fn is_healthy(&self) -> bool {
+        self.is_started()
+    }
+
+    /// Record activity on this session so that it is not reaped while still 
in use.
+    pub(crate) fn touch(&self) {
+        self.last_activity_millis
+            .store(now_millis(), std::sync::atomic::Ordering::Relaxed);
+    }
+
+    /// How long this session has been idle.
+    pub(crate) fn idle_duration(&self) -> std::time::Duration {
+        let last = self
+            .last_activity_millis
+            .load(std::sync::atomic::Ordering::Relaxed);
+        std::time::Duration::from_millis(now_millis().saturating_sub(last))
+    }
+
+    /// Deadline applied to a receive request.
+    ///
+    /// The request itself carries `long_polling_timeout`, so the server may 
hold the
+    /// response for exactly that long before answering. The gRPC deadline 
therefore has to
+    /// leave room for the request tolerance on top of it, which is what
+    /// `ClientOption::set_long_polling_timeout` documents and what the Java 
client does
+    /// (`awaitDuration.plus(tolerance)`).
+    ///
+    /// Using `long_polling_timeout` alone races the server response: the 
client gives up
+    /// right as the answer arrives, so every poll fails once 
`long_polling_timeout` is short
+    /// (a few seconds or less) even though the server behaves correctly.
+    pub(crate) fn receive_deadline(&self) -> std::time::Duration {
+        *self.long_polling_timeout.read() + *self.option.timeout()
+    }
+
     pub(crate) async fn update_settings(
         &mut self,
         settings: TelemetryCommand,
@@ -496,7 +637,7 @@ impl RPCClient for Session {
     ) -> Result<Vec<ReceiveMessageResponse>, ClientError> {
         let batch_size = request.batch_size;
         let mut request = self.sign(request);
-        request.set_timeout(*self.option.long_polling_timeout());
+        request.set_timeout(self.receive_deadline());
         let mut stream = self
             .stub
             .receive_message(request)
@@ -669,9 +810,18 @@ mock! {
             endpoints: &Endpoints,
             client_id: String,
             option: &ClientOption,
+            long_polling_timeout: SharedLongPollingTimeout,
         ) -> Result<Self, ClientError>;
         pub(crate) fn peer(&self) -> &str;
         pub(crate) fn is_started(&self) -> bool;
+        pub(crate) fn is_healthy(&self) -> bool;
+        pub(crate) fn touch(&self);
+        pub(crate) fn idle_duration(&self) -> std::time::Duration;
+        pub(crate) fn set_stream_closed_tx(&mut self, tx: 
mpsc::Sender<String>);
+        pub(crate) async fn reconnect(
+            &mut self,
+            settings: TelemetryCommand,
+        ) -> Result<(), ClientError>;
         pub(crate) async fn update_settings(
             &mut self,
             settings: TelemetryCommand,
@@ -753,6 +903,7 @@ mod tests {
             &Endpoints::from_url(&format!("localhost:{}", 
server.address().port())).unwrap(),
             "test_client".to_string(),
             &ClientOption::default(),
+            
Arc::new(parking_lot::RwLock::new(std::time::Duration::from_secs(40))),
         )
         .await;
         debug!("session: {:?}", session);
@@ -765,6 +916,7 @@ mod tests {
             &Endpoints::from_url("127.0.0.1:8080,127.0.0.1:8081").unwrap(),
             "test_client".to_string(),
             &ClientOption::default(),
+            
Arc::new(parking_lot::RwLock::new(std::time::Duration::from_secs(40))),
         )
         .await;
         debug!("session: {:?}", session);
@@ -775,4 +927,45 @@ mod tests {
     async fn session_start() {
         // TODO: add grpc bi-stream test
     }
+
+    #[tokio::test]
+    async fn receive_deadline_leaves_room_for_the_server_response() {
+        let mut option = ClientOption::default();
+        // A short long polling timeout used to make every poll fail, because 
the gRPC
+        // deadline was exactly the time the server is allowed to hold the 
response.
+        option.long_polling_timeout = std::time::Duration::from_secs(3);
+        option.timeout = std::time::Duration::from_secs(3);
+
+        let server = RocketMQMockServer::start_default().await;
+        // The deadline is driven by the shared handle, which the server can 
update through
+        // the telemetry stream at any time.
+        let long_polling_timeout =
+            
Arc::new(parking_lot::RwLock::new(std::time::Duration::from_secs(3)));
+        let session = Session::new(
+            &Endpoints::from_url(&format!("localhost:{}", 
server.address().port())).unwrap(),
+            "test_client".to_string(),
+            &option,
+            Arc::clone(&long_polling_timeout),
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(
+            session.receive_deadline(),
+            std::time::Duration::from_secs(6),
+            "the deadline must be long_polling_timeout + timeout, as 
documented"
+        );
+        assert!(
+            session.receive_deadline() > *option.long_polling_timeout(),
+            "the deadline must be longer than the server side long polling"
+        );
+
+        // A server-pushed update must be picked up without recreating the 
session.
+        *long_polling_timeout.write() = std::time::Duration::from_secs(10);
+        assert_eq!(
+            session.receive_deadline(),
+            std::time::Duration::from_secs(13),
+            "the deadline must follow the shared long_polling_timeout"
+        );
+    }
 }
diff --git a/rust/src/simple_consumer.rs b/rust/src/simple_consumer.rs
index c78bed81..0584b414 100644
--- a/rust/src/simple_consumer.rs
+++ b/rust/src/simple_consumer.rs
@@ -28,6 +28,8 @@ use crate::conf::{ClientOption, SimpleConsumerOption};
 use crate::error::{ClientError, ErrorKind};
 use crate::model::common::{ClientType, FilterExpression};
 use crate::model::message::{AckMessageEntry, MessageView};
+use crate::pb::telemetry_command::Command;
+use crate::session::SharedLongPollingTimeout;
 use crate::util::{
     build_endpoints_by_message_queue, build_simple_consumer_settings, 
select_message_queue,
 };
@@ -98,11 +100,21 @@ impl SimpleConsumer {
         }
         let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
         self.shutdown_tx = Some(shutdown_tx);
+        let long_polling_timeout = self.client.long_polling_timeout_handle();
         tokio::spawn(async move {
             loop {
                 select! {
                     command = telemetry_command_rx.recv() => {
-                        warn!("command {:?} cannot be handled in simple 
consumer.", command);
+                        match command {
+                            Some(Command::Settings(settings)) => {
+                                Self::apply_server_settings(&settings, 
&long_polling_timeout);
+                            }
+                            Some(command) => {
+                                warn!("command {:?} cannot be handled in 
simple consumer.", command);
+                            }
+                            // The client was dropped; there is nothing left 
to handle.
+                            None => break,
+                        }
                     }
 
                     _ = &mut shutdown_rx => {
@@ -125,6 +137,32 @@ impl SimpleConsumer {
         self.client.shutdown().await
     }
 
+    /// Apply the settings pushed by the server.
+    ///
+    /// The simple consumer only consumes the long-polling timeout for now: it 
drives both
+    /// the gRPC deadline of a receive request and the `long_polling_timeout` 
field of the
+    /// request body itself. Mirrors `SimpleSubscriptionSettings#sync` of the 
Java client.
+    fn apply_server_settings(
+        settings: &crate::pb::Settings,
+        long_polling_timeout: &SharedLongPollingTimeout,
+    ) {
+        if let Some(crate::pb::settings::PubSub::Subscription(ref 
subscription)) = settings.pub_sub
+        {
+            if let Some(ref timeout) = subscription.long_polling_timeout {
+                let seconds = timeout.seconds;
+                let nanos = timeout.nanos;
+                if seconds > 0 || nanos > 0 {
+                    let timeout = Duration::new(seconds as u64, nanos as u32);
+                    *long_polling_timeout.write() = timeout;
+                    info!(
+                        "simple consumer applied server-pushed 
long_polling_timeout: {:?}",
+                        timeout
+                    );
+                }
+            }
+        }
+    }
+
     /// receive messages from the specified topic
     ///
     /// # Arguments
@@ -232,6 +270,9 @@ mod tests {
                 }))
             });
             client.expect_start().returning(|_| Ok(()));
+            client
+                .expect_long_polling_timeout_handle()
+                .returning(|| 
Arc::new(parking_lot::RwLock::new(Duration::from_secs(40))));
             client
                 .expect_client_id()
                 .return_const("fake_id".to_string());
diff --git a/rust/tests/rolling_restart_it.rs b/rust/tests/rolling_restart_it.rs
new file mode 100644
index 00000000..8c9f6c57
--- /dev/null
+++ b/rust/tests/rolling_restart_it.rs
@@ -0,0 +1,210 @@
+/*
+ * 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.
+ */
+
+//! Live integration test: the telemetry stream must be re-established 
proactively after a
+//! rolling restart of the proxy, instead of degrading until the next periodic 
settings
+//! sync.
+//!
+//! Protocol:
+//! 1. the test starts a producer (one message per second) and a simple 
consumer;
+//! 2. an external driver touches the signal file, kills the proxy and 
restarts it; when the
+//!    new proxy listens again, the driver writes the recovery timestamp 
(seconds since the
+//!    UNIX epoch) into the proxy-up file;
+//! 3. the test measures the client-side recovery: from the proxy being up 
again to the
+//!    first successful receive. With a proactive reconnect this is bounded by 
the retry
+//!    delay (about a second); the old behaviour degraded for 42-70 seconds 
because the
+//!    client only noticed the dead stream at the next 30s settings sync and 
never
+//!    re-registered.
+
+use std::collections::HashSet;
+use std::time::Duration;
+
+use rocketmq::conf::{ClientOption, ProducerOption, SimpleConsumerOption};
+use rocketmq::model::common::{FilterExpression, FilterType};
+use rocketmq::model::message::MessageBuilder;
+use rocketmq::{Producer, SimpleConsumer};
+
+const TOPIC: &str = "rust-rolling-topic";
+const GROUP: &str = "rust-rolling-group";
+const SIGNAL_FILE: &str = "/tmp/rmq_rust_live/rolling_signal";
+const PROXY_UP_FILE: &str = "/tmp/rmq_rust_live/proxy_up";
+/// The proxy gRPC server is up and the stream must be re-established well 
within this
+/// budget. The proactive reconnect retries every second; the remaining budget 
is the time
+/// the restarted proxy itself needs to rebuild the topic routes from the 
broker heartbeats
+/// (about 20s in practice), which no client can skip.
+const CLIENT_RECOVERY_BUDGET: Duration = Duration::from_secs(30);
+const MAX_TEST_DURATION: Duration = Duration::from_secs(180);
+
+fn file_mtime(path: &str) -> Option<std::time::SystemTime> {
+    std::fs::metadata(path).and_then(|m| m.modified()).ok()
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn simple_consumer_recovers_quickly_after_rolling_restart() {
+    // This test needs a live cluster and an external driver that kills and 
restarts the
+    // proxy; opt in with ROCKETMQ_ROLLING_IT=1 (see the module docs for the 
protocol).
+    if std::env::var("ROCKETMQ_ROLLING_IT").as_deref() != Ok("1") {
+        println!("skipping: set ROCKETMQ_ROLLING_IT=1 and follow the module 
docs to run");
+        return;
+    }
+    tracing_subscriber::fmt()
+        .with_max_level(tracing::Level::INFO)
+        .init();
+    let _ = std::fs::remove_file(SIGNAL_FILE);
+    let _ = std::fs::remove_file(PROXY_UP_FILE);
+
+    let mut consumer_option = SimpleConsumerOption::default();
+    consumer_option.set_topics(vec![TOPIC]);
+    consumer_option.set_consumer_group(GROUP);
+
+    let mut consumer_client_option = ClientOption::default();
+    consumer_client_option.set_access_url("localhost:8081");
+    consumer_client_option.set_enable_tls(false);
+
+    let mut consumer = SimpleConsumer::new(consumer_option, 
consumer_client_option).unwrap();
+    consumer.start().await.unwrap();
+
+    let mut producer_option = ProducerOption::default();
+    producer_option.set_topics(vec![TOPIC]);
+    let mut producer_client_option = ClientOption::default();
+    producer_client_option.set_access_url("localhost:8081");
+    producer_client_option.set_enable_tls(false);
+    let mut producer = Producer::new(producer_option, 
producer_client_option).unwrap();
+    producer.start().await.unwrap();
+    let producer = std::sync::Arc::new(producer);
+
+    // Producer task: one message per second, retrying on failures. Send 
failures are
+    // printed only a few times: when the broker disk is full every send fails 
and the log
+    // would otherwise drown the timeline.
+    let producer_for_task = std::sync::Arc::clone(&producer);
+    let producer_handle = tokio::spawn(async move {
+        let producer = producer_for_task;
+        let start = std::time::Instant::now();
+        let mut seq = 0u64;
+        let mut send_failures = 0u64;
+        while start.elapsed() < MAX_TEST_DURATION {
+            let body = format!("rolling-{}", seq);
+            let message = MessageBuilder::builder()
+                .set_topic(TOPIC)
+                .set_body(body.clone().into_bytes())
+                .build()
+                .unwrap();
+            match producer.send(message).await {
+                Ok(_) => {
+                    seq += 1;
+                }
+                Err(e) => {
+                    send_failures += 1;
+                    if send_failures <= 3 {
+                        println!("send failed (seq={}): {:?}", body, e);
+                    }
+                }
+            }
+            tokio::time::sleep(Duration::from_secs(1)).await;
+        }
+        (seq, send_failures)
+    });
+
+    // Consumer loop: every receive outcome is recorded; the first successful 
receive after
+    // the signal marks the recovery.
+    let t0 = std::time::Instant::now();
+    let mut received: HashSet<String> = HashSet::new();
+    let mut receive_failures = 0u64;
+    let mut signal_seen = false;
+    let mut recovery: Option<Duration> = None;
+
+    while t0.elapsed() < MAX_TEST_DURATION {
+        if !signal_seen && std::path::Path::new(SIGNAL_FILE).exists() {
+            signal_seen = true;
+            println!("signal file observed at +{:?}", t0.elapsed());
+        }
+
+        match consumer
+            .receive(TOPIC, &FilterExpression::new(FilterType::Tag, "*"))
+            .await
+        {
+            Ok(messages) => {
+                for message in messages {
+                    let body = 
String::from_utf8_lossy(message.body()).to_string();
+                    if body.starts_with("rolling-") && 
received.insert(body.clone()) {
+                        println!("received {} at +{:?}", body, t0.elapsed());
+                    }
+                }
+                if signal_seen {
+                    // The client can recover faster than the driver detects 
the proxy
+                    // being up, so the marker file may not exist yet for a 
moment.
+                    let proxy_up = loop {
+                        if let Some(mtime) = file_mtime(PROXY_UP_FILE) {
+                            break mtime;
+                        }
+                        tokio::time::sleep(Duration::from_millis(100)).await;
+                    };
+                    recovery = Some(
+                        std::time::SystemTime::now()
+                            .duration_since(proxy_up)
+                            .expect("proxy-up file must be in the past"),
+                    );
+                    println!(
+                        "RECOVERED at +{:?}: first successful receive {:?} 
after the proxy came back up",
+                        t0.elapsed(),
+                        recovery.unwrap()
+                    );
+                    break;
+                }
+                // No sleep only during the warm-up phase; afterwards keep the 
loop calm.
+                if !signal_seen {
+                    tokio::time::sleep(Duration::from_millis(500)).await;
+                }
+            }
+            Err(e) => {
+                receive_failures += 1;
+                if receive_failures <= 5 || receive_failures % 50 == 0 {
+                    println!("receive failed at +{:?}: {:?}", t0.elapsed(), e);
+                }
+                tokio::time::sleep(Duration::from_millis(200)).await;
+            }
+        }
+    }
+
+    let (produced, send_failures) = producer_handle.await.unwrap();
+    if let Ok(producer) = std::sync::Arc::try_unwrap(producer) {
+        let _ = producer.shutdown().await;
+    }
+    consumer.shutdown().await.unwrap();
+
+    println!(
+        "produced {} messages ({} send failures), received {} unique messages, 
{} receive failures",
+        produced,
+        send_failures,
+        received.len(),
+        receive_failures
+    );
+
+    let recovery = recovery.unwrap_or_else(|| {
+        panic!(
+            "the consumer never recovered within {:?}",
+            MAX_TEST_DURATION
+        );
+    });
+    println!("client-side recovery time: {:?}", recovery);
+    assert!(
+        recovery < CLIENT_RECOVERY_BUDGET,
+        "the client must re-establish its telemetry stream within {:?} after 
the proxy came back, took {:?}",
+        CLIENT_RECOVERY_BUDGET,
+        recovery
+    );
+}

Reply via email to