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

CritasWang pushed a commit to branch fix/pool-timeouts-and-lifecycle
in repository https://gitbox.apache.org/repos/asf/iotdb-client-rust.git

commit 13c8e1d0e3af14646517c0ab27c53d5d36bdcf29
Author: CritasWang <[email protected]>
AuthorDate: Tue Aug 25 17:46:32 2026 +0800

    Fix socket timeouts, connection lifecycle and session pool accounting
    
    - Bound every blocking socket read/write after the TCP handshake with a
      new socket_timeout (SO_RCVTIMEO/SO_SNDTIMEO via socket2), applied
      before the TLS handshake: RPC reads, drop-time closeSession and the
      TLS handshake can no longer block forever; SO_KEEPALIVE is enabled.
      connect_timeout is now one total budget per endpoint across all
      resolved addresses.
    - Fail over at the handshake level in Session::open (connect +
      openSession + requestStatementId), mirroring reconnect().
    - Mark a connection broken on transport-level failures so is_open()
      stops lying; pools discard the session instead of handing it out
      again, and fetch_results (which cannot be retried) also marks it.
    - SessionPool: spend the acquire_timeout budget on growth/hand-out
      failures instead of failing instantly, decrement live under the state
      lock (no lost Condvar wakeups), wake waiters from close() before the
      blocking closeSession calls, and treat acquire_timeout=Duration::MAX
      as "wait without deadline" instead of panicking.
    - Skip the reconnect pacing sleeps for pooled sessions so a pool slot
      is not held for the full reconnect walk.
    - Redirect: normalize endpoint matching (case/brackets/loopback),
      prefer the newest hint when idle sessions hold conflicting hints
      (process-wide insertion seq), make cache TTL/capacity configurable,
      and expose TableSessionPool::acquire_for_device.
    - Add regression tests using silent/fake listeners for every fix above.
    
    Closes #4, closes #5, closes #6, closes #7, closes #8, closes #9, closes 
#10.
---
 Cargo.toml                  |   2 +
 README.md                   |  23 ++++
 README_ZH.md                |  19 +++
 src/client/pool.rs          | 289 ++++++++++++++++++++++++++++++++++++++------
 src/client/redirect.rs      |  26 +++-
 src/client/session.rs       | 170 ++++++++++++++++++++++----
 src/client/table_session.rs |  17 +++
 src/connection/mod.rs       | 222 +++++++++++++++++++++++++++++++---
 8 files changed, 689 insertions(+), 79 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 5b58379..c95d2d9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,8 @@ thrift = "0.23"
 byteorder = "1.5"
 chrono = "0.4"
 log = "0.4"
+# SO_RCVTIMEO/SO_SNDTIMEO + SO_KEEPALIVE on the blocking TcpStream.
+socket2 = "0.5"
 # thrift 0.23 allows any uuid 1.x; 1.21+ raises MSRV to Rust 1.85.
 uuid = "=1.20.0"
 rustls = { version = "0.23", default-features = false, features = ["ring", 
"std", "tls12"], optional = true }
diff --git a/README.md b/README.md
index 93e607b..eaaf898 100644
--- a/README.md
+++ b/README.md
@@ -172,6 +172,29 @@ cargo run --example table_session
 cargo run --example session_pool
 ```
 
+## Timeouts & liveness
+
+`connect_timeout` bounds the TCP connect per endpoint attempt. `socket_timeout`
+(default 60 s) bounds **every blocking socket read/write after that** — the TLS
+handshake, every RPC read, and the best-effort `closeSession` sent when a
+session is dropped — so a peer that accepts the connection and then goes silent
+cannot hang the client forever. Set it to `None` to restore the old unbounded
+blocking behaviour (zero is treated the same as `None`).
+
+```rust
+let config = SessionConfig {
+    connect_timeout: std::time::Duration::from_secs(10),
+    socket_timeout: Some(std::time::Duration::from_secs(30)),
+    ..Default::default()
+};
+// or: TableSession::builder().connect_timeout(..).socket_timeout(..)
+```
+
+On a transport-level failure with `enable_auto_reconnect` (default `true`) the
+session reconnects and retries the operation once; without auto-reconnect the
+connection is marked broken and pools discard the session instead of handing it
+out again.
+
 ## TLS & RPC compression
 
 **RPC compression** (IoTDB's term for the Thrift *compact protocol*) is a 
plain config flag:
diff --git a/README_ZH.md b/README_ZH.md
index 3da4c98..88942cd 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -172,6 +172,25 @@ cargo run --example table_session
 cargo run --example session_pool
 ```
 
+## 超时与连接活性
+
+`connect_timeout` 限定每个端点的 TCP 建连时长。`socket_timeout`(默认 60 s)限定建连之后的
+**每一次阻塞式 socket 读写**——包括 TLS 握手、所有 RPC 读取,以及会话销毁时发送的尽力而为
+`closeSession`——因此"接受连接后保持沉默"的对端不会再让客户端无限阻塞。设为 `None`
+可恢复旧的无限阻塞行为(0 与 `None` 等价)。
+
+```rust
+let config = SessionConfig {
+    connect_timeout: std::time::Duration::from_secs(10),
+    socket_timeout: Some(std::time::Duration::from_secs(30)),
+    ..Default::default()
+};
+// 或:TableSession::builder().connect_timeout(..).socket_timeout(..)
+```
+
+传输层失败时,若 `enable_auto_reconnect`(默认 `true`)开启,会话会重连并重试该操作一次;
+未开启自动重连时,连接会被标记为已损坏,连接池将丢弃该会话而不是再次发放。
+
 ## TLS 与 RPC 压缩
 
 **RPC 压缩**(IoTDB 术语,实为 Thrift *compact 协议*)只是一个配置开关:
diff --git a/src/client/pool.rs b/src/client/pool.rs
index 3df10aa..df501ab 100644
--- a/src/client/pool.rs
+++ b/src/client/pool.rs
@@ -204,12 +204,27 @@ impl SessionPool {
     /// the acquire retried; a fresh session is opened while under
     /// `max_size`.
     pub fn acquire(&self) -> Result<PooledSession<'_>> {
-        let deadline = Instant::now() + self.config.acquire_timeout;
+        // `checked_add` makes `acquire_timeout = Duration::MAX` mean
+        // "wait without a deadline" instead of panicking on Instant overflow.
+        let deadline = Instant::now().checked_add(self.config.acquire_timeout);
+        let mut last_err: Option<Error> = None;
         let mut state = self.state.lock().expect("pool lock poisoned");
         loop {
             if state.closed {
                 return Err(Error::Client("session pool is closed".into()));
             }
+            if let Some(deadline) = deadline {
+                if Instant::now() >= deadline {
+                    return Err(last_err.unwrap_or_else(|| {
+                        Error::Client(format!(
+                            "pool exhausted: no session available within {:?} 
({} live, max {})",
+                            self.config.acquire_timeout,
+                            self.live.load(Ordering::Relaxed),
+                            self.config.max_size
+                        ))
+                    }));
+                }
+            }
             let expired = self.sweep_idle(&mut state);
             if !expired.is_empty() {
                 drop(state);
@@ -220,12 +235,25 @@ impl SessionPool {
                 continue; // re-check closed/idle after re-locking
             }
             // Idle session available → validate liveness, evict the dead.
-            while let Some(entry) = state.idle.pop_front() {
+            if let Some(entry) = state.idle.pop_front() {
                 if entry.session.is_open() {
                     drop(state);
-                    return self.hand_out(entry.session);
+                    match self.hand_out(entry.session) {
+                        Ok(guard) => return Ok(guard),
+                        Err(e) => {
+                            // Account under the lock, then keep spending the
+                            // acquire_timeout budget on the remaining idle
+                            // candidates instead of failing instantly.
+                            last_err = Some(e);
+                            state = self.state.lock().expect("pool lock 
poisoned");
+                            self.live.fetch_sub(1, Ordering::Relaxed);
+                            self.available.notify_one();
+                            continue;
+                        }
+                    }
                 }
                 self.live.fetch_sub(1, Ordering::Relaxed);
+                continue; // dead session discarded; retry
             }
             // Below capacity → grow lazily. Count the slot while still
             // holding the lock so concurrent acquires cannot overshoot.
@@ -233,29 +261,57 @@ impl SessionPool {
                 self.live.fetch_add(1, Ordering::Relaxed);
                 drop(state);
                 match self.open_session() {
-                    Ok(session) => return self.hand_out(session),
+                    Ok(session) => match self.hand_out(session) {
+                        Ok(guard) => return Ok(guard),
+                        Err(e) => {
+                            let state = self.state.lock().expect("pool lock 
poisoned");
+                            self.live.fetch_sub(1, Ordering::Relaxed);
+                            self.available.notify_one();
+                            drop(state);
+                            // A broken USE replay is a terminal error for
+                            // this acquire: report it directly.
+                            return Err(e);
+                        }
+                    },
                     Err(e) => {
+                        last_err = Some(e);
+                        state = self.state.lock().expect("pool lock poisoned");
                         self.live.fetch_sub(1, Ordering::Relaxed);
                         self.available.notify_one();
-                        return Err(e);
+                        // Spend the remaining acquire_timeout budget waiting
+                        // for a release instead of failing immediately; the
+                        // loop re-checks closed/idle/deadline at the top.
+                        state = self.wait_for_change(state, deadline);
                     }
                 }
+                continue;
             }
             // At capacity → wait for a release, bounded by the deadline.
-            let now = Instant::now();
-            if now >= deadline {
-                return Err(Error::Client(format!(
-                    "pool exhausted: no session available within {:?} ({} 
live, max {})",
-                    self.config.acquire_timeout,
-                    self.live.load(Ordering::Relaxed),
-                    self.config.max_size
-                )));
+            state = self.wait_for_change(state, deadline);
+        }
+    }
+
+    /// Park on the availability Condvar until a release or (when a deadline
+    /// is set) the deadline passes. The returned guard still holds the state
+    /// lock so the caller re-evaluates the predicate safely.
+    fn wait_for_change<'a>(
+        &self,
+        state: std::sync::MutexGuard<'a, PoolState>,
+        deadline: Option<Instant>,
+    ) -> std::sync::MutexGuard<'a, PoolState> {
+        match deadline {
+            Some(deadline) => {
+                let now = Instant::now();
+                if now >= deadline {
+                    return state;
+                }
+                let (guard, _) = self
+                    .available
+                    .wait_timeout(state, deadline - now)
+                    .expect("pool lock poisoned");
+                guard
             }
-            let (guard, _) = self
-                .available
-                .wait_timeout(state, deadline - now)
-                .expect("pool lock poisoned");
-            state = guard;
+            None => self.available.wait(state).expect("pool lock poisoned"),
         }
     }
 
@@ -271,24 +327,45 @@ impl SessionPool {
     /// connection to the hinted endpoint (Node.js-style dedicated
     /// per-endpoint sessions are future work).
     pub fn acquire_for_device(&self, device_id: &str) -> 
Result<PooledSession<'_>> {
-        {
+        let matched = {
             let mut state = self.state.lock().expect("pool lock poisoned");
-            if !state.closed {
+            if state.closed {
+                None
+            } else {
                 // Any idle session may hold the hint (the one that got the
                 // 400), not necessarily one connected to the hinted node.
+                // When several sessions hold conflicting hints, prefer the
+                // newest (highest cache seq).
                 let hint = state
                     .idle
                     .iter_mut()
-                    .find_map(|e| e.session.redirect_hint(device_id));
-                if let Some(endpoint) = hint {
-                    let matching = state.idle.iter().position(|e| {
-                        e.session.is_open() && e.session.current_endpoint() == 
Some(&endpoint)
-                    });
-                    if let Some(pos) = matching {
-                        let entry = state.idle.remove(pos).expect("index in 
bounds");
-                        drop(state);
-                        return self.hand_out(entry.session);
-                    }
+                    .filter_map(|e| 
e.session.redirect_hint_with_seq(device_id))
+                    .max_by_key(|(_, seq)| *seq);
+                match hint {
+                    Some((endpoint, _)) => state
+                        .idle
+                        .iter()
+                        .position(|e| {
+                            e.session.is_open()
+                                && e.session
+                                    .current_endpoint()
+                                    .is_some_and(|current| 
current.equivalent(&endpoint))
+                        })
+                        .map(|pos| state.idle.remove(pos).expect("index in 
bounds").session),
+                    None => None,
+                }
+            }
+        };
+        if let Some(session) = matched {
+            match self.hand_out(session) {
+                Ok(guard) => return Ok(guard),
+                Err(_) => {
+                    // Account under the lock, then fall back to the normal
+                    // acquire path (the hinted hand-out may still succeed).
+                    let state = self.state.lock().expect("pool lock poisoned");
+                    self.live.fetch_sub(1, Ordering::Relaxed);
+                    self.available.notify_one();
+                    drop(state);
                 }
             }
         }
@@ -305,16 +382,20 @@ impl SessionPool {
     /// sessions. Sessions currently handed out are closed when their guards
     /// drop.
     pub fn close(&self) {
+        // Set the flag, decrement `live` and wake every waiter under the
+        // same lock the waiters re-check the predicate under (no lost
+        // Condvar wakeups); the blocking closeSession RPCs run after the
+        // lock is released, bounded by the session's socket_timeout.
         let drained = {
             let mut state = self.state.lock().expect("pool lock poisoned");
             state.closed = true;
+            self.live.fetch_sub(state.idle.len(), Ordering::Relaxed);
+            self.available.notify_all();
             std::mem::take(&mut state.idle)
         };
-        self.live.fetch_sub(drained.len(), Ordering::Relaxed);
         for mut entry in drained {
             let _ = entry.session.close();
         }
-        self.available.notify_all();
     }
 
     fn open_session(&self) -> Result<Session> {
@@ -329,19 +410,22 @@ impl SessionPool {
 
     /// Final step of acquire: sync the session onto the pool's current
     /// database before handing it out. On USE failure the session is
-    /// discarded, not returned to the pool.
+    /// discarded, not returned to the pool; the caller re-takes the state
+    /// lock to decrement `live` and notify, keeping the decrement and the
+    /// predicate waiters re-evaluate under the same lock.
     fn hand_out(&self, mut session: Session) -> Result<PooledSession<'_>> {
         let pool_db = self.database.lock().expect("pool lock 
poisoned").clone();
         if let Some(db) = pool_db {
             if session.database() != Some(db.as_str()) {
                 if let Err(e) = session.execute_non_query(&format!("USE 
{db}")) {
                     let _ = session.close();
-                    self.live.fetch_sub(1, Ordering::Relaxed);
-                    self.available.notify_one();
                     return Err(e);
                 }
             }
         }
+        // Pooled sessions skip the reconnect pacing sleeps so a pool slot is
+        // never held for the full C#-style reconnect walk.
+        session.mark_pooled();
         Ok(PooledSession {
             pool: self,
             session: Some(session),
@@ -352,8 +436,10 @@ impl SessionPool {
     /// live ones update the pool database and go back to the idle queue.
     fn release(&self, session: Session) {
         if !session.is_open() {
+            let state = self.state.lock().expect("pool lock poisoned");
             self.live.fetch_sub(1, Ordering::Relaxed);
             self.available.notify_one();
+            drop(state);
             return;
         }
         if let Some(db) = session.database() {
@@ -364,19 +450,20 @@ impl SessionPool {
         }
         let mut state = self.state.lock().expect("pool lock poisoned");
         if state.closed {
-            drop(state);
             self.live.fetch_sub(1, Ordering::Relaxed);
+            self.available.notify_one();
+            drop(state);
             let mut session = session;
             let _ = session.close();
         } else {
             state.idle.push_back(IdleEntry::new(session));
             let expired = self.sweep_idle(&mut state);
+            self.available.notify_one();
             drop(state);
             for mut session in expired {
                 let _ = session.close();
             }
         }
-        self.available.notify_one();
     }
 
     /// Test hook: push a pre-built session (possibly dead) into the idle
@@ -454,6 +541,14 @@ impl TableSessionPool {
         self.pool.acquire()
     }
 
+    /// Acquire a table-dialect session for writes to `device_id`,
+    /// preferring an idle session already connected to the device's
+    /// redirected endpoint — the table-model counterpart of
+    /// [`SessionPool::acquire_for_device`].
+    pub fn acquire_for_device(&self, device_id: &str) -> 
Result<PooledSession<'_>> {
+        self.pool.acquire_for_device(device_id)
+    }
+
     /// Convenience: acquire, run one non-query statement, release.
     pub fn execute_non_query(&self, sql: &str) -> Result<()> {
         self.pool.execute_non_query(sql)
@@ -505,6 +600,91 @@ mod tests {
         assert_eq!(cfg.idle_sweep_interval, Duration::from_secs(30));
     }
 
+    /// F5: a failed growth attempt must spend the acquire_timeout budget
+    /// (waiting for a release) instead of failing instantly — the old
+    /// behaviour returned the connect error in ~200us against a 300ms budget.
+    #[test]
+    fn growth_failure_spends_acquire_timeout_budget() {
+        let cfg = SessionPoolConfig {
+            max_size: 1,
+            acquire_timeout: Duration::from_millis(300),
+            session: dead_endpoint_config(),
+            ..Default::default()
+        };
+        let pool = SessionPool::new(cfg).unwrap();
+        let started = Instant::now();
+        match pool.acquire() {
+            Err(Error::Thrift(_)) => {}
+            other => panic!("expected thrift connect error, got {other:?}"),
+        }
+        let elapsed = started.elapsed();
+        assert!(
+            elapsed >= Duration::from_millis(250),
+            "acquire failed in {elapsed:?} instead of spending the budget"
+        );
+        assert_eq!(pool.live_count(), 0);
+    }
+
+    /// F6: waiters blocked on a full pool must be woken by `close()`
+    /// promptly — the wakeup cannot depend on the blocking closeSession RPCs
+    /// that run afterwards (the old code notified only after them).
+    #[test]
+    fn close_wakes_waiters_promptly() {
+        let ep = fake_listener();
+        let cfg = SessionPoolConfig {
+            max_size: 1,
+            acquire_timeout: Duration::from_secs(10),
+            session: dead_endpoint_config(),
+            ..Default::default()
+        };
+        let pool = std::sync::Arc::new(SessionPool::new(cfg).unwrap());
+        pool.inject_idle(injected_session(&ep));
+        let _held = pool.acquire().unwrap();
+
+        let (tx, rx) = std::sync::mpsc::channel();
+        let waiter_pool = std::sync::Arc::clone(&pool);
+        let waiter = std::thread::spawn(move || {
+            let _ = tx.send(waiter_pool.acquire().map(|_| ()));
+        });
+        std::thread::sleep(Duration::from_millis(100));
+        pool.close();
+        match rx.recv_timeout(Duration::from_secs(2)) {
+            Ok(Err(Error::Client(msg))) => assert!(msg.contains("closed"), 
"{msg}"),
+            other => panic!("waiter should get the closed-pool error promptly, 
got {other:?}"),
+        }
+        waiter.join().expect("waiter thread");
+    }
+
+    /// F13: `acquire_timeout = Duration::MAX` must mean "wait without a
+    /// deadline", not panic on `Instant + Duration` overflow. The close
+    /// below is what releases the waiter.
+    #[test]
+    fn acquire_timeout_max_waits_until_closed_without_panicking() {
+        let ep = fake_listener();
+        let cfg = SessionPoolConfig {
+            max_size: 1,
+            acquire_timeout: Duration::MAX,
+            session: dead_endpoint_config(),
+            ..Default::default()
+        };
+        let pool = std::sync::Arc::new(SessionPool::new(cfg).unwrap());
+        pool.inject_idle(injected_session(&ep));
+        let _held = pool.acquire().unwrap();
+
+        let (tx, rx) = std::sync::mpsc::channel();
+        let waiter_pool = std::sync::Arc::clone(&pool);
+        let waiter = std::thread::spawn(move || {
+            let _ = tx.send(waiter_pool.acquire().map(|_| ()));
+        });
+        std::thread::sleep(Duration::from_millis(100));
+        pool.close();
+        match rx.recv_timeout(Duration::from_secs(2)) {
+            Ok(Err(Error::Client(msg))) => assert!(msg.contains("closed"), 
"{msg}"),
+            other => panic!("Duration::MAX waiter should wake on close, got 
{other:?}"),
+        }
+        waiter.join().expect("waiter thread");
+    }
+
     #[test]
     fn min_greater_than_max_is_rejected() {
         let cfg = SessionPoolConfig {
@@ -665,6 +845,41 @@ mod tests {
         assert_eq!(guard.current_endpoint(), Some(&ep_a));
     }
 
+    /// F12: when idle sessions hold conflicting hints for one device, the
+    /// **newest** hint wins, not the session nearest the queue head.
+    #[test]
+    fn acquire_for_device_prefers_newest_conflicting_hint() {
+        let ep_x = fake_listener();
+        let ep_y = fake_listener();
+        let ep_b = fake_listener();
+        let ep_c = fake_listener();
+
+        let cfg = SessionPoolConfig {
+            max_size: 4,
+            acquire_timeout: Duration::from_millis(50),
+            session: dead_endpoint_config(),
+            ..Default::default()
+        };
+        let pool = SessionPool::new(cfg).unwrap();
+
+        // Older hint (inserted first, process-wide seq 1) points at B.
+        let mut s_old_hint = injected_session(&ep_x);
+        s_old_hint.test_inject_redirect_hint("root.sg.d1", ep_b.clone());
+        // Newer hint (seq 2) points at C. Both hint-holders precede the
+        // hinted sessions in the idle queue, so queue position would pick B.
+        let mut s_new_hint = injected_session(&ep_y);
+        s_new_hint.test_inject_redirect_hint("root.sg.d1", ep_c.clone());
+        let s_on_b = injected_session(&ep_b);
+        let s_on_c = injected_session(&ep_c);
+        pool.inject_idle(s_old_hint);
+        pool.inject_idle(s_new_hint);
+        pool.inject_idle(s_on_b);
+        pool.inject_idle(s_on_c);
+
+        let guard = pool.acquire_for_device("root.sg.d1").unwrap();
+        assert_eq!(guard.current_endpoint(), Some(&ep_c));
+    }
+
     #[test]
     fn acquire_for_device_falls_back_when_no_session_matches_hint() {
         let ep_a = fake_listener();
diff --git a/src/client/redirect.rs b/src/client/redirect.rs
index 4505504..7e52a72 100644
--- a/src/client/redirect.rs
+++ b/src/client/redirect.rs
@@ -30,6 +30,7 @@
 //! future work.
 
 use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, Ordering};
 use std::time::{Duration, Instant};
 
 use crate::connection::Endpoint;
@@ -40,6 +41,11 @@ pub const DEFAULT_REDIRECT_TTL: Duration = 
Duration::from_secs(300);
 /// Default capacity; the oldest entry is evicted when full.
 pub const DEFAULT_REDIRECT_MAX_ENTRIES: usize = 1024;
 
+/// Process-wide insertion counter: `seq` values stay comparable across
+/// per-session caches, so a pool can tell which of two conflicting hints
+/// for one device is newer.
+static NEXT_SEQ: AtomicU64 = AtomicU64::new(0);
+
 /// TTL predicate, kept as a pure function so expiry logic is testable
 /// without sleeping: an entry is expired once `elapsed` exceeds `ttl`.
 /// A zero `ttl` disables expiry entirely (Node.js `ttl > 0` semantics).
@@ -68,7 +74,6 @@ pub struct RedirectCache {
     entries: HashMap<String, Entry>,
     ttl: Duration,
     max_entries: usize,
-    seq: u64,
 }
 
 impl Default for RedirectCache {
@@ -85,7 +90,6 @@ impl RedirectCache {
             entries: HashMap::new(),
             ttl,
             max_entries,
-            seq: 0,
         }
     }
 
@@ -95,15 +99,27 @@ impl RedirectCache {
         self.get_at(device_id, Instant::now())
     }
 
+    /// Like [`RedirectCache::get`], plus the insertion sequence — higher
+    /// is newer. Pools use it to prefer the newest hint when idle sessions
+    /// hold conflicting hints for one device.
+    pub fn get_with_seq(&mut self, device_id: &str) -> Option<(Endpoint, u64)> 
{
+        self.get_at_with_seq(device_id, Instant::now())
+    }
+
     /// [`RedirectCache::get`] against an explicit "now" — the seam the TTL
     /// tests use instead of sleeping.
     fn get_at(&mut self, device_id: &str, now: Instant) -> Option<Endpoint> {
+        self.get_at_with_seq(device_id, now)
+            .map(|(endpoint, _)| endpoint)
+    }
+
+    fn get_at_with_seq(&mut self, device_id: &str, now: Instant) -> 
Option<(Endpoint, u64)> {
         let entry = self.entries.get(device_id)?;
         if is_expired(now.saturating_duration_since(entry.inserted), self.ttl) 
{
             self.entries.remove(device_id);
             return None;
         }
-        Some(entry.endpoint.clone())
+        Some((entry.endpoint.clone(), entry.seq))
     }
 
     /// Record (or refresh) the hint for `device_id`. When the cache is full
@@ -123,13 +139,13 @@ impl RedirectCache {
                 self.entries.remove(&oldest);
             }
         }
-        self.seq += 1;
+        let seq = NEXT_SEQ.fetch_add(1, Ordering::Relaxed);
         self.entries.insert(
             device_id,
             Entry {
                 endpoint,
                 inserted: Instant::now(),
-                seq: self.seq,
+                seq,
             },
         );
     }
diff --git a/src/client/session.rs b/src/client/session.rs
index 91480c4..d4c9236 100644
--- a/src/client/session.rs
+++ b/src/client/session.rs
@@ -56,10 +56,21 @@ pub struct SessionConfig {
     pub sql_dialect: String,
     pub fetch_size: i32,
     pub zone_id: String,
-    /// TCP connect timeout per endpoint attempt.
+    /// Total TCP connect timeout per endpoint attempt.
     pub connect_timeout: Duration,
+    /// Client-side bound on each blocking socket read/write
+    /// (SO_RCVTIMEO/SO_SNDTIMEO), applied after the TCP connect and before
+    /// the TLS handshake: it bounds the handshake, every RPC read and the
+    /// best-effort drop-time `closeSession`. `None` restores unbounded
+    /// blocking. Default 60 s.
+    pub socket_timeout: Option<Duration>,
     /// Per-query server-side timeout in milliseconds (request body field).
     pub query_timeout_ms: i64,
+    /// Redirect-hint cache TTL. Default 300 s (Node.js `RedirectCache`).
+    pub redirect_cache_ttl: Duration,
+    /// Redirect-hint cache capacity (oldest entry evicted when full);
+    /// `0` disables the cache. Default 1024.
+    pub redirect_cache_max_entries: usize,
     /// Database to select at open time (table dialect; sent as config key 
`db`).
     pub database: Option<String>,
     /// Reopen the connection and retry an op once when an RPC fails at the
@@ -116,7 +127,10 @@ impl Default for SessionConfig {
             fetch_size: 1024,
             zone_id: "UTC+8".into(),
             connect_timeout: Duration::from_secs(10),
+            socket_timeout: Some(Duration::from_secs(60)),
             query_timeout_ms: 60_000,
+            redirect_cache_ttl: redirect::DEFAULT_REDIRECT_TTL,
+            redirect_cache_max_entries: redirect::DEFAULT_REDIRECT_MAX_ENTRIES,
             database: None,
             enable_auto_reconnect: true,
             max_reconnect_attempts: 3,
@@ -155,6 +169,7 @@ impl SessionConfig {
     pub fn connection_options(&self) -> ConnectionOptions {
         ConnectionOptions {
             connect_timeout: self.connect_timeout,
+            socket_timeout: self.socket_timeout,
             protocol: if self.enable_rpc_compression {
                 RpcProtocol::Compact
             } else {
@@ -204,11 +219,21 @@ pub struct Session {
     last_endpoint: Option<Endpoint>,
     /// Device → endpoint hints harvested from status-400 insert responses.
     redirect_cache: RedirectCache,
+    /// A transport-level RPC failure was observed and the connection is
+    /// presumed desynchronized; with auto-reconnect off there is no reopen
+    /// path, so `is_open` reports false and pools discard the session.
+    broken: bool,
+    /// This session is handed out from a pool: reconnect skips the
+    /// between-attempt sleeps so a pool slot is not held for the full
+    /// C#-style pacing (pool.rs).
+    pooled: bool,
 }
 
 impl Session {
     pub fn new(config: SessionConfig) -> Self {
         let database = config.database.clone();
+        let redirect_cache =
+            RedirectCache::new(config.redirect_cache_ttl, 
config.redirect_cache_max_entries);
         Self {
             config,
             connection: None,
@@ -216,7 +241,9 @@ impl Session {
             statement_id: -1,
             database,
             last_endpoint: None,
-            redirect_cache: RedirectCache::default(),
+            redirect_cache,
+            broken: false,
+            pooled: false,
         }
     }
 
@@ -233,28 +260,33 @@ impl Session {
 
         let start = ENDPOINT_START_INDEX.fetch_add(1, Ordering::Relaxed) % n;
         let options = self.config.connection_options();
-        let mut connection = None;
         let mut last_err: Option<Error> = None;
+        // Fail over at the *full* handshake level, not just TCP: a node that
+        // accepts the connection but rejects openSession must not fail the
+        // whole open while other nodes are healthy (reconnect() has always
+        // retried connect + authenticate together).
         for i in 0..n {
             let endpoint = self.config.endpoints[(start + i) % n].clone();
-            match Connection::open(endpoint, &options) {
-                Ok(c) => {
-                    connection = Some(c);
-                    break;
+            let result = Connection::open(endpoint.clone(), 
&options).and_then(|mut connection| {
+                let ids = self.authenticate(&mut connection)?;
+                Ok((connection, ids))
+            });
+            match result {
+                Ok((connection, (session_id, statement_id))) => {
+                    self.session_id = session_id;
+                    self.statement_id = statement_id;
+                    self.last_endpoint = Some(connection.endpoint().clone());
+                    self.connection = Some(connection);
+                    self.broken = false;
+                    return Ok(());
+                }
+                Err(e) => {
+                    log::warn!("open against {endpoint} failed: {e}");
+                    last_err = Some(e);
                 }
-                Err(e) => last_err = Some(e),
             }
         }
-        let mut connection = connection.ok_or_else(|| {
-            last_err.unwrap_or_else(|| Error::Client("no endpoints 
configured".into()))
-        })?;
-
-        let (session_id, statement_id) = self.authenticate(&mut connection)?;
-        self.session_id = session_id;
-        self.statement_id = statement_id;
-        self.last_endpoint = Some(connection.endpoint().clone());
-        self.connection = Some(connection);
-        Ok(())
+        Err(last_err.unwrap_or_else(|| Error::Client("no endpoints 
configured".into())))
     }
 
     /// Handshake on a fresh connection: `openSession` (dialect + current
@@ -298,6 +330,7 @@ impl Session {
     /// (mirroring the C# SDK's `Reconnect`).
     fn reconnect(&mut self) -> Result<()> {
         self.connection = None; // drop closes the old transport
+        self.broken = false;
         let n = self.config.endpoints.len();
         if n == 0 {
             return Err(Error::Client("no endpoints configured".into()));
@@ -311,7 +344,10 @@ impl Session {
         let options = self.config.connection_options();
         let mut last_err: Option<Error> = None;
         for attempt in 0..attempts {
-            if attempt > 0 {
+            // Pooled sessions hold their pool slot during reconnect; skip
+            // the pacing sleeps so a full reconnect walk cannot monopolize
+            // the slot for tens of seconds.
+            if attempt > 0 && !self.pooled {
                 std::thread::sleep(self.config.retry_interval);
             }
             for i in 0..n {
@@ -327,6 +363,7 @@ impl Session {
                         self.statement_id = statement_id;
                         self.last_endpoint = 
Some(connection.endpoint().clone());
                         self.connection = Some(connection);
+                        self.broken = false;
                         return Ok(());
                     }
                     Err(e) => {
@@ -353,11 +390,26 @@ impl Session {
             {
                 e
             }
+            Err(e @ Error::Thrift(_)) => {
+                // No reconnect path: the connection may be desynchronized
+                // (a socket timeout abandons a half-read frame; a
+                // frame-too-large rejection abandons an undrained body).
+                // Mark it broken so is_open() turns false and pools
+                // discard the session instead of handing it out again.
+                self.broken = true;
+                return Err(e);
+            }
             other => return other,
         };
         log::warn!("RPC failed at transport level ({original}); reconnecting");
         match self.reconnect() {
-            Ok(()) => op(self),
+            Ok(()) => match op(self) {
+                Err(e @ Error::Thrift(_)) => {
+                    self.broken = true;
+                    Err(e)
+                }
+                other => other,
+            },
             Err(reconnect_err) => {
                 log::warn!("reconnect failed ({reconnect_err}); surfacing the 
original error");
                 Err(original)
@@ -366,7 +418,7 @@ impl Session {
     }
 
     pub fn is_open(&self) -> bool {
-        self.connection.is_some()
+        self.connection.is_some() && !self.broken
     }
 
     /// The database currently selected on this session, if any.
@@ -379,6 +431,12 @@ impl Session {
         self.connection.as_ref().map(Connection::endpoint)
     }
 
+    /// Mark this session as pool-owned: reconnect skips the pacing sleeps
+    /// so a pool slot is not held for the full C#-style reconnect walk.
+    pub(crate) fn mark_pooled(&mut self) {
+        self.pooled = true;
+    }
+
     /// The cached redirect endpoint for `device_id`, if a status-400 insert
     /// response recommended one and the hint has not expired.
     ///
@@ -390,6 +448,13 @@ impl Session {
         self.redirect_cache.get(device_id)
     }
 
+    /// Like [`Session::redirect_hint`], but also returns the cache's
+    /// insertion sequence so pools can prefer the newest hint when idle
+    /// sessions hold conflicting hints for one device.
+    pub(crate) fn redirect_hint_with_seq(&mut self, device_id: &str) -> 
Option<(Endpoint, u64)> {
+        self.redirect_cache.get_with_seq(device_id)
+    }
+
     /// Occupancy/config snapshot of the redirect cache.
     pub fn redirect_cache_stats(&self) -> RedirectCacheStats {
         self.redirect_cache.stats()
@@ -484,7 +549,17 @@ impl Session {
             self.config.query_timeout_ms,
             self.statement_id,
         );
-        let resp = self.connection_mut()?.client_mut().fetch_results_v2(req)?;
+        let resp = match 
self.connection_mut()?.client_mut().fetch_results_v2(req) {
+            Ok(resp) => resp,
+            Err(e) => {
+                // fetch_results deliberately bypasses with_retry (spec
+                // gotcha #13: the result set is pinned to its node), so a
+                // transport-level failure here must still mark the
+                // connection broken for the pool.
+                self.broken = true;
+                return Err(Error::from(e));
+            }
+        };
         check_status(&resp.status)?;
         if !resp.has_result_set {
             return Ok((Vec::new(), false));
@@ -1016,6 +1091,10 @@ mod tests {
     /// tests' inserts.
     static LIVE_DB_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
 
+    /// Serializes the one test that pins the shared endpoint rotation
+    /// counter to a known start index.
+    static OPEN_INDEX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
     fn status(code: i32) -> TSStatus {
         TSStatus::new(code, None, None, None, None, None)
     }
@@ -1030,6 +1109,9 @@ mod tests {
         assert_eq!(cfg.fetch_size, 1024);
         assert_eq!(cfg.query_timeout_ms, 60_000);
         assert_eq!(cfg.connect_timeout, Duration::from_secs(10));
+        assert_eq!(cfg.socket_timeout, Some(Duration::from_secs(60)));
+        assert_eq!(cfg.redirect_cache_ttl, Duration::from_secs(300));
+        assert_eq!(cfg.redirect_cache_max_entries, 1024);
         assert!(cfg.database.is_none());
         assert!(cfg.enable_auto_reconnect);
         assert_eq!(cfg.max_reconnect_attempts, 3);
@@ -1056,6 +1138,7 @@ mod tests {
         let cfg = SessionConfig::default();
         let options = cfg.connection_options();
         assert_eq!(options.connect_timeout, cfg.connect_timeout);
+        assert_eq!(options.socket_timeout, cfg.socket_timeout);
         assert_eq!(options.protocol, RpcProtocol::Binary);
         #[cfg(feature = "tls")]
         assert!(options.tls.is_none());
@@ -1487,6 +1570,49 @@ mod tests {
         let err = session.execute_non_query("SHOW DATABASES").unwrap_err();
         assert!(matches!(err, Error::Thrift(_)), "got {err:?}");
         assert_eq!(accepts.load(Ordering::SeqCst), 1, "no reconnect attempts");
+        // F7: without a reconnect path the desynchronized connection is
+        // marked broken, so is_open() stops lying and pools discard the
+        // session instead of handing it out again.
+        assert!(!session.is_open());
+    }
+
+    /// F4 regression: failover covers the **handshake**, not just the TCP
+    /// connect. The first endpoint accepts the connection but drops it
+    /// mid-openSession; the second endpoint (a live server) must take over.
+    /// Skipped when no IoTDB server is reachable.
+    #[test]
+    fn open_fails_over_to_next_endpoint_when_authentication_fails() {
+        use std::net::TcpStream;
+        if TcpStream::connect_timeout(
+            &"127.0.0.1:6667".parse().unwrap(),
+            Duration::from_millis(300),
+        )
+        .is_err()
+        {
+            eprintln!(
+                "skipping 
open_fails_over_to_next_endpoint_when_authentication_fails: no server"
+            );
+            return;
+        }
+        let _guard = OPEN_INDEX_LOCK.lock().expect("open index lock");
+        ENDPOINT_START_INDEX.store(0, Ordering::Relaxed);
+        let (drop_endpoint, accepts) = accept_then_drop_listener();
+        let live = Endpoint::new("127.0.0.1", 6667);
+        let mut session = Session::new(SessionConfig {
+            endpoints: vec![drop_endpoint, live.clone()],
+            connect_timeout: Duration::from_millis(500),
+            ..Default::default()
+        });
+        session
+            .open()
+            .expect("the second endpoint must take over after the handshake 
failure");
+        assert_eq!(session.current_endpoint(), Some(&live));
+        assert_eq!(
+            accepts.load(Ordering::SeqCst),
+            1,
+            "the failing first endpoint must have been attempted"
+        );
+        let _ = session.close();
     }
 
     #[test]
diff --git a/src/client/table_session.rs b/src/client/table_session.rs
index b708e26..02fb409 100644
--- a/src/client/table_session.rs
+++ b/src/client/table_session.rs
@@ -102,6 +102,23 @@ impl TableSessionBuilder {
         self
     }
 
+    /// Client-side bound on each socket read/write (SO_RCVTIMEO/SO_SNDTIMEO),
+    /// applied after the TCP connect and before the TLS handshake.
+    /// See [`SessionConfig::socket_timeout`].
+    pub fn socket_timeout(mut self, timeout: Duration) -> Self {
+        self.config.socket_timeout = Some(timeout);
+        self
+    }
+
+    /// Redirect-hint cache TTL and capacity.
+    /// See [`SessionConfig::redirect_cache_ttl`] /
+    /// [`SessionConfig::redirect_cache_max_entries`].
+    pub fn redirect_cache(mut self, ttl: Duration, max_entries: usize) -> Self 
{
+        self.config.redirect_cache_ttl = ttl;
+        self.config.redirect_cache_max_entries = max_entries;
+        self
+    }
+
     pub fn query_timeout_ms(mut self, timeout_ms: i64) -> Self {
         self.config.query_timeout_ms = timeout_ms;
         self
diff --git a/src/connection/mod.rs b/src/connection/mod.rs
index 7bcdf81..a416c6e 100644
--- a/src/connection/mod.rs
+++ b/src/connection/mod.rs
@@ -26,7 +26,9 @@ use std::io::{Read, Write};
 use std::net::{TcpStream, ToSocketAddrs};
 #[cfg(feature = "tls")]
 use std::sync::Arc;
-use std::time::Duration;
+use std::time::{Duration, Instant};
+
+use socket2::{Domain, Protocol, Socket, Type};
 
 #[cfg(feature = "tls")]
 use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, 
ServerCertVerifier};
@@ -97,11 +99,19 @@ pub struct TlsOptions {
     pub client_key_path: Option<std::path::PathBuf>,
 }
 
-/// How to open a [`Connection`]: timeout, wire protocol, optional TLS.
+/// How to open a [`Connection`]: timeouts, wire protocol, optional TLS.
 #[derive(Debug, Clone)]
 pub struct ConnectionOptions {
-    /// TCP connect timeout per endpoint attempt. Default 10 s.
+    /// Total TCP connect timeout per endpoint attempt (shared across every
+    /// resolved address of that endpoint). Default 10 s.
     pub connect_timeout: Duration,
+    /// Client-side bound on each blocking socket read/write (SO_RCVTIMEO /
+    /// SO_SNDTIMEO), applied after the TCP connect and **before** the TLS
+    /// handshake, so it bounds the handshake, every RPC read and the
+    /// best-effort drop-time `closeSession` alike. `None` restores the
+    /// old unbounded blocking behaviour. Default 60 s (matches the default
+    /// server-side `query_timeout_ms`).
+    pub socket_timeout: Option<Duration>,
     /// Wire protocol; must match the server (see [`RpcProtocol`]).
     pub protocol: RpcProtocol,
     /// Wrap the TCP stream in TLS before the Thrift transports.
@@ -113,6 +123,7 @@ impl Default for ConnectionOptions {
     fn default() -> Self {
         Self {
             connect_timeout: Duration::from_secs(10),
+            socket_timeout: Some(Duration::from_secs(60)),
             protocol: RpcProtocol::Binary,
             #[cfg(feature = "tls")]
             tls: None,
@@ -167,6 +178,32 @@ impl Endpoint {
         }
         Ok(Self::new(host, port))
     }
+
+    /// Loose equality for redirect-hint matching: the port must match and
+    /// the host compares case-insensitively after trimming and stripping
+    /// IPv6 brackets; loopback spellings (`localhost`, `127.x.y.z`,
+    /// `::1`) are equivalent to each other. General hostname-vs-IP
+    /// resolution would need DNS and is deliberately not done here.
+    pub fn equivalent(&self, other: &Self) -> bool {
+        self.port == other.port
+            && (normalized_host(&self.host) == normalized_host(&other.host)
+                || (is_loopback_host(&self.host) && 
is_loopback_host(&other.host)))
+    }
+}
+
+fn normalized_host(host: &str) -> String {
+    host.trim()
+        .trim_start_matches('[')
+        .trim_end_matches(']')
+        .trim_end_matches('.')
+        .to_ascii_lowercase()
+}
+
+fn is_loopback_host(host: &str) -> bool {
+    host == "localhost"
+        || host
+            .parse::<std::net::IpAddr>()
+            .is_ok_and(|ip| ip.is_loopback())
 }
 
 impl std::fmt::Display for Endpoint {
@@ -189,15 +226,15 @@ pub struct Connection {
 
 impl Connection {
     /// Establish a TCP connection to `endpoint` (bounded by
-    /// `options.connect_timeout`), optionally wrap it in TLS, and stack
-    /// framed transport + the selected protocol on top.
+    /// `options.connect_timeout`; every subsequent socket read/write is
+    /// bounded by `options.socket_timeout`), optionally wrap it in TLS, and
+    /// stack framed transport + the selected protocol on top.
     pub fn open(endpoint: Endpoint, options: &ConnectionOptions) -> 
Result<Self> {
-        let stream = connect_stream(&endpoint, options.connect_timeout)?;
-        stream.set_nodelay(true).map_err(thrift::Error::from)?;
+        let stream = connect_stream(&endpoint, options.connect_timeout, 
options.socket_timeout)?;
 
         #[cfg(feature = "tls")]
         if let Some(tls) = &options.tls {
-            let stream = tls_handshake(&endpoint, stream, tls)?;
+            let stream = tls_handshake(&endpoint, stream, tls, 
options.socket_timeout)?;
             let shared = SharedTlsStream::new(stream);
             let (input, output) = build_protocols(shared.clone(), shared, 
options.protocol);
             return Ok(Self {
@@ -257,14 +294,38 @@ where
     }
 }
 
-/// Resolve the endpoint and try each resolved address with the connect 
timeout.
-fn connect_stream(endpoint: &Endpoint, connect_timeout: Duration) -> 
Result<TcpStream> {
+/// Resolve the endpoint and try each resolved address, sharing one total
+/// `connect_timeout` budget across all of them (a multi-address hostname
+/// must not multiply the configured bound), then apply `socket_timeout`
+/// and TCP keepalive before handing the stream up.
+fn connect_stream(
+    endpoint: &Endpoint,
+    connect_timeout: Duration,
+    socket_timeout: Option<Duration>,
+) -> Result<TcpStream> {
     let addrs = (endpoint.host.as_str(), endpoint.port)
         .to_socket_addrs()
         .map_err(thrift::Error::from)?;
+    // A single deadline for the whole endpoint attempt. `checked_add`
+    // treats Duration::MAX as "no bound" instead of panicking on overflow.
+    let deadline = Instant::now().checked_add(connect_timeout);
     let mut last_err: Option<std::io::Error> = None;
     for addr in addrs {
-        match TcpStream::connect_timeout(&addr, connect_timeout) {
+        let attempt_timeout = match deadline {
+            Some(deadline) => {
+                let remaining = 
deadline.saturating_duration_since(Instant::now());
+                if remaining.is_zero() {
+                    last_err = Some(std::io::Error::new(
+                        std::io::ErrorKind::TimedOut,
+                        format!("connect to {endpoint} timed out"),
+                    ));
+                    break;
+                }
+                remaining
+            }
+            None => connect_timeout,
+        };
+        match connect_one(addr, attempt_timeout, socket_timeout) {
             Ok(stream) => return Ok(stream),
             Err(e) => last_err = Some(e),
         }
@@ -275,12 +336,38 @@ fn connect_stream(endpoint: &Endpoint, connect_timeout: 
Duration) -> Result<TcpS
     })
 }
 
-/// Run the TLS handshake over an established TCP stream.
+/// Connect one resolved address with the remaining endpoint budget and
+/// configure the socket: TCP_NODELAY, SO_KEEPALIVE, and the optional
+/// SO_RCVTIMEO/SO_SNDTIMEO that bounds every later read/write on this
+/// connection (TLS handshake included).
+fn connect_one(
+    addr: std::net::SocketAddr,
+    connect_timeout: Duration,
+    socket_timeout: Option<Duration>,
+) -> std::io::Result<TcpStream> {
+    let socket = Socket::new(Domain::for_address(addr), Type::STREAM, 
Some(Protocol::TCP))?;
+    socket.connect_timeout(&socket2::SockAddr::from(addr), connect_timeout)?;
+    socket.set_nodelay(true)?;
+    socket.set_keepalive(true)?;
+    // A zero duration would select non-blocking mode on some platforms;
+    // treat it as "no timeout" instead (None is the documented way).
+    if let Some(timeout) = socket_timeout.filter(|timeout| !timeout.is_zero()) 
{
+        socket.set_read_timeout(Some(timeout))?;
+        socket.set_write_timeout(Some(timeout))?;
+    }
+    Ok(TcpStream::from(socket))
+}
+
+/// Run the TLS handshake over an established TCP stream. The stream
+/// already carries the socket-level read/write timeout, so a peer that
+/// accepts and then stalls the handshake fails here instead of blocking
+/// forever.
 #[cfg(feature = "tls")]
 fn tls_handshake(
     endpoint: &Endpoint,
     mut stream: TcpStream,
     tls: &TlsOptions,
+    socket_timeout: Option<Duration>,
 ) -> Result<StreamOwned<ClientConnection, TcpStream>> {
     let config = tls_client_config(tls)?;
     let domain = tls.domain_override.as_deref().unwrap_or(&endpoint.host);
@@ -289,9 +376,19 @@ fn tls_handshake(
     let mut connection =
         ClientConnection::new(config, server_name).map_err(|e| 
Error::Tls(e.to_string()))?;
 
-    connection
-        .complete_io(&mut stream)
-        .map_err(|e| Error::Tls(e.to_string()))?;
+    connection.complete_io(&mut stream).map_err(|e| {
+        // A blocking socket with SO_RCVTIMEO reports WouldBlock/TimedOut
+        // when the peer stalls the handshake: turn it into an actionable
+        // error instead of leaking the OS error kind.
+        if matches!(
+            e.kind(),
+            std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
+        ) {
+            let bound = socket_timeout.map_or("no socket timeout".into(), |t| 
format!("{t:?}"));
+            return Error::Tls(format!("TLS handshake timed out ({bound})"));
+        }
+        Error::Tls(e.to_string())
+    })?;
     if connection.is_handshaking() {
         return Err(Error::Tls("TLS handshake did not complete".into()));
     }
@@ -547,15 +644,71 @@ mod tests {
         );
     }
 
+    /// F11: redirect-hint matching must tolerate case/bracket/loopback
+    /// spelling differences, but still require the same port.
+    #[test]
+    fn endpoint_equivalent_normalizes_and_matches_loopback() {
+        assert!(Endpoint::new("LOCALHOST", 
6667).equivalent(&Endpoint::new("localhost", 6667)));
+        assert!(Endpoint::new("localhost", 
6667).equivalent(&Endpoint::new("127.0.0.1", 6667)));
+        assert!(Endpoint::new("127.0.0.1", 
6667).equivalent(&Endpoint::new("::1", 6667)));
+        assert!(Endpoint::new("[::1]", 6667).equivalent(&Endpoint::new("::1", 
6667)));
+        assert!(!Endpoint::new("localhost", 
6667).equivalent(&Endpoint::new("localhost", 6668)));
+        assert!(
+            !Endpoint::new("localhost", 
6667).equivalent(&Endpoint::new("iotdb.example.com", 6667))
+        );
+    }
+
     #[test]
     fn default_options_are_binary_no_tls() {
         let options = ConnectionOptions::default();
         assert_eq!(options.connect_timeout, Duration::from_secs(10));
+        assert_eq!(options.socket_timeout, Some(Duration::from_secs(60)));
         assert_eq!(options.protocol, RpcProtocol::Binary);
         #[cfg(feature = "tls")]
         assert!(options.tls.is_none());
     }
 
+    /// A local listener that accepts connections and then stays silent —
+    /// the equivalent of a peer that finished the TCP handshake and never
+    /// replies (GC pause, dropped firewall state, accepting LB). The accept
+    /// thread parks while holding the stream so it never sends EOF.
+    pub(super) fn silent_listener() -> Endpoint {
+        let listener = 
std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
+        let port = listener.local_addr().expect("local_addr").port();
+        std::thread::spawn(move || {
+            let (_stream, _) = listener.accept().expect("accept");
+            std::thread::park();
+        });
+        Endpoint::new("127.0.0.1", port)
+    }
+
+    /// `socket_timeout` must bound reads after the TCP handshake: against
+    /// a peer that accepts and never replies, the RPC returns a Thrift
+    /// error around the configured bound instead of blocking forever.
+    #[test]
+    fn socket_timeout_bounds_reads_after_handshake() {
+        use crate::protocol::client::TIClientRPCServiceSyncClient;
+
+        let endpoint = silent_listener();
+        let options = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            socket_timeout: Some(Duration::from_millis(300)),
+            ..Default::default()
+        };
+        let mut connection = Connection::open(endpoint, &options).expect("TCP 
connect succeeds");
+        let started = Instant::now();
+        let err = connection
+            .client_mut()
+            .request_statement_id(1)
+            .expect_err("silent peer must not block forever");
+        assert!(matches!(err, thrift::Error::Transport(_)), "got {err:?}");
+        let elapsed = started.elapsed();
+        assert!(
+            elapsed < Duration::from_secs(2),
+            "read took {elapsed:?}, socket timeout not applied"
+        );
+    }
+
     /// A local listener that accepts and immediately drops connections, so
     /// `Connection::open` (which issues no RPC) succeeds for any protocol.
     pub(super) fn accept_then_drop_listener() -> Endpoint {
@@ -744,6 +897,7 @@ mod tls_tests {
                 domain_override: Some("localhost".into()),
                 ..Default::default()
             }),
+            ..Default::default()
         };
         let mut connection = Connection::open(endpoint, &options).expect("TLS 
handshake");
         assert_eq!(connection.protocol(), RpcProtocol::Binary);
@@ -771,6 +925,7 @@ mod tls_tests {
                 domain_override: Some("localhost".into()),
                 ..Default::default()
             }),
+            ..Default::default()
         };
         let err = match Connection::open(endpoint, &options) {
             Ok(_) => panic!("untrusted self-signed cert must fail the 
handshake"),
@@ -791,6 +946,7 @@ mod tls_tests {
                 accept_invalid_certs: true,
                 ..Default::default()
             }),
+            ..Default::default()
         };
         let connection = Connection::open(endpoint, &options).expect("TLS 
handshake");
         assert_eq!(connection.protocol(), RpcProtocol::Compact);
@@ -810,6 +966,7 @@ mod tls_tests {
                 accept_invalid_certs: true,
                 ..Default::default()
             }),
+            ..Default::default()
         };
         assert!(
             Connection::open(endpoint, &options).is_err(),
@@ -824,6 +981,34 @@ mod tls_tests {
         );
     }
 
+    /// A socket timeout bounds the TLS handshake itself: against a peer
+    /// that accepts and then stays silent, `Connection::open` fails with a
+    /// TLS timeout error around the configured bound instead of blocking
+    /// forever.
+    #[test]
+    fn tls_handshake_times_out_against_silent_peer() {
+        let endpoint = super::tests::silent_listener();
+        let options = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            socket_timeout: Some(Duration::from_millis(300)),
+            protocol: RpcProtocol::Binary,
+            tls: Some(TlsOptions {
+                accept_invalid_certs: true,
+                ..Default::default()
+            }),
+        };
+        let started = Instant::now();
+        let err = match Connection::open(endpoint, &options) {
+            Ok(_) => panic!("silent peer must not complete a TLS handshake"),
+            Err(e) => e,
+        };
+        assert!(matches!(err, Error::Tls(_)), "got {err:?}");
+        assert!(
+            started.elapsed() < Duration::from_secs(2),
+            "TLS handshake was not bounded by socket_timeout"
+        );
+    }
+
     /// Dispatch pair against the *same kind* of plain (non-TLS) endpoint:
     /// `tls: None` opens fine (plain TCP), `tls: Some(..)` — even with
     /// certificate verification disabled — dies in the handshake with a
@@ -838,6 +1023,7 @@ mod tls_tests {
             connect_timeout: Duration::from_millis(500),
             protocol: RpcProtocol::Binary,
             tls: None,
+            ..Default::default()
         };
         Connection::open(endpoint.clone(), &plain).expect("plain open against 
plain listener");
 
@@ -848,6 +1034,7 @@ mod tls_tests {
                 accept_invalid_certs: true,
                 ..Default::default()
             }),
+            ..Default::default()
         };
         let err = match Connection::open(endpoint, &tls) {
             Ok(_) => panic!("TLS handshake against a plain endpoint must 
fail"),
@@ -872,6 +1059,7 @@ mod tls_tests {
                 client_key_path: Some(fixture("client-key.pem")),
                 ..Default::default()
             }),
+            ..Default::default()
         };
         let connection = Connection::open(endpoint, &options).expect("TLS 
handshake with identity");
         assert_eq!(connection.protocol(), RpcProtocol::Binary);
@@ -899,6 +1087,7 @@ mod tls_tests {
                     client_key_path: key.clone(),
                     ..Default::default()
                 }),
+                ..Default::default()
             };
             let err = match Connection::open(endpoint.clone(), &options) {
                 Ok(_) => panic!("half a client identity must fail"),
@@ -924,6 +1113,7 @@ mod tls_tests {
                 client_key_path: Some(fixture("does-not-exist-key.pem")),
                 ..Default::default()
             }),
+            ..Default::default()
         };
         let err = match Connection::open(endpoint, &options) {
             Ok(_) => panic!("missing client key must fail"),
@@ -949,6 +1139,7 @@ mod tls_tests {
                 client_key_path: Some(fixture("client-cert.pem")), // not a key
                 ..Default::default()
             }),
+            ..Default::default()
         };
         let err = match Connection::open(endpoint, &options) {
             Ok(_) => panic!("a certificate is not a private key"),
@@ -968,6 +1159,7 @@ mod tls_tests {
                 ca_cert_path: Some(fixture("does-not-exist.pem")),
                 ..Default::default()
             }),
+            ..Default::default()
         };
         let err = match Connection::open(endpoint, &options) {
             Ok(_) => panic!("missing CA file must fail"),

Reply via email to