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