Skip to main content

thunder/client/
conn.rs

1//! The multiplexed Thunder client (SPEC-003).
2//!
3//! One [`Client`] owns one TCP connection (CLT-001; pooling is a layer
4//! above, CLT-080) and demultiplexes concurrent in-flight calls over it:
5//!
6//! - ids are monotonically increasing `u32`s skipping [`PUSH_ID`]
7//!   (CLT-010);
8//! - a background tokio reader task routes each response to its caller's
9//!   `oneshot` channel by id (CLT-010), drops unknown ids (CLT-013), and
10//!   poisons the connection on malformed / oversized frames — every
11//!   pending call fails with the same typed error (CLT-014);
12//! - writes are serialized behind an async mutex so frames never
13//!   interleave (CLT-011);
14//! - in-flight calls are bounded by the config's `max_in_flight` via a
15//!   semaphore — excess calls wait, they are not refused (CLT-012);
16//! - per-call timeouts remove the pending entry so a late response falls
17//!   under the unknown-id drop (CLT-020);
18//! - when a call finds the connection dead, the client lazily re-dials
19//!   and re-handshakes up to 2 attempts with capped backoff; calls that
20//!   were pending when the connection died fail typed and are never
21//!   replayed (CLT-030/031);
22//! - frames with `id == PUSH_ID` go to the registered push handler under
23//!   `PushPolicy::Enabled` and poison the connection under `Reserved`
24//!   (CLT-060).
25//!
26//! The demux architecture follows the family's best client (the
27//! Vectorizer Rust SDK reader-task + oneshot-map pattern).
28
29use std::collections::HashMap;
30use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
31use std::sync::{Arc, Mutex as StdMutex, MutexGuard, PoisonError};
32use std::time::Duration;
33
34use tokio::io::{AsyncWriteExt, BufReader, BufWriter};
35use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
36use tokio::net::TcpStream;
37use tokio::sync::{mpsc, oneshot, Mutex as TokioMutex, Semaphore};
38use tokio::task::JoinHandle;
39
40use crate::wire::config::{Handshake, HelloStyle, PushPolicy};
41use crate::wire::{
42    encode_frame, read_response_with_limit, Config, Request, Response, Value, PUSH_ID,
43};
44
45use crate::client::endpoint::{parse_endpoint, Endpoint};
46use crate::client::error::ClientError;
47
48/// Reconnect backoff: first re-dial retries after `BACKOFF_BASE`, doubling
49/// up to `BACKOFF_CAP` (CLT-030 "capped backoff").
50const BACKOFF_BASE: Duration = Duration::from_millis(50);
51const BACKOFF_CAP: Duration = Duration::from_millis(500);
52
53/// Re-dial budget when a call finds the connection dead (CLT-030).
54const RECONNECT_ATTEMPTS: u32 = 2;
55
56/// Credentials for the configured handshake (CLT-002). Auth state is
57/// per-connection and sticky — there are no per-call credentials
58/// (CLT-003).
59#[derive(Debug, Clone)]
60pub enum Credentials {
61    /// Bearer token (`token` key under `HelloMandatory`).
62    Token(String),
63    /// API key (`api_key` key under `HelloMandatory`, single-arg `AUTH`
64    /// under `AuthCommand`).
65    ApiKey(String),
66    /// User + password (`AUTH [user, pass]` under `AuthCommand`).
67    UserPass {
68        /// User name.
69        user: String,
70        /// Password.
71        pass: String,
72    },
73}
74
75/// Client configuration: connect timeout default **10 s** (CLT-001),
76/// per-call timeout default **30 s** (CLT-020), optional credentials and
77/// client name for the handshake (CLT-002).
78#[derive(Debug, Clone)]
79pub struct ClientConfig {
80    /// TCP connect timeout (CLT-001). Default 10 s.
81    pub connect_timeout: Duration,
82    /// Default per-call timeout (CLT-020); override per call with
83    /// [`Client::call_with_timeout`]. Default 30 s.
84    pub call_timeout: Duration,
85    /// Handshake credentials, when the configured handshake wants them.
86    pub credentials: Option<Credentials>,
87    /// Client identifier sent in the `HELLO` map (`HelloMandatory`).
88    pub client_name: Option<String>,
89}
90
91impl Default for ClientConfig {
92    fn default() -> Self {
93        Self {
94            connect_timeout: Duration::from_secs(10),
95            call_timeout: Duration::from_secs(30),
96            credentials: None,
97            client_name: None,
98        }
99    }
100}
101
102impl ClientConfig {
103    /// Defaults: 10 s connect, 30 s per call, no credentials.
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Set the connect timeout (CLT-001).
109    #[must_use]
110    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
111        self.connect_timeout = timeout;
112        self
113    }
114
115    /// Set the default per-call timeout (CLT-020).
116    #[must_use]
117    pub fn call_timeout(mut self, timeout: Duration) -> Self {
118        self.call_timeout = timeout;
119        self
120    }
121
122    /// Authenticate with a bearer token.
123    #[must_use]
124    pub fn token(mut self, token: impl Into<String>) -> Self {
125        self.credentials = Some(Credentials::Token(token.into()));
126        self
127    }
128
129    /// Authenticate with an API key.
130    #[must_use]
131    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
132        self.credentials = Some(Credentials::ApiKey(api_key.into()));
133        self
134    }
135
136    /// Authenticate with user + password (`AuthCommand` handshakes).
137    #[must_use]
138    pub fn user_pass(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
139        self.credentials = Some(Credentials::UserPass {
140            user: user.into(),
141            pass: pass.into(),
142        });
143        self
144    }
145
146    /// Set the client name announced in the `HELLO` map.
147    #[must_use]
148    pub fn client_name(mut self, name: impl Into<String>) -> Self {
149        self.client_name = Some(name.into());
150        self
151    }
152}
153
154/// What the handshake learned about this connection (CLT-002).
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
156pub struct HandshakeInfo {
157    /// `true` once the server accepted the credentials (`AUTH` succeeded
158    /// or the `HELLO` reply said so).
159    pub authenticated: bool,
160    /// Capability names from the `HELLO` reply (`HelloMandatory`).
161    pub capabilities: Vec<String>,
162}
163
164/// Registered push handler (CLT-060). Runs on the reader task — keep it
165/// fast and offload real work to a channel.
166type PushHandler = Arc<dyn Fn(Value) + Send + Sync>;
167
168type PendingTx = oneshot::Sender<Result<Response, ClientError>>;
169
170/// State shared between one connection's caller side and its reader task.
171struct ConnShared {
172    /// id → oneshot sender demux map (CLT-010).
173    pending: StdMutex<HashMap<u32, PendingTx>>,
174    /// Cleared when the connection is poisoned or closed.
175    alive: AtomicBool,
176}
177
178impl ConnShared {
179    /// Poison: mark dead and fail every pending call with the same typed
180    /// error (CLT-014). Idempotent.
181    fn poison(&self, err: &ClientError) {
182        self.alive.store(false, Ordering::SeqCst);
183        let drained: Vec<PendingTx> = {
184            let mut pending = lock(&self.pending);
185            pending.drain().map(|(_, tx)| tx).collect()
186        };
187        for tx in drained {
188            let _ = tx.send(Err(err.clone()));
189        }
190    }
191}
192
193/// One live connection: the write queue + demux state + the two tasks.
194struct Conn {
195    shared: Arc<ConnShared>,
196    /// Encoded request frames queued for the writer task (CLT-011).
197    ///
198    /// A single writer owns the socket, so frames can never interleave —
199    /// a stronger guarantee than the mutex this replaced, and the reason
200    /// callers no longer contend at all. The writer coalesces everything
201    /// already queued into one flush (the SRV-006 drain-then-flush pattern
202    /// the server has always had); at pipeline depth that turns N syscalls
203    /// into one, which is what the T4.3 matrix showed Thunder paying for.
204    write_tx: mpsc::Sender<Vec<u8>>,
205    reader_task: JoinHandle<()>,
206    writer_task: JoinHandle<()>,
207}
208
209impl Conn {
210    fn is_alive(&self) -> bool {
211        self.shared.alive.load(Ordering::SeqCst)
212    }
213
214    /// Tear down: stop both tasks and fail all pending calls typed.
215    fn kill(&self, err: &ClientError) {
216        self.reader_task.abort();
217        self.writer_task.abort();
218        self.shared.poison(err);
219    }
220}
221
222/// The connection's writer task: own the socket, coalesce, flush once.
223///
224/// Mirrors `thunder::server`'s SRV-006 hot path — write the frame that woke
225/// us, drain every frame already queued via `try_recv`, then flush a single
226/// time. A poisoned write kills the connection so every pending call fails
227/// typed (CLT-014).
228async fn writer_loop(
229    write_half: OwnedWriteHalf,
230    mut rx: mpsc::Receiver<Vec<u8>>,
231    shared: Arc<ConnShared>,
232) {
233    let mut writer = BufWriter::new(write_half);
234    while let Some(frame) = rx.recv().await {
235        if writer.write_all(&frame).await.is_err() {
236            break;
237        }
238        // Drain-then-flush: everything already queued rides the same syscall.
239        while let Ok(next) = rx.try_recv() {
240            if writer.write_all(&next).await.is_err() {
241                shared.poison(&ClientError::Connection {
242                    message: "write failed".to_owned(),
243                });
244                return;
245            }
246        }
247        if writer.flush().await.is_err() {
248            break;
249        }
250    }
251    let _ = writer.flush().await;
252    let _ = writer.shutdown().await;
253}
254
255impl Drop for Conn {
256    fn drop(&mut self) {
257        self.kill(&ClientError::Connection {
258            message: "connection dropped".to_owned(),
259        });
260    }
261}
262
263/// Outcome of one dispatch attempt on one connection.
264enum DispatchError {
265    /// The request never reached the wire — safe to resend on a fresh
266    /// connection (not a replay; CLT-031 concerns frames that were sent).
267    WriteFailed(ClientError),
268    /// Final for this call: the frame may have reached the server, or the
269    /// outcome is a server / timeout / poison error. Never retried.
270    Fatal(ClientError),
271}
272
273impl DispatchError {
274    fn into_error(self) -> ClientError {
275        match self {
276            Self::WriteFailed(e) | Self::Fatal(e) => e,
277        }
278    }
279}
280
281/// A multiplexed, config-driven Thunder RPC client (SPEC-003).
282///
283/// Cheap to share behind an `Arc`; every method takes `&self` and calls
284/// may run concurrently (CLT-010).
285pub struct Client {
286    /// The application's protocol config (SPEC-002): what the peer speaks.
287    config: Config,
288    /// This caller's credentials and timeouts — a different thing.
289    client_config: ClientConfig,
290    endpoint: Endpoint,
291    /// Monotonic id allocator, skipping `PUSH_ID` (CLT-010).
292    next_id: AtomicU32,
293    /// In-flight bound sized `config.max_in_flight` (CLT-012).
294    in_flight: Semaphore,
295    /// Current connection; `None` after close.
296    conn: StdMutex<Option<Arc<Conn>>>,
297    /// Serializes re-dial attempts so one caller reconnects at a time.
298    reconnect: TokioMutex<()>,
299    closed: AtomicBool,
300    /// Push hook shared with every connection's reader task (CLT-060).
301    push_handler: Arc<StdMutex<Option<PushHandler>>>,
302    /// Responses whose id matched no pending call (CLT-013).
303    unknown_drops: Arc<AtomicU64>,
304    handshake_info: StdMutex<HandshakeInfo>,
305}
306
307impl Client {
308    /// Connect with default [`ClientConfig`] and run the configured
309    /// handshake (CLT-001/002).
310    ///
311    /// `config` is the **application's protocol config** (SPEC-002) — the
312    /// handshake, caps and error conventions of the thing you are dialing.
313    /// Not to be confused with [`ClientConfig`], which is *this caller's*
314    /// credentials and timeouts; see [`Client::connect_with`].
315    ///
316    /// `endpoint` accepts every form of [`parse_endpoint`] (CLT-070):
317    /// `scheme://host[:port]` or bare `host:port`.
318    pub async fn connect(endpoint: &str, config: Config) -> Result<Self, ClientError> {
319        Self::connect_with(endpoint, config, ClientConfig::default()).await
320    }
321
322    /// Connect with an explicit [`ClientConfig`].
323    ///
324    /// The two configs are different things and both are required:
325    /// - `config`: the application's **protocol** config (SPEC-002) — what
326    ///   the peer speaks;
327    /// - `client_config`: **this caller's** credentials and timeouts.
328    pub async fn connect_with(
329        endpoint: &str,
330        config: Config,
331        client_config: ClientConfig,
332    ) -> Result<Self, ClientError> {
333        let endpoint = parse_endpoint(endpoint, &config)?;
334        let client = Self {
335            next_id: AtomicU32::new(1),
336            in_flight: Semaphore::new(config.max_in_flight),
337            conn: StdMutex::new(None),
338            reconnect: TokioMutex::new(()),
339            closed: AtomicBool::new(false),
340            push_handler: Arc::new(StdMutex::new(None)),
341            unknown_drops: Arc::new(AtomicU64::new(0)),
342            handshake_info: StdMutex::new(HandshakeInfo::default()),
343            endpoint,
344            config,
345            client_config,
346        };
347        let conn = client.establish().await?;
348        *lock(&client.conn) = Some(conn);
349        Ok(client)
350    }
351
352    /// Issue one call with the client's default timeout (CLT-020).
353    ///
354    /// Concurrent callers multiplex over the one connection; completion
355    /// order follows the server, not submission order (CLT-010).
356    pub async fn call(
357        &self,
358        command: impl Into<String>,
359        args: Vec<Value>,
360    ) -> Result<Value, ClientError> {
361        let command = command.into();
362        self.call_with_timeout(&command, args, self.client_config.call_timeout)
363            .await
364    }
365
366    /// Issue one call with a per-call timeout override (CLT-020).
367    pub async fn call_with_timeout(
368        &self,
369        command: &str,
370        args: Vec<Value>,
371        timeout: Duration,
372    ) -> Result<Value, ClientError> {
373        // CLT-012: bounded in-flight — excess calls wait here, never refused.
374        let _permit = self
375            .in_flight
376            .acquire()
377            .await
378            .map_err(|_| Self::closed_error())?;
379        let mut redials_left = RECONNECT_ATTEMPTS;
380        loop {
381            let conn = self.live_conn(&mut redials_left).await?;
382            match self.dispatch(&conn, command, args.clone(), timeout).await {
383                Ok(value) => return Ok(value),
384                Err(DispatchError::Fatal(err)) => return Err(err),
385                Err(DispatchError::WriteFailed(err)) => {
386                    if redials_left == 0 {
387                        return Err(err);
388                    }
389                    // The frame never hit the wire: reconnect and resend.
390                }
391            }
392        }
393    }
394
395    /// Register the push hook (CLT-060). Frames with `id == PUSH_ID` are
396    /// routed here under `PushPolicy::Enabled` and never matched against
397    /// pending calls. The handler runs on the reader task.
398    pub fn on_push<F>(&self, handler: F)
399    where
400        F: Fn(Value) + Send + Sync + 'static,
401    {
402        *lock(&self.push_handler) = Some(Arc::new(handler));
403    }
404
405    /// Explicit, idempotent close (CLT-004): fails all in-flight calls
406    /// with a typed connection-closed error and shuts the socket down.
407    pub async fn close(&self) {
408        self.closed.store(true, Ordering::SeqCst);
409        self.in_flight.close();
410        let conn = lock(&self.conn).take();
411        if let Some(conn) = conn {
412            // Dropping the sender ends the writer loop, which flushes and
413            // shuts the socket down on its way out.
414            conn.kill(&Self::closed_error());
415        }
416    }
417
418    /// `true` once the current connection's handshake authenticated
419    /// (CLT-003 — auth is sticky per connection).
420    pub fn is_authenticated(&self) -> bool {
421        lock(&self.handshake_info).authenticated
422    }
423
424    /// Capabilities the server advertised in the `HELLO` reply.
425    pub fn capabilities(&self) -> Vec<String> {
426        lock(&self.handshake_info).capabilities.clone()
427    }
428
429    /// Snapshot of what the handshake learned (CLT-002).
430    pub fn handshake_info(&self) -> HandshakeInfo {
431        lock(&self.handshake_info).clone()
432    }
433
434    /// How many responses matched no pending call and were dropped
435    /// (CLT-013 — client stats, never fatal).
436    pub fn unknown_response_drops(&self) -> u64 {
437        self.unknown_drops.load(Ordering::Relaxed)
438    }
439
440    /// The application's protocol config this client drives its behavior
441    /// from (SPEC-002).
442    pub fn config(&self) -> &Config {
443        &self.config
444    }
445
446    // ── internals ──────────────────────────────────────────────────────
447
448    fn closed_error() -> ClientError {
449        ClientError::Connection {
450            message: "client is closed".to_owned(),
451        }
452    }
453
454    /// Allocate the next request id, skipping `PUSH_ID` (CLT-010).
455    fn alloc_id(&self) -> u32 {
456        loop {
457            let id = self.next_id.fetch_add(1, Ordering::Relaxed);
458            if id != PUSH_ID {
459                return id;
460            }
461        }
462    }
463
464    /// Return the current live connection, lazily reconnecting when it is
465    /// dead or absent: up to `redials_left` re-dial + re-handshake
466    /// attempts with capped backoff (CLT-030). Never replays in-flight
467    /// calls — those already failed typed when the connection died
468    /// (CLT-031).
469    async fn live_conn(&self, redials_left: &mut u32) -> Result<Arc<Conn>, ClientError> {
470        if self.closed.load(Ordering::SeqCst) {
471            return Err(Self::closed_error());
472        }
473        let current = { lock(&self.conn).clone() };
474        if let Some(conn) = current {
475            if conn.is_alive() {
476                return Ok(conn);
477            }
478        }
479        let _guard = self.reconnect.lock().await;
480        if self.closed.load(Ordering::SeqCst) {
481            return Err(Self::closed_error());
482        }
483        // Another caller may have reconnected while we waited.
484        let current = { lock(&self.conn).clone() };
485        if let Some(conn) = current {
486            if conn.is_alive() {
487                return Ok(conn);
488            }
489        }
490        let mut last_err = ClientError::Connection {
491            message: "connection is dead".to_owned(),
492        };
493        let mut backoff = BACKOFF_BASE;
494        while *redials_left > 0 {
495            *redials_left -= 1;
496            match self.establish().await {
497                Ok(conn) => {
498                    *lock(&self.conn) = Some(Arc::clone(&conn));
499                    return Ok(conn);
500                }
501                // An auth rejection is deterministic — retrying cannot fix it.
502                Err(err @ ClientError::Auth { .. }) => return Err(err),
503                Err(err) => {
504                    last_err = err;
505                    if *redials_left > 0 {
506                        tokio::time::sleep(backoff).await;
507                        backoff = (backoff * 2).min(BACKOFF_CAP);
508                    }
509                }
510            }
511        }
512        Err(last_err)
513    }
514
515    /// Dial (with the connect timeout, TCP_NODELAY on — CLT-001), spawn
516    /// the reader task, and run the profile handshake (CLT-002).
517    async fn establish(&self) -> Result<Arc<Conn>, ClientError> {
518        let addr = (self.endpoint.host.as_str(), self.endpoint.port);
519        let stream =
520            tokio::time::timeout(self.client_config.connect_timeout, TcpStream::connect(addr))
521                .await
522                .map_err(|_| ClientError::Timeout)?
523                .map_err(|e| ClientError::Connection {
524                    message: format!(
525                        "connect to {}:{} failed: {e}",
526                        self.endpoint.host, self.endpoint.port
527                    ),
528                })?;
529        stream
530            .set_nodelay(true)
531            .map_err(|e| ClientError::Connection {
532                message: format!("TCP_NODELAY failed: {e}"),
533            })?;
534        let (read_half, write_half) = stream.into_split();
535        let shared = Arc::new(ConnShared {
536            pending: StdMutex::new(HashMap::new()),
537            alive: AtomicBool::new(true),
538        });
539        let reader_task = tokio::spawn(reader_loop(
540            BufReader::new(read_half),
541            Arc::clone(&shared),
542            self.config.max_frame_bytes,
543            self.config.push,
544            Arc::clone(&self.push_handler),
545            Arc::clone(&self.unknown_drops),
546        ));
547        // Bounded so a caller that outruns the socket waits here rather
548        // than growing an unbounded queue; the in-flight semaphore
549        // (CLT-012) already bounds how many can be waiting.
550        let (write_tx, write_rx) = mpsc::channel::<Vec<u8>>(1024);
551        let writer_task = tokio::spawn(writer_loop(write_half, write_rx, Arc::clone(&shared)));
552        let conn = Arc::new(Conn {
553            shared,
554            write_tx,
555            reader_task,
556            writer_task,
557        });
558        // On handshake failure the `Err` return drops `conn`, whose Drop
559        // aborts the reader and closes the socket.
560        let info = self.handshake(&conn).await?;
561        *lock(&self.handshake_info) = info;
562        Ok(conn)
563    }
564
565    /// Run the profile handshake before user calls proceed (CLT-002):
566    /// `None` sends nothing; `AuthCommand` sends the optional arg-less
567    /// `HELLO` (when the profile has one) then `AUTH` when credentials are
568    /// configured; `HelloMandatory` sends the `HELLO` map as the first frame
569    /// and parses the reply.
570    ///
571    /// Under `AuthCommand`, no credentials means no `AUTH` frame — which is
572    /// the correct behavior against a deployment that does not require them
573    /// (`auth_required` / `require_auth` off). Enforcement is the server's
574    /// policy, not the profile's.
575    async fn handshake(&self, conn: &Arc<Conn>) -> Result<HandshakeInfo, ClientError> {
576        match self.config.handshake {
577            Handshake::None => Ok(HandshakeInfo::default()),
578            Handshake::AuthCommand => {
579                let Some(credentials) = self.client_config.credentials.clone() else {
580                    return Ok(HandshakeInfo::default());
581                };
582                if self.config.hello_style == HelloStyle::ArgLess {
583                    // Optional metadata HELLO — takes no arguments; the
584                    // reply carries {server, version, proto, id,
585                    // authenticated}. Credentials go in AUTH below.
586                    self.handshake_call(conn, "HELLO", Vec::new()).await?;
587                }
588                let args = match credentials {
589                    Credentials::Token(token) => vec![Value::Str(token)],
590                    Credentials::ApiKey(api_key) => vec![Value::Str(api_key)],
591                    Credentials::UserPass { user, pass } => {
592                        vec![Value::Str(user), Value::Str(pass)]
593                    }
594                };
595                self.handshake_call(conn, "AUTH", args).await?;
596                Ok(HandshakeInfo {
597                    authenticated: true,
598                    capabilities: Vec::new(),
599                })
600            }
601            Handshake::HelloMandatory => {
602                let mut pairs = vec![(Value::Str("version".to_owned()), Value::Int(1))];
603                match &self.client_config.credentials {
604                    Some(Credentials::Token(token)) => {
605                        pairs.push((Value::Str("token".to_owned()), Value::Str(token.clone())));
606                    }
607                    Some(Credentials::ApiKey(api_key)) => {
608                        pairs.push((
609                            Value::Str("api_key".to_owned()),
610                            Value::Str(api_key.clone()),
611                        ));
612                    }
613                    Some(Credentials::UserPass { .. }) => {
614                        return Err(ClientError::Auth {
615                            message: "user/password credentials are not supported by \
616                                      HelloMandatory profiles — use a token or api_key (PRO-001)"
617                                .to_owned(),
618                        });
619                    }
620                    None => {}
621                }
622                let name = self
623                    .client_config
624                    .client_name
625                    .clone()
626                    .unwrap_or_else(|| "thunder-client".to_owned());
627                pairs.push((Value::Str("client_name".to_owned()), Value::Str(name)));
628                let reply = self
629                    .handshake_call(conn, "HELLO", vec![Value::Map(pairs)])
630                    .await?;
631                Ok(HandshakeInfo {
632                    authenticated: reply
633                        .map_get("authenticated")
634                        .and_then(Value::as_bool)
635                        .unwrap_or(false),
636                    capabilities: reply
637                        .map_get("capabilities")
638                        .and_then(Value::as_array)
639                        .map(|caps| {
640                            caps.iter()
641                                .filter_map(|v| v.as_str().map(str::to_owned))
642                                .collect()
643                        })
644                        .unwrap_or_default(),
645                })
646            }
647        }
648    }
649
650    /// One handshake round-trip. Server rejections surface as the typed
651    /// auth class, never a generic error (CLT-003); transport failures
652    /// keep their own class.
653    async fn handshake_call(
654        &self,
655        conn: &Arc<Conn>,
656        command: &str,
657        args: Vec<Value>,
658    ) -> Result<Value, ClientError> {
659        self.dispatch(conn, command, args, self.client_config.call_timeout)
660            .await
661            .map_err(|e| match e.into_error() {
662                ClientError::Server { message, .. } | ClientError::Auth { message } => {
663                    ClientError::Auth { message }
664                }
665                other => other,
666            })
667    }
668
669    /// One request/response attempt on one connection: register the
670    /// pending entry, write the frame (serialized, CLT-011), await the
671    /// demuxed response under the timeout (CLT-020).
672    async fn dispatch(
673        &self,
674        conn: &Arc<Conn>,
675        command: &str,
676        args: Vec<Value>,
677        timeout: Duration,
678    ) -> Result<Value, DispatchError> {
679        let id = self.alloc_id();
680        let (tx, rx) = oneshot::channel();
681        {
682            // Register under the pending lock, checking liveness inside
683            // the same critical section the poisoner drains under — a
684            // dying connection either fails this entry or is seen dead.
685            let mut pending = lock(&conn.shared.pending);
686            if !conn.shared.alive.load(Ordering::SeqCst) {
687                return Err(DispatchError::WriteFailed(ClientError::Connection {
688                    message: "connection is dead".to_owned(),
689                }));
690            }
691            pending.insert(id, tx);
692        }
693        let request = Request {
694            id,
695            command: command.to_owned(),
696            args,
697        };
698        // Encode once, hand the frame to the writer task: no caller ever
699        // touches the socket, so N concurrent calls cost one flush instead
700        // of N syscalls (CLT-011).
701        let frame = match encode_frame(&request) {
702            Ok(frame) => frame,
703            Err(e) => {
704                lock(&conn.shared.pending).remove(&id);
705                let err = ClientError::Connection {
706                    message: format!("encode failed: {e}"),
707                };
708                return Err(DispatchError::WriteFailed(err));
709            }
710        };
711        if conn.write_tx.send(frame).await.is_err() {
712            lock(&conn.shared.pending).remove(&id);
713            let err = ClientError::Connection {
714                message: "write failed: connection closed".to_owned(),
715            };
716            conn.kill(&err);
717            return Err(DispatchError::WriteFailed(err));
718        }
719        match tokio::time::timeout(timeout, rx).await {
720            // CLT-020: remove the pending entry on timeout; a late
721            // response to this id is dropped per CLT-013.
722            Err(_elapsed) => {
723                lock(&conn.shared.pending).remove(&id);
724                Err(DispatchError::Fatal(ClientError::Timeout))
725            }
726            // Poison always sends before dropping senders; a bare drop
727            // still means the connection went away.
728            Ok(Err(_recv)) => Err(DispatchError::Fatal(ClientError::Connection {
729                message: "connection closed before response".to_owned(),
730            })),
731            Ok(Ok(Err(poison))) => Err(DispatchError::Fatal(poison)),
732            Ok(Ok(Ok(response))) => match response.result {
733                Ok(value) => Ok(value),
734                Err(message) => Err(DispatchError::Fatal(ClientError::from_server_message(
735                    message,
736                    self.config.error_codes,
737                ))),
738            },
739        }
740    }
741}
742
743impl std::fmt::Debug for Client {
744    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
745        f.debug_struct("Client")
746            .field("scheme", &self.config.scheme)
747            .field("endpoint", &self.endpoint)
748            .field("closed", &self.closed.load(Ordering::Relaxed))
749            .finish_non_exhaustive()
750    }
751}
752
753impl Drop for Client {
754    fn drop(&mut self) {
755        // CLT-004: dropping the client closes the socket and fails all
756        // in-flight calls with a typed connection-closed error.
757        if let Ok(mut guard) = self.conn.lock() {
758            if let Some(conn) = guard.take() {
759                conn.kill(&Self::closed_error());
760            }
761        }
762    }
763}
764
765/// The background reader (CLT-010): reads frames with the profile cap,
766/// demuxes by id, routes push frames (CLT-060), drops unknown ids
767/// (CLT-013), and poisons the connection on any read failure (CLT-014).
768async fn reader_loop(
769    mut reader: BufReader<OwnedReadHalf>,
770    shared: Arc<ConnShared>,
771    max_frame_bytes: usize,
772    push: PushPolicy,
773    push_handler: Arc<StdMutex<Option<PushHandler>>>,
774    unknown_drops: Arc<AtomicU64>,
775) {
776    let err = loop {
777        match read_response_with_limit(&mut reader, max_frame_bytes).await {
778            Ok((response, _frame_bytes)) => {
779                if response.id == PUSH_ID {
780                    match push {
781                        PushPolicy::Enabled => {
782                            let handler = { lock(&push_handler).clone() };
783                            if let (Some(handler), Ok(value)) = (handler, response.result) {
784                                handler(value);
785                            }
786                        }
787                        PushPolicy::Reserved => {
788                            // Protocol error: poison per CLT-014.
789                            break ClientError::Decode {
790                                message: "server sent a push frame but the profile reserves \
791                                          PUSH_ID (CLT-060)"
792                                    .to_owned(),
793                            };
794                        }
795                    }
796                    continue;
797                }
798                let tx = lock(&shared.pending).remove(&response.id);
799                match tx {
800                    Some(tx) => {
801                        let _ = tx.send(Ok(response));
802                    }
803                    // CLT-013: unknown id — count and drop, never fatal.
804                    None => {
805                        unknown_drops.fetch_add(1, Ordering::Relaxed);
806                    }
807                }
808            }
809            Err(e) => break classify_read_error(&e),
810        }
811    };
812    // CLT-014: fail all pending calls typed; dropping the read half on
813    // return closes our side of the socket.
814    shared.poison(&err);
815}
816
817/// Map a reader I/O failure onto the stable error classes. The wire layer
818/// reports both cap violations and malformed MessagePack as
819/// `InvalidData`; the cap message is pinned by thunder::wire
820/// ("… exceeds limit …", WIRE-020/021).
821fn classify_read_error(e: &std::io::Error) -> ClientError {
822    if e.kind() == std::io::ErrorKind::InvalidData {
823        let message = e.to_string();
824        if message.contains("exceeds limit") {
825            ClientError::FrameTooLarge { message }
826        } else {
827            ClientError::Decode { message }
828        }
829    } else {
830        ClientError::Connection {
831            message: format!("connection lost: {e}"),
832        }
833    }
834}
835
836/// Lock a std mutex, riding through poisoning (a panicked holder must not
837/// take the whole client down — the guarded state stays consistent).
838fn lock<T>(mutex: &StdMutex<T>) -> MutexGuard<'_, T> {
839    mutex.lock().unwrap_or_else(PoisonError::into_inner)
840}