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}