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}