Skip to main content

krabka_client_core/
connection.rs

1//! Single-broker `Connection`.
2//!
3//! A `Connection` holds a TCP socket, reader and writer tasks, and
4//! correlation-ID multiplexing.
5
6use std::{
7    future::Future,
8    io,
9    net::SocketAddr,
10    sync::{
11        Arc, OnceLock,
12        atomic::{AtomicI32, AtomicU64, Ordering},
13    },
14    time::{self, Duration},
15};
16
17use bytes::{BufMut, Bytes, BytesMut};
18use dashmap::DashMap;
19use krabka_ids::{ApiKey, ApiVersion};
20use krabka_protocol::owned::{api_versions_request, api_versions_response};
21use krabka_units::{
22    ByteSize, Time,
23    convert::{ByteSizeExt as _, TimeExt as _},
24    kibibytes, mebibytes, millis, minutes, secs,
25};
26use refined_type::rule::{GreaterI64, GreaterUsize};
27use tokio::{
28    io::{AsyncRead, AsyncWrite, split},
29    net::TcpStream,
30    sync::{RwLockReadGuard, mpsc, oneshot},
31    task::JoinHandle,
32    time::{Instant, sleep_until, timeout},
33};
34use tokio_util::sync::CancellationToken;
35use tracing::field;
36
37#[cfg(test)]
38use crate::mock;
39use crate::{
40    error::{AuthenticationError, ClientError},
41    framing, reauth,
42    request::ProtocolRequest,
43    sasl, security, telemetry,
44    transport::{SocketOptions, dial_address},
45    version::ApiVersionTable,
46};
47
48/// Trait alias for the duplex stream types `Connection::from_stream` accepts,
49/// such as `TcpStream` and `tokio_rustls::client::TlsStream`.
50///
51/// The trait is boxed so callers can hand in different stream types through
52/// one path.
53pub trait ClientDuplex: AsyncRead + AsyncWrite + Send + Unpin {}
54impl<T: AsyncRead + AsyncWrite + Send + Unpin + ?Sized> ClientDuplex for T {}
55
56type Pending = Arc<DashMap<i32, oneshot::Sender<Result<Bytes, ClientError>>>>;
57
58/// Kafka API key for `ApiVersionsRequest` / `ApiVersionsResponse`.
59///
60/// `Connection` uses this key to apply the response-header quirk:
61/// `ApiVersionsResponse` always uses `ResponseHeader v0` (no tagged-fields
62/// byte) even when the request version is flexible (v3+).
63const API_VERSIONS_KEY: i16 = 18;
64
65/// Default deadline for one client DNS lookup.
66pub const DEFAULT_CLIENT_DNS_TIMEOUT: Time = secs(10);
67/// Kafka's `socket.connection.setup.timeout.ms` default: the TCP connect
68/// deadline of the first attempt to a broker.
69pub const DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT: Time = secs(10);
70/// Kafka's `socket.connection.setup.timeout.max.ms` default: the connect
71/// deadline grows exponentially per failed attempt up to this value.
72pub const DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT_MAX: Time = secs(30);
73/// Kafka's `reconnect.backoff.ms` default.
74pub const DEFAULT_RECONNECT_BACKOFF: Time = millis(50);
75/// Kafka's `reconnect.backoff.max.ms` default.
76pub const DEFAULT_RECONNECT_BACKOFF_MAX: Time = secs(1);
77/// Kafka's producer and consumer `connections.max.idle.ms` default (9
78/// minutes, below the broker's 10 minutes).
79pub const DEFAULT_CONNECTIONS_MAX_IDLE: Time = minutes(9);
80/// Kafka's producer, consumer and admin `send.buffer.bytes` default.
81pub const DEFAULT_SEND_BUFFER: ByteSize = kibibytes(128);
82/// Kafka's consumer and admin `receive.buffer.bytes` default. The producer
83/// default is 32 KiB.
84pub const DEFAULT_RECEIVE_BUFFER: ByteSize = kibibytes(64);
85/// Default deadline for one client request.
86pub const DEFAULT_CLIENT_REQUEST_TIMEOUT: Time = secs(30);
87/// Default capacity of one connection's pending request dispatch queue.
88pub const DEFAULT_CONNECTION_DISPATCH_QUEUE_CAPACITY: usize = 64;
89/// Fixed security ceiling for accepted client frames.
90pub const MAX_CLIENT_FRAME_BYTES: ByteSize = mebibytes(100);
91/// Default maximum accepted client frame size.
92pub const DEFAULT_CLIENT_FRAME_MAX: ByteSize = MAX_CLIENT_FRAME_BYTES;
93/// [`DEFAULT_CLIENT_FRAME_MAX`] as a byte count.
94const DEFAULT_CLIENT_FRAME_MAX_BYTES: usize = 100 * 1024 * 1024;
95
96/// Positive, whole-millisecond DNS lookup deadline.
97///
98/// Stores the validated millisecond count so policy structs can retain `Eq`
99/// while public configuration boundaries use dimensioned [`Time`] values.
100#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
101pub struct ClientDnsTimeout(i64);
102
103impl ClientDnsTimeout {
104    /// Validate a DNS lookup deadline.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error when the duration is non-finite, zero, negative,
109    /// fractional in milliseconds, or cannot be represented as `i64`
110    /// milliseconds.
111    pub fn new(value: Time) -> Result<Self, String> {
112        let milliseconds = GreaterI64::<0>::new(value.millis_i64())
113            .map_err(|error| format!("client DNS timeout: {error}"))?
114            .into_value();
115        if !value.secs_f64().is_finite() || Time::from_millis(milliseconds) != value {
116            return Err("client DNS timeout must be a whole number of milliseconds".to_owned());
117        }
118        Ok(Self(milliseconds))
119    }
120
121    /// Return the validated timeout.
122    #[must_use]
123    pub fn time(self) -> Time {
124        Time::from_millis(self.0)
125    }
126
127    /// Return the validated timeout in milliseconds.
128    #[must_use]
129    pub const fn milliseconds(self) -> i64 {
130        self.0
131    }
132}
133
134impl Default for ClientDnsTimeout {
135    fn default() -> Self {
136        Self(DEFAULT_CLIENT_DNS_TIMEOUT.millis_i64())
137    }
138}
139
140/// Positive capacity of one connection's pending request dispatch queue.
141#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
142pub struct ConnectionDispatchQueueCapacity(usize);
143
144impl ConnectionDispatchQueueCapacity {
145    /// Validate a dispatch queue capacity.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error when `value` is zero.
150    pub fn new(value: usize) -> Result<Self, String> {
151        GreaterUsize::<0>::new(value)
152            .map(|value| Self(value.into_value()))
153            .map_err(|error| format!("client dispatch queue capacity: {error}"))
154    }
155
156    /// Return the validated capacity.
157    #[must_use]
158    pub const fn get(self) -> usize {
159        self.0
160    }
161}
162
163impl Default for ConnectionDispatchQueueCapacity {
164    fn default() -> Self {
165        Self(DEFAULT_CONNECTION_DISPATCH_QUEUE_CAPACITY)
166    }
167}
168
169/// Positive whole-byte accepted-frame limit bounded by the fixed security ceiling.
170#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Into)]
171pub struct ClientFrameMax(usize);
172
173impl ClientFrameMax {
174    /// Return the validated byte count.
175    #[must_use]
176    pub const fn bytes(self) -> usize {
177        self.0
178    }
179
180    /// Return the validated limit as a dimensioned quantity.
181    #[must_use]
182    pub fn size(self) -> ByteSize {
183        ByteSize::from_bytes(u64::try_from(self.0).unwrap_or(u64::MAX))
184    }
185}
186
187impl TryFrom<ByteSize> for ClientFrameMax {
188    type Error = String;
189
190    fn try_from(value: ByteSize) -> Result<Self, Self::Error> {
191        let bytes = value.bytes_f64();
192        if !bytes.is_finite()
193            || bytes.fract() != 0.0
194            || !(1.0..=MAX_CLIENT_FRAME_BYTES.bytes_f64()).contains(&bytes)
195        {
196            return Err(
197                "client frame max must be a positive whole-byte value no greater than 100MiB"
198                    .to_owned(),
199            );
200        }
201        usize::try_from(value.bytes_u64())
202            .map(Self)
203            .map_err(|_| "client frame max does not fit usize".to_owned())
204    }
205}
206
207impl Default for ClientFrameMax {
208    fn default() -> Self {
209        Self(DEFAULT_CLIENT_FRAME_MAX_BYTES)
210    }
211}
212
213/// How the client resolves broker host names (Kafka `client.dns.lookup`).
214#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
215pub enum ClientDnsLookup {
216    /// `use_all_dns_ips`: resolve each host to all its addresses of the family
217    /// of the first address, and try them in turn.
218    #[default]
219    UseAllDnsIps,
220    /// `resolve_canonical_bootstrap_servers_only`: replace each bootstrap
221    /// address with the canonical host name of its reverse lookup, then use
222    /// all addresses as `UseAllDnsIps` does.
223    ResolveCanonicalBootstrapServersOnly,
224}
225
226/// Connect-time + per-request configuration knobs.
227#[derive(Debug, Clone)]
228pub struct ConnectionOptions {
229    pub client_id: String,
230    pub dns_timeout: ClientDnsTimeout,
231    /// The TCP connect deadline of one attempt
232    /// (`socket.connection.setup.timeout.ms`). The broker pool doubles it,
233    /// with jitter, for each failed attempt to the same broker, up to
234    /// [`Self::socket_connection_setup_timeout_max`].
235    pub socket_connection_setup_timeout: Time,
236    /// `socket.connection.setup.timeout.max.ms`.
237    pub socket_connection_setup_timeout_max: Time,
238    /// The wait before the broker pool connects again to a broker after a
239    /// failure or a disconnect (`reconnect.backoff.ms`). It doubles, with
240    /// jitter, for each failure in a row.
241    pub reconnect_backoff: Time,
242    /// `reconnect.backoff.max.ms`.
243    pub reconnect_backoff_max: Time,
244    /// The connection closes after this time with no request and no response
245    /// (`connections.max.idle.ms`).
246    pub connections_max_idle: Time,
247    /// `client.dns.lookup`.
248    pub client_dns_lookup: ClientDnsLookup,
249    /// The socket send buffer (`send.buffer.bytes`). `None` keeps the
250    /// operating system default, as Kafka's `-1` does.
251    pub send_buffer: Option<ByteSize>,
252    /// The socket receive buffer (`receive.buffer.bytes`). `None` keeps the
253    /// operating system default.
254    pub receive_buffer: Option<ByteSize>,
255    pub request_timeout: Time,
256    pub dispatch_queue_capacity: ConnectionDispatchQueueCapacity,
257    pub frame_max: ClientFrameMax,
258    /// Client-side TLS/SASL policy. `None` = plaintext (default).
259    ///
260    /// This field is boxed so `ConnectionOptions` stays small. Many call
261    /// sites clone `ConnectionOptions` and embed it in connection-building
262    /// futures, and `ClientSecurity` carries several `String`/`PathBuf`
263    /// fields that would otherwise make every such future large.
264    pub security: Option<Box<security::ClientSecurity>>,
265    /// The network metrics of a client that pushes its metrics (KIP-714).
266    /// [`Connection::connect_with_options`] counts the new connection in
267    /// them. `None` counts nowhere.
268    pub network_metrics: Option<telemetry::NetworkMetrics>,
269}
270
271impl ConnectionOptions {
272    /// Validate transport budgets before converting durations or opening sockets.
273    ///
274    /// # Errors
275    /// Rejects nonfinite, unrepresentable or invalid durations, inverted retry
276    /// bounds, nonpositive or fractional socket buffer sizes, and a SASL
277    /// mechanism this build cannot run: GSSAPI without the `gssapi` feature.
278    pub fn validate(&self) -> Result<(), String> {
279        for (name, value, allow_zero) in [
280            ("request_timeout", self.request_timeout, false),
281            (
282                "socket_connection_setup_timeout",
283                self.socket_connection_setup_timeout,
284                false,
285            ),
286            (
287                "socket_connection_setup_timeout_max",
288                self.socket_connection_setup_timeout_max,
289                false,
290            ),
291            ("reconnect_backoff", self.reconnect_backoff, true),
292            ("reconnect_backoff_max", self.reconnect_backoff_max, true),
293            ("connections_max_idle", self.connections_max_idle, true),
294        ] {
295            let seconds = value.secs_f64();
296            let duration = Duration::try_from_secs_f64(seconds);
297            if !seconds.is_finite()
298                || seconds < 0.0
299                || duration.as_ref().is_err()
300                || (!allow_zero && duration.as_ref().is_ok_and(Duration::is_zero))
301                || duration
302                    .as_ref()
303                    .is_ok_and(|duration| time::Instant::now().checked_add(*duration).is_none())
304            {
305                return Err(format!(
306                    "{name} must be {} and representable as a transport deadline",
307                    if allow_zero {
308                        "nonnegative"
309                    } else {
310                        "positive"
311                    }
312                ));
313            }
314        }
315        if self.socket_connection_setup_timeout_max < self.socket_connection_setup_timeout {
316            return Err("socket_connection_setup_timeout_max must not be below socket_connection_setup_timeout".into());
317        }
318        if self.reconnect_backoff_max < self.reconnect_backoff {
319            return Err("reconnect_backoff_max must not be below reconnect_backoff".into());
320        }
321        for (name, value) in [
322            ("send_buffer", self.send_buffer),
323            ("receive_buffer", self.receive_buffer),
324        ] {
325            if let Some(value) = value {
326                let bytes = value.bytes_f64();
327                if !bytes.is_finite()
328                    || bytes < 1.0
329                    || bytes > f64::from(i32::MAX)
330                    || bytes.fract().abs() > f64::EPSILON
331                {
332                    return Err(format!(
333                        "{name} must contain positive whole bytes fitting i32"
334                    ));
335                }
336            }
337        }
338        if let Some(mechanism) = self
339            .security
340            .as_ref()
341            .and_then(|security| security.sasl.as_ref())
342            .map(sasl::SaslCredentials::mechanism)
343            && !sasl::mechanism_supported(mechanism)
344        {
345            return Err(format!(
346                "SASL/{}: {}",
347                mechanism.wire_name(),
348                sasl::GSSAPI_UNAVAILABLE
349            ));
350        }
351        Ok(())
352    }
353}
354
355impl Default for ConnectionOptions {
356    fn default() -> Self {
357        Self {
358            client_id: "krabka".into(),
359            dns_timeout: ClientDnsTimeout::default(),
360            socket_connection_setup_timeout: DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT,
361            socket_connection_setup_timeout_max: DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT_MAX,
362            reconnect_backoff: DEFAULT_RECONNECT_BACKOFF,
363            reconnect_backoff_max: DEFAULT_RECONNECT_BACKOFF_MAX,
364            connections_max_idle: DEFAULT_CONNECTIONS_MAX_IDLE,
365            client_dns_lookup: ClientDnsLookup::default(),
366            send_buffer: Some(DEFAULT_SEND_BUFFER),
367            receive_buffer: Some(DEFAULT_RECEIVE_BUFFER),
368            request_timeout: DEFAULT_CLIENT_REQUEST_TIMEOUT,
369            dispatch_queue_capacity: ConnectionDispatchQueueCapacity::default(),
370            frame_max: ClientFrameMax::default(),
371            security: None,
372            network_metrics: None,
373        }
374    }
375}
376
377/// A connection to a single Kafka broker.
378#[derive(Clone)]
379pub struct Connection {
380    inner: Arc<ConnectionInner>,
381}
382
383struct ConnectionInner {
384    versions: ApiVersionTable,
385    options: ConnectionOptions,
386    next_corr_id: AtomicI32,
387    pending: Pending,
388    writer_tx: mpsc::Sender<DispatchItem>,
389    shutdown: CancellationToken,
390    /// SASL re-authentication state (KIP-368), for a connection whose broker
391    /// sent a session lifetime.
392    reauth: Option<reauth::Reauth>,
393    /// The network metrics of the client, once attached. They count the
394    /// requests after the `ApiVersions` exchange.
395    metrics: OnceLock<telemetry::ConnectionMetrics>,
396    _reader: JoinHandle<()>,
397    _writer: JoinHandle<()>,
398}
399
400impl ConnectionInner {
401    /// The next correlation id of a normal request. As Kafka's
402    /// `NetworkClient.nextCorrelationId` does, the ids wrap to 0 before the
403    /// range that SASL re-authentication reserves.
404    fn next_correlation_id(&self) -> i32 {
405        let last_normal = sasl::MIN_RESERVED_CORRELATION_ID - 1;
406        self.next_corr_id
407            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
408                Some(if current >= last_normal {
409                    0
410                } else {
411                    current + 1
412                })
413            })
414            .unwrap_or(0)
415    }
416}
417
418struct DispatchItem {
419    bytes: Bytes,
420}
421
422/// Classify a failed TLS handshake.
423///
424/// Kafka's `SslTransportLayer.maybeProcessHandshakeFailure` raises an
425/// `SslAuthenticationException` for an error from the TLS engine, and keeps a
426/// `close_notify` during the handshake and a plain I/O error retriable.
427/// `tokio-rustls` wraps an error from the TLS engine as the inner error of the
428/// `io::Error`.
429fn tls_handshake_error(addr: SocketAddr, source: io::Error) -> ClientError {
430    let rejected = source
431        .get_ref()
432        .and_then(|inner| inner.downcast_ref::<rustls::Error>())
433        .is_some_and(|error| {
434            !matches!(
435                error,
436                rustls::Error::AlertReceived(rustls::AlertDescription::CloseNotify)
437            )
438        });
439    if rejected {
440        ClientError::Authentication {
441            addr,
442            source: AuthenticationError::Tls(source),
443        }
444    } else {
445        ClientError::Tls { addr, source }
446    }
447}
448
449/// Classify a failed SASL exchange. Only a rejection is an authentication
450/// failure.
451pub fn sasl_error(addr: SocketAddr, source: sasl::OutboundSaslError) -> ClientError {
452    match source {
453        sasl::OutboundSaslError::Authentication(error) => ClientError::Authentication {
454            addr,
455            source: error.into(),
456        },
457        source => ClientError::Sasl { addr, source },
458    }
459}
460
461/// The last time that a connection sent or received a frame, as milliseconds
462/// since the connection started.
463struct Activity {
464    started: Instant,
465    last_ms: AtomicU64,
466}
467
468impl Activity {
469    fn new() -> Self {
470        Self {
471            started: Instant::now(),
472            last_ms: AtomicU64::new(0),
473        }
474    }
475
476    fn touch(&self) {
477        let elapsed = u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX);
478        self.last_ms.fetch_max(elapsed, Ordering::Relaxed);
479    }
480
481    fn last(&self) -> Instant {
482        self.started + Duration::from_millis(self.last_ms.load(Ordering::Relaxed))
483    }
484}
485
486/// Open a TCP connection to `addr` through [`crate::transport::dial`], with
487/// the `send.buffer.bytes` and `receive.buffer.bytes` of `options`.
488async fn tcp_connect(
489    addr: SocketAddr,
490    options: &ConnectionOptions,
491) -> Result<TcpStream, ClientError> {
492    let socket = SocketOptions {
493        send_buffer: options.send_buffer,
494        receive_buffer: options.receive_buffer,
495        nodelay: true,
496    };
497    dial_address(addr, socket)
498        .await
499        .map_err(|source| ClientError::Connect { addr, source })
500}
501
502/// Run `setup` within `options.socket_connection_setup_timeout`.
503///
504/// Kafka's connection setup timeout covers a node in the `CONNECTING` state:
505/// the TCP connection and the TLS and SASL handshakes, until the client sends
506/// `ApiVersions` (`ClusterConnectionStates.checkingApiVersions`).
507async fn within_setup_timeout<T>(
508    options: &ConnectionOptions,
509    setup: impl Future<Output = Result<T, ClientError>>,
510) -> Result<T, ClientError> {
511    options.validate().map_err(ClientError::InvalidConfig)?;
512    timeout(options.socket_connection_setup_timeout.to_std(), setup)
513        .await
514        .map_err(|_| ClientError::Timeout(options.socket_connection_setup_timeout))?
515}
516
517impl Connection {
518    /// Connect to `addr`, negotiate API versions, return a usable `Connection`.
519    #[tracing::instrument(level = "debug", skip_all, fields(addr = %addr), err(level = "debug"))]
520    /// # Errors
521    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
522    pub async fn connect(
523        addr: SocketAddr,
524        options: ConnectionOptions,
525    ) -> Result<Self, ClientError> {
526        let stream = within_setup_timeout(&options, tcp_connect(addr, &options)).await?;
527        Self::from_stream(Box::new(stream), options).await
528    }
529
530    /// Connect to `addr` and honour `options.security`.
531    ///
532    /// This method makes a secured (TLS/SASL) dial when a policy is set, and
533    /// a plaintext dial otherwise. It is the single connect entry point for
534    /// every metadata-client site (pool, admin, RLMM fetch loop), so the
535    /// plaintext-versus-secured branch cannot drift between them. The
536    /// plaintext (`None`) path is byte-identical to [`Self::connect`].
537    ///
538    /// # Errors
539    /// Propagates [`Self::connect`] / [`Self::connect_secured`] failures.
540    #[tracing::instrument(
541        level = "debug",
542        skip_all,
543        fields(addr = %addr, secured = options.security.is_some()),
544        err(level = "debug"),
545    )]
546    pub async fn connect_with_options(
547        addr: SocketAddr,
548        options: ConnectionOptions,
549    ) -> Result<Self, ClientError> {
550        let metrics = options.network_metrics.clone();
551        // The TLS and SASL handshakes make a large future. Boxing it keeps the
552        // futures of the callers small, and it keeps the type depth of a
553        // caller that nests several connections below the compiler limit.
554        let connection = match options.security.clone() {
555            Some(sec) => Box::pin(Self::connect_secured(addr, options, sec.as_ref())).await,
556            None => Box::pin(Self::connect(addr, options)).await,
557        }?;
558        if let Some(metrics) = &metrics {
559            connection.attach_metrics(metrics);
560        }
561        Ok(connection)
562    }
563
564    /// Connect to `addr` and apply `security` (TLS then SASL) before the
565    /// API-versions bootstrap.
566    ///
567    /// `Plaintext` is identical to [`Self::connect`].
568    ///
569    /// # Errors
570    ///
571    /// Returns [`ClientError::Connect`] / [`ClientError::Timeout`] on the
572    /// TCP dial, [`ClientError::Tls`] or [`ClientError::Sasl`] if the TLS
573    /// handshake or the SASL exchange fails with no verdict from the peer,
574    /// [`ClientError::Authentication`] if the peer rejects authentication,
575    /// [`ClientError::InvalidConfig`] if the TLS settings do not build, or
576    /// [`ClientError::Io`] if the security policy is internally inconsistent
577    /// (e.g. a TLS protocol with no TLS config).
578    #[tracing::instrument(
579        level = "debug",
580        skip_all,
581        fields(addr = %addr, protocol = ?security.protocol),
582        err(level = "debug"),
583    )]
584    pub async fn connect_secured(
585        addr: SocketAddr,
586        options: ConnectionOptions,
587        security: &security::ClientSecurity,
588    ) -> Result<Self, ClientError> {
589        let (stream, reauth) = within_setup_timeout(&options, async {
590            let tcp = tcp_connect(addr, &options).await?;
591            Self::secure_stream(addr, tcp, &options, security).await
592        })
593        .await?;
594        Self::from_stream_with_reauth(stream, options, reauth).await
595    }
596
597    /// Run the TLS and SASL handshakes of `security` over `tcp`.
598    async fn secure_stream(
599        addr: SocketAddr,
600        tcp: TcpStream,
601        options: &ConnectionOptions,
602        security: &security::ClientSecurity,
603    ) -> Result<(Box<dyn ClientDuplex>, Option<reauth::Reauth>), ClientError> {
604        // 1. TLS (if the protocol demands it).
605        let mut stream: Box<dyn ClientDuplex> = if security.protocol.requires_tls() {
606            let tls = security.tls.as_ref().ok_or_else(|| {
607                ClientError::Io(io::Error::other("TLS protocol without tls config"))
608            })?;
609            let connector = tls
610                .connector()
611                .map_err(|error| ClientError::InvalidConfig(error.to_string()))?;
612            let server_name = if tls.server_name.is_empty() {
613                addr.ip().to_string()
614            } else {
615                tls.server_name.clone()
616            };
617            let sni = tokio_rustls::rustls::pki_types::ServerName::try_from(server_name)
618                .map_err(|e| ClientError::Io(io::Error::other(format!("invalid SNI: {e}"))))?;
619            let s = connector
620                .connect(sni, tcp)
621                .await
622                .map_err(|source| tls_handshake_error(addr, source))?;
623            Box::new(s)
624        } else {
625            Box::new(tcp)
626        };
627
628        // 2. SASL (if the protocol demands it).
629        let mut reauth = None;
630        if security.protocol.requires_sasl() {
631            let creds = security.sasl.as_ref().ok_or_else(|| {
632                ClientError::Io(io::Error::other("SASL protocol without credentials"))
633            })?;
634            // GSSAPI SPN host: explicit `sasl_host`, else TLS SNI, else the
635            // connection's target IP, else "localhost". The target IP is a
636            // last resort — for GSSAPI the caller should set `sasl_host` so
637            // the principal matches the broker's advertised hostname.
638            let target = addr.ip().to_string();
639            let server_name = security.sasl_handshake_host(Some(target.as_str()));
640            let session = sasl::outbound_sasl(
641                &mut *stream,
642                creds,
643                server_name,
644                &options.client_id,
645                options.frame_max,
646            )
647            .await
648            .map_err(|source| sasl_error(addr, source))?;
649            reauth = session
650                .needs_reauthentication()
651                .then(|| reauth::Reauth::new(addr, creds.clone(), server_name.to_owned(), session));
652        }
653        Ok((stream, reauth))
654    }
655
656    /// Build a `Connection` over a pre-established, optionally
657    /// pre-authenticated stream.
658    ///
659    /// This method negotiates API versions over the stream and returns a
660    /// usable `Connection`. The broker's `InterBrokerClient` integration
661    /// calls it: the TLS + SASL handshake runs before this call, so the
662    /// stream is already authenticated. From here on the connection's normal
663    /// request / response framing applies.
664    #[tracing::instrument(level = "debug", skip_all, err(level = "debug"))]
665    /// # Errors
666    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
667    /// # Panics
668    /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
669    pub async fn from_stream(
670        stream: Box<dyn ClientDuplex>,
671        options: ConnectionOptions,
672    ) -> Result<Self, ClientError> {
673        Self::from_stream_with_reauth(stream, options, None).await
674    }
675
676    /// [`Self::from_stream`] for a stream that a SASL exchange authenticated,
677    /// with the state to authenticate again before the session ends.
678    pub(crate) async fn from_stream_with_reauth(
679        stream: Box<dyn ClientDuplex>,
680        options: ConnectionOptions,
681        reauth: Option<reauth::Reauth>,
682    ) -> Result<Self, ClientError> {
683        options.validate().map_err(ClientError::InvalidConfig)?;
684        let (writer_tx, writer_rx) =
685            mpsc::channel::<DispatchItem>(options.dispatch_queue_capacity.get());
686        let shutdown = CancellationToken::new();
687        let pending: Pending = Arc::new(DashMap::new());
688        let activity = Arc::new(Activity::new());
689
690        let (reader_handle, writer_handle) = spawn_io_tasks(
691            stream,
692            writer_rx,
693            IoContext {
694                shutdown: shutdown.clone(),
695                pending: Arc::clone(&pending),
696                activity,
697                frame_max: options.frame_max,
698                max_idle: options.connections_max_idle.to_std(),
699            },
700        );
701
702        let mut inner = ConnectionInner {
703            versions: ApiVersionTable::default(),
704            options: options.clone(),
705            next_corr_id: AtomicI32::new(0),
706            pending,
707            writer_tx,
708            shutdown,
709            reauth,
710            metrics: OnceLock::new(),
711            _reader: reader_handle,
712            _writer: writer_handle,
713        };
714        inner.versions = fetch_api_versions(&inner).await?;
715
716        Ok(Self {
717            inner: Arc::new(inner),
718        })
719    }
720
721    /// Return the peer-advertised version range for `api_key`.
722    ///
723    /// Higher-level clients use this to distinguish an API that is absent
724    /// from a listener's surface from one whose advertised versions merely do
725    /// not overlap their codec range.
726    #[must_use]
727    pub fn advertised_api_range(&self, api_key: i16) -> Option<(i16, i16)> {
728        self.inner.versions.broker_range(api_key)
729    }
730
731    /// Send a typed request and await the typed response.
732    ///
733    /// This method negotiates the version from the broker-advertised table
734    /// that `connect` populated. It encodes and decodes the request and
735    /// response headers automatically.
736    ///
737    /// # Errors
738    ///
739    /// Returns `ClientError::IncompatibleVersion` if there is no mutually
740    /// supported version, `ClientError::Disconnected` if the I/O loop has
741    /// exited, or `ClientError::Timeout` if no response arrives in time.
742    // cargo-mutants: live-broker send path; not unit-testable
743    #[cfg_attr(test, mutants::skip)]
744    #[tracing::instrument(
745        level = "debug",
746        skip_all,
747        fields(api_key = R::API_KEY, version = field::Empty),
748        err(level = "debug"),
749    )]
750    pub async fn send<R: ProtocolRequest>(&self, req: R) -> Result<R::Response, ClientError> {
751        // 1. Negotiate version.
752        let version = self.inner.versions.negotiate::<R>()?;
753        tracing::Span::current().record("version", version);
754
755        // 2. Allocate correlation ID.
756        let corr_id = self.next_correlation_id();
757
758        // 3. Build request header + encoded body into one frame.
759        //
760        // The header has a trailing tagged-fields byte (header v2) iff the
761        // body is flexible. The `client_id` field is always i16 NULLABLE_STRING
762        // per the upstream `RequestHeader.json` schema.
763        let body_flexible = version >= R::FLEXIBLE_MIN;
764        let mut frame = build_request_header(
765            ApiKey(R::API_KEY),
766            ApiVersion(version),
767            corr_id,
768            &self.inner.options.client_id,
769            body_flexible,
770        )?;
771        req.encode(&mut frame, version)?;
772
773        // 4. Dispatch request and await response.
774        let body_bytes = self.dispatch_request(corr_id, frame).await?;
775
776        // 5. Decode the response.
777        //
778        // The reader has already stripped the 4-byte correlation_id prefix.
779        // What remains is: [ResponseHeader fields after corr_id] + [response body].
780        //
781        // ResponseHeader version rules:
782        //   - ApiVersionsResponse (api_key=18): always ResponseHeader v0, which
783        //     has NO fields after the correlation_id. This is a long-standing
784        //     Kafka asymmetry — even flexible ApiVersions responses use v0 header.
785        //   - All other flexible messages (version >= FLEXIBLE_MIN): ResponseHeader
786        //     v1 adds 1 byte for the tagged-fields count (0x00 when empty).
787        //   - Non-flexible messages: ResponseHeader v0 (no bytes after corr_id).
788        let mut cursor: &[u8] = &body_bytes;
789        let uses_flexible_resp_header = body_flexible && R::API_KEY != API_VERSIONS_KEY;
790        if uses_flexible_resp_header {
791            cursor = skip_tagged_fields(cursor)?;
792        }
793
794        let resp = <R::Response as krabka_protocol::Decode>::decode(&mut cursor, version)?;
795        Ok(resp)
796    }
797
798    /// Encode and enqueue a typed request that intentionally has no response.
799    /// Kafka Produce with `acks=0` is the standard use case.
800    ///
801    /// # Errors
802    ///
803    /// Returns an error if version negotiation or encoding fails, or if the
804    /// connection writer has stopped before accepting the frame.
805    pub async fn send_no_response<R: ProtocolRequest>(&self, req: R) -> Result<(), ClientError> {
806        let version = self.inner.versions.negotiate::<R>()?;
807        let corr_id = self.next_correlation_id();
808        let body_flexible = version >= R::FLEXIBLE_MIN;
809        let mut frame = build_request_header(
810            ApiKey(R::API_KEY),
811            ApiVersion(version),
812            corr_id,
813            &self.inner.options.client_id,
814            body_flexible,
815        )?;
816        req.encode(&mut frame, version)?;
817        let guard = self.reauthenticate_if_due().await?;
818        if let Some(metrics) = self.inner.metrics.get() {
819            metrics.request(frame.len());
820        }
821        let sent = self
822            .inner
823            .writer_tx
824            .send(DispatchItem {
825                bytes: frame.freeze(),
826            })
827            .await
828            .map_err(|_| ClientError::Disconnected);
829        drop(guard);
830        sent
831    }
832
833    /// Send a hand-framed request and await the raw response body.
834    ///
835    /// This method bypasses the typed [`ProtocolRequest`] codegen path so
836    /// callers can speak Krabka-private APIs whose wire types live outside
837    /// `krabka-protocol`, for example the controller's Raft RPCs at api keys
838    /// 1000+.
839    ///
840    /// This method always writes the header as `RequestHeader v2` (flexible)
841    /// with an empty trailing tagged-fields byte. It assumes the response
842    /// uses `ResponseHeader v1` (flexible): the I/O loop strips the 4-byte
843    /// correlation id, and this method strips the leading tagged-fields byte
844    /// before it returns. Callers receive the raw body bytes only.
845    ///
846    /// `body` is the encoded request body, which is everything after the
847    /// request header, exactly as it should appear on the wire.
848    ///
849    /// # Errors
850    ///
851    /// Returns [`ClientError::Disconnected`] if the I/O loop has exited
852    /// or [`ClientError::Timeout`] if no response arrives within the
853    /// configured request timeout.
854    // cargo-mutants: live-broker I/O path; not unit-testable
855    #[cfg_attr(test, mutants::skip)]
856    #[tracing::instrument(
857        level = "debug",
858        skip_all,
859        fields(api_key, api_version),
860        err(level = "debug")
861    )]
862    pub async fn raw_request(
863        &self,
864        api_key: i16,
865        api_version: i16,
866        body: Bytes,
867    ) -> Result<Bytes, ClientError> {
868        let corr_id = self.next_correlation_id();
869
870        // RequestHeader v2 (flexible). Krabka-private api keys are always
871        // declared flexible so the header shape is predictable.
872        let mut frame = build_request_header(
873            ApiKey(api_key),
874            ApiVersion(api_version),
875            corr_id,
876            &self.inner.options.client_id,
877            true,
878        )?;
879        frame.put_slice(&body);
880
881        let body_bytes = self.dispatch_request(corr_id, frame).await?;
882
883        // ResponseHeader v1: the tagged fields after the already-stripped
884        // correlation id.
885        let body = skip_tagged_fields(&body_bytes)?;
886        Ok(body_bytes.slice(body_bytes.len() - body.len()..))
887    }
888
889    /// Negotiated API versions known to this connection.
890    // cargo-mutants: one-line accessor returning a borrowed field
891    #[must_use]
892    #[cfg_attr(test, mutants::skip)]
893    pub fn versions(&self) -> &ApiVersionTable {
894        &self.inner.versions
895    }
896
897    /// The number of requests that wait for a response on this connection.
898    #[must_use]
899    pub fn in_flight(&self) -> usize {
900        self.inner.pending.len()
901    }
902
903    /// The next correlation id of a normal request. As Kafka's
904    /// `NetworkClient.nextCorrelationId` does, the ids wrap to 0 before the
905    /// range that SASL re-authentication reserves.
906    fn next_correlation_id(&self) -> i32 {
907        self.inner.next_correlation_id()
908    }
909
910    /// Whether the connection has closed: the peer closed it, an I/O error
911    /// ended it, it was idle for `connections_max_idle`, or [`Self::close`]
912    /// ran. A closed connection fails every request with `Disconnected`.
913    #[must_use]
914    pub fn is_closed(&self) -> bool {
915        self.inner.shutdown.is_cancelled()
916    }
917
918    /// Close the connection, cancelling all background tasks.
919    // cargo-mutants: teardown; no observable return to assert against
920    #[cfg_attr(test, mutants::skip)]
921    pub fn close(self) {
922        self.inner.shutdown.cancel();
923        // The Arc gets dropped when `self` does; `JoinHandle`s abort naturally.
924    }
925
926    async fn dispatch_request(&self, corr_id: i32, frame: BytesMut) -> Result<Bytes, ClientError> {
927        let guard = self.reauthenticate_if_due().await?;
928        // A caller that drops this future must not leave its id in `pending`,
929        // where it would keep the connection from its idle close.
930        let _registration = PendingRegistration {
931            pending: &self.inner.pending,
932            corr_id,
933        };
934        let metrics = self.inner.metrics.get();
935        if let Some(metrics) = metrics {
936            metrics.request(frame.len());
937        }
938        let rx = self.enqueue(corr_id, frame).await?;
939        drop(guard);
940        let response = self.await_response(corr_id, rx).await?;
941        if let Some(metrics) = metrics {
942            metrics.response(response.len());
943        }
944        Ok(response)
945    }
946
947    /// Count this connection in the client's network metrics from now on.
948    /// A connection counts in one set of metrics; a second call does
949    /// nothing.
950    pub fn attach_metrics(&self, metrics: &telemetry::NetworkMetrics) {
951        self.inner.metrics.get_or_init(|| metrics.opened());
952    }
953
954    /// Send a frame and wait for its response, with no re-authentication
955    /// check. A re-authentication sends its own frames this way.
956    pub(crate) async fn dispatch_unguarded(
957        &self,
958        corr_id: i32,
959        frame: BytesMut,
960    ) -> Result<Bytes, ClientError> {
961        let _registration = PendingRegistration {
962            pending: &self.inner.pending,
963            corr_id,
964        };
965        let rx = self.enqueue(corr_id, frame).await?;
966        self.await_response(corr_id, rx).await
967    }
968
969    /// The request header of a frame that this connection sends.
970    pub(crate) fn request_header(
971        &self,
972        api_key: ApiKey,
973        version: ApiVersion,
974        corr_id: i32,
975        flexible: bool,
976    ) -> Result<BytesMut, ClientError> {
977        build_request_header(
978            api_key,
979            version,
980            corr_id,
981            &self.inner.options.client_id,
982            flexible,
983        )
984    }
985
986    /// Wait for a due SASL re-authentication to finish, and return a guard
987    /// that keeps a new one from starting until the caller enqueues its frame.
988    ///
989    /// Kafka's `KafkaChannel.maybeBeginClientReauthentication` starts the
990    /// exchange before the first request after the due time. A failed
991    /// exchange closes the connection and fails the request.
992    async fn reauthenticate_if_due(
993        &self,
994    ) -> Result<Option<RwLockReadGuard<'_, Option<Instant>>>, ClientError> {
995        let Some(reauth) = self.inner.reauth.as_ref() else {
996            return Ok(None);
997        };
998        loop {
999            let next = reauth.next.read().await;
1000            if next.is_none_or(|due| Instant::now() < due) {
1001                return Ok(Some(next));
1002            }
1003            drop(next);
1004            let mut next = reauth.next.write().await;
1005            if next.is_some_and(|due| Instant::now() >= due) {
1006                let mut channel = reauth::ConnectionChannel { connection: self };
1007                // The exchange nests the whole SASL state machine. Boxing it
1008                // keeps every send future small.
1009                match Box::pin(reauth.authenticate(
1010                    &mut channel,
1011                    &self.inner.options.client_id,
1012                    self.inner.options.frame_max,
1013                ))
1014                .await
1015                {
1016                    Ok(due) => *next = due,
1017                    Err(error) => {
1018                        tracing::warn!(error = %error, "SASL re-authentication failed");
1019                        self.inner.shutdown.cancel();
1020                        return Err(error);
1021                    }
1022                }
1023            }
1024        }
1025    }
1026
1027    /// Register `corr_id` and queue `frame` for the writer.
1028    async fn enqueue(
1029        &self,
1030        corr_id: i32,
1031        frame: BytesMut,
1032    ) -> Result<oneshot::Receiver<Result<Bytes, ClientError>>, ClientError> {
1033        let (tx, rx) = oneshot::channel::<Result<Bytes, ClientError>>();
1034        self.inner.pending.insert(corr_id, tx);
1035        self.inner
1036            .writer_tx
1037            .send(DispatchItem {
1038                bytes: frame.freeze(),
1039            })
1040            .await
1041            .map_err(|_| ClientError::Disconnected)?;
1042        Ok(rx)
1043    }
1044
1045    /// Wait for the response of `corr_id` within the request timeout.
1046    async fn await_response(
1047        &self,
1048        corr_id: i32,
1049        rx: oneshot::Receiver<Result<Bytes, ClientError>>,
1050    ) -> Result<Bytes, ClientError> {
1051        match timeout(self.inner.options.request_timeout.to_std(), rx).await {
1052            Ok(Ok(Ok(bytes))) => Ok(bytes),
1053            Ok(Ok(Err(err))) => Err(err),
1054            Ok(Err(_recv_closed)) => Err(ClientError::Disconnected),
1055            Err(_timeout) => {
1056                self.inner.pending.remove(&corr_id);
1057                // Kafka's `NetworkClient.handleTimedOutRequests` closes the
1058                // connection of a timed-out request: a late response must
1059                // not match a later request, and the next use connects again.
1060                self.inner.shutdown.cancel();
1061                Err(ClientError::Timeout(self.inner.options.request_timeout))
1062            }
1063        }
1064    }
1065}
1066
1067/// Removes a request from `pending` when its caller stops waiting.
1068struct PendingRegistration<'a> {
1069    pending: &'a Pending,
1070    corr_id: i32,
1071}
1072
1073impl Drop for PendingRegistration<'_> {
1074    fn drop(&mut self) {
1075        self.pending.remove(&self.corr_id);
1076    }
1077}
1078
1079/// Skip the tagged fields of a flexible response header: an unsigned varint
1080/// count, then for each field a varint tag, a varint size and the data.
1081///
1082/// # Errors
1083/// Returns a codec error for a header that ends early.
1084pub fn skip_tagged_fields(mut bytes: &[u8]) -> Result<&[u8], ClientError> {
1085    fn uvarint(bytes: &mut &[u8]) -> Result<u32, ClientError> {
1086        let mut value = 0_u32;
1087        for shift in (0..35).step_by(7) {
1088            let (&byte, rest) = bytes.split_first().ok_or_else(truncated)?;
1089            *bytes = rest;
1090            value |= u32::from(byte & 0x7F) << shift;
1091            if byte & 0x80 == 0 {
1092                return Ok(value);
1093            }
1094        }
1095        Err(truncated())
1096    }
1097    fn truncated() -> ClientError {
1098        ClientError::Io(io::Error::new(
1099            io::ErrorKind::InvalidData,
1100            "invalid tagged fields in a response header",
1101        ))
1102    }
1103    if bytes.is_empty() {
1104        return Ok(bytes);
1105    }
1106    for _ in 0..uvarint(&mut bytes)? {
1107        uvarint(&mut bytes)?;
1108        let size = usize::try_from(uvarint(&mut bytes)?).map_err(|_| truncated())?;
1109        bytes = bytes.get(size..).ok_or_else(truncated)?;
1110    }
1111    Ok(bytes)
1112}
1113
1114/// Spawn independent reader and writer tasks over the split socket.
1115///
1116/// This function splits the socket into a read half and a write half, and one
1117/// task drives each half. It does not multiplex both directions through one
1118/// `select!` over a single shared `Framed`. A combined task must `await` the
1119/// `framed.send(...)` flush *inside* a `select!` arm, and it does not poll the
1120/// read arm during that time. On a request/response connection the broker
1121/// stays silent until it receives the next request. A write that does not
1122/// complete in one poll therefore wedges the whole connection: the frame sits
1123/// buffered, no inbound traffic ever re-drives the loop, and the caller's
1124/// request never reaches the wire.
1125///
1126/// This is what made `krabka-client-consumer`'s group rejoin hang under the
1127/// jemalloc heap-profiling allocator. Its per-alloc sampling latency widened
1128/// that window enough to trip the hang deterministically. Independent halves
1129/// keep an inbound frame pollable while an outbound write is in flight, and
1130/// the reverse.
1131///
1132/// Liveness on teardown: when either task exits on EOF, an I/O error, a
1133/// dropped `Connection`, or `close()`, it cancels the shared `shutdown` token
1134/// so the other task also stops. The reader then fails every outstanding
1135/// request with `Disconnected`. A write-half failure therefore reaches
1136/// callers promptly instead of stalling them until the request timeout.
1137///
1138/// Idle close: the writer also closes the connection when no frame went out
1139/// or came in for `max_idle` and no request waits for a response, as Kafka's
1140/// `Selector` closes a connection idle for `connections.max.idle.ms`.
1141fn spawn_io_tasks(
1142    stream: Box<dyn ClientDuplex>,
1143    mut writer_rx: mpsc::Receiver<DispatchItem>,
1144    context: IoContext,
1145) -> (JoinHandle<()>, JoinHandle<()>) {
1146    use futures_util::StreamExt;
1147    use tokio_util::codec::{FramedRead, FramedWrite};
1148
1149    let IoContext {
1150        shutdown,
1151        pending,
1152        activity,
1153        frame_max,
1154        max_idle,
1155    } = context;
1156
1157    let (read_half, write_half) = split(stream);
1158    let mut framed_read = FramedRead::new(read_half, framing::codec_with_max(frame_max));
1159    let mut framed_write = FramedWrite::new(write_half, framing::codec_with_max(frame_max));
1160
1161    // WRITER: drains the dispatch channel, flushing each frame in receive
1162    // order. Owns only the write half, so a not-yet-writable socket can never
1163    // block the reader.
1164    let writer_shutdown = shutdown.clone();
1165    let writer_activity = Arc::clone(&activity);
1166    let writer_pending = Arc::clone(&pending);
1167    let writer = tokio::spawn(async move {
1168        write_loop(
1169            &writer_shutdown,
1170            &writer_activity,
1171            &writer_pending,
1172            max_idle,
1173            &mut writer_rx,
1174            &mut framed_write,
1175        )
1176        .await;
1177        // A write-side failure (or all senders dropped) must wake the reader
1178        // so it drains pending callers to `Disconnected` rather than letting
1179        // them wait out the request timeout.
1180        writer_shutdown.cancel();
1181    });
1182
1183    // READER: pulls frames and fulfils the matching pending oneshot. Owns only
1184    // the read half.
1185    let reader = tokio::spawn(async move {
1186        loop {
1187            tokio::select! {
1188                () = shutdown.cancelled() => break,
1189                maybe_frame = framed_read.next() => {
1190                    let Some(frame) = maybe_frame else { break; };
1191                    let Ok(frame) = frame else { break; };
1192                    activity.touch();
1193                    let Some((corr_id, body)) = frame.split_first_chunk::<4>() else { continue; };
1194                    let corr_id = i32::from_be_bytes(*corr_id);
1195                    if let Some((_, tx)) = pending.remove(&corr_id) {
1196                        let body = Bytes::copy_from_slice(body);
1197                        let _ = tx.send(Ok(body));
1198                    }
1199                }
1200            }
1201        }
1202        // Stop the writer too, then fail every outstanding request.
1203        shutdown.cancel();
1204        let keys: Vec<i32> = pending.iter().map(|e| *e.key()).collect();
1205        for k in keys {
1206            if let Some((_, tx)) = pending.remove(&k) {
1207                let _ = tx.send(Err(ClientError::Disconnected));
1208            }
1209        }
1210    });
1211
1212    (reader, writer)
1213}
1214
1215/// The shared state of a connection's I/O tasks.
1216struct IoContext {
1217    shutdown: CancellationToken,
1218    pending: Pending,
1219    activity: Arc<Activity>,
1220    frame_max: ClientFrameMax,
1221    max_idle: Duration,
1222}
1223
1224/// The writer loop: send each dispatched frame in order, and stop when the
1225/// connection shuts down, a write fails, or the connection is idle for
1226/// `max_idle` with no pending request.
1227async fn write_loop<W>(
1228    shutdown: &CancellationToken,
1229    activity: &Activity,
1230    pending: &Pending,
1231    max_idle: Duration,
1232    writer_rx: &mut mpsc::Receiver<DispatchItem>,
1233    framed_write: &mut W,
1234) where
1235    W: futures_util::Sink<Bytes> + Unpin,
1236{
1237    use futures_util::SinkExt;
1238
1239    loop {
1240        let idle_deadline = activity.last() + max_idle;
1241        tokio::select! {
1242            () = shutdown.cancelled() => break,
1243            item = writer_rx.recv() => {
1244                let Some(item) = item else { break; };
1245                activity.touch();
1246                // A peer that stops reading can hold a write for ever. The
1247                // write must still end when the connection shuts down, for
1248                // example after a request timeout.
1249                let written = tokio::select! {
1250                    () = shutdown.cancelled() => break,
1251                    written = framed_write.send(item.bytes) => written,
1252                };
1253                if written.is_err() {
1254                    break;
1255                }
1256                activity.touch();
1257            }
1258            () = sleep_until(idle_deadline), if !max_idle.is_zero() => {
1259                let idle = activity.last() + max_idle <= Instant::now();
1260                if idle && pending.is_empty() && writer_rx.is_empty() {
1261                    tracing::debug!(
1262                        max_idle_ms = max_idle.as_millis(),
1263                        "closing a connection idle for connections.max.idle.ms"
1264                    );
1265                    break;
1266                }
1267                if idle {
1268                    // A request waits for its response. Look again after the
1269                    // idle interval.
1270                    activity.touch();
1271                }
1272            }
1273        }
1274    }
1275}
1276
1277/// Build an encoded `RequestHeader` into a `BytesMut`.
1278///
1279/// Kafka has only two `RequestHeader` formats:
1280///
1281/// - **v1** (non-flexible): `api_key` + `version` + `corr_id` + i16
1282///   `client_id` length + `client_id` bytes.
1283/// - **v2** (flexible): same fields *plus* a trailing `tagged_fields` byte
1284///   (`0x00` when empty).
1285///
1286/// Note that `client_id` is `NULLABLE_STRING` (i16 length) in **both**
1287/// versions. The upstream `RequestHeader.json` schema marks the field as
1288/// `"flexibleVersions": "none"`, so even a v2 header keeps the i16-length
1289/// encoding. A UVARINT here makes the broker misread the length and throw
1290/// `InvalidRequestException` during header parsing.
1291///
1292/// Pass `with_tagged_fields = true` iff the request body is flexible
1293/// (`version >= R::FLEXIBLE_MIN`).
1294fn build_request_header(
1295    api_key: ApiKey,
1296    version: ApiVersion,
1297    corr_id: i32,
1298    client_id: &str,
1299    with_tagged_fields: bool,
1300) -> Result<BytesMut, ClientError> {
1301    let mut buf = BytesMut::with_capacity(32);
1302    buf.put_i16(api_key.0);
1303    buf.put_i16(version.0);
1304    buf.put_i32(corr_id);
1305    let n = i16::try_from(client_id.len()).map_err(|_| {
1306        ClientError::InvalidConfig(format!(
1307            "client_id is {} bytes, longer than {} bytes",
1308            client_id.len(),
1309            i16::MAX
1310        ))
1311    })?;
1312    buf.put_i16(n);
1313    buf.put_slice(client_id.as_bytes());
1314    if with_tagged_fields {
1315        buf.put_u8(0); // empty tagged fields
1316    }
1317    Ok(buf)
1318}
1319
1320/// The client software name that `ApiVersions` v3 and later sends (KIP-511).
1321pub const CLIENT_SOFTWARE_NAME: &str = "krabka-client-rs";
1322
1323/// The client software version that `ApiVersions` v3 and later sends.
1324pub const CLIENT_SOFTWARE_VERSION: &str = env!("CARGO_PKG_VERSION");
1325
1326/// Kafka's `UNSUPPORTED_VERSION` error code.
1327const UNSUPPORTED_VERSION: i16 = 35;
1328
1329/// The `ApiVersions` request of a new connection at `version`, as Kafka's
1330/// `ApiVersionsRequest.Builder` fills it.
1331pub fn api_versions_request() -> api_versions_request::ApiVersionsRequest {
1332    api_versions_request::ApiVersionsRequest {
1333        client_software_name: CLIENT_SOFTWARE_NAME.to_owned(),
1334        client_software_version: CLIENT_SOFTWARE_VERSION.to_owned(),
1335        ..Default::default()
1336    }
1337}
1338
1339/// Decode an `ApiVersions` response body sent for a request at `version`.
1340///
1341/// Kafka's `ApiVersionsResponse.parse` falls back to version 0: a broker that
1342/// does not support `version` answers with a version 0 `UNSUPPORTED_VERSION`
1343/// response. A body that does not decode to its end at `version` is read as
1344/// version 0.
1345pub fn decode_api_versions_response(
1346    body: &[u8],
1347    version: i16,
1348) -> Result<api_versions_response::ApiVersionsResponse, ClientError> {
1349    use krabka_protocol::{Decode as _, owned::api_versions_response::ApiVersionsResponse};
1350
1351    let decode = |version| {
1352        let mut cursor = body;
1353        let response = ApiVersionsResponse::decode(&mut cursor, version)?;
1354        if cursor.is_empty() {
1355            Ok(response)
1356        } else {
1357            Err(ClientError::Io(io::Error::new(
1358                io::ErrorKind::InvalidData,
1359                format!(
1360                    "ApiVersions response v{version} has {} bytes after its end",
1361                    cursor.len()
1362                ),
1363            )))
1364        }
1365    };
1366    match decode(version) {
1367        Err(_) if version != 0 => decode(0),
1368        result => result,
1369    }
1370}
1371
1372/// The next `ApiVersions` version after a response at `version`, or `None`
1373/// when the response ends the negotiation.
1374///
1375/// Kafka's `NetworkClient.handleApiVersionsResponse` retries an
1376/// `UNSUPPORTED_VERSION` answer to a version above 0 at the highest
1377/// `ApiVersions` version that the broker lists, or at version 0.
1378pub fn api_versions_retry_version(
1379    response: &api_versions_response::ApiVersionsResponse,
1380    version: i16,
1381) -> Option<i16> {
1382    use api_versions_request::API_KEY;
1383
1384    (response.error_code == UNSUPPORTED_VERSION && version > 0).then(|| {
1385        response
1386            .api_keys
1387            .iter()
1388            .find(|key| key.api_key == API_KEY)
1389            .map_or(0, |key| key.max_version)
1390            .min(version - 1)
1391            .max(0)
1392    })
1393}
1394
1395/// Send `ApiVersions` and return the broker's table.
1396///
1397/// This is the bootstrap step inside `connect`. No version table exists yet,
1398/// so this function cannot use `Connection::send`. The first request uses the
1399/// highest version of the client, as Kafka's `NetworkClient` does, and an
1400/// `UNSUPPORTED_VERSION` answer makes it retry at the version that the broker
1401/// lists.
1402#[tracing::instrument(level = "debug", skip_all, err(level = "debug"))]
1403async fn fetch_api_versions(conn: &ConnectionInner) -> Result<ApiVersionTable, ClientError> {
1404    use krabka_protocol::{Encode, owned::api_versions_request::ApiVersionsRequest};
1405
1406    let mut version = ApiVersionsRequest::MAX_VERSION;
1407    loop {
1408        let corr_id = conn.next_correlation_id();
1409        let mut frame = build_request_header(
1410            ApiKey(ApiVersionsRequest::API_KEY),
1411            ApiVersion(version),
1412            corr_id,
1413            &conn.options.client_id,
1414            version >= ApiVersionsRequest::FLEXIBLE_MIN,
1415        )?;
1416        api_versions_request().encode(&mut frame, version)?;
1417
1418        let (tx, rx) = oneshot::channel::<Result<Bytes, ClientError>>();
1419        conn.pending.insert(corr_id, tx);
1420        conn.writer_tx
1421            .send(DispatchItem {
1422                bytes: frame.freeze(),
1423            })
1424            .await
1425            .map_err(|_| ClientError::Disconnected)?;
1426
1427        let body_bytes = timeout(conn.options.request_timeout.to_std(), rx)
1428            .await
1429            .map_err(|_| ClientError::Timeout(conn.options.request_timeout))?
1430            .map_err(|_| ClientError::Disconnected)??;
1431
1432        // ResponseHeader v0: only correlation_id (already stripped by the
1433        // reader), for every ApiVersions version (the Kafka asymmetry
1434        // documented in `send`).
1435        let response = decode_api_versions_response(&body_bytes, version)?;
1436        if let Some(retry) = api_versions_retry_version(&response, version) {
1437            version = retry;
1438            continue;
1439        }
1440        if response.error_code != 0 {
1441            return Err(ClientError::Server {
1442                error_code: response.error_code,
1443            });
1444        }
1445        return Ok(ApiVersionTable::from_response(&response));
1446    }
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451    use assert2::assert;
1452    use krabka_units::{ByteSize, bytes, kibibytes, mebibytes, micros, millis};
1453    use tokio::io::duplex;
1454
1455    use super::*;
1456    use crate::Client;
1457
1458    #[test]
1459    fn client_dns_timeout_validates_and_preserves_milliseconds() {
1460        let timeout = ClientDnsTimeout::new(millis(37)).expect("positive timeout");
1461        assert!(timeout.time() == millis(37));
1462        assert!(timeout.milliseconds() == 37);
1463        assert!(ClientDnsTimeout::new(Time::ZERO).is_err());
1464        assert!(ClientDnsTimeout::new(micros(1)).is_err());
1465        assert!(ClientDnsTimeout::new(millis(1) + micros(1)).is_err());
1466    }
1467
1468    #[test]
1469    fn connection_options_default_to_kafka_client_settings() {
1470        let options = ConnectionOptions::default();
1471        assert!(options.reconnect_backoff == millis(50));
1472        assert!(options.reconnect_backoff_max == secs(1));
1473        assert!(options.socket_connection_setup_timeout_max == secs(30));
1474        assert!(options.connections_max_idle == minutes(9));
1475        assert!(options.client_dns_lookup == ClientDnsLookup::UseAllDnsIps);
1476        assert!(options.send_buffer == Some(kibibytes(128)));
1477        assert!(options.receive_buffer == Some(kibibytes(64)));
1478    }
1479
1480    #[test]
1481    fn connection_options_own_named_defaults() {
1482        let options = ConnectionOptions::default();
1483        assert!(DEFAULT_CLIENT_DNS_TIMEOUT == secs(10));
1484        assert!(DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT == secs(10));
1485        assert!(DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT_MAX == secs(30));
1486        assert!(DEFAULT_CLIENT_REQUEST_TIMEOUT == secs(30));
1487        assert!(options.dns_timeout == ClientDnsTimeout::default());
1488        assert!(options.dns_timeout.time() == DEFAULT_CLIENT_DNS_TIMEOUT);
1489        assert!(options.socket_connection_setup_timeout == DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT);
1490        assert!(options.request_timeout == DEFAULT_CLIENT_REQUEST_TIMEOUT);
1491    }
1492
1493    #[test]
1494    fn connection_resource_defaults_preserve_existing_values() {
1495        assert!(
1496            ConnectionDispatchQueueCapacity::default().get()
1497                == DEFAULT_CONNECTION_DISPATCH_QUEUE_CAPACITY
1498        );
1499        assert!(ConnectionDispatchQueueCapacity::default().get() == 64);
1500        assert!(ClientFrameMax::default().size() == mebibytes(100));
1501        assert!(MAX_CLIENT_FRAME_BYTES == mebibytes(100));
1502    }
1503
1504    /// A GSSAPI credential is a config error in a build without the Kerberos
1505    /// client, and valid with it. Other mechanisms are valid either way.
1506    #[test]
1507    fn validate_rejects_gssapi_only_without_the_kerberos_client() {
1508        use crate::security::{ClientSecurity, SaslCredentials};
1509
1510        let with_sasl = |sasl| ConnectionOptions {
1511            security: Some(Box::new(ClientSecurity {
1512                protocol: krabka_security::ListenerProtocol::SaslPlaintext,
1513                tls: None,
1514                sasl: Some(sasl),
1515                sasl_host: Some("broker-1.example.com".into()),
1516            })),
1517            ..Default::default()
1518        };
1519        let gssapi = with_sasl(SaslCredentials::Gssapi {
1520            keytab_path: "/etc/krb5.keytab".into(),
1521            client_principal: "[email protected]".into(),
1522            service_name: "kafka".into(),
1523            kdc_url: "kdc.example.com:88".into(),
1524        });
1525        let plain = with_sasl(SaslCredentials::Plain {
1526            username: "u".into(),
1527            password: "p".into(),
1528        });
1529        let want_gssapi = sasl::GSSAPI_CLIENT
1530            .then_some(())
1531            .ok_or_else(|| format!("SASL/GSSAPI: {}", sasl::GSSAPI_UNAVAILABLE));
1532        assert!((gssapi.validate(), plain.validate()) == (want_gssapi, Ok(())));
1533    }
1534
1535    #[test]
1536    fn defaults_equal_their_validated_constants() {
1537        assert!(
1538            ClientDnsTimeout::default()
1539                == ClientDnsTimeout::new(DEFAULT_CLIENT_DNS_TIMEOUT).unwrap()
1540        );
1541        assert!(
1542            ConnectionDispatchQueueCapacity::default()
1543                == ConnectionDispatchQueueCapacity::new(DEFAULT_CONNECTION_DISPATCH_QUEUE_CAPACITY)
1544                    .unwrap()
1545        );
1546        assert!(
1547            ClientFrameMax::default()
1548                == ClientFrameMax::try_from(DEFAULT_CLIENT_FRAME_MAX).unwrap()
1549        );
1550    }
1551
1552    #[test]
1553    fn request_header_rejects_a_client_id_longer_than_i16() {
1554        let client_id = "x".repeat(usize::from(i16::MAX.unsigned_abs()) + 1);
1555        let result = build_request_header(ApiKey(18), ApiVersion(0), 1, &client_id, false);
1556        assert!(let Err(ClientError::InvalidConfig(_)) = result);
1557    }
1558
1559    #[test]
1560    fn transport_options_validate_duration_and_socket_boundaries() {
1561        let setters: [fn(&mut ConnectionOptions, Time); 6] = [
1562            |options, value| options.request_timeout = value,
1563            |options, value| options.socket_connection_setup_timeout = value,
1564            |options, value| options.socket_connection_setup_timeout_max = value,
1565            |options, value| options.reconnect_backoff = value,
1566            |options, value| options.reconnect_backoff_max = value,
1567            |options, value| options.connections_max_idle = value,
1568        ];
1569        for set in setters {
1570            for value in [
1571                Time::from_secs_f64(-1.0),
1572                Time::from_secs_f64(f64::NAN),
1573                Time::from_secs_f64(f64::INFINITY),
1574                Time::from_secs_f64(f64::MAX),
1575            ] {
1576                let mut options = ConnectionOptions::default();
1577                set(&mut options, value);
1578                assert!(options.validate().is_err());
1579            }
1580        }
1581        for value in [
1582            ByteSize::ZERO,
1583            ByteSize::from_bytes_f64(-1.0),
1584            ByteSize::from_bytes_f64(0.5),
1585            ByteSize::from_bytes_f64(f64::NAN),
1586            ByteSize::from_bytes_f64(f64::INFINITY),
1587            ByteSize::from_bytes_f64(f64::from(i32::MAX) + 1.0),
1588        ] {
1589            assert!(
1590                (ConnectionOptions {
1591                    send_buffer: Some(value),
1592                    ..Default::default()
1593                })
1594                .validate()
1595                .is_err()
1596            );
1597            assert!(
1598                (ConnectionOptions {
1599                    receive_buffer: Some(value),
1600                    ..Default::default()
1601                })
1602                .validate()
1603                .is_err()
1604            );
1605        }
1606        assert!(
1607            (ConnectionOptions {
1608                request_timeout: Time::ZERO,
1609                ..Default::default()
1610            })
1611            .validate()
1612            .is_err()
1613        );
1614        assert!(
1615            (ConnectionOptions {
1616                socket_connection_setup_timeout: Time::ZERO,
1617                ..Default::default()
1618            })
1619            .validate()
1620            .is_err()
1621        );
1622        assert!(
1623            (ConnectionOptions {
1624                socket_connection_setup_timeout: secs(31),
1625                ..Default::default()
1626            })
1627            .validate()
1628            .is_err()
1629        );
1630        assert!(
1631            (ConnectionOptions {
1632                reconnect_backoff: secs(2),
1633                ..Default::default()
1634            })
1635            .validate()
1636            .is_err()
1637        );
1638        assert!(
1639            (ConnectionOptions {
1640                reconnect_backoff: Time::ZERO,
1641                reconnect_backoff_max: Time::ZERO,
1642                connections_max_idle: Time::ZERO,
1643                send_buffer: None,
1644                receive_buffer: Some(bytes(1)),
1645                request_timeout: millis(1),
1646                socket_connection_setup_timeout: millis(1),
1647                socket_connection_setup_timeout_max: millis(1),
1648                ..Default::default()
1649            })
1650            .validate()
1651            .is_ok()
1652        );
1653    }
1654
1655    #[tokio::test]
1656    async fn invalid_options_fail_before_connect_or_stream_negotiation() {
1657        let options = ConnectionOptions {
1658            request_timeout: Time::from_secs_f64(f64::NAN),
1659            ..Default::default()
1660        };
1661        let result = Connection::connect("127.0.0.1:1".parse().unwrap(), options.clone()).await;
1662        assert!(matches!(result, Err(ClientError::InvalidConfig(_))));
1663        let result = Client::builder()
1664            .bootstrap("invalid-bootstrap")
1665            .request_timeout(Time::from_secs_f64(f64::NAN))
1666            .build()
1667            .await;
1668        assert!(matches!(result, Err(ClientError::InvalidConfig(_))));
1669        let (stream, _peer) = duplex(1024);
1670        let result = Connection::from_stream(Box::new(stream), options).await;
1671        assert!(matches!(result, Err(ClientError::InvalidConfig(_))));
1672    }
1673
1674    #[test]
1675    fn connection_resource_policy_validates_boundaries() {
1676        assert!(ConnectionDispatchQueueCapacity::new(0).is_err());
1677        assert!(ConnectionDispatchQueueCapacity::new(7).unwrap().get() == 7);
1678
1679        assert!(ClientFrameMax::try_from(bytes(0)).is_err());
1680        assert!(ClientFrameMax::try_from(ByteSize::from_bytes_f64(1.5)).is_err());
1681        assert!(ClientFrameMax::try_from(mebibytes(100) + bytes(1)).is_err());
1682        assert!(ClientFrameMax::try_from(kibibytes(32)).unwrap().size() == kibibytes(32));
1683    }
1684}
1685
1686#[cfg(test)]
1687mod secured_tests {
1688    use assert2::check;
1689    use krabka_protocol::Encode as _;
1690    use krabka_security::ListenerProtocol;
1691    use security::{ClientSecurity, SaslCredentials};
1692    use tokio::net::TcpListener;
1693
1694    use super::*;
1695
1696    /// A TLS protocol without a TLS config, and a SASL protocol without
1697    /// credentials, are inconsistent policies, and the connect fails before
1698    /// any handshake.
1699    #[tokio::test]
1700    async fn connect_secured_rejects_a_policy_without_its_material() {
1701        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1702        let addr = listener.local_addr().unwrap();
1703        let mut messages = Vec::new();
1704        for protocol in [ListenerProtocol::Ssl, ListenerProtocol::SaslPlaintext] {
1705            let security = ClientSecurity {
1706                protocol,
1707                tls: None,
1708                sasl: None,
1709                sasl_host: None,
1710            };
1711            let result =
1712                Connection::connect_secured(addr, ConnectionOptions::default(), &security).await;
1713            messages.push(result.err().map(|error| error.to_string()));
1714        }
1715        check!(
1716            messages
1717                == vec![
1718                    Some("I/O: TLS protocol without tls config".to_owned()),
1719                    Some("I/O: SASL protocol without credentials".to_owned()),
1720                ]
1721        );
1722    }
1723
1724    /// An `ApiVersions` response with bytes after its end is invalid at its
1725    /// own version and at version 0.
1726    #[test]
1727    fn api_versions_response_with_trailing_bytes_is_invalid() {
1728        let mut body = BytesMut::new();
1729        api_versions_response::ApiVersionsResponse::default()
1730            .encode(&mut body, 0)
1731            .unwrap();
1732        body.put_u8(0xff);
1733
1734        let result = decode_api_versions_response(&body, 0).map_err(|error| error.to_string());
1735
1736        check!(result == Err("I/O: ApiVersions response v0 has 1 bytes after its end".to_owned()));
1737    }
1738
1739    // A SASL_PLAINTEXT connect drives the handshake then ApiVersions.
1740    // The fake broker answers SaslHandshake(0), SaslAuthenticate(0),
1741    // then a minimal ApiVersionsResponse v0 so from_stream succeeds.
1742    #[tokio::test]
1743    async fn connect_secured_runs_sasl_then_api_versions() {
1744        use krabka_protocol::{
1745            Encode,
1746            owned::{
1747                api_versions_response::ApiVersionsResponse,
1748                sasl_authenticate_response::SaslAuthenticateResponse,
1749                sasl_handshake_response::SaslHandshakeResponse,
1750            },
1751        };
1752        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1753
1754        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1755        let addr = listener.local_addr().unwrap();
1756        let server = tokio::spawn(async move {
1757            let (mut s, _) = listener.accept().await.unwrap();
1758            // (body, flexible_response_header)
1759            let replies: [(BytesMut, bool); 4] = [
1760                {
1761                    // The ApiVersions v0 that starts the SASL exchange lists
1762                    // the SASL APIs.
1763                    let mock::MockReply::Respond(body) = mock::MockSaslAnswer::Accept
1764                        .reply(api_versions_request::API_KEY, 0)
1765                        .unwrap()
1766                        .unwrap()
1767                    else {
1768                        unreachable!("ApiVersions v0 has a reply")
1769                    };
1770                    (BytesMut::from(&body[..]), false)
1771                },
1772                {
1773                    let mut b = BytesMut::new();
1774                    SaslHandshakeResponse {
1775                        error_code: 0,
1776                        ..Default::default()
1777                    }
1778                    .encode(&mut b, 1)
1779                    .unwrap();
1780                    (b, false)
1781                },
1782                {
1783                    let mut b = BytesMut::new();
1784                    SaslAuthenticateResponse {
1785                        error_code: 0,
1786                        ..Default::default()
1787                    }
1788                    .encode(&mut b, 2)
1789                    .unwrap();
1790                    (b, true)
1791                },
1792                {
1793                    let mut b = BytesMut::new();
1794                    ApiVersionsResponse::default().encode(&mut b, 0).unwrap();
1795                    // ApiVersions always uses a v0 response header.
1796                    (b, false)
1797                },
1798            ];
1799            for (body, flex_header) in replies {
1800                let req_len = s.read_u32().await.unwrap();
1801                let mut req = vec![0u8; req_len as usize];
1802                s.read_exact(&mut req).await.unwrap();
1803                let corr = i32::from_be_bytes([req[4], req[5], req[6], req[7]]);
1804                let mut frame = BytesMut::new();
1805                frame.put_i32(corr);
1806                if flex_header {
1807                    frame.put_u8(0);
1808                }
1809                frame.put_slice(&body);
1810                s.write_u32(u32::try_from(frame.len()).unwrap())
1811                    .await
1812                    .unwrap();
1813                s.write_all(&frame).await.unwrap();
1814                s.flush().await.unwrap();
1815            }
1816        });
1817        let security = ClientSecurity {
1818            protocol: ListenerProtocol::SaslPlaintext,
1819            tls: None,
1820            sasl: Some(SaslCredentials::Plain {
1821                username: "u".into(),
1822                password: "p".into(),
1823            }),
1824            sasl_host: None,
1825        };
1826        let conn = Connection::connect_secured(addr, ConnectionOptions::default(), &security)
1827            .await
1828            .expect("secured connect completes");
1829        conn.close();
1830        server.await.unwrap();
1831    }
1832}
1833
1834#[cfg(test)]
1835mod tls_handshake_failure_tests {
1836    use krabka_security::ListenerProtocol;
1837    use tokio::{
1838        io::{AsyncReadExt, AsyncWriteExt},
1839        net::TcpListener,
1840    };
1841
1842    use super::*;
1843    use crate::{
1844        error::AuthenticationError,
1845        security::{ClientSecurity, TlsConnectorConfig},
1846    };
1847
1848    /// What the TLS listener does after it accepts the connection.
1849    #[derive(Clone, Copy, Debug)]
1850    enum Peer {
1851        /// Answer the `ClientHello` with plaintext, as a plaintext listener
1852        /// does.
1853        Plaintext,
1854        /// Close the connection before the handshake completes.
1855        Close,
1856    }
1857
1858    /// How the handshake failed, as a caller classifies it.
1859    #[derive(Debug, PartialEq, Eq)]
1860    enum Failure {
1861        Rejected(io::ErrorKind),
1862        Transport(io::ErrorKind),
1863        Other(String),
1864    }
1865
1866    async fn listener(peer: Peer) -> SocketAddr {
1867        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1868        let addr = listener.local_addr().unwrap();
1869        tokio::spawn(async move {
1870            let (mut stream, _) = listener.accept().await.unwrap();
1871            let mut buf = [0_u8; 1024];
1872            let _ = stream.read(&mut buf).await;
1873            if matches!(peer, Peer::Plaintext) {
1874                let _ = stream.write_all(b"HTTP/1.0 400 Bad Request\r\n\r\n").await;
1875                while matches!(stream.read(&mut buf).await, Ok(n) if n > 0) {}
1876            }
1877        });
1878        addr
1879    }
1880
1881    /// Kafka's `SslTransportLayer.maybeProcessHandshakeFailure` raises an
1882    /// `SslAuthenticationException` for "Unrecognized SSL message", and keeps
1883    /// a disconnect during the handshake retriable.
1884    #[tokio::test]
1885    async fn tls_rejection_is_an_authentication_failure_and_eof_is_not() {
1886        let security = ClientSecurity {
1887            protocol: ListenerProtocol::Ssl,
1888            tls: Some(TlsConnectorConfig::default()),
1889            sasl: None,
1890            sasl_host: None,
1891        };
1892        for (peer, expected) in [
1893            (
1894                Peer::Plaintext,
1895                Failure::Rejected(io::ErrorKind::InvalidData),
1896            ),
1897            (
1898                Peer::Close,
1899                Failure::Transport(io::ErrorKind::UnexpectedEof),
1900            ),
1901        ] {
1902            let addr = listener(peer).await;
1903            let result =
1904                Connection::connect_secured(addr, ConnectionOptions::default(), &security).await;
1905            let failure = match result {
1906                Err(ClientError::Authentication {
1907                    source: AuthenticationError::Tls(source),
1908                    ..
1909                }) => Failure::Rejected(source.kind()),
1910                Err(ClientError::Tls { source, .. }) => Failure::Transport(source.kind()),
1911                Err(error) => Failure::Other(error.to_string()),
1912                Ok(_) => Failure::Other("connected".into()),
1913            };
1914            assert2::check!(failure == expected, "{peer:?}");
1915        }
1916    }
1917}
1918
1919#[cfg(test)]
1920mod io_task_tests {
1921    use std::time::{Duration, Instant};
1922
1923    use krabka_protocol::{
1924        Encode,
1925        owned::{
1926            api_versions_response::{ApiVersion, ApiVersionsResponse},
1927            metadata_request::MetadataRequest,
1928            metadata_response::MetadataResponse,
1929        },
1930    };
1931    use tokio::{
1932        io::{AsyncReadExt, AsyncWriteExt},
1933        net::TcpListener,
1934    };
1935
1936    use super::*;
1937
1938    // The split reader/writer tasks must keep their teardown contract: a
1939    // server that closes the connection mid-request has to surface to the
1940    // caller promptly as `Disconnected`, NOT stall until the request timeout.
1941    // The reader's EOF cancels the shared shutdown (stopping the writer) and
1942    // drains every outstanding request — so a write-half failure can't strand
1943    // a caller for the full timeout. (Regression guard for the io-task split.)
1944    #[tokio::test]
1945    async fn server_close_mid_request_yields_prompt_disconnected() {
1946        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1947        let addr = listener.local_addr().unwrap();
1948        let server = tokio::spawn(async move {
1949            let (mut s, _) = listener.accept().await.unwrap();
1950            // Answer the `from_stream` ApiVersions handshake.
1951            let len = s.read_u32().await.unwrap();
1952            let mut req = vec![0u8; len as usize];
1953            s.read_exact(&mut req).await.unwrap();
1954            let corr = i32::from_be_bytes([req[4], req[5], req[6], req[7]]);
1955            let mut body = BytesMut::new();
1956            ApiVersionsResponse::default().encode(&mut body, 0).unwrap();
1957            let mut frame = BytesMut::new();
1958            frame.put_i32(corr);
1959            frame.put_slice(&body);
1960            s.write_u32(u32::try_from(frame.len()).unwrap())
1961                .await
1962                .unwrap();
1963            s.write_all(&frame).await.unwrap();
1964            s.flush().await.unwrap();
1965            // Read the next request fully (so its pending entry is registered),
1966            // then drop the socket without replying.
1967            let len2 = s.read_u32().await.unwrap();
1968            let mut req2 = vec![0u8; len2 as usize];
1969            s.read_exact(&mut req2).await.unwrap();
1970            // `s` drops here -> the connection closes with no response.
1971        });
1972
1973        let stream = TcpStream::connect(addr).await.unwrap();
1974        let opts = ConnectionOptions {
1975            request_timeout: secs(5),
1976            socket_connection_setup_timeout: secs(5),
1977            ..Default::default()
1978        };
1979        let conn = Connection::from_stream(Box::new(stream), opts)
1980            .await
1981            .expect("plaintext from_stream completes");
1982
1983        let started = Instant::now();
1984        // Hand-framed Metadata request; the server reads it then closes.
1985        let result = conn.raw_request(3, 0, Bytes::new()).await;
1986        assert!(
1987            matches!(result, Err(ClientError::Disconnected)),
1988            "server close mid-request must yield Disconnected, got {result:?}"
1989        );
1990        assert!(
1991            started.elapsed() < Duration::from_secs(4),
1992            "drain must be prompt (reader EOF), not a request-timeout stall"
1993        );
1994        server.await.unwrap();
1995    }
1996
1997    #[tokio::test]
1998    async fn no_response_request_does_not_capture_the_next_response() {
1999        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2000        let addr = listener.local_addr().unwrap();
2001        let server = tokio::spawn(async move {
2002            let (mut socket, _) = listener.accept().await.unwrap();
2003
2004            let handshake_len = socket.read_u32().await.unwrap();
2005            let mut handshake = vec![0u8; handshake_len as usize];
2006            socket.read_exact(&mut handshake).await.unwrap();
2007            let handshake_corr =
2008                i32::from_be_bytes([handshake[4], handshake[5], handshake[6], handshake[7]]);
2009            let mut handshake_body = BytesMut::new();
2010            ApiVersionsResponse {
2011                api_keys: vec![ApiVersion {
2012                    api_key: MetadataRequest::API_KEY,
2013                    min_version: 0,
2014                    max_version: 0,
2015                    ..Default::default()
2016                }],
2017                ..Default::default()
2018            }
2019            .encode(&mut handshake_body, 0)
2020            .unwrap();
2021            let mut handshake_response = BytesMut::new();
2022            handshake_response.put_i32(handshake_corr);
2023            handshake_response.put_slice(&handshake_body);
2024            socket
2025                .write_u32(u32::try_from(handshake_response.len()).unwrap())
2026                .await
2027                .unwrap();
2028            socket.write_all(&handshake_response).await.unwrap();
2029            socket.flush().await.unwrap();
2030
2031            let one_way_len = socket.read_u32().await.unwrap();
2032            let mut one_way = vec![0u8; one_way_len as usize];
2033            socket.read_exact(&mut one_way).await.unwrap();
2034            let one_way_corr = i32::from_be_bytes([one_way[4], one_way[5], one_way[6], one_way[7]]);
2035
2036            let request_len = socket.read_u32().await.unwrap();
2037            let mut request = vec![0u8; request_len as usize];
2038            socket.read_exact(&mut request).await.unwrap();
2039            let request_corr = i32::from_be_bytes([request[4], request[5], request[6], request[7]]);
2040            assert2::assert!(request_corr == one_way_corr.wrapping_add(1));
2041
2042            let mut body = BytesMut::new();
2043            MetadataResponse::default().encode(&mut body, 0).unwrap();
2044            let mut response = BytesMut::new();
2045            response.put_i32(request_corr);
2046            response.put_slice(&body);
2047            socket
2048                .write_u32(u32::try_from(response.len()).unwrap())
2049                .await
2050                .unwrap();
2051            socket.write_all(&response).await.unwrap();
2052            socket.flush().await.unwrap();
2053        });
2054
2055        let stream = TcpStream::connect(addr).await.unwrap();
2056        let conn = Connection::from_stream(Box::new(stream), ConnectionOptions::default())
2057            .await
2058            .expect("plaintext from_stream completes");
2059        conn.send_no_response(MetadataRequest::default())
2060            .await
2061            .expect("one-way request enqueues");
2062        let response = conn
2063            .send(MetadataRequest::default())
2064            .await
2065            .expect("subsequent response remains correlated");
2066        assert2::assert!(response == MetadataResponse::default());
2067
2068        conn.close();
2069        server.await.unwrap();
2070    }
2071}
2072
2073#[cfg(test)]
2074mod connection_policy_tests {
2075    use std::{iter::repeat_with, time::Duration};
2076
2077    use assert2::check;
2078    use krabka_protocol::{
2079        Encode,
2080        owned::{
2081            api_versions_response::{ApiVersion, ApiVersionsResponse},
2082            metadata_request::MetadataRequest,
2083        },
2084    };
2085    use krabka_security::ListenerProtocol;
2086    use security::{ClientSecurity, SaslCredentials};
2087    use tokio::{
2088        io::{AsyncReadExt, AsyncWriteExt, DuplexStream, duplex},
2089        net::TcpListener,
2090        time::sleep,
2091    };
2092
2093    use super::*;
2094
2095    /// Answer the `ApiVersions` request of `from_stream` on `server`, with
2096    /// `Metadata` v0 supported, and return the stream.
2097    async fn answer_api_versions(mut server: DuplexStream) -> DuplexStream {
2098        let len = server.read_u32().await.unwrap();
2099        let mut request = vec![0_u8; len as usize];
2100        server.read_exact(&mut request).await.unwrap();
2101        let mut body = BytesMut::new();
2102        body.put_slice(&request[4..8]);
2103        ApiVersionsResponse {
2104            api_keys: vec![ApiVersion {
2105                api_key: MetadataRequest::API_KEY,
2106                min_version: 0,
2107                max_version: 0,
2108                ..Default::default()
2109            }],
2110            ..Default::default()
2111        }
2112        .encode(&mut body, 0)
2113        .unwrap();
2114        server
2115            .write_u32(u32::try_from(body.len()).unwrap())
2116            .await
2117            .unwrap();
2118        server.write_all(&body).await.unwrap();
2119        server
2120    }
2121
2122    async fn connection(options: ConnectionOptions) -> (Connection, DuplexStream) {
2123        let (client, server) = duplex(64 * 1024);
2124        let (connection, server) = tokio::join!(
2125            Connection::from_stream(Box::new(client), options),
2126            answer_api_versions(server)
2127        );
2128        (connection.unwrap(), server)
2129    }
2130
2131    /// Kafka's `Selector.maybeCloseOldestConnection` closes a connection
2132    /// with no traffic for `connections.max.idle.ms`. Each row is the idle
2133    /// time after the last response and whether a request waits.
2134    #[tokio::test(start_paused = true)]
2135    async fn an_idle_connection_closes_after_connections_max_idle() {
2136        let max_idle = Duration::from_mins(9);
2137        let ms = Duration::from_millis;
2138        for (name, idle, waiting, closed) in [
2139            (
2140                "just below the limit",
2141                max_idle.saturating_sub(ms(1)),
2142                false,
2143                false,
2144            ),
2145            ("just past the limit", max_idle + ms(1), false, true),
2146            (
2147                "a request waits for its response",
2148                max_idle * 2,
2149                true,
2150                false,
2151            ),
2152        ] {
2153            let (connection, _server) = connection(ConnectionOptions {
2154                request_timeout: secs(3600),
2155                ..ConnectionOptions::default()
2156            })
2157            .await;
2158            let waiter = waiting.then(|| {
2159                let connection = connection.clone();
2160                tokio::spawn(async move { connection.send(MetadataRequest::default()).await })
2161            });
2162            sleep(idle).await;
2163            check!(connection.is_closed() == closed, "{name}");
2164            if let Some(waiter) = waiter {
2165                waiter.abort();
2166            }
2167        }
2168    }
2169
2170    #[tokio::test(start_paused = true)]
2171    async fn zero_idle_budget_disables_eviction_instead_of_closing_immediately() {
2172        let (connection, _server) = connection(ConnectionOptions {
2173            connections_max_idle: Time::ZERO,
2174            ..ConnectionOptions::default()
2175        })
2176        .await;
2177        sleep(Duration::from_hours(24)).await;
2178        check!(!connection.is_closed());
2179        connection.close();
2180    }
2181
2182    /// Kafka's `NetworkClient.handleTimedOutRequests` closes the connection
2183    /// of a request that timed out.
2184    #[tokio::test(start_paused = true)]
2185    async fn a_request_timeout_closes_the_connection() {
2186        let (connection, _server) = connection(ConnectionOptions {
2187            request_timeout: secs(30),
2188            ..ConnectionOptions::default()
2189        })
2190        .await;
2191        let result = connection.send(MetadataRequest::default()).await;
2192        check!(matches!(result, Err(ClientError::Timeout(_))));
2193        check!(connection.is_closed());
2194        check!(connection.in_flight() == 0);
2195    }
2196
2197    /// The connection setup timeout covers the TLS and SASL handshakes, as a
2198    /// Kafka node stays `CONNECTING` until it sends `ApiVersions`.
2199    #[tokio::test]
2200    async fn the_setup_timeout_covers_a_sasl_handshake_that_never_answers() {
2201        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2202        let addr = listener.local_addr().unwrap();
2203        let server = tokio::spawn(async move {
2204            let (mut stream, _) = listener.accept().await.unwrap();
2205            let mut buffer = [0_u8; 1024];
2206            while matches!(stream.read(&mut buffer).await, Ok(read) if read > 0) {}
2207        });
2208        let security = ClientSecurity {
2209            protocol: ListenerProtocol::SaslPlaintext,
2210            tls: None,
2211            sasl: Some(SaslCredentials::Plain {
2212                username: "u".into(),
2213                password: "p".into(),
2214            }),
2215            sasl_host: None,
2216        };
2217        let options = ConnectionOptions {
2218            socket_connection_setup_timeout: millis(100),
2219            request_timeout: secs(30),
2220            ..ConnectionOptions::default()
2221        };
2222        let started = time::Instant::now();
2223        let result = Connection::connect_secured(addr, options, &security).await;
2224        check!(matches!(result, Err(ClientError::Timeout(timeout)) if timeout == millis(100)));
2225        check!(started.elapsed() < Duration::from_secs(5));
2226        server.abort();
2227    }
2228
2229    /// A request timeout closes the connection, and the writer ends even
2230    /// while a peer that does not read holds its write.
2231    #[tokio::test(start_paused = true)]
2232    async fn a_write_blocked_by_a_peer_that_does_not_read_ends_at_shutdown() {
2233        let (client, server) = duplex(64 * 1024);
2234        let (connection, _server) = tokio::join!(
2235            Connection::from_stream(
2236                Box::new(client),
2237                ConnectionOptions {
2238                    request_timeout: secs(30),
2239                    ..ConnectionOptions::default()
2240                },
2241            ),
2242            answer_api_versions(server)
2243        );
2244        let connection = connection.unwrap();
2245        // The raw request is larger than the duplex buffer, so its write
2246        // blocks.
2247        let result = connection
2248            .raw_request(3, 0, Bytes::from(vec![0_u8; 256 * 1024]))
2249            .await;
2250        check!(matches!(result, Err(ClientError::Timeout(_))));
2251        sleep(Duration::from_millis(1)).await;
2252        // The writer task drops the receiver when it ends.
2253        check!(connection.inner.writer_tx.is_closed());
2254    }
2255
2256    /// How a scripted broker answers each `ApiVersions` version.
2257    #[derive(Clone, Copy, Debug)]
2258    enum Broker {
2259        /// It supports `ApiVersions` up to this version, and lists it in an
2260        /// `UNSUPPORTED_VERSION` answer, as Kafka 2.4 and later do.
2261        ListsUpTo(i16),
2262        /// It supports only version 0 and lists nothing in its
2263        /// `UNSUPPORTED_VERSION` answer.
2264        OnlyVersionZero,
2265    }
2266
2267    /// Kafka's `NetworkClient` sends `ApiVersions` at its highest version and
2268    /// retries an `UNSUPPORTED_VERSION` answer at the version that the broker
2269    /// lists, or at version 0. A version 3 or later answer holds the finalized
2270    /// features.
2271    #[tokio::test]
2272    async fn api_versions_negotiation_follows_the_broker_and_keeps_the_features() {
2273        use krabka_protocol::{
2274            Decode as _,
2275            owned::{
2276                api_versions_request::ApiVersionsRequest,
2277                api_versions_response::{FinalizedFeatureKey, SupportedFeatureKey},
2278            },
2279        };
2280
2281        let features = |version: i16| {
2282            if version >= 3 {
2283                crate::FinalizedFeatures {
2284                    epoch: 7,
2285                    levels: maplit::btreemap! {"transaction.version".to_owned() => 2},
2286                }
2287            } else {
2288                crate::FinalizedFeatures::default()
2289            }
2290        };
2291        let software = |version: i16| {
2292            if version >= 3 {
2293                (
2294                    CLIENT_SOFTWARE_NAME.to_owned(),
2295                    CLIENT_SOFTWARE_VERSION.to_owned(),
2296                )
2297            } else {
2298                (String::new(), String::new())
2299            }
2300        };
2301        for (name, broker, expected_versions, expected_features) in [
2302            (
2303                "a broker at version 5",
2304                Broker::ListsUpTo(5),
2305                vec![5],
2306                features(5),
2307            ),
2308            (
2309                "a broker at version 3",
2310                Broker::ListsUpTo(3),
2311                vec![5, 3],
2312                features(3),
2313            ),
2314            (
2315                "a broker at version 2",
2316                Broker::ListsUpTo(2),
2317                vec![5, 2],
2318                features(2),
2319            ),
2320            (
2321                "an old broker",
2322                Broker::OnlyVersionZero,
2323                vec![5, 0],
2324                features(0),
2325            ),
2326        ] {
2327            let (client, mut server) = duplex(64 * 1024);
2328            let script = tokio::spawn(async move {
2329                let mut seen = Vec::new();
2330                loop {
2331                    let Ok(len) = server.read_u32().await else {
2332                        return seen;
2333                    };
2334                    let mut request = vec![0_u8; len as usize];
2335                    server.read_exact(&mut request).await.unwrap();
2336                    let version = i16::from_be_bytes([request[2], request[3]]);
2337                    let client_id_len = usize::from(u16::from_be_bytes([request[8], request[9]]));
2338                    let mut body = &request[10 + client_id_len + usize::from(version >= 3)..];
2339                    let decoded = ApiVersionsRequest::decode(&mut body, version).unwrap();
2340                    seen.push((
2341                        version,
2342                        (
2343                            decoded.client_software_name,
2344                            decoded.client_software_version,
2345                        ),
2346                    ));
2347                    let max = match broker {
2348                        Broker::ListsUpTo(max) => max,
2349                        Broker::OnlyVersionZero => 0,
2350                    };
2351                    let (response, encoded_at) = if version > max {
2352                        let api_keys = match broker {
2353                            Broker::ListsUpTo(max) => vec![ApiVersion {
2354                                api_key: ApiVersionsRequest::API_KEY,
2355                                min_version: 0,
2356                                max_version: max,
2357                                ..Default::default()
2358                            }],
2359                            Broker::OnlyVersionZero => Vec::new(),
2360                        };
2361                        (
2362                            ApiVersionsResponse {
2363                                error_code: UNSUPPORTED_VERSION,
2364                                api_keys,
2365                                ..Default::default()
2366                            },
2367                            0,
2368                        )
2369                    } else {
2370                        (
2371                            ApiVersionsResponse {
2372                                api_keys: vec![ApiVersion {
2373                                    api_key: MetadataRequest::API_KEY,
2374                                    min_version: 0,
2375                                    max_version: 12,
2376                                    ..Default::default()
2377                                }],
2378                                supported_features: vec![SupportedFeatureKey {
2379                                    name: "transaction.version".into(),
2380                                    min_version: 0,
2381                                    max_version: 2,
2382                                    ..Default::default()
2383                                }],
2384                                finalized_features_epoch: 7,
2385                                finalized_features: vec![FinalizedFeatureKey {
2386                                    name: "transaction.version".into(),
2387                                    max_version_level: 2,
2388                                    min_version_level: 2,
2389                                    ..Default::default()
2390                                }],
2391                                ..Default::default()
2392                            },
2393                            version,
2394                        )
2395                    };
2396                    let mut frame = BytesMut::new();
2397                    frame.put_slice(&request[4..8]);
2398                    response.encode(&mut frame, encoded_at).unwrap();
2399                    server
2400                        .write_u32(u32::try_from(frame.len()).unwrap())
2401                        .await
2402                        .unwrap();
2403                    server.write_all(&frame).await.unwrap();
2404                }
2405            });
2406            let connection =
2407                Connection::from_stream(Box::new(client), ConnectionOptions::default())
2408                    .await
2409                    .unwrap();
2410            let observed_features = connection.versions().finalized_features().clone();
2411            let metadata = connection.advertised_api_range(MetadataRequest::API_KEY);
2412            connection.close();
2413            let seen = script.await.unwrap();
2414            check!(
2415                seen == expected_versions
2416                    .iter()
2417                    .map(|version| (*version, software(*version)))
2418                    .collect::<Vec<_>>(),
2419                "{name}"
2420            );
2421            check!(observed_features == expected_features, "{name}");
2422            check!(metadata == Some((0, 12)), "{name}");
2423        }
2424    }
2425
2426    /// Kafka's `NetworkClient.nextCorrelationId` wraps before the SASL
2427    /// reserved range.
2428    #[tokio::test]
2429    async fn normal_correlation_ids_wrap_before_the_sasl_range() {
2430        let (connection, _server) = connection(ConnectionOptions::default()).await;
2431        let reserved = sasl::MIN_RESERVED_CORRELATION_ID;
2432        connection
2433            .inner
2434            .next_corr_id
2435            .store(reserved - 2, Ordering::Relaxed);
2436        let ids = repeat_with(|| connection.next_correlation_id())
2437            .take(3)
2438            .collect::<Vec<_>>();
2439        check!(ids == vec![reserved - 2, reserved - 1, 0]);
2440    }
2441
2442    #[test]
2443    fn flexible_response_headers_skip_every_tagged_field() {
2444        for (name, header_and_body, expected) in [
2445            ("empty body", vec![], Ok(vec![])),
2446            ("no tagged field", vec![0, 7, 8], Ok(vec![7, 8])),
2447            (
2448                "two tagged fields",
2449                vec![2, 1, 2, 0xAA, 0xBB, 5, 0, 7],
2450                Ok(vec![7]),
2451            ),
2452            ("truncated field", vec![1, 1, 4, 0xAA], Err(())),
2453        ] {
2454            check!(
2455                skip_tagged_fields(&header_and_body)
2456                    .map(<[u8]>::to_vec)
2457                    .map_err(drop)
2458                    == expected,
2459                "{name}"
2460            );
2461        }
2462    }
2463
2464    /// A caller that stops waiting leaves nothing in `pending`, so the idle
2465    /// close still applies.
2466    #[tokio::test(start_paused = true)]
2467    async fn a_dropped_request_leaves_no_pending_entry() {
2468        let (connection, _server) = connection(ConnectionOptions::default()).await;
2469        let sending = connection.clone();
2470        let task = tokio::spawn(async move { sending.send(MetadataRequest::default()).await });
2471        sleep(Duration::from_millis(1)).await;
2472        check!(connection.in_flight() == 1);
2473        task.abort();
2474        let _ = task.await;
2475        check!(connection.in_flight() == 0);
2476    }
2477}