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}