Skip to main content

weir_client/
client.rs

1//! Transport-agnostic client core: the error type, the `WeirClient` struct, and
2//! every method that only needs a blocking `Read + Write`.
3//!
4//! This lives apart from `unix.rs` for one reason: none of it depends on Unix.
5//! While it sat inside the Unix module, `tls.rs` — which uses `TcpStream` and
6//! rustls and nothing else — had to be gated `#[cfg(all(unix, feature = "tls"))]`
7//! purely because it imports these two types. That made the mTLS client
8//! Unix-only by file layout rather than by any real constraint.
9
10use std::io::{self, Read, Write};
11
12use weir_core::{
13    Durability, Envelope, HEADER_LEN, Header, MAX_PAYLOAD_HARD_CAP, MAX_TRACKED_ACK_PAYLOAD_LEN,
14    MessageType, NackReason, RecordCoordinate,
15};
16
17/// The largest payload the *v1 response set* carries: `Ack` and
18/// `HealthCheckResponse` are empty, `Nack` is one reason byte, and
19/// `VersionMismatch` adds the daemon's wire version — 2 bytes.
20///
21/// A larger declared length on one of those responses is a desync or a non-weir
22/// peer. A future wire version that grew this would arrive with a version bump
23/// the client already rejects before reaching here.
24const MAX_RESPONSE_PAYLOAD_LEN: usize = 2;
25
26/// The pre-allocation cap for a response, chosen by the type the header
27/// declares.
28///
29/// The cap is per message type rather than one flat number because
30/// `AckTracked` — the only response that carries more than two bytes — is only
31/// ever sent to a client that asked for it with a `PushTracked`. A client that
32/// never sends one keeps the old 2-byte bound on every response it can receive,
33/// which is exactly why adding the coordinate did not weaken anybody's
34/// allocation guard.
35fn max_response_payload_len(message_type: MessageType) -> usize {
36    match message_type {
37        MessageType::AckTracked => MAX_TRACKED_ACK_PAYLOAD_LEN,
38        _ => MAX_RESPONSE_PAYLOAD_LEN,
39    }
40}
41
42/// The transport [`WeirClient`] uses when no type parameter is given.
43///
44/// Unix keeps the Unix-domain socket it has always defaulted to, so
45/// `WeirClient` continues to mean `WeirClient<UnixStream>` there and no
46/// existing signature changes. Elsewhere there is no such socket, so the
47/// default is the TCP stream the TLS connector wraps.
48#[cfg(unix)]
49pub type DefaultTransport = std::os::unix::net::UnixStream;
50/// The transport [`WeirClient`] uses when no type parameter is given.
51#[cfg(not(unix))]
52pub type DefaultTransport = std::net::TcpStream;
53
54/// All errors that can be returned by [`WeirClient`] methods.
55///
56/// `#[non_exhaustive]`: the client error set may grow post-1.0 (it already gained
57/// `VersionMismatch` during the 1.0 hardening), so downstream matches must carry
58/// a wildcard arm and adding a variant later is not a breaking change.
59#[derive(Debug)]
60#[non_exhaustive]
61pub enum ClientError {
62    /// An I/O error on the underlying socket.
63    Io(io::Error),
64    /// The daemon sent a Nack with a recognised reason.
65    Nack(NackReason),
66    /// The daemon rejected the frame because its wire version differs from the
67    /// client's. Carries the daemon's `WIRE_VERSION` (the second Nack-payload
68    /// byte) so the caller can report both sides and decide whether to upgrade
69    /// the daemon or downgrade the client.
70    VersionMismatch {
71        /// The wire-protocol version the daemon speaks.
72        daemon_version: u8,
73    },
74    /// The daemon sent a Nack with an unrecognised reason byte.
75    UnknownNack(u8),
76    /// The payload exceeds the protocol hard cap and was rejected locally,
77    /// before any bytes were sent. The daemon would always reject a payload this
78    /// large (and a large frame can race the daemon's Nack-and-close, surfacing as
79    /// a bare broken-pipe), so the client rejects it up front with no round-trip.
80    /// A deployment's configured `max_payload_bytes` may be lower than this cap; a
81    /// payload over that lower limit is reported by the daemon as
82    /// [`ClientError::Nack`]`(`[`NackReason::PayloadTooLarge`]`)`.
83    PayloadTooLarge {
84        /// The rejected payload's length in bytes.
85        len: usize,
86        /// The protocol hard cap ([`weir_core::MAX_PAYLOAD_HARD_CAP`]).
87        limit: usize,
88    },
89    /// The daemon's response violated the wire protocol.
90    Protocol(String),
91    /// `push_default` was called but no default durability was set.
92    NoDefaultDurability,
93    /// `push` was called with an empty payload, rejected locally before any bytes
94    /// are sent. An empty payload IS the WAB end-of-records sentinel, so the daemon
95    /// Nacks it ([`NackReason::EmptyPayload`]) and closes the connection; rejecting
96    /// up front keeps the connection usable, mirroring the local
97    /// [`PayloadTooLarge`][Self::PayloadTooLarge] reject.
98    EmptyPayload,
99}
100
101impl ClientError {
102    /// Whether the connection is still usable after this error.
103    ///
104    /// - **Recoverable (`true`)** — the connection is still usable: the error is a
105    ///   *local* or *per-record* rejection that does **not** poison the client.
106    ///   Retry a different payload, or continue, on the **same** client.
107    /// - **Non-recoverable (`false`)** — the connection is dead or in an
108    ///   indeterminate state: the error poisoned the client (set its internal
109    ///   `poisoned`/`closed_after_nack` flag). **Drop this client and reconnect**;
110    ///   every subsequent call on it will fail fast (see [`WeirClient::is_poisoned`]).
111    ///
112    /// This is the stable way to branch on whether to reconnect: [`ClientError`] is
113    /// `#[non_exhaustive]`, so an exhaustive `match` on its variants will not compile
114    /// downstream, and the variant set may grow post-1.0. Calling `is_recoverable`
115    /// (or checking [`WeirClient::is_poisoned`] after a failed call) keeps working
116    /// across additions.
117    ///
118    /// The classification is kept in lock-step with the client's poison logic: a
119    /// variant is recoverable **iff** producing it does not set the client's
120    /// `poisoned` or `closed_after_nack` flag.
121    ///
122    /// | Variant | Recoverable? | Why |
123    /// |---|---|---|
124    /// | [`PayloadTooLarge`][Self::PayloadTooLarge] | yes | local pre-send rejection; no bytes sent, connection untouched |
125    /// | [`EmptyPayload`][Self::EmptyPayload] | yes | local pre-send rejection; no bytes sent, connection untouched |
126    /// | [`NoDefaultDurability`][Self::NoDefaultDurability] | yes | local misconfiguration; returned before any I/O |
127    /// | [`Nack`][Self::Nack]`(`[`InternalError`][NackReason::InternalError]`)` | yes | transient daemon condition; the daemon keeps the connection open |
128    /// | [`Nack`][Self::Nack]`(`any other reason`)` | no | the daemon closes the connection after the Nack |
129    /// | [`VersionMismatch`][Self::VersionMismatch] | no | a Nack reason; the daemon closes the connection |
130    /// | [`UnknownNack`][Self::UnknownNack] | no | a Nack reason; the daemon closes the connection |
131    /// | [`Io`][Self::Io] | no | a socket read/write failure poisons the client |
132    /// | [`Protocol`][Self::Protocol] | no | a malformed/aborted response poisons the client (also the "reconnect" guard error) |
133    ///
134    /// Note: a *server* `Nack(PayloadTooLarge)` (a payload over the daemon's
135    /// configured cap) is **not** recoverable — the daemon Nacks then closes the
136    /// connection — whereas the *local* [`PayloadTooLarge`][Self::PayloadTooLarge]
137    /// error (a payload over the protocol hard cap, rejected before any bytes are
138    /// sent) **is**. They are distinct variants despite the shared name.
139    ///
140    /// New `#[non_exhaustive]` variants default to **non-recoverable** (`false`):
141    /// "drop and reconnect" is the safe assumption for an error this build does not
142    /// yet understand.
143    #[must_use]
144    pub fn is_recoverable(&self) -> bool {
145        match self {
146            // Local, pre-I/O rejections: the connection was never touched.
147            Self::PayloadTooLarge { .. } | Self::NoDefaultDurability | Self::EmptyPayload => true,
148            // The only Nack the daemon keeps the connection open for (transient);
149            // every other Nack reason closes it (see `WeirClient::note_nack`).
150            Self::Nack(NackReason::InternalError) => true,
151            // Every other Nack reason closes the connection.
152            Self::Nack(_) => false,
153            // VersionMismatch / UnknownNack are Nack reasons too — the daemon
154            // closes the connection after them (they route through `note_nack`).
155            Self::VersionMismatch { .. } | Self::UnknownNack(_) => false,
156            // A socket failure poisons the client; a protocol violation either
157            // poisons it (bad response) or IS the "drop and reconnect" guard error.
158            Self::Io(_) | Self::Protocol(_) => false,
159            // `#[non_exhaustive]`: a future variant we don't recognise — assume the
160            // worst (reconnect) rather than risk reusing a poisoned connection.
161            // Unreachable within this crate (all current variants are matched
162            // above), but required so the match stays total if a variant is added.
163            #[allow(unreachable_patterns)]
164            _ => false,
165        }
166    }
167}
168
169impl std::fmt::Display for ClientError {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            Self::Io(e) => write!(f, "socket I/O error: {e}"),
173            Self::Nack(r) => write!(f, "server nack: {r:?}"),
174            Self::VersionMismatch { daemon_version } => write!(
175                f,
176                "wire version mismatch: daemon speaks v{daemon_version}, this client speaks v{}",
177                weir_core::WIRE_VERSION
178            ),
179            Self::UnknownNack(b) => write!(f, "server nack with unknown reason {b:#04x}"),
180            Self::PayloadTooLarge { len, limit } => write!(
181                f,
182                "payload too large: {len} bytes exceeds the {limit}-byte protocol hard cap"
183            ),
184            Self::Protocol(msg) => write!(f, "protocol error: {msg}"),
185            Self::NoDefaultDurability => {
186                write!(f, "push_default called but no default durability was set")
187            }
188            Self::EmptyPayload => write!(
189                f,
190                "payload is empty; rejected locally (an empty payload is the WAB end-of-records sentinel)"
191            ),
192        }
193    }
194}
195
196impl std::error::Error for ClientError {
197    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
198        match self {
199            Self::Io(e) => Some(e),
200            _ => None,
201        }
202    }
203}
204
205impl From<io::Error> for ClientError {
206    fn from(e: io::Error) -> Self {
207        Self::Io(e)
208    }
209}
210
211/// A synchronous blocking client for one connection to the weir daemon.
212///
213/// The type parameter `S` is the underlying transport. The default is
214/// [`DefaultTransport`] — a Unix domain socket on Unix. When the `tls` feature
215/// is enabled, `S` can also be `TlsStream` (TCP + mutual TLS).
216///
217/// The underlying stream runs in blocking mode. Each method sends one request
218/// frame and reads one response frame before returning. Requests are not
219/// pipelined; for concurrent producers, create one `WeirClient` per thread.
220///
221/// Drop the client to close the connection. The daemon treats EOF as a clean
222/// disconnect.
223pub struct WeirClient<S = DefaultTransport> {
224    // pub(crate) so the TLS connector (a sibling module) can reach the
225    // underlying TcpStream to apply socket timeouts (F43).
226    pub(crate) stream: S,
227    pub(crate) default_durability: Option<Durability>,
228    /// Set once a response read fails (a timeout mid-frame, a partial/aborted
229    /// read, or a protocol violation). The stream is then in an indeterminate
230    /// state — leftover bytes from the aborted response could be mis-read as the
231    /// NEXT request's reply, acking a record the daemon never confirmed (a false
232    /// ack). Once poisoned, every method fails fast; the caller must reconnect
233    /// (G04).
234    pub(crate) poisoned: bool,
235    /// Set once the daemon Nacked a frame for a reason that closes the connection
236    /// (every Nack except the transient `InternalError`). The connection is then
237    /// cleanly dead — the next call would otherwise just hit a broken pipe — so we
238    /// fail fast with a clear "reconnect" error instead (QA#2).
239    pub(crate) closed_after_nack: bool,
240}
241
242// Manual Debug (not derived): a `#[derive(Debug)]` would require `S: Debug`, which
243// excludes `WeirClient<TlsStream>` (rustls's `StreamOwned` is not `Debug`). Print
244// the non-secret fields and omit the transport so every client type — Unix and
245// TLS — implements `Debug` (C-DEBUG, S40).
246impl<S> std::fmt::Debug for WeirClient<S> {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        f.debug_struct("WeirClient")
249            .field("default_durability", &self.default_durability)
250            .field("poisoned", &self.poisoned)
251            .field("closed_after_nack", &self.closed_after_nack)
252            .finish_non_exhaustive()
253    }
254}
255
256// ── Shared methods over any blocking Read+Write transport ──────────────────────
257
258impl<S: Read + Write> WeirClient<S> {
259    /// Sets the default durability tier used by [`push_default`][Self::push_default].
260    pub fn set_default_durability(&mut self, durability: Durability) {
261        self.default_durability = Some(durability);
262    }
263
264    /// Pushes `payload` to the daemon with the given `durability` tier.
265    ///
266    /// Blocks until the daemon replies. On `Ack` the record is durably stored
267    /// according to the requested tier. On `Nack` returns the reason as
268    /// [`ClientError::Nack`].
269    ///
270    /// An `Ack` means the record is durably **buffered** at the requested tier —
271    /// **not** that it has reached the downstream sink yet (the daemon drains in
272    /// batches once a segment seals). See the crate-level "Ack vs. delivery" note.
273    ///
274    /// The ack is **per record, not a liveness probe for the next one.** If the
275    /// daemon dies (or the socket is severed) between two pushes, the *first*
276    /// push after the failure can still return `Ok` — its frame was written and
277    /// acked, or the breakage isn't observed until the next read/write — and only
278    /// the *following* push surfaces the broken pipe. So a single `Ok` does not
279    /// guarantee the connection is healthy for subsequent pushes. After any push
280    /// error, branch on [`ClientError::is_recoverable`] /
281    /// [`is_poisoned`][Self::is_poisoned] to decide whether to keep using this
282    /// client or drop and reconnect, rather than assuming a prior `Ok` proved the
283    /// connection good.
284    #[must_use = "a dropped push result hides whether the record was acked; an \
285                  unhandled Nack also closes the connection"]
286    pub fn push(
287        &mut self,
288        payload: impl AsRef<[u8]>,
289        durability: Durability,
290    ) -> Result<(), ClientError> {
291        let resp = self.send_record(MessageType::Push, payload, durability)?;
292        match resp.header().message_type() {
293            MessageType::Ack => Ok(()),
294            // A Nack may carry an empty payload, which `nack_error` reports as a
295            // `Protocol` desync; poison in that case (see `surface_nack`).
296            MessageType::Nack => Err(self.surface_nack(resp.payload())),
297            // Any other frame type is a stream desync: the daemon sent something we
298            // never expect here, so leftover/unexpected bytes could be mis-read as a
299            // later reply (a false ack). Poison the connection (Protocol → not
300            // recoverable, matching `ensure_usable`).
301            other => {
302                self.poisoned = true;
303                Err(ClientError::Protocol(format!(
304                    "expected Ack or Nack, got {other:?}"
305                )))
306            }
307        }
308    }
309
310    /// Pushes `payload` and returns the [`RecordCoordinate`] the daemon assigned
311    /// it — where the record landed in the write-ahead buffer, and the id the
312    /// downstream sink will see for it.
313    ///
314    /// Identical to [`push`][Self::push] in every other respect: same durability
315    /// tiers, same local guards, same Nack reasons, same connection effects. It
316    /// sends `PushTracked` instead of `Push` and reads back an `AckTracked`.
317    ///
318    /// # What the coordinate is for
319    ///
320    /// The [`record_id`][RecordCoordinate::record_id] is byte-identical to the
321    /// per-record idempotency key the drain hands the sink (the HTTP sink's
322    /// `Idempotency-Key: sha256:<hex>` header, the S3 sink's object name), so a
323    /// producer can reconcile what it sent against what the downstream received.
324    /// That is the question `push` alone cannot answer.
325    ///
326    /// # What it is not
327    ///
328    /// - **Not a delivery receipt.** Like `push`, an ack means the record is
329    ///   durably buffered at the requested tier — not that it has reached the
330    ///   sink. The coordinate says where it will be read from, not that it has
331    ///   been read.
332    /// - **Not a gap-free sequence.** The coordinate is a buffer address. Other
333    ///   producers' records interleave, so one producer's indices have holes.
334    /// - **Not a durability upgrade.** A `Buffered` push gets a coordinate too,
335    ///   and can still be lost on power loss; the tier still decides that.
336    ///
337    /// # Talking to a daemon that predates this
338    ///
339    /// A daemon built before `PushTracked` existed rejects the frame with
340    /// [`NackReason::UnknownMessage`] and closes the connection — the protocol's
341    /// existing permanent-error path for version skew. So
342    /// `Err(ClientError::Nack(NackReason::UnknownMessage))` from this method
343    /// means "this daemon does not support tracked pushes"; fall back to
344    /// [`push`][Self::push] on a fresh connection.
345    #[must_use = "a dropped push result hides whether the record was acked; an \
346                  unhandled Nack also closes the connection"]
347    pub fn push_tracked(
348        &mut self,
349        payload: impl AsRef<[u8]>,
350        durability: Durability,
351    ) -> Result<RecordCoordinate, ClientError> {
352        let resp = self.send_record(MessageType::PushTracked, payload, durability)?;
353        match resp.header().message_type() {
354            MessageType::AckTracked => {
355                RecordCoordinate::decode(resp.payload()).map_err(|e| {
356                    // The frame was well-formed and fully consumed, so the stream
357                    // is still in sync — but a daemon whose coordinate layout this
358                    // build cannot read is a version skew that every subsequent
359                    // tracked push would hit again. Poison so the caller
360                    // reconnects (and so `is_poisoned` stays the exact complement
361                    // of `is_recoverable`, which the crate promises).
362                    self.poisoned = true;
363                    ClientError::Protocol(format!("malformed record coordinate: {e}"))
364                })
365            }
366            MessageType::Nack => Err(self.surface_nack(resp.payload())),
367            // A bare Ack here is NOT success: the daemon accepted the record but
368            // did not answer the question that was asked, and reporting Ok would
369            // hand the caller a coordinate it never received. Treat it as the
370            // protocol violation it is.
371            other => {
372                self.poisoned = true;
373                Err(ClientError::Protocol(format!(
374                    "expected AckTracked or Nack, got {other:?}"
375                )))
376            }
377        }
378    }
379
380    /// The shared send half of [`push`][Self::push] and
381    /// [`push_tracked`][Self::push_tracked]: the local guards, the frame write,
382    /// and the response read. Returns the response frame for the caller to
383    /// interpret, because that is the only part the two differ on.
384    fn send_record(
385        &mut self,
386        message_type: MessageType,
387        payload: impl AsRef<[u8]>,
388        durability: Durability,
389    ) -> Result<Envelope, ClientError> {
390        self.ensure_usable()?;
391        let bytes = payload.as_ref();
392        // Local guard against an empty payload. An empty payload IS the WAB
393        // end-of-records sentinel, so the daemon Nacks it (NackReason::EmptyPayload)
394        // and closes the connection — which would poison this client. Reject it up
395        // front, before any bytes are sent, so the connection stays usable
396        // (recoverable), mirroring the hard-cap guard below.
397        if bytes.is_empty() {
398            return Err(ClientError::EmptyPayload);
399        }
400        // Local guard against the protocol hard cap. The daemon always rejects a
401        // payload this large, and for a big frame it Nacks then closes the
402        // connection before we finish streaming — so the Nack races our write and
403        // would surface as a bare broken-pipe. Reject up front, no round-trip, with
404        // a clear typed error. (A deployment's configured cap may be lower than the
405        // hard cap; a payload over that lower limit still goes to the daemon and is
406        // handled by the write-error recovery below.)
407        if bytes.len() > MAX_PAYLOAD_HARD_CAP {
408            return Err(ClientError::PayloadTooLarge {
409                len: bytes.len(),
410                limit: MAX_PAYLOAD_HARD_CAP,
411            });
412        }
413        // copy_from_slice at the API boundary so downstream handling is zero-copy.
414        let payload = weir_core::Payload::copy_from_slice(bytes);
415        let header = Header::new(message_type, durability, 0);
416        let frame = Envelope::new(header, payload).encode();
417
418        if let Err(write_err) = self.stream.write_all(&frame) {
419            // The write failed partway. The daemon may have Nacked (e.g. a payload
420            // over its configured cap) and closed the connection before we finished
421            // streaming — its Nack can already be sitting in our receive buffer.
422            // Read it so the caller sees the real reason instead of a broken-pipe.
423            // Either way the connection is now dead.
424            if let Ok(resp) = self.read_response()
425                && resp.header().message_type() == MessageType::Nack
426            {
427                self.closed_after_nack = true;
428                return Err(nack_error(resp.payload()));
429            }
430            self.poisoned = true;
431            return Err(write_err.into());
432        }
433
434        self.read_response()
435    }
436
437    /// Records that a Nack closed the connection (every reason except the
438    /// transient `InternalError`, which the daemon keeps open) so the next call
439    /// fails fast with a clear reconnect error rather than a broken pipe (QA#2).
440    /// Returns the error unchanged for convenient `Err(self.note_nack(e))` use.
441    fn note_nack(&mut self, err: ClientError) -> ClientError {
442        if !matches!(err, ClientError::Nack(NackReason::InternalError)) {
443            self.closed_after_nack = true;
444        }
445        err
446    }
447
448    /// Decodes a Nack frame's payload and records its connection effect.
449    ///
450    /// `nack_error` maps a well-formed Nack to a `Nack`/`VersionMismatch`/`UnknownNack`
451    /// error (routed through [`note_nack`][Self::note_nack] to mark a closing Nack),
452    /// but an **empty** Nack payload is a stream desync that it reports as
453    /// [`ClientError::Protocol`]. A desync means leftover/unexpected bytes could be
454    /// mis-read as a later reply (a false ack), so we poison the connection in that
455    /// case — keeping `Protocol` non-recoverable and `is_poisoned()` in agreement.
456    fn surface_nack(&mut self, payload: &[u8]) -> ClientError {
457        match nack_error(payload) {
458            ClientError::Protocol(msg) => {
459                self.poisoned = true;
460                ClientError::Protocol(msg)
461            }
462            other => self.note_nack(other),
463        }
464    }
465
466    /// Pushes at the connection's default durability tier.
467    ///
468    /// Returns [`ClientError::NoDefaultDurability`] if no default was set via
469    /// [`set_default_durability`][Self::set_default_durability] (or via a
470    /// `connect_with_default` / `connect_tls` constructor that accepted a
471    /// `default_durability`).
472    pub fn push_default(&mut self, payload: impl AsRef<[u8]>) -> Result<(), ClientError> {
473        let d = self
474            .default_durability
475            .ok_or(ClientError::NoDefaultDurability)?;
476        self.push(payload, d)
477    }
478
479    /// [`push_tracked`][Self::push_tracked] at the connection's default
480    /// durability tier.
481    ///
482    /// Returns [`ClientError::NoDefaultDurability`] if no default was set.
483    pub fn push_tracked_default(
484        &mut self,
485        payload: impl AsRef<[u8]>,
486    ) -> Result<RecordCoordinate, ClientError> {
487        let d = self
488            .default_durability
489            .ok_or(ClientError::NoDefaultDurability)?;
490        self.push_tracked(payload, d)
491    }
492
493    /// Sends a `HealthCheck` frame and returns `Ok(())` on a valid
494    /// `HealthCheckResponse`. Returns an error if the daemon is unreachable
495    /// or responds with a Nack.
496    pub fn health_check(&mut self) -> Result<(), ClientError> {
497        self.ensure_usable()?;
498        let header = Header::new(MessageType::HealthCheck, Durability::Durable, 0);
499        let frame = Envelope::new(header, vec![]).encode();
500        // Poison on a write failure, exactly as `push` does. A bare `?` here
501        // left the client in a state the crate documents as impossible: the
502        // error is non-recoverable while `is_poisoned()` returns false, and
503        // those two are promised to be equivalent (see `is_poisoned`). A partial
504        // write of the frame also desyncs the stream, which is precisely what
505        // poisoning exists to stop the caller from ignoring.
506        if let Err(e) = self.stream.write_all(&frame) {
507            self.poisoned = true;
508            return Err(e.into());
509        }
510
511        let resp = self.read_response()?;
512        match resp.header().message_type() {
513            MessageType::HealthCheckResponse => Ok(()),
514            // An empty Nack payload is a desync `nack_error` reports as `Protocol`;
515            // poison in that case (see `surface_nack`).
516            MessageType::Nack => Err(self.surface_nack(resp.payload())),
517            // Any other frame type is a stream desync: poison the connection so a
518            // later call can't mis-read a stale frame as its reply (Protocol → not
519            // recoverable, matching `ensure_usable`).
520            other => {
521                self.poisoned = true;
522                Err(ClientError::Protocol(format!(
523                    "expected HealthCheckResponse, got {other:?}"
524                )))
525            }
526        }
527    }
528
529    /// Whether this client's connection is dead and must be rebuilt.
530    ///
531    /// Reports the union of the two ways a connection becomes unusable, so it is the
532    /// exact complement of [`is_recoverable`][ClientError::is_recoverable] for the
533    /// error that caused it:
534    ///
535    /// - an **indeterminate stream** (the internal `poisoned` flag) — a
536    ///   response-read failure/timeout, a protocol violation in the reply, or any
537    ///   stream desync (an unexpected daemon message type, an empty-payload Nack);
538    ///   leftover/unexpected bytes could be mis-read as a later reply (a false ack);
539    /// - a **connection-closing Nack** (the internal `closed_after_nack` flag) —
540    ///   every Nack reason except the transient
541    ///   [`InternalError`][NackReason::InternalError], after which the daemon closes
542    ///   the connection.
543    ///
544    /// In either state **every** method fails fast (it will not touch the socket
545    /// again); the caller must drop this client and open a fresh connection.
546    ///
547    /// A long-lived producer thread (e.g. one driven from an async runtime) should
548    /// check this after a failed call — or, equivalently, branch on
549    /// [`ClientError::is_recoverable`] — and rebuild the connection when it reports
550    /// poisoned / non-recoverable, rather than retrying on the dead client:
551    ///
552    /// ```no_run
553    /// # use weir_client::{WeirClient, ClientError};
554    /// # use weir_core::Durability;
555    /// # fn reconnect() -> WeirClient { unreachable!() }
556    /// # let mut client = reconnect();
557    /// if let Err(e) = client.push(b"record", Durability::Durable) {
558    ///     if !e.is_recoverable() || client.is_poisoned() {
559    ///         client = reconnect(); // drop the dead client, rebuild
560    ///     }
561    /// }
562    /// ```
563    ///
564    /// See [`ClientError::is_recoverable`] for the per-error view of the same
565    /// distinction (recoverable = connection still usable; non-recoverable = drop
566    /// and reconnect). The two are kept equivalent: `is_poisoned()` is true after a
567    /// failed call exactly when that call's error is non-recoverable.
568    #[must_use]
569    pub fn is_poisoned(&self) -> bool {
570        self.poisoned || self.closed_after_nack
571    }
572
573    /// Returns the poisoned-connection error if a prior response read failed.
574    /// Once poisoned the stream may hold leftover bytes from an aborted response
575    /// that would be mis-read as this call's reply — a false ack — so we refuse
576    /// to use it (G04). The caller must drop the client and reconnect.
577    pub(crate) fn ensure_usable(&self) -> Result<(), ClientError> {
578        if self.closed_after_nack {
579            return Err(ClientError::Protocol(
580                "connection closed by the daemon after a Nack; \
581                 drop this client and reconnect before sending again"
582                    .into(),
583            ));
584        }
585        if self.poisoned {
586            return Err(ClientError::Protocol(
587                "client connection poisoned by a prior response-read error/timeout; \
588                 drop this client and reconnect before sending again"
589                    .into(),
590            ));
591        }
592        Ok(())
593    }
594
595    fn read_response(&mut self) -> Result<Envelope, ClientError> {
596        // Any failure here leaves the stream in an indeterminate state (a timeout
597        // mid-frame, a partial/aborted read, or a protocol violation): the unread
598        // tail of this response could be mis-read as the NEXT request's reply.
599        // Poison the client so every subsequent call fails fast (G04).
600        let result = self.read_response_inner();
601        if result.is_err() {
602            self.poisoned = true;
603        }
604        result
605    }
606
607    fn read_response_inner(&mut self) -> Result<Envelope, ClientError> {
608        let mut header_buf = [0u8; HEADER_LEN];
609        self.stream.read_exact(&mut header_buf)?;
610
611        let header =
612            Header::decode(&header_buf).map_err(|e| ClientError::Protocol(e.to_string()))?;
613
614        let payload_len = header.payload_len() as usize;
615        // Cap before allocating. The bound is the RESPONSE bound, not the record
616        // one: an `Ack` / `HealthCheckResponse` carries 0 bytes, a `Nack` 1 (2 for
617        // `VersionMismatch`), and an `AckTracked` at most one record coordinate —
618        // see the producer checklist in docs/wire_protocol.md, which tells every
619        // other implementer to "cap the response payload_len at a few bytes".
620        //
621        // This used to compare against MAX_PAYLOAD_HARD_CAP (16 MiB), the cap on
622        // a RECORD being sent the other way. So the reference client violated the
623        // checklist it publishes: a desynced or hostile peer could make it
624        // allocate 16 MiB and — with the default `read_timeout` of None — block
625        // forever waiting for bytes that never come.
626        //
627        // The bound is taken from the type the header declares, so a client that
628        // never asks for a coordinate never accepts a payload larger than the two
629        // bytes it always could.
630        let cap = max_response_payload_len(header.message_type());
631        if payload_len > cap {
632            return Err(ClientError::Protocol(format!(
633                "response payload_len {payload_len} exceeds the {cap}-byte cap for \
634                 {:?}; refusing to allocate",
635                header.message_type()
636            )));
637        }
638        let mut payload_buf = vec![0u8; payload_len];
639        if payload_len > 0 {
640            self.stream.read_exact(&mut payload_buf)?;
641        }
642
643        let mut crc_buf = [0u8; 4];
644        self.stream.read_exact(&mut crc_buf)?;
645        let expected = u32::from_le_bytes(crc_buf);
646        let computed = crc32fast::hash(&payload_buf);
647        if expected != computed {
648            return Err(ClientError::Protocol(format!(
649                "response payload CRC mismatch: expected {expected:#010x}, computed {computed:#010x}"
650            )));
651        }
652
653        Ok(Envelope::new(header, payload_buf))
654    }
655
656    /// Crate-internal constructor so `tls.rs` can build the struct without
657    /// going through a Unix-socket constructor.
658    #[cfg_attr(not(feature = "tls"), allow(dead_code))]
659    pub(crate) fn from_parts(stream: S, default_durability: Option<Durability>) -> Self {
660        Self {
661            stream,
662            default_durability,
663            poisoned: false,
664            closed_after_nack: false,
665        }
666    }
667}
668
669pub(crate) fn nack_error(payload: &[u8]) -> ClientError {
670    match payload.first().copied() {
671        Some(b) => match NackReason::try_from(b) {
672            // A VersionMismatch Nack carries the daemon's WIRE_VERSION as a
673            // second payload byte (`[0x02, daemon_version]`). Surface it so the
674            // caller can report both sides; fall back to the bare reason if the
675            // daemon (somehow) omitted it.
676            Ok(NackReason::VersionMismatch) => match payload.get(1).copied() {
677                Some(daemon_version) => ClientError::VersionMismatch { daemon_version },
678                None => ClientError::Nack(NackReason::VersionMismatch),
679            },
680            Ok(r) => ClientError::Nack(r),
681            Err(_) => ClientError::UnknownNack(b),
682        },
683        None => ClientError::Protocol("Nack frame had empty payload".into()),
684    }
685}