Skip to main content

weir_client/
unix.rs

1use std::{
2    io::{self, Read, Write},
3    os::unix::net::UnixStream,
4    path::Path,
5    time::Duration,
6};
7
8use weir_core::{
9    Durability, Envelope, HEADER_LEN, Header, MAX_PAYLOAD_HARD_CAP, MessageType, NackReason,
10};
11
12/// All errors that can be returned by [`WeirClient`] methods.
13///
14/// `#[non_exhaustive]`: the client error set may grow post-1.0 (it already gained
15/// `VersionMismatch` during the 1.0 hardening), so downstream matches must carry
16/// a wildcard arm and adding a variant later is not a breaking change.
17#[derive(Debug)]
18#[non_exhaustive]
19pub enum ClientError {
20    /// An I/O error on the underlying socket.
21    Io(io::Error),
22    /// The daemon sent a Nack with a recognised reason.
23    Nack(NackReason),
24    /// The daemon rejected the frame because its wire version differs from the
25    /// client's. Carries the daemon's `WIRE_VERSION` (the second Nack-payload
26    /// byte) so the caller can report both sides and decide whether to upgrade
27    /// the daemon or downgrade the client.
28    VersionMismatch {
29        /// The wire-protocol version the daemon speaks.
30        daemon_version: u8,
31    },
32    /// The daemon sent a Nack with an unrecognised reason byte.
33    UnknownNack(u8),
34    /// The payload exceeds the protocol hard cap and was rejected locally,
35    /// before any bytes were sent. The daemon would always reject a payload this
36    /// large (and a large frame can race the daemon's Nack-and-close, surfacing as
37    /// a bare broken-pipe), so the client rejects it up front with no round-trip.
38    /// A deployment's configured `max_payload_bytes` may be lower than this cap; a
39    /// payload over that lower limit is reported by the daemon as
40    /// [`ClientError::Nack`]`(`[`NackReason::PayloadTooLarge`]`)`.
41    PayloadTooLarge {
42        /// The rejected payload's length in bytes.
43        len: usize,
44        /// The protocol hard cap ([`weir_core::MAX_PAYLOAD_HARD_CAP`]).
45        limit: usize,
46    },
47    /// The daemon's response violated the wire protocol.
48    Protocol(String),
49    /// `push_default` was called but no default durability was set.
50    NoDefaultDurability,
51    /// `push` was called with an empty payload, rejected locally before any bytes
52    /// are sent. An empty payload IS the WAB end-of-records sentinel, so the daemon
53    /// Nacks it ([`NackReason::EmptyPayload`]) and closes the connection; rejecting
54    /// up front keeps the connection usable, mirroring the local
55    /// [`PayloadTooLarge`][Self::PayloadTooLarge] reject.
56    EmptyPayload,
57}
58
59impl ClientError {
60    /// Whether the connection is still usable after this error.
61    ///
62    /// - **Recoverable (`true`)** — the connection is still usable: the error is a
63    ///   *local* or *per-record* rejection that does **not** poison the client.
64    ///   Retry a different payload, or continue, on the **same** client.
65    /// - **Non-recoverable (`false`)** — the connection is dead or in an
66    ///   indeterminate state: the error poisoned the client (set its internal
67    ///   `poisoned`/`closed_after_nack` flag). **Drop this client and reconnect**;
68    ///   every subsequent call on it will fail fast (see [`WeirClient::is_poisoned`]).
69    ///
70    /// This is the stable way to branch on whether to reconnect: [`ClientError`] is
71    /// `#[non_exhaustive]`, so an exhaustive `match` on its variants will not compile
72    /// downstream, and the variant set may grow post-1.0. Calling `is_recoverable`
73    /// (or checking [`WeirClient::is_poisoned`] after a failed call) keeps working
74    /// across additions.
75    ///
76    /// The classification is kept in lock-step with the client's poison logic: a
77    /// variant is recoverable **iff** producing it does not set the client's
78    /// `poisoned` or `closed_after_nack` flag.
79    ///
80    /// | Variant | Recoverable? | Why |
81    /// |---|---|---|
82    /// | [`PayloadTooLarge`][Self::PayloadTooLarge] | yes | local pre-send rejection; no bytes sent, connection untouched |
83    /// | [`EmptyPayload`][Self::EmptyPayload] | yes | local pre-send rejection; no bytes sent, connection untouched |
84    /// | [`NoDefaultDurability`][Self::NoDefaultDurability] | yes | local misconfiguration; returned before any I/O |
85    /// | [`Nack`][Self::Nack]`(`[`InternalError`][NackReason::InternalError]`)` | yes | transient daemon condition; the daemon keeps the connection open |
86    /// | [`Nack`][Self::Nack]`(`any other reason`)` | no | the daemon closes the connection after the Nack |
87    /// | [`VersionMismatch`][Self::VersionMismatch] | no | a Nack reason; the daemon closes the connection |
88    /// | [`UnknownNack`][Self::UnknownNack] | no | a Nack reason; the daemon closes the connection |
89    /// | [`Io`][Self::Io] | no | a socket read/write failure poisons the client |
90    /// | [`Protocol`][Self::Protocol] | no | a malformed/aborted response poisons the client (also the "reconnect" guard error) |
91    ///
92    /// Note: a *server* `Nack(PayloadTooLarge)` (a payload over the daemon's
93    /// configured cap) is **not** recoverable — the daemon Nacks then closes the
94    /// connection — whereas the *local* [`PayloadTooLarge`][Self::PayloadTooLarge]
95    /// error (a payload over the protocol hard cap, rejected before any bytes are
96    /// sent) **is**. They are distinct variants despite the shared name.
97    ///
98    /// New `#[non_exhaustive]` variants default to **non-recoverable** (`false`):
99    /// "drop and reconnect" is the safe assumption for an error this build does not
100    /// yet understand.
101    #[must_use]
102    pub fn is_recoverable(&self) -> bool {
103        match self {
104            // Local, pre-I/O rejections: the connection was never touched.
105            Self::PayloadTooLarge { .. } | Self::NoDefaultDurability | Self::EmptyPayload => true,
106            // The only Nack the daemon keeps the connection open for (transient);
107            // every other Nack reason closes it (see `WeirClient::note_nack`).
108            Self::Nack(NackReason::InternalError) => true,
109            // Every other Nack reason closes the connection.
110            Self::Nack(_) => false,
111            // VersionMismatch / UnknownNack are Nack reasons too — the daemon
112            // closes the connection after them (they route through `note_nack`).
113            Self::VersionMismatch { .. } | Self::UnknownNack(_) => false,
114            // A socket failure poisons the client; a protocol violation either
115            // poisons it (bad response) or IS the "drop and reconnect" guard error.
116            Self::Io(_) | Self::Protocol(_) => false,
117            // `#[non_exhaustive]`: a future variant we don't recognise — assume the
118            // worst (reconnect) rather than risk reusing a poisoned connection.
119            // Unreachable within this crate (all current variants are matched
120            // above), but required so the match stays total if a variant is added.
121            #[allow(unreachable_patterns)]
122            _ => false,
123        }
124    }
125}
126
127impl std::fmt::Display for ClientError {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Self::Io(e) => write!(f, "socket I/O error: {e}"),
131            Self::Nack(r) => write!(f, "server nack: {r:?}"),
132            Self::VersionMismatch { daemon_version } => write!(
133                f,
134                "wire version mismatch: daemon speaks v{daemon_version}, this client speaks v{}",
135                weir_core::WIRE_VERSION
136            ),
137            Self::UnknownNack(b) => write!(f, "server nack with unknown reason {b:#04x}"),
138            Self::PayloadTooLarge { len, limit } => write!(
139                f,
140                "payload too large: {len} bytes exceeds the {limit}-byte protocol hard cap"
141            ),
142            Self::Protocol(msg) => write!(f, "protocol error: {msg}"),
143            Self::NoDefaultDurability => {
144                write!(f, "push_default called but no default durability was set")
145            }
146            Self::EmptyPayload => write!(
147                f,
148                "payload is empty; rejected locally (an empty payload is the WAB end-of-records sentinel)"
149            ),
150        }
151    }
152}
153
154impl std::error::Error for ClientError {
155    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
156        match self {
157            Self::Io(e) => Some(e),
158            _ => None,
159        }
160    }
161}
162
163impl From<io::Error> for ClientError {
164    fn from(e: io::Error) -> Self {
165        Self::Io(e)
166    }
167}
168
169/// A synchronous blocking client for one connection to the weir daemon.
170///
171/// The type parameter `S` is the underlying transport. The default is
172/// [`UnixStream`] (Unix domain socket). When the `tls` feature is enabled,
173/// `S` can also be `TlsStream` (TCP + mutual TLS).
174///
175/// The underlying stream runs in blocking mode. Each method sends one request
176/// frame and reads one response frame before returning. Requests are not
177/// pipelined; for concurrent producers, create one `WeirClient` per thread.
178///
179/// Drop the client to close the connection. The daemon treats EOF as a clean
180/// disconnect.
181pub struct WeirClient<S = UnixStream> {
182    // pub(crate) so the TLS connector (a sibling module) can reach the
183    // underlying TcpStream to apply socket timeouts (F43).
184    pub(crate) stream: S,
185    default_durability: Option<Durability>,
186    /// Set once a response read fails (a timeout mid-frame, a partial/aborted
187    /// read, or a protocol violation). The stream is then in an indeterminate
188    /// state — leftover bytes from the aborted response could be mis-read as the
189    /// NEXT request's reply, acking a record the daemon never confirmed (a false
190    /// ack). Once poisoned, every method fails fast; the caller must reconnect
191    /// (G04).
192    poisoned: bool,
193    /// Set once the daemon Nacked a frame for a reason that closes the connection
194    /// (every Nack except the transient `InternalError`). The connection is then
195    /// cleanly dead — the next call would otherwise just hit a broken pipe — so we
196    /// fail fast with a clear "reconnect" error instead (QA#2).
197    closed_after_nack: bool,
198}
199
200// Manual Debug (not derived): a `#[derive(Debug)]` would require `S: Debug`, which
201// excludes `WeirClient<TlsStream>` (rustls's `StreamOwned` is not `Debug`). Print
202// the non-secret fields and omit the transport so every client type — Unix and
203// TLS — implements `Debug` (C-DEBUG, S40).
204impl<S> std::fmt::Debug for WeirClient<S> {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        f.debug_struct("WeirClient")
207            .field("default_durability", &self.default_durability)
208            .field("poisoned", &self.poisoned)
209            .field("closed_after_nack", &self.closed_after_nack)
210            .finish_non_exhaustive()
211    }
212}
213
214// ── Shared methods over any blocking Read+Write transport ──────────────────────
215
216impl<S: Read + Write> WeirClient<S> {
217    /// Sets the default durability tier used by [`push_default`][Self::push_default].
218    pub fn set_default_durability(&mut self, durability: Durability) {
219        self.default_durability = Some(durability);
220    }
221
222    /// Pushes `payload` to the daemon with the given `durability` tier.
223    ///
224    /// Blocks until the daemon replies. On `Ack` the record is durably stored
225    /// according to the requested tier. On `Nack` returns the reason as
226    /// [`ClientError::Nack`].
227    ///
228    /// An `Ack` means the record is durably **buffered** at the requested tier —
229    /// **not** that it has reached the downstream sink yet (the daemon drains in
230    /// batches once a segment seals). See the crate-level "Ack vs. delivery" note.
231    ///
232    /// The ack is **per record, not a liveness probe for the next one.** If the
233    /// daemon dies (or the socket is severed) between two pushes, the *first*
234    /// push after the failure can still return `Ok` — its frame was written and
235    /// acked, or the breakage isn't observed until the next read/write — and only
236    /// the *following* push surfaces the broken pipe. So a single `Ok` does not
237    /// guarantee the connection is healthy for subsequent pushes. After any push
238    /// error, branch on [`ClientError::is_recoverable`] /
239    /// [`is_poisoned`][Self::is_poisoned] to decide whether to keep using this
240    /// client or drop and reconnect, rather than assuming a prior `Ok` proved the
241    /// connection good.
242    #[must_use = "a dropped push result hides whether the record was acked; an \
243                  unhandled Nack also closes the connection"]
244    pub fn push(
245        &mut self,
246        payload: impl AsRef<[u8]>,
247        durability: Durability,
248    ) -> Result<(), ClientError> {
249        self.ensure_usable()?;
250        let bytes = payload.as_ref();
251        // Local guard against an empty payload. An empty payload IS the WAB
252        // end-of-records sentinel, so the daemon Nacks it (NackReason::EmptyPayload)
253        // and closes the connection — which would poison this client. Reject it up
254        // front, before any bytes are sent, so the connection stays usable
255        // (recoverable), mirroring the hard-cap guard below.
256        if bytes.is_empty() {
257            return Err(ClientError::EmptyPayload);
258        }
259        // Local guard against the protocol hard cap. The daemon always rejects a
260        // payload this large, and for a big frame it Nacks then closes the
261        // connection before we finish streaming — so the Nack races our write and
262        // would surface as a bare broken-pipe. Reject up front, no round-trip, with
263        // a clear typed error. (A deployment's configured cap may be lower than the
264        // hard cap; a payload over that lower limit still goes to the daemon and is
265        // handled by the write-error recovery below.)
266        if bytes.len() > MAX_PAYLOAD_HARD_CAP {
267            return Err(ClientError::PayloadTooLarge {
268                len: bytes.len(),
269                limit: MAX_PAYLOAD_HARD_CAP,
270            });
271        }
272        // copy_from_slice at the API boundary so downstream handling is zero-copy.
273        let payload = weir_core::Payload::copy_from_slice(bytes);
274        let header = Header::new(MessageType::Push, durability, 0);
275        let frame = Envelope::new(header, payload).encode();
276
277        if let Err(write_err) = self.stream.write_all(&frame) {
278            // The write failed partway. The daemon may have Nacked (e.g. a payload
279            // over its configured cap) and closed the connection before we finished
280            // streaming — its Nack can already be sitting in our receive buffer.
281            // Read it so the caller sees the real reason instead of a broken-pipe.
282            // Either way the connection is now dead.
283            if let Ok(resp) = self.read_response()
284                && resp.header().message_type() == MessageType::Nack
285            {
286                self.closed_after_nack = true;
287                return Err(nack_error(resp.payload()));
288            }
289            self.poisoned = true;
290            return Err(write_err.into());
291        }
292
293        let resp = self.read_response()?;
294        match resp.header().message_type() {
295            MessageType::Ack => Ok(()),
296            // A Nack may carry an empty payload, which `nack_error` reports as a
297            // `Protocol` desync; poison in that case (see `surface_nack`).
298            MessageType::Nack => Err(self.surface_nack(resp.payload())),
299            // Any other frame type is a stream desync: the daemon sent something we
300            // never expect here, so leftover/unexpected bytes could be mis-read as a
301            // later reply (a false ack). Poison the connection (Protocol → not
302            // recoverable, matching `ensure_usable`).
303            other => {
304                self.poisoned = true;
305                Err(ClientError::Protocol(format!(
306                    "expected Ack or Nack, got {other:?}"
307                )))
308            }
309        }
310    }
311
312    /// Records that a Nack closed the connection (every reason except the
313    /// transient `InternalError`, which the daemon keeps open) so the next call
314    /// fails fast with a clear reconnect error rather than a broken pipe (QA#2).
315    /// Returns the error unchanged for convenient `Err(self.note_nack(e))` use.
316    fn note_nack(&mut self, err: ClientError) -> ClientError {
317        if !matches!(err, ClientError::Nack(NackReason::InternalError)) {
318            self.closed_after_nack = true;
319        }
320        err
321    }
322
323    /// Decodes a Nack frame's payload and records its connection effect.
324    ///
325    /// `nack_error` maps a well-formed Nack to a `Nack`/`VersionMismatch`/`UnknownNack`
326    /// error (routed through [`note_nack`][Self::note_nack] to mark a closing Nack),
327    /// but an **empty** Nack payload is a stream desync that it reports as
328    /// [`ClientError::Protocol`]. A desync means leftover/unexpected bytes could be
329    /// mis-read as a later reply (a false ack), so we poison the connection in that
330    /// case — keeping `Protocol` non-recoverable and `is_poisoned()` in agreement.
331    fn surface_nack(&mut self, payload: &[u8]) -> ClientError {
332        match nack_error(payload) {
333            ClientError::Protocol(msg) => {
334                self.poisoned = true;
335                ClientError::Protocol(msg)
336            }
337            other => self.note_nack(other),
338        }
339    }
340
341    /// Pushes at the connection's default durability tier.
342    ///
343    /// Returns [`ClientError::NoDefaultDurability`] if no default was set via
344    /// [`set_default_durability`][Self::set_default_durability] (or via a
345    /// `connect_with_default` / `connect_tls` constructor that accepted a
346    /// `default_durability`).
347    pub fn push_default(&mut self, payload: impl AsRef<[u8]>) -> Result<(), ClientError> {
348        let d = self
349            .default_durability
350            .ok_or(ClientError::NoDefaultDurability)?;
351        self.push(payload, d)
352    }
353
354    /// Sends a `HealthCheck` frame and returns `Ok(())` on a valid
355    /// `HealthCheckResponse`. Returns an error if the daemon is unreachable
356    /// or responds with a Nack.
357    pub fn health_check(&mut self) -> Result<(), ClientError> {
358        self.ensure_usable()?;
359        let header = Header::new(MessageType::HealthCheck, Durability::Durable, 0);
360        let frame = Envelope::new(header, vec![]).encode();
361        self.stream.write_all(&frame)?;
362
363        let resp = self.read_response()?;
364        match resp.header().message_type() {
365            MessageType::HealthCheckResponse => Ok(()),
366            // An empty Nack payload is a desync `nack_error` reports as `Protocol`;
367            // poison in that case (see `surface_nack`).
368            MessageType::Nack => Err(self.surface_nack(resp.payload())),
369            // Any other frame type is a stream desync: poison the connection so a
370            // later call can't mis-read a stale frame as its reply (Protocol → not
371            // recoverable, matching `ensure_usable`).
372            other => {
373                self.poisoned = true;
374                Err(ClientError::Protocol(format!(
375                    "expected HealthCheckResponse, got {other:?}"
376                )))
377            }
378        }
379    }
380
381    /// Whether this client's connection is dead and must be rebuilt.
382    ///
383    /// Reports the union of the two ways a connection becomes unusable, so it is the
384    /// exact complement of [`is_recoverable`][ClientError::is_recoverable] for the
385    /// error that caused it:
386    ///
387    /// - an **indeterminate stream** (the internal `poisoned` flag) — a
388    ///   response-read failure/timeout, a protocol violation in the reply, or any
389    ///   stream desync (an unexpected daemon message type, an empty-payload Nack);
390    ///   leftover/unexpected bytes could be mis-read as a later reply (a false ack);
391    /// - a **connection-closing Nack** (the internal `closed_after_nack` flag) —
392    ///   every Nack reason except the transient
393    ///   [`InternalError`][NackReason::InternalError], after which the daemon closes
394    ///   the connection.
395    ///
396    /// In either state **every** method fails fast (it will not touch the socket
397    /// again); the caller must drop this client and open a fresh connection.
398    ///
399    /// A long-lived producer thread (e.g. one driven from an async runtime) should
400    /// check this after a failed call — or, equivalently, branch on
401    /// [`ClientError::is_recoverable`] — and rebuild the connection when it reports
402    /// poisoned / non-recoverable, rather than retrying on the dead client:
403    ///
404    /// ```no_run
405    /// # use weir_client::{WeirClient, ClientError};
406    /// # use weir_core::Durability;
407    /// # fn reconnect() -> WeirClient { unreachable!() }
408    /// # let mut client = reconnect();
409    /// if let Err(e) = client.push(b"record", Durability::Durable) {
410    ///     if !e.is_recoverable() || client.is_poisoned() {
411    ///         client = reconnect(); // drop the dead client, rebuild
412    ///     }
413    /// }
414    /// ```
415    ///
416    /// See [`ClientError::is_recoverable`] for the per-error view of the same
417    /// distinction (recoverable = connection still usable; non-recoverable = drop
418    /// and reconnect). The two are kept equivalent: `is_poisoned()` is true after a
419    /// failed call exactly when that call's error is non-recoverable.
420    #[must_use]
421    pub fn is_poisoned(&self) -> bool {
422        self.poisoned || self.closed_after_nack
423    }
424
425    /// Returns the poisoned-connection error if a prior response read failed.
426    /// Once poisoned the stream may hold leftover bytes from an aborted response
427    /// that would be mis-read as this call's reply — a false ack — so we refuse
428    /// to use it (G04). The caller must drop the client and reconnect.
429    fn ensure_usable(&self) -> Result<(), ClientError> {
430        if self.closed_after_nack {
431            return Err(ClientError::Protocol(
432                "connection closed by the daemon after a Nack; \
433                 drop this client and reconnect before sending again"
434                    .into(),
435            ));
436        }
437        if self.poisoned {
438            return Err(ClientError::Protocol(
439                "client connection poisoned by a prior response-read error/timeout; \
440                 drop this client and reconnect before sending again"
441                    .into(),
442            ));
443        }
444        Ok(())
445    }
446
447    fn read_response(&mut self) -> Result<Envelope, ClientError> {
448        // Any failure here leaves the stream in an indeterminate state (a timeout
449        // mid-frame, a partial/aborted read, or a protocol violation): the unread
450        // tail of this response could be mis-read as the NEXT request's reply.
451        // Poison the client so every subsequent call fails fast (G04).
452        let result = self.read_response_inner();
453        if result.is_err() {
454            self.poisoned = true;
455        }
456        result
457    }
458
459    fn read_response_inner(&mut self) -> Result<Envelope, ClientError> {
460        let mut header_buf = [0u8; HEADER_LEN];
461        self.stream.read_exact(&mut header_buf)?;
462
463        let header =
464            Header::decode(&header_buf).map_err(|e| ClientError::Protocol(e.to_string()))?;
465
466        let payload_len = header.payload_len() as usize;
467        // Cap before allocating, mirroring the server's pre-allocation guard
468        // (Envelope::decode). A malformed or hostile daemon could otherwise
469        // declare payload_len up to u32::MAX and make the client allocate ~4 GiB
470        // for a response (F44). Daemon responses are tiny, so this never trips
471        // legitimately.
472        if payload_len > MAX_PAYLOAD_HARD_CAP {
473            return Err(ClientError::Protocol(format!(
474                "response payload_len {payload_len} exceeds MAX_PAYLOAD_HARD_CAP \
475                 ({MAX_PAYLOAD_HARD_CAP}); refusing to allocate"
476            )));
477        }
478        let mut payload_buf = vec![0u8; payload_len];
479        if payload_len > 0 {
480            self.stream.read_exact(&mut payload_buf)?;
481        }
482
483        let mut crc_buf = [0u8; 4];
484        self.stream.read_exact(&mut crc_buf)?;
485        let expected = u32::from_le_bytes(crc_buf);
486        let computed = crc32fast::hash(&payload_buf);
487        if expected != computed {
488            return Err(ClientError::Protocol(format!(
489                "response payload CRC mismatch: expected {expected:#010x}, computed {computed:#010x}"
490            )));
491        }
492
493        Ok(Envelope::new(header, payload_buf))
494    }
495
496    /// Crate-internal constructor so `tls.rs` can build the struct without
497    /// going through a Unix-socket constructor.
498    #[cfg_attr(not(feature = "tls"), allow(dead_code))]
499    pub(crate) fn from_parts(stream: S, default_durability: Option<Durability>) -> Self {
500        Self {
501            stream,
502            default_durability,
503            poisoned: false,
504            closed_after_nack: false,
505        }
506    }
507}
508
509// ── Unix-specific constructors ─────────────────────────────────────────────────
510
511impl WeirClient<UnixStream> {
512    /// Opens a connection to the weir daemon's Unix socket at `path`.
513    pub fn connect(path: impl AsRef<Path>) -> Result<Self, ClientError> {
514        let stream = UnixStream::connect(path.as_ref())?;
515        Ok(Self {
516            stream,
517            default_durability: None,
518            poisoned: false,
519            closed_after_nack: false,
520        })
521    }
522
523    /// Opens a connection and sets a default durability tier in one step.
524    ///
525    /// Equivalent to calling [`connect`][Self::connect] then
526    /// [`set_default_durability`][Self::set_default_durability].
527    pub fn connect_with_default(
528        path: impl AsRef<Path>,
529        durability: Durability,
530    ) -> Result<Self, ClientError> {
531        let mut c = Self::connect(path)?;
532        c.default_durability = Some(durability);
533        Ok(c)
534    }
535
536    /// Wraps an already-connected [`UnixStream`]. Useful for callers that
537    /// manage their own connection setup (systemd socket activation,
538    /// pre-authenticated file descriptors passed from a parent process,
539    /// `UnixStream::pair`-based test harnesses).
540    pub fn from_stream(stream: UnixStream) -> Self {
541        Self {
542            stream,
543            default_durability: None,
544            poisoned: false,
545            closed_after_nack: false,
546        }
547    }
548
549    /// Sets the read timeout on the underlying socket. `None` (the default)
550    /// blocks indefinitely.
551    ///
552    /// **Opt-in availability guard.** By default every method blocks in the
553    /// response-read path (inside [`push`][Self::push] /
554    /// [`health_check`][Self::health_check]) waiting for the daemon's Ack/Nack, so a
555    /// wedged daemon (hung flusher, `SIGSTOP`, half-open connection) would block
556    /// a producer's hot path forever. With a read timeout set, a stalled reply
557    /// surfaces as a [`ClientError::Io`] timeout instead; the producer can retry
558    /// — the record may still have been durably written, which the at-least-once
559    /// contract covers. Pick a value comfortably above the daemon's Durable ack
560    /// latency under load: the daemon's own `ACK_TIMEOUT` is 30 s, so e.g.
561    /// 45–60 s lets the daemon's Nack win rather than racing it.
562    pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
563        self.stream.set_read_timeout(timeout)
564    }
565
566    /// Sets the write timeout on the underlying socket. `None` (the default)
567    /// blocks indefinitely. See [`set_read_timeout`][Self::set_read_timeout] for
568    /// the rationale — this bounds a stalled `write_all` of the request frame.
569    pub fn set_write_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
570        self.stream.set_write_timeout(timeout)
571    }
572}
573
574fn nack_error(payload: &[u8]) -> ClientError {
575    match payload.first().copied() {
576        Some(b) => match NackReason::try_from(b) {
577            // A VersionMismatch Nack carries the daemon's WIRE_VERSION as a
578            // second payload byte (`[0x02, daemon_version]`). Surface it so the
579            // caller can report both sides; fall back to the bare reason if the
580            // daemon (somehow) omitted it.
581            Ok(NackReason::VersionMismatch) => match payload.get(1).copied() {
582                Some(daemon_version) => ClientError::VersionMismatch { daemon_version },
583                None => ClientError::Nack(NackReason::VersionMismatch),
584            },
585            Ok(r) => ClientError::Nack(r),
586            Err(_) => ClientError::UnknownNack(b),
587        },
588        None => ClientError::Protocol("Nack frame had empty payload".into()),
589    }
590}
591
592// ── Tests ──────────────────────────────────────────────────────────────────────
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn push_default_without_default_errors() {
600        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
601        let mut c = WeirClient::from_stream(a);
602        assert!(matches!(
603            c.push_default(b"x").unwrap_err(),
604            ClientError::NoDefaultDurability
605        ));
606    }
607
608    #[test]
609    fn set_default_durability_used_by_push_default() {
610        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
611        let mut c = WeirClient::from_stream(client_end);
612        // Buffered, not Durable: Durable (0x01) is the wire byte several
613        // other code paths default to, so asserting Durable here would pass
614        // even if push_default ignored the stored default and hardcoded
615        // Durable. Buffered (0x03) is the only tier that can't leak through
616        // by accident, so it's the one that actually proves the stored
617        // default is threaded onto the wire.
618        c.set_default_durability(Durability::Buffered);
619
620        let reader = std::thread::spawn(move || {
621            use std::io::{Read, Write};
622            let mut hdr = [0u8; weir_core::HEADER_LEN];
623            server_end.read_exact(&mut hdr).unwrap();
624            let h = weir_core::Header::decode(&hdr).unwrap();
625            let mut rest = vec![0u8; h.payload_len() as usize + 4];
626            server_end.read_exact(&mut rest).unwrap();
627            // Send back an Ack so push_default can complete.
628            let ack = weir_core::Envelope::new(
629                weir_core::Header::new(
630                    weir_core::MessageType::Ack,
631                    weir_core::Durability::Durable,
632                    0,
633                ),
634                vec![],
635            )
636            .encode();
637            server_end.write_all(&ack).unwrap();
638            // Byte 6 of the header is the durability byte on the wire.
639            (h.durability(), hdr[6])
640        });
641
642        c.push_default(b"hello").unwrap();
643        let (durability, wire_byte) = reader.join().unwrap();
644        assert_eq!(durability, Durability::Buffered);
645        assert_eq!(wire_byte, 0x03);
646    }
647
648    #[test]
649    fn nack_error_surfaces_daemon_version_on_version_mismatch() {
650        // Daemon sends `[VersionMismatch (0x02), daemon_wire_version]`.
651        let payload = [NackReason::VersionMismatch as u8, 7];
652        match nack_error(&payload) {
653            ClientError::VersionMismatch { daemon_version } => assert_eq!(daemon_version, 7),
654            other => panic!("expected VersionMismatch, got {other:?}"),
655        }
656    }
657
658    #[test]
659    fn nack_error_version_mismatch_without_version_byte_falls_back() {
660        // A malformed VersionMismatch Nack with no second byte must not panic;
661        // it degrades to the bare reason.
662        let payload = [NackReason::VersionMismatch as u8];
663        assert!(matches!(
664            nack_error(&payload),
665            ClientError::Nack(NackReason::VersionMismatch)
666        ));
667    }
668
669    #[test]
670    fn nack_error_other_reasons_unaffected() {
671        let payload = [NackReason::PayloadTooLarge as u8];
672        assert!(matches!(
673            nack_error(&payload),
674            ClientError::Nack(NackReason::PayloadTooLarge)
675        ));
676    }
677
678    // ── Bug #1: oversized payloads must surface as PayloadTooLarge, not broken-pipe ──
679
680    #[test]
681    fn push_rejects_over_hard_cap_locally() {
682        // A payload above the protocol hard cap is rejected locally, before any
683        // bytes hit the wire — no round-trip, and the connection stays usable.
684        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
685        let mut c = WeirClient::from_stream(client_end);
686        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
687        let err = c.push(&oversized, Durability::Durable).unwrap_err();
688        assert!(
689            matches!(err, ClientError::PayloadTooLarge { len, limit }
690                if len == MAX_PAYLOAD_HARD_CAP + 1 && limit == MAX_PAYLOAD_HARD_CAP),
691            "expected a local PayloadTooLarge, got {err:?}"
692        );
693        assert!(
694            !c.poisoned,
695            "a local rejection must not poison the connection"
696        );
697    }
698
699    #[test]
700    fn push_surfaces_payload_too_large_nack_when_server_closes_mid_write() {
701        // Mirrors the daemon's over-configured-cap path: read only the header, send
702        // Nack(PayloadTooLarge), then close without draining the payload. Before the
703        // fix the client's large write hit the closed socket and returned a bare
704        // broken-pipe, hiding the Nack. Now the Nack is read from the receive buffer
705        // and surfaced — whether the write fails partway or just-barely succeeds.
706        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
707        let mut c = WeirClient::from_stream(client_end);
708
709        let server = std::thread::spawn(move || {
710            use std::io::{Read, Write};
711            let mut hdr = [0u8; weir_core::HEADER_LEN];
712            server_end.read_exact(&mut hdr).unwrap();
713            let nack = weir_core::Envelope::new(
714                weir_core::Header::new(
715                    weir_core::MessageType::Nack,
716                    weir_core::Durability::Durable,
717                    0,
718                ),
719                vec![NackReason::PayloadTooLarge as u8],
720            )
721            .encode();
722            server_end.write_all(&nack).unwrap();
723            // drop server_end here → connection closes without reading the payload.
724        });
725
726        // 2 MiB: above any default socket buffer (so the write blocks then fails
727        // once the server stops reading and closes), but under the hard cap (so it
728        // is not caught by the local pre-check).
729        let payload = vec![0u8; 2 * 1024 * 1024];
730        let err = c.push(&payload, Durability::Durable).unwrap_err();
731        server.join().unwrap();
732        assert!(
733            matches!(err, ClientError::Nack(NackReason::PayloadTooLarge)),
734            "expected Nack(PayloadTooLarge) surfaced from the buffered reply, got {err:?}"
735        );
736    }
737
738    // ── Bug #2: a connection-closing Nack must give a clear next-call error ──────
739
740    // Reads one full frame (header + payload + CRC) off a server-side stream.
741    fn drain_one_frame(s: &mut std::os::unix::net::UnixStream) {
742        use std::io::Read;
743        let mut hdr = [0u8; weir_core::HEADER_LEN];
744        s.read_exact(&mut hdr).unwrap();
745        let n = weir_core::Header::decode(&hdr).unwrap().payload_len() as usize + 4;
746        let mut rest = vec![0u8; n];
747        s.read_exact(&mut rest).unwrap();
748    }
749
750    fn nack_frame(reason: NackReason) -> Vec<u8> {
751        weir_core::Envelope::new(
752            weir_core::Header::new(
753                weir_core::MessageType::Nack,
754                weir_core::Durability::Durable,
755                0,
756            ),
757            vec![reason as u8],
758        )
759        .encode()
760    }
761
762    #[test]
763    fn closing_nack_makes_next_call_fail_with_a_clear_reconnect_error() {
764        // The daemon closes the connection after a validation Nack. Before the fix,
765        // the next push hit the dead socket and returned a bare broken-pipe; now it
766        // fails fast with a clear "reconnect" error.
767        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
768        let mut c = WeirClient::from_stream(client_end);
769        let server = std::thread::spawn(move || {
770            use std::io::Write;
771            drain_one_frame(&mut server_end);
772            server_end
773                .write_all(&nack_frame(NackReason::EmptyPayload))
774                .unwrap();
775            // drop server_end → connection closes, as the daemon does after a Nack.
776        });
777        let first = c.push(b"x", Durability::Durable).unwrap_err();
778        server.join().unwrap();
779        assert!(
780            matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
781            "first push should surface the real reason, got {first:?}"
782        );
783        // Second call must NOT be a broken-pipe — it must clearly say reconnect.
784        match c.push(b"y", Durability::Durable).unwrap_err() {
785            ClientError::Protocol(msg) => assert!(
786                msg.contains("closed by the daemon after a Nack") && msg.contains("reconnect"),
787                "unexpected message: {msg}"
788            ),
789            other => panic!("expected a clear reconnect Protocol error, got {other:?}"),
790        }
791    }
792
793    #[test]
794    fn internal_error_nack_keeps_connection_usable() {
795        // InternalError is transient — the daemon keeps the connection open — so the
796        // client must NOT mark it closed, and a retry on the same connection works.
797        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
798        let mut c = WeirClient::from_stream(client_end);
799        let server = std::thread::spawn(move || {
800            use std::io::Write;
801            drain_one_frame(&mut server_end);
802            server_end
803                .write_all(&nack_frame(NackReason::InternalError))
804                .unwrap();
805            drain_one_frame(&mut server_end); // the retry
806            let ack = weir_core::Envelope::new(
807                weir_core::Header::new(
808                    weir_core::MessageType::Ack,
809                    weir_core::Durability::Durable,
810                    0,
811                ),
812                vec![],
813            )
814            .encode();
815            server_end.write_all(&ack).unwrap();
816        });
817        let first = c.push(b"a", Durability::Durable).unwrap_err();
818        assert!(
819            matches!(first, ClientError::Nack(NackReason::InternalError)),
820            "got {first:?}"
821        );
822        assert!(
823            !c.closed_after_nack,
824            "InternalError is transient and must not close the connection"
825        );
826        c.push(b"b", Durability::Durable).unwrap(); // retry on the same connection succeeds
827        server.join().unwrap();
828    }
829
830    // ── Hostile / malformed daemon responses: refuse + poison, never false-ack ──
831
832    /// F44: a daemon-declared response payload_len above the hard cap must be
833    /// refused BEFORE allocating ~4 GiB, and poison the connection.
834    #[test]
835    fn response_oversized_payload_len_refuses_to_allocate_and_poisons() {
836        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
837        let mut c = WeirClient::from_stream(client_end);
838        let server = std::thread::spawn(move || {
839            use std::io::Write;
840            drain_one_frame(&mut server_end);
841            // A valid 16-byte header whose payload_len is CAP+1, with a recomputed
842            // header CRC; then EOF (no payload) — the client must refuse before it
843            // would try to read/allocate the declared payload.
844            let mut hdr = Header::new(MessageType::Ack, Durability::Durable, 0).encode();
845            let big = (MAX_PAYLOAD_HARD_CAP as u32).wrapping_add(1);
846            hdr[8..12].copy_from_slice(&big.to_le_bytes());
847            let crc = crc32fast::hash(&hdr[0..12]);
848            hdr[12..16].copy_from_slice(&crc.to_le_bytes());
849            server_end.write_all(&hdr).unwrap();
850        });
851        let err = c.push(b"x", Durability::Durable).unwrap_err();
852        server.join().unwrap();
853        match err {
854            ClientError::Protocol(msg) => assert!(
855                msg.contains("refusing to allocate"),
856                "expected the cap refusal, got: {msg}"
857            ),
858            other => panic!("expected a Protocol cap refusal, got {other:?}"),
859        }
860        assert!(
861            c.is_poisoned(),
862            "an oversized response must poison the client"
863        );
864    }
865
866    /// An empty Nack payload is a stream desync: `nack_error` reports `Protocol`,
867    /// and the connection must be poisoned so a stale frame can't be mis-read as a
868    /// later ack (a false ack).
869    #[test]
870    fn empty_nack_payload_is_protocol_desync_and_poisons() {
871        // (1) the helper maps an empty payload to a Protocol error.
872        match nack_error(&[]) {
873            ClientError::Protocol(msg) => assert!(msg.contains("empty payload"), "{msg}"),
874            other => panic!("expected Protocol for empty Nack, got {other:?}"),
875        }
876        // (2) end-to-end: an empty-payload Nack poisons + is not recoverable.
877        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
878        let mut c = WeirClient::from_stream(client_end);
879        let server = std::thread::spawn(move || {
880            use std::io::Write;
881            drain_one_frame(&mut server_end);
882            let frame = Envelope::new(
883                Header::new(MessageType::Nack, Durability::Durable, 0),
884                vec![],
885            )
886            .encode();
887            server_end.write_all(&frame).unwrap();
888        });
889        let err = c.push(b"x", Durability::Durable).unwrap_err();
890        server.join().unwrap();
891        assert!(matches!(err, ClientError::Protocol(_)), "got {err:?}");
892        assert!(
893            !err.is_recoverable(),
894            "a desync Protocol error is not recoverable"
895        );
896        assert!(
897            c.is_poisoned(),
898            "an empty-Nack desync must poison the client"
899        );
900    }
901
902    /// A corrupted response payload CRC is a protocol violation: surface it and
903    /// poison (the stream tail is now untrustworthy).
904    #[test]
905    fn response_payload_crc_mismatch_poisons() {
906        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
907        let mut c = WeirClient::from_stream(client_end);
908        let server = std::thread::spawn(move || {
909            use std::io::Write;
910            drain_one_frame(&mut server_end);
911            // A Nack frame with a flipped trailing CRC byte.
912            let mut frame = nack_frame(NackReason::InternalError);
913            *frame.last_mut().unwrap() ^= 0xff;
914            server_end.write_all(&frame).unwrap();
915        });
916        let err = c.push(b"x", Durability::Durable).unwrap_err();
917        server.join().unwrap();
918        match err {
919            ClientError::Protocol(msg) => assert!(msg.contains("CRC mismatch"), "{msg}"),
920            other => panic!("expected a CRC-mismatch Protocol error, got {other:?}"),
921        }
922        assert!(
923            c.is_poisoned(),
924            "a CRC-mismatched response must poison the client"
925        );
926    }
927
928    /// An unrecognised Nack reason byte (reserved range) surfaces as
929    /// `UnknownNack(byte)` and closes the connection.
930    #[test]
931    fn unknown_nack_reason_byte_surfaces_unknown_nack() {
932        assert!(matches!(
933            nack_error(&[0x0A]),
934            ClientError::UnknownNack(0x0A)
935        ));
936
937        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
938        let mut c = WeirClient::from_stream(client_end);
939        let server = std::thread::spawn(move || {
940            use std::io::Write;
941            drain_one_frame(&mut server_end);
942            let frame = Envelope::new(
943                Header::new(MessageType::Nack, Durability::Durable, 0),
944                vec![0x0A],
945            )
946            .encode();
947            server_end.write_all(&frame).unwrap();
948        });
949        let err = c.push(b"x", Durability::Durable).unwrap_err();
950        server.join().unwrap();
951        assert!(matches!(err, ClientError::UnknownNack(0x0A)), "got {err:?}");
952        assert!(
953            c.is_poisoned(),
954            "an unknown closing Nack must mark the client unusable"
955        );
956    }
957
958    /// A VersionMismatch Nack surfaces the daemon's version through push() and is
959    /// non-recoverable + connection-closing.
960    #[test]
961    fn version_mismatch_surfaces_through_push() {
962        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
963        let mut c = WeirClient::from_stream(client_end);
964        let server = std::thread::spawn(move || {
965            use std::io::Write;
966            drain_one_frame(&mut server_end);
967            let frame = Envelope::new(
968                Header::new(MessageType::Nack, Durability::Durable, 0),
969                vec![NackReason::VersionMismatch as u8, 9],
970            )
971            .encode();
972            server_end.write_all(&frame).unwrap();
973        });
974        let err = c.push(b"x", Durability::Durable).unwrap_err();
975        server.join().unwrap();
976        assert!(
977            matches!(err, ClientError::VersionMismatch { daemon_version: 9 }),
978            "got {err:?}"
979        );
980        assert!(!err.is_recoverable());
981        assert!(c.is_poisoned());
982    }
983
984    /// health_check() against a daemon that replies with the wrong frame type is a
985    /// desync: Protocol error + poison + non-recoverable.
986    #[test]
987    fn health_check_unexpected_frame_type_poisons() {
988        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
989        let mut c = WeirClient::from_stream(client_end);
990        let server = std::thread::spawn(move || {
991            use std::io::Write;
992            drain_one_frame(&mut server_end);
993            // Reply to a HealthCheck with an Ack — never expected here.
994            let frame = Envelope::new(
995                Header::new(MessageType::Ack, Durability::Durable, 0),
996                vec![],
997            )
998            .encode();
999            server_end.write_all(&frame).unwrap();
1000        });
1001        let err = c.health_check().unwrap_err();
1002        server.join().unwrap();
1003        match &err {
1004            ClientError::Protocol(msg) => {
1005                assert!(msg.contains("expected HealthCheckResponse"), "{msg}")
1006            }
1007            other => panic!("expected a Protocol desync, got {other:?}"),
1008        }
1009        assert!(!err.is_recoverable());
1010        assert!(c.is_poisoned());
1011    }
1012
1013    // ── F43: opt-in socket timeouts ───────────────────────────────────────────
1014
1015    #[test]
1016    fn set_read_timeout_applies_to_socket() {
1017        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
1018        let c = WeirClient::from_stream(a);
1019        c.set_read_timeout(Some(Duration::from_millis(250)))
1020            .unwrap();
1021        assert_eq!(
1022            c.stream.read_timeout().unwrap(),
1023            Some(Duration::from_millis(250))
1024        );
1025        // None clears it back to blocking.
1026        c.set_read_timeout(None).unwrap();
1027        assert_eq!(c.stream.read_timeout().unwrap(), None);
1028    }
1029
1030    #[test]
1031    fn read_timeout_bounds_a_silent_daemon_instead_of_blocking_forever() {
1032        // `_b` is the daemon end: it's held open (so the connection stays up)
1033        // but never replies. Without a read timeout, push would block forever.
1034        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
1035        let mut c = WeirClient::from_stream(a);
1036        c.set_read_timeout(Some(Duration::from_millis(150)))
1037            .unwrap();
1038        // push writes the (tiny) frame, then blocks reading the reply that never
1039        // comes → the timeout fires → an Io error rather than an indefinite hang.
1040        let err = c.push(b"x", Durability::Durable).unwrap_err();
1041        assert!(
1042            matches!(err, ClientError::Io(_)),
1043            "expected Io timeout, got {err:?}"
1044        );
1045    }
1046
1047    #[test]
1048    fn client_is_poisoned_after_a_read_failure() {
1049        // G04: after a response read fails (here a timeout), the stream may hold
1050        // leftover bytes that a subsequent read would mis-attribute to the next
1051        // request — a false ack. The client must poison itself and reject further
1052        // use fast, instead of reading again.
1053        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
1054        let mut c = WeirClient::from_stream(a);
1055        c.set_read_timeout(Some(Duration::from_millis(100)))
1056            .unwrap();
1057
1058        // First push: the peer never replies → read times out → Io error.
1059        let first = c.push(b"x", Durability::Durable).unwrap_err();
1060        assert!(matches!(first, ClientError::Io(_)), "{first:?}");
1061
1062        // Second push must fail FAST as poisoned, not block on another read.
1063        let started = std::time::Instant::now();
1064        let second = c.push(b"y", Durability::Durable).unwrap_err();
1065        assert!(
1066            started.elapsed() < Duration::from_millis(50),
1067            "poisoned client must reject immediately, not read again"
1068        );
1069        match second {
1070            ClientError::Protocol(msg) => assert!(msg.contains("poisoned"), "{msg}"),
1071            other => panic!("expected a poisoned Protocol error, got {other:?}"),
1072        }
1073    }
1074
1075    // ── is_poisoned / is_recoverable helpers ─────────────────────────────────
1076
1077    #[test]
1078    fn is_poisoned_flips_after_a_read_failure() {
1079        // A fresh client is not poisoned; after a response-read failure (here a
1080        // timeout) the flag flips, mirroring `client_is_poisoned_after_a_read_failure`.
1081        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
1082        let mut c = WeirClient::from_stream(a);
1083        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
1084
1085        c.set_read_timeout(Some(Duration::from_millis(100)))
1086            .unwrap();
1087        // Peer never replies → the response read times out → the client poisons.
1088        let err = c.push(b"x", Durability::Durable).unwrap_err();
1089        assert!(matches!(err, ClientError::Io(_)), "{err:?}");
1090        assert!(
1091            c.is_poisoned(),
1092            "a read failure must flip is_poisoned() to true"
1093        );
1094        // And the poisoning error itself is non-recoverable.
1095        assert!(!err.is_recoverable(), "an Io failure is not recoverable");
1096    }
1097
1098    #[test]
1099    fn is_poisoned_after_a_connection_closing_nack() {
1100        // A connection-closing Nack (every reason except InternalError) leaves the
1101        // connection dead. `is_poisoned()` must report that — it reflects
1102        // `closed_after_nack`, not just `poisoned` — and the connection must be
1103        // refused by `ensure_usable` on the next call. (Models the closing-nack
1104        // test `closing_nack_makes_next_call_fail_with_a_clear_reconnect_error`.)
1105        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
1106        let mut c = WeirClient::from_stream(client_end);
1107        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
1108        let server = std::thread::spawn(move || {
1109            use std::io::Write;
1110            drain_one_frame(&mut server_end);
1111            server_end
1112                .write_all(&nack_frame(NackReason::EmptyPayload))
1113                .unwrap();
1114            // drop server_end → connection closes, as the daemon does after a Nack.
1115        });
1116        let first = c.push(b"x", Durability::Durable).unwrap_err();
1117        server.join().unwrap();
1118        assert!(
1119            matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
1120            "expected the real Nack reason, got {first:?}"
1121        );
1122        // The closing Nack is non-recoverable, and the client reports poisoned.
1123        assert!(
1124            !first.is_recoverable(),
1125            "a connection-closing Nack is non-recoverable"
1126        );
1127        assert!(
1128            c.is_poisoned(),
1129            "a connection-closing Nack must flip is_poisoned() to true"
1130        );
1131        // And the connection is refused fast on the next attempt.
1132        assert!(
1133            matches!(c.ensure_usable(), Err(ClientError::Protocol(_))),
1134            "ensure_usable must reject a closed-after-nack connection"
1135        );
1136    }
1137
1138    #[test]
1139    fn is_poisoned_and_is_recoverable_agree_after_a_desync() {
1140        // A stream desync — the daemon sends an unexpected message type where an
1141        // Ack/Nack was expected — poisons the connection: leftover/unexpected bytes
1142        // could be mis-read as a later reply (a false ack). `is_poisoned()` must be
1143        // true AND the returned error must be non-recoverable, i.e. the two agree.
1144        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
1145        let mut c = WeirClient::from_stream(client_end);
1146        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
1147        let server = std::thread::spawn(move || {
1148            use std::io::Write;
1149            drain_one_frame(&mut server_end);
1150            // A HealthCheck frame is a valid frame, but it is NOT a reply push expects
1151            // (push expects Ack or Nack) — a genuine stream desync.
1152            let bogus = weir_core::Envelope::new(
1153                weir_core::Header::new(
1154                    weir_core::MessageType::HealthCheck,
1155                    weir_core::Durability::Durable,
1156                    0,
1157                ),
1158                vec![],
1159            )
1160            .encode();
1161            server_end.write_all(&bogus).unwrap();
1162        });
1163        let err = c.push(b"x", Durability::Durable).unwrap_err();
1164        server.join().unwrap();
1165        // The error is a Protocol desync, which is non-recoverable...
1166        assert!(
1167            matches!(err, ClientError::Protocol(_)),
1168            "expected a Protocol desync error, got {err:?}"
1169        );
1170        assert!(!err.is_recoverable(), "a stream desync is non-recoverable");
1171        // ...and is_poisoned() agrees (the two must never disagree).
1172        assert!(
1173            c.is_poisoned(),
1174            "a stream desync must flip is_poisoned() to true"
1175        );
1176        assert_eq!(
1177            c.is_poisoned(),
1178            !err.is_recoverable(),
1179            "is_poisoned() and !is_recoverable() must agree"
1180        );
1181    }
1182
1183    #[test]
1184    fn is_recoverable_true_for_local_over_cap_rejection() {
1185        // The local pre-send PayloadTooLarge rejection does not touch the socket, so
1186        // it is recoverable and must not poison the client.
1187        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
1188        let mut c = WeirClient::from_stream(client_end);
1189        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
1190        let err = c.push(&oversized, Durability::Durable).unwrap_err();
1191        assert!(
1192            matches!(err, ClientError::PayloadTooLarge { .. }),
1193            "expected a local PayloadTooLarge, got {err:?}"
1194        );
1195        assert!(
1196            err.is_recoverable(),
1197            "a local over-cap rejection keeps the connection usable"
1198        );
1199        assert!(!c.is_poisoned(), "a recoverable error must not poison");
1200    }
1201
1202    #[test]
1203    fn push_rejects_empty_payload_locally_without_poisoning() {
1204        // An empty payload is the WAB end-of-records sentinel; the daemon would
1205        // Nack+close it. The local guard rejects it before any bytes are sent, so
1206        // the connection stays usable (recoverable, not poisoned) — mirroring the
1207        // over-cap guard.
1208        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
1209        let mut c = WeirClient::from_stream(client_end);
1210        let err = c.push(b"", Durability::Durable).unwrap_err();
1211        assert!(
1212            matches!(err, ClientError::EmptyPayload),
1213            "expected a local EmptyPayload, got {err:?}"
1214        );
1215        assert!(
1216            err.is_recoverable(),
1217            "a local empty-payload rejection keeps the connection usable"
1218        );
1219        assert!(!c.is_poisoned(), "a recoverable error must not poison");
1220    }
1221
1222    #[test]
1223    fn is_recoverable_true_for_no_default_durability() {
1224        // A local misconfiguration returned before any I/O is recoverable.
1225        let err = ClientError::NoDefaultDurability;
1226        assert!(err.is_recoverable());
1227    }
1228
1229    #[test]
1230    fn is_recoverable_true_for_internal_error_nack() {
1231        // InternalError is the one Nack the daemon keeps the connection open for.
1232        let err = ClientError::Nack(NackReason::InternalError);
1233        assert!(
1234            err.is_recoverable(),
1235            "a transient InternalError Nack keeps the connection usable"
1236        );
1237    }
1238
1239    #[test]
1240    fn is_recoverable_false_for_connection_closing_nacks() {
1241        // Every Nack reason except InternalError closes the connection, so it is
1242        // non-recoverable. VersionMismatch and UnknownNack are Nacks too.
1243        for err in [
1244            ClientError::Nack(NackReason::EmptyPayload),
1245            ClientError::Nack(NackReason::PayloadTooLarge),
1246            ClientError::Nack(NackReason::UnknownMessage),
1247            ClientError::VersionMismatch { daemon_version: 7 },
1248            ClientError::UnknownNack(0xff),
1249        ] {
1250            assert!(
1251                !err.is_recoverable(),
1252                "{err:?} closes the connection and must be non-recoverable"
1253            );
1254        }
1255    }
1256
1257    #[test]
1258    fn is_recoverable_false_for_io_and_protocol() {
1259        // A socket failure poisons; a protocol violation poisons or IS the
1260        // reconnect guard — both non-recoverable.
1261        let io = ClientError::Io(io::Error::new(io::ErrorKind::BrokenPipe, "pipe"));
1262        assert!(!io.is_recoverable());
1263        let proto = ClientError::Protocol("connection poisoned".into());
1264        assert!(!proto.is_recoverable());
1265    }
1266}