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