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::Sync, 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::Sync) {
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 Sync 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        c.set_default_durability(Durability::Batched);
613
614        let reader = std::thread::spawn(move || {
615            use std::io::{Read, Write};
616            let mut hdr = [0u8; weir_core::HEADER_LEN];
617            server_end.read_exact(&mut hdr).unwrap();
618            let h = weir_core::Header::decode(&hdr).unwrap();
619            let mut rest = vec![0u8; h.payload_len() as usize + 4];
620            server_end.read_exact(&mut rest).unwrap();
621            // Send back an Ack so push_default can complete.
622            let ack = weir_core::Envelope::new(
623                weir_core::Header::new(weir_core::MessageType::Ack, weir_core::Durability::Sync, 0),
624                vec![],
625            )
626            .encode();
627            server_end.write_all(&ack).unwrap();
628            h.durability()
629        });
630
631        c.push_default(b"hello").unwrap();
632        assert_eq!(reader.join().unwrap(), Durability::Batched);
633    }
634
635    #[test]
636    fn nack_error_surfaces_daemon_version_on_version_mismatch() {
637        // Daemon sends `[VersionMismatch (0x02), daemon_wire_version]`.
638        let payload = [NackReason::VersionMismatch as u8, 7];
639        match nack_error(&payload) {
640            ClientError::VersionMismatch { daemon_version } => assert_eq!(daemon_version, 7),
641            other => panic!("expected VersionMismatch, got {other:?}"),
642        }
643    }
644
645    #[test]
646    fn nack_error_version_mismatch_without_version_byte_falls_back() {
647        // A malformed VersionMismatch Nack with no second byte must not panic;
648        // it degrades to the bare reason.
649        let payload = [NackReason::VersionMismatch as u8];
650        assert!(matches!(
651            nack_error(&payload),
652            ClientError::Nack(NackReason::VersionMismatch)
653        ));
654    }
655
656    #[test]
657    fn nack_error_other_reasons_unaffected() {
658        let payload = [NackReason::PayloadTooLarge as u8];
659        assert!(matches!(
660            nack_error(&payload),
661            ClientError::Nack(NackReason::PayloadTooLarge)
662        ));
663    }
664
665    // ── Bug #1: oversized payloads must surface as PayloadTooLarge, not broken-pipe ──
666
667    #[test]
668    fn push_rejects_over_hard_cap_locally() {
669        // A payload above the protocol hard cap is rejected locally, before any
670        // bytes hit the wire — no round-trip, and the connection stays usable.
671        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
672        let mut c = WeirClient::from_stream(client_end);
673        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
674        let err = c.push(&oversized, Durability::Sync).unwrap_err();
675        assert!(
676            matches!(err, ClientError::PayloadTooLarge { len, limit }
677                if len == MAX_PAYLOAD_HARD_CAP + 1 && limit == MAX_PAYLOAD_HARD_CAP),
678            "expected a local PayloadTooLarge, got {err:?}"
679        );
680        assert!(
681            !c.poisoned,
682            "a local rejection must not poison the connection"
683        );
684    }
685
686    #[test]
687    fn push_surfaces_payload_too_large_nack_when_server_closes_mid_write() {
688        // Mirrors the daemon's over-configured-cap path: read only the header, send
689        // Nack(PayloadTooLarge), then close without draining the payload. Before the
690        // fix the client's large write hit the closed socket and returned a bare
691        // broken-pipe, hiding the Nack. Now the Nack is read from the receive buffer
692        // and surfaced — whether the write fails partway or just-barely succeeds.
693        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
694        let mut c = WeirClient::from_stream(client_end);
695
696        let server = std::thread::spawn(move || {
697            use std::io::{Read, Write};
698            let mut hdr = [0u8; weir_core::HEADER_LEN];
699            server_end.read_exact(&mut hdr).unwrap();
700            let nack = weir_core::Envelope::new(
701                weir_core::Header::new(
702                    weir_core::MessageType::Nack,
703                    weir_core::Durability::Sync,
704                    0,
705                ),
706                vec![NackReason::PayloadTooLarge as u8],
707            )
708            .encode();
709            server_end.write_all(&nack).unwrap();
710            // drop server_end here → connection closes without reading the payload.
711        });
712
713        // 2 MiB: above any default socket buffer (so the write blocks then fails
714        // once the server stops reading and closes), but under the hard cap (so it
715        // is not caught by the local pre-check).
716        let payload = vec![0u8; 2 * 1024 * 1024];
717        let err = c.push(&payload, Durability::Sync).unwrap_err();
718        server.join().unwrap();
719        assert!(
720            matches!(err, ClientError::Nack(NackReason::PayloadTooLarge)),
721            "expected Nack(PayloadTooLarge) surfaced from the buffered reply, got {err:?}"
722        );
723    }
724
725    // ── Bug #2: a connection-closing Nack must give a clear next-call error ──────
726
727    // Reads one full frame (header + payload + CRC) off a server-side stream.
728    fn drain_one_frame(s: &mut std::os::unix::net::UnixStream) {
729        use std::io::Read;
730        let mut hdr = [0u8; weir_core::HEADER_LEN];
731        s.read_exact(&mut hdr).unwrap();
732        let n = weir_core::Header::decode(&hdr).unwrap().payload_len() as usize + 4;
733        let mut rest = vec![0u8; n];
734        s.read_exact(&mut rest).unwrap();
735    }
736
737    fn nack_frame(reason: NackReason) -> Vec<u8> {
738        weir_core::Envelope::new(
739            weir_core::Header::new(weir_core::MessageType::Nack, weir_core::Durability::Sync, 0),
740            vec![reason as u8],
741        )
742        .encode()
743    }
744
745    #[test]
746    fn closing_nack_makes_next_call_fail_with_a_clear_reconnect_error() {
747        // The daemon closes the connection after a validation Nack. Before the fix,
748        // the next push hit the dead socket and returned a bare broken-pipe; now it
749        // fails fast with a clear "reconnect" error.
750        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
751        let mut c = WeirClient::from_stream(client_end);
752        let server = std::thread::spawn(move || {
753            use std::io::Write;
754            drain_one_frame(&mut server_end);
755            server_end
756                .write_all(&nack_frame(NackReason::EmptyPayload))
757                .unwrap();
758            // drop server_end → connection closes, as the daemon does after a Nack.
759        });
760        let first = c.push(b"x", Durability::Sync).unwrap_err();
761        server.join().unwrap();
762        assert!(
763            matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
764            "first push should surface the real reason, got {first:?}"
765        );
766        // Second call must NOT be a broken-pipe — it must clearly say reconnect.
767        match c.push(b"y", Durability::Sync).unwrap_err() {
768            ClientError::Protocol(msg) => assert!(
769                msg.contains("closed by the daemon after a Nack") && msg.contains("reconnect"),
770                "unexpected message: {msg}"
771            ),
772            other => panic!("expected a clear reconnect Protocol error, got {other:?}"),
773        }
774    }
775
776    #[test]
777    fn internal_error_nack_keeps_connection_usable() {
778        // InternalError is transient — the daemon keeps the connection open — so the
779        // client must NOT mark it closed, and a retry on the same connection works.
780        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
781        let mut c = WeirClient::from_stream(client_end);
782        let server = std::thread::spawn(move || {
783            use std::io::Write;
784            drain_one_frame(&mut server_end);
785            server_end
786                .write_all(&nack_frame(NackReason::InternalError))
787                .unwrap();
788            drain_one_frame(&mut server_end); // the retry
789            let ack = weir_core::Envelope::new(
790                weir_core::Header::new(weir_core::MessageType::Ack, weir_core::Durability::Sync, 0),
791                vec![],
792            )
793            .encode();
794            server_end.write_all(&ack).unwrap();
795        });
796        let first = c.push(b"a", Durability::Sync).unwrap_err();
797        assert!(
798            matches!(first, ClientError::Nack(NackReason::InternalError)),
799            "got {first:?}"
800        );
801        assert!(
802            !c.closed_after_nack,
803            "InternalError is transient and must not close the connection"
804        );
805        c.push(b"b", Durability::Sync).unwrap(); // retry on the same connection succeeds
806        server.join().unwrap();
807    }
808
809    // ── Hostile / malformed daemon responses: refuse + poison, never false-ack ──
810
811    /// F44: a daemon-declared response payload_len above the hard cap must be
812    /// refused BEFORE allocating ~4 GiB, and poison the connection.
813    #[test]
814    fn response_oversized_payload_len_refuses_to_allocate_and_poisons() {
815        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
816        let mut c = WeirClient::from_stream(client_end);
817        let server = std::thread::spawn(move || {
818            use std::io::Write;
819            drain_one_frame(&mut server_end);
820            // A valid 16-byte header whose payload_len is CAP+1, with a recomputed
821            // header CRC; then EOF (no payload) — the client must refuse before it
822            // would try to read/allocate the declared payload.
823            let mut hdr = Header::new(MessageType::Ack, Durability::Sync, 0).encode();
824            let big = (MAX_PAYLOAD_HARD_CAP as u32).wrapping_add(1);
825            hdr[8..12].copy_from_slice(&big.to_le_bytes());
826            let crc = crc32fast::hash(&hdr[0..12]);
827            hdr[12..16].copy_from_slice(&crc.to_le_bytes());
828            server_end.write_all(&hdr).unwrap();
829        });
830        let err = c.push(b"x", Durability::Sync).unwrap_err();
831        server.join().unwrap();
832        match err {
833            ClientError::Protocol(msg) => assert!(
834                msg.contains("refusing to allocate"),
835                "expected the cap refusal, got: {msg}"
836            ),
837            other => panic!("expected a Protocol cap refusal, got {other:?}"),
838        }
839        assert!(
840            c.is_poisoned(),
841            "an oversized response must poison the client"
842        );
843    }
844
845    /// An empty Nack payload is a stream desync: `nack_error` reports `Protocol`,
846    /// and the connection must be poisoned so a stale frame can't be mis-read as a
847    /// later ack (a false ack).
848    #[test]
849    fn empty_nack_payload_is_protocol_desync_and_poisons() {
850        // (1) the helper maps an empty payload to a Protocol error.
851        match nack_error(&[]) {
852            ClientError::Protocol(msg) => assert!(msg.contains("empty payload"), "{msg}"),
853            other => panic!("expected Protocol for empty Nack, got {other:?}"),
854        }
855        // (2) end-to-end: an empty-payload Nack poisons + is not recoverable.
856        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
857        let mut c = WeirClient::from_stream(client_end);
858        let server = std::thread::spawn(move || {
859            use std::io::Write;
860            drain_one_frame(&mut server_end);
861            let frame =
862                Envelope::new(Header::new(MessageType::Nack, Durability::Sync, 0), vec![]).encode();
863            server_end.write_all(&frame).unwrap();
864        });
865        let err = c.push(b"x", Durability::Sync).unwrap_err();
866        server.join().unwrap();
867        assert!(matches!(err, ClientError::Protocol(_)), "got {err:?}");
868        assert!(
869            !err.is_recoverable(),
870            "a desync Protocol error is not recoverable"
871        );
872        assert!(
873            c.is_poisoned(),
874            "an empty-Nack desync must poison the client"
875        );
876    }
877
878    /// A corrupted response payload CRC is a protocol violation: surface it and
879    /// poison (the stream tail is now untrustworthy).
880    #[test]
881    fn response_payload_crc_mismatch_poisons() {
882        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
883        let mut c = WeirClient::from_stream(client_end);
884        let server = std::thread::spawn(move || {
885            use std::io::Write;
886            drain_one_frame(&mut server_end);
887            // A Nack frame with a flipped trailing CRC byte.
888            let mut frame = nack_frame(NackReason::InternalError);
889            *frame.last_mut().unwrap() ^= 0xff;
890            server_end.write_all(&frame).unwrap();
891        });
892        let err = c.push(b"x", Durability::Sync).unwrap_err();
893        server.join().unwrap();
894        match err {
895            ClientError::Protocol(msg) => assert!(msg.contains("CRC mismatch"), "{msg}"),
896            other => panic!("expected a CRC-mismatch Protocol error, got {other:?}"),
897        }
898        assert!(
899            c.is_poisoned(),
900            "a CRC-mismatched response must poison the client"
901        );
902    }
903
904    /// An unrecognised Nack reason byte (reserved range) surfaces as
905    /// `UnknownNack(byte)` and closes the connection.
906    #[test]
907    fn unknown_nack_reason_byte_surfaces_unknown_nack() {
908        assert!(matches!(
909            nack_error(&[0x0A]),
910            ClientError::UnknownNack(0x0A)
911        ));
912
913        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
914        let mut c = WeirClient::from_stream(client_end);
915        let server = std::thread::spawn(move || {
916            use std::io::Write;
917            drain_one_frame(&mut server_end);
918            let frame = Envelope::new(
919                Header::new(MessageType::Nack, Durability::Sync, 0),
920                vec![0x0A],
921            )
922            .encode();
923            server_end.write_all(&frame).unwrap();
924        });
925        let err = c.push(b"x", Durability::Sync).unwrap_err();
926        server.join().unwrap();
927        assert!(matches!(err, ClientError::UnknownNack(0x0A)), "got {err:?}");
928        assert!(
929            c.is_poisoned(),
930            "an unknown closing Nack must mark the client unusable"
931        );
932    }
933
934    /// A VersionMismatch Nack surfaces the daemon's version through push() and is
935    /// non-recoverable + connection-closing.
936    #[test]
937    fn version_mismatch_surfaces_through_push() {
938        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
939        let mut c = WeirClient::from_stream(client_end);
940        let server = std::thread::spawn(move || {
941            use std::io::Write;
942            drain_one_frame(&mut server_end);
943            let frame = Envelope::new(
944                Header::new(MessageType::Nack, Durability::Sync, 0),
945                vec![NackReason::VersionMismatch as u8, 9],
946            )
947            .encode();
948            server_end.write_all(&frame).unwrap();
949        });
950        let err = c.push(b"x", Durability::Sync).unwrap_err();
951        server.join().unwrap();
952        assert!(
953            matches!(err, ClientError::VersionMismatch { daemon_version: 9 }),
954            "got {err:?}"
955        );
956        assert!(!err.is_recoverable());
957        assert!(c.is_poisoned());
958    }
959
960    /// health_check() against a daemon that replies with the wrong frame type is a
961    /// desync: Protocol error + poison + non-recoverable.
962    #[test]
963    fn health_check_unexpected_frame_type_poisons() {
964        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
965        let mut c = WeirClient::from_stream(client_end);
966        let server = std::thread::spawn(move || {
967            use std::io::Write;
968            drain_one_frame(&mut server_end);
969            // Reply to a HealthCheck with an Ack — never expected here.
970            let frame =
971                Envelope::new(Header::new(MessageType::Ack, Durability::Sync, 0), vec![]).encode();
972            server_end.write_all(&frame).unwrap();
973        });
974        let err = c.health_check().unwrap_err();
975        server.join().unwrap();
976        match &err {
977            ClientError::Protocol(msg) => {
978                assert!(msg.contains("expected HealthCheckResponse"), "{msg}")
979            }
980            other => panic!("expected a Protocol desync, got {other:?}"),
981        }
982        assert!(!err.is_recoverable());
983        assert!(c.is_poisoned());
984    }
985
986    // ── F43: opt-in socket timeouts ───────────────────────────────────────────
987
988    #[test]
989    fn set_read_timeout_applies_to_socket() {
990        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
991        let c = WeirClient::from_stream(a);
992        c.set_read_timeout(Some(Duration::from_millis(250)))
993            .unwrap();
994        assert_eq!(
995            c.stream.read_timeout().unwrap(),
996            Some(Duration::from_millis(250))
997        );
998        // None clears it back to blocking.
999        c.set_read_timeout(None).unwrap();
1000        assert_eq!(c.stream.read_timeout().unwrap(), None);
1001    }
1002
1003    #[test]
1004    fn read_timeout_bounds_a_silent_daemon_instead_of_blocking_forever() {
1005        // `_b` is the daemon end: it's held open (so the connection stays up)
1006        // but never replies. Without a read timeout, push would block forever.
1007        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
1008        let mut c = WeirClient::from_stream(a);
1009        c.set_read_timeout(Some(Duration::from_millis(150)))
1010            .unwrap();
1011        // push writes the (tiny) frame, then blocks reading the reply that never
1012        // comes → the timeout fires → an Io error rather than an indefinite hang.
1013        let err = c.push(b"x", Durability::Sync).unwrap_err();
1014        assert!(
1015            matches!(err, ClientError::Io(_)),
1016            "expected Io timeout, got {err:?}"
1017        );
1018    }
1019
1020    #[test]
1021    fn client_is_poisoned_after_a_read_failure() {
1022        // G04: after a response read fails (here a timeout), the stream may hold
1023        // leftover bytes that a subsequent read would mis-attribute to the next
1024        // request — a false ack. The client must poison itself and reject further
1025        // use fast, instead of reading again.
1026        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
1027        let mut c = WeirClient::from_stream(a);
1028        c.set_read_timeout(Some(Duration::from_millis(100)))
1029            .unwrap();
1030
1031        // First push: the peer never replies → read times out → Io error.
1032        let first = c.push(b"x", Durability::Sync).unwrap_err();
1033        assert!(matches!(first, ClientError::Io(_)), "{first:?}");
1034
1035        // Second push must fail FAST as poisoned, not block on another read.
1036        let started = std::time::Instant::now();
1037        let second = c.push(b"y", Durability::Sync).unwrap_err();
1038        assert!(
1039            started.elapsed() < Duration::from_millis(50),
1040            "poisoned client must reject immediately, not read again"
1041        );
1042        match second {
1043            ClientError::Protocol(msg) => assert!(msg.contains("poisoned"), "{msg}"),
1044            other => panic!("expected a poisoned Protocol error, got {other:?}"),
1045        }
1046    }
1047
1048    // ── is_poisoned / is_recoverable helpers ─────────────────────────────────
1049
1050    #[test]
1051    fn is_poisoned_flips_after_a_read_failure() {
1052        // A fresh client is not poisoned; after a response-read failure (here a
1053        // timeout) the flag flips, mirroring `client_is_poisoned_after_a_read_failure`.
1054        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
1055        let mut c = WeirClient::from_stream(a);
1056        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
1057
1058        c.set_read_timeout(Some(Duration::from_millis(100)))
1059            .unwrap();
1060        // Peer never replies → the response read times out → the client poisons.
1061        let err = c.push(b"x", Durability::Sync).unwrap_err();
1062        assert!(matches!(err, ClientError::Io(_)), "{err:?}");
1063        assert!(
1064            c.is_poisoned(),
1065            "a read failure must flip is_poisoned() to true"
1066        );
1067        // And the poisoning error itself is non-recoverable.
1068        assert!(!err.is_recoverable(), "an Io failure is not recoverable");
1069    }
1070
1071    #[test]
1072    fn is_poisoned_after_a_connection_closing_nack() {
1073        // A connection-closing Nack (every reason except InternalError) leaves the
1074        // connection dead. `is_poisoned()` must report that — it reflects
1075        // `closed_after_nack`, not just `poisoned` — and the connection must be
1076        // refused by `ensure_usable` on the next call. (Models the closing-nack
1077        // test `closing_nack_makes_next_call_fail_with_a_clear_reconnect_error`.)
1078        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
1079        let mut c = WeirClient::from_stream(client_end);
1080        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
1081        let server = std::thread::spawn(move || {
1082            use std::io::Write;
1083            drain_one_frame(&mut server_end);
1084            server_end
1085                .write_all(&nack_frame(NackReason::EmptyPayload))
1086                .unwrap();
1087            // drop server_end → connection closes, as the daemon does after a Nack.
1088        });
1089        let first = c.push(b"x", Durability::Sync).unwrap_err();
1090        server.join().unwrap();
1091        assert!(
1092            matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
1093            "expected the real Nack reason, got {first:?}"
1094        );
1095        // The closing Nack is non-recoverable, and the client reports poisoned.
1096        assert!(
1097            !first.is_recoverable(),
1098            "a connection-closing Nack is non-recoverable"
1099        );
1100        assert!(
1101            c.is_poisoned(),
1102            "a connection-closing Nack must flip is_poisoned() to true"
1103        );
1104        // And the connection is refused fast on the next attempt.
1105        assert!(
1106            matches!(c.ensure_usable(), Err(ClientError::Protocol(_))),
1107            "ensure_usable must reject a closed-after-nack connection"
1108        );
1109    }
1110
1111    #[test]
1112    fn is_poisoned_and_is_recoverable_agree_after_a_desync() {
1113        // A stream desync — the daemon sends an unexpected message type where an
1114        // Ack/Nack was expected — poisons the connection: leftover/unexpected bytes
1115        // could be mis-read as a later reply (a false ack). `is_poisoned()` must be
1116        // true AND the returned error must be non-recoverable, i.e. the two agree.
1117        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
1118        let mut c = WeirClient::from_stream(client_end);
1119        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
1120        let server = std::thread::spawn(move || {
1121            use std::io::Write;
1122            drain_one_frame(&mut server_end);
1123            // A HealthCheck frame is a valid frame, but it is NOT a reply push expects
1124            // (push expects Ack or Nack) — a genuine stream desync.
1125            let bogus = weir_core::Envelope::new(
1126                weir_core::Header::new(
1127                    weir_core::MessageType::HealthCheck,
1128                    weir_core::Durability::Sync,
1129                    0,
1130                ),
1131                vec![],
1132            )
1133            .encode();
1134            server_end.write_all(&bogus).unwrap();
1135        });
1136        let err = c.push(b"x", Durability::Sync).unwrap_err();
1137        server.join().unwrap();
1138        // The error is a Protocol desync, which is non-recoverable...
1139        assert!(
1140            matches!(err, ClientError::Protocol(_)),
1141            "expected a Protocol desync error, got {err:?}"
1142        );
1143        assert!(!err.is_recoverable(), "a stream desync is non-recoverable");
1144        // ...and is_poisoned() agrees (the two must never disagree).
1145        assert!(
1146            c.is_poisoned(),
1147            "a stream desync must flip is_poisoned() to true"
1148        );
1149        assert_eq!(
1150            c.is_poisoned(),
1151            !err.is_recoverable(),
1152            "is_poisoned() and !is_recoverable() must agree"
1153        );
1154    }
1155
1156    #[test]
1157    fn is_recoverable_true_for_local_over_cap_rejection() {
1158        // The local pre-send PayloadTooLarge rejection does not touch the socket, so
1159        // it is recoverable and must not poison the client.
1160        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
1161        let mut c = WeirClient::from_stream(client_end);
1162        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
1163        let err = c.push(&oversized, Durability::Sync).unwrap_err();
1164        assert!(
1165            matches!(err, ClientError::PayloadTooLarge { .. }),
1166            "expected a local PayloadTooLarge, got {err:?}"
1167        );
1168        assert!(
1169            err.is_recoverable(),
1170            "a local over-cap rejection keeps the connection usable"
1171        );
1172        assert!(!c.is_poisoned(), "a recoverable error must not poison");
1173    }
1174
1175    #[test]
1176    fn push_rejects_empty_payload_locally_without_poisoning() {
1177        // An empty payload is the WAB end-of-records sentinel; the daemon would
1178        // Nack+close it. The local guard rejects it before any bytes are sent, so
1179        // the connection stays usable (recoverable, not poisoned) — mirroring the
1180        // over-cap guard.
1181        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
1182        let mut c = WeirClient::from_stream(client_end);
1183        let err = c.push(b"", Durability::Sync).unwrap_err();
1184        assert!(
1185            matches!(err, ClientError::EmptyPayload),
1186            "expected a local EmptyPayload, got {err:?}"
1187        );
1188        assert!(
1189            err.is_recoverable(),
1190            "a local empty-payload rejection keeps the connection usable"
1191        );
1192        assert!(!c.is_poisoned(), "a recoverable error must not poison");
1193    }
1194
1195    #[test]
1196    fn is_recoverable_true_for_no_default_durability() {
1197        // A local misconfiguration returned before any I/O is recoverable.
1198        let err = ClientError::NoDefaultDurability;
1199        assert!(err.is_recoverable());
1200    }
1201
1202    #[test]
1203    fn is_recoverable_true_for_internal_error_nack() {
1204        // InternalError is the one Nack the daemon keeps the connection open for.
1205        let err = ClientError::Nack(NackReason::InternalError);
1206        assert!(
1207            err.is_recoverable(),
1208            "a transient InternalError Nack keeps the connection usable"
1209        );
1210    }
1211
1212    #[test]
1213    fn is_recoverable_false_for_connection_closing_nacks() {
1214        // Every Nack reason except InternalError closes the connection, so it is
1215        // non-recoverable. VersionMismatch and UnknownNack are Nacks too.
1216        for err in [
1217            ClientError::Nack(NackReason::EmptyPayload),
1218            ClientError::Nack(NackReason::PayloadTooLarge),
1219            ClientError::Nack(NackReason::UnknownMessage),
1220            ClientError::VersionMismatch { daemon_version: 7 },
1221            ClientError::UnknownNack(0xff),
1222        ] {
1223            assert!(
1224                !err.is_recoverable(),
1225                "{err:?} closes the connection and must be non-recoverable"
1226            );
1227        }
1228    }
1229
1230    #[test]
1231    fn is_recoverable_false_for_io_and_protocol() {
1232        // A socket failure poisons; a protocol violation poisons or IS the
1233        // reconnect guard — both non-recoverable.
1234        let io = ClientError::Io(io::Error::new(io::ErrorKind::BrokenPipe, "pipe"));
1235        assert!(!io.is_recoverable());
1236        let proto = ClientError::Protocol("connection poisoned".into());
1237        assert!(!proto.is_recoverable());
1238    }
1239}