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}