Skip to main content

weir_client/
unix.rs

1//! Unix-domain-socket transport for [`WeirClient`].
2//!
3//! Only the constructors and the socket-timeout setters live here; everything
4//! that does not need a Unix socket is in [`crate::client`].
5
6use std::{io, os::unix::net::UnixStream, path::Path, time::Duration};
7
8use weir_core::Durability;
9
10use crate::{ClientError, WeirClient};
11
12#[cfg(test)]
13use crate::client::nack_error;
14#[cfg(test)]
15use weir_core::{Envelope, Header, MAX_PAYLOAD_HARD_CAP, MessageType, NackReason};
16
17// ── Unix-specific constructors ─────────────────────────────────────────────────
18
19impl WeirClient<UnixStream> {
20    /// Opens a connection to the weir daemon's Unix socket at `path`.
21    pub fn connect(path: impl AsRef<Path>) -> Result<Self, ClientError> {
22        let stream = UnixStream::connect(path.as_ref())?;
23        Ok(Self {
24            stream,
25            default_durability: None,
26            poisoned: false,
27            closed_after_nack: false,
28        })
29    }
30
31    /// Opens a connection and sets a default durability tier in one step.
32    ///
33    /// Equivalent to calling [`connect`][Self::connect] then
34    /// [`set_default_durability`][Self::set_default_durability].
35    pub fn connect_with_default(
36        path: impl AsRef<Path>,
37        durability: Durability,
38    ) -> Result<Self, ClientError> {
39        let mut c = Self::connect(path)?;
40        c.default_durability = Some(durability);
41        Ok(c)
42    }
43
44    /// Wraps an already-connected [`UnixStream`]. Useful for callers that
45    /// manage their own connection setup (systemd socket activation,
46    /// pre-authenticated file descriptors passed from a parent process,
47    /// `UnixStream::pair`-based test harnesses).
48    pub fn from_stream(stream: UnixStream) -> Self {
49        Self {
50            stream,
51            default_durability: None,
52            poisoned: false,
53            closed_after_nack: false,
54        }
55    }
56
57    /// Sets the read timeout on the underlying socket. `None` (the default)
58    /// blocks indefinitely.
59    ///
60    /// **Opt-in availability guard.** By default every method blocks in the
61    /// response-read path (inside [`push`][Self::push] /
62    /// [`health_check`][Self::health_check]) waiting for the daemon's Ack/Nack, so a
63    /// wedged daemon (hung flusher, `SIGSTOP`, half-open connection) would block
64    /// a producer's hot path forever. With a read timeout set, a stalled reply
65    /// surfaces as a [`ClientError::Io`] timeout instead; the producer can retry
66    /// — the record may still have been durably written, which the at-least-once
67    /// contract covers. Pick a value comfortably above the daemon's Durable ack
68    /// latency under load: the daemon's own `ACK_TIMEOUT` is 30 s, so e.g.
69    /// 45–60 s lets the daemon's Nack win rather than racing it.
70    pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
71        self.stream.set_read_timeout(timeout)
72    }
73
74    /// Sets the write timeout on the underlying socket. `None` (the default)
75    /// blocks indefinitely. See [`set_read_timeout`][Self::set_read_timeout] for
76    /// the rationale — this bounds a stalled `write_all` of the request frame.
77    pub fn set_write_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
78        self.stream.set_write_timeout(timeout)
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn push_default_without_default_errors() {
88        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
89        let mut c = WeirClient::from_stream(a);
90        assert!(matches!(
91            c.push_default(b"x").unwrap_err(),
92            ClientError::NoDefaultDurability
93        ));
94    }
95
96    #[test]
97    fn set_default_durability_used_by_push_default() {
98        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
99        let mut c = WeirClient::from_stream(client_end);
100        // Buffered, not Durable: Durable (0x01) is the wire byte several
101        // other code paths default to, so asserting Durable here would pass
102        // even if push_default ignored the stored default and hardcoded
103        // Durable. Buffered (0x03) is the only tier that can't leak through
104        // by accident, so it's the one that actually proves the stored
105        // default is threaded onto the wire.
106        c.set_default_durability(Durability::Buffered);
107
108        let reader = std::thread::spawn(move || {
109            use std::io::{Read, Write};
110            let mut hdr = [0u8; weir_core::HEADER_LEN];
111            server_end.read_exact(&mut hdr).unwrap();
112            let h = weir_core::Header::decode(&hdr).unwrap();
113            let mut rest = vec![0u8; h.payload_len() as usize + 4];
114            server_end.read_exact(&mut rest).unwrap();
115            // Send back an Ack so push_default can complete.
116            let ack = weir_core::Envelope::new(
117                weir_core::Header::new(
118                    weir_core::MessageType::Ack,
119                    weir_core::Durability::Durable,
120                    0,
121                ),
122                vec![],
123            )
124            .encode();
125            server_end.write_all(&ack).unwrap();
126            // Byte 6 of the header is the durability byte on the wire.
127            (h.durability(), hdr[6])
128        });
129
130        c.push_default(b"hello").unwrap();
131        let (durability, wire_byte) = reader.join().unwrap();
132        assert_eq!(durability, Durability::Buffered);
133        assert_eq!(wire_byte, 0x03);
134    }
135
136    #[test]
137    fn nack_error_surfaces_daemon_version_on_version_mismatch() {
138        // Daemon sends `[VersionMismatch (0x02), daemon_wire_version]`.
139        let payload = [NackReason::VersionMismatch as u8, 7];
140        match nack_error(&payload) {
141            ClientError::VersionMismatch { daemon_version } => assert_eq!(daemon_version, 7),
142            other => panic!("expected VersionMismatch, got {other:?}"),
143        }
144    }
145
146    #[test]
147    fn nack_error_version_mismatch_without_version_byte_falls_back() {
148        // A malformed VersionMismatch Nack with no second byte must not panic;
149        // it degrades to the bare reason.
150        let payload = [NackReason::VersionMismatch as u8];
151        assert!(matches!(
152            nack_error(&payload),
153            ClientError::Nack(NackReason::VersionMismatch)
154        ));
155    }
156
157    #[test]
158    fn nack_error_other_reasons_unaffected() {
159        let payload = [NackReason::PayloadTooLarge as u8];
160        assert!(matches!(
161            nack_error(&payload),
162            ClientError::Nack(NackReason::PayloadTooLarge)
163        ));
164    }
165
166    // ── Bug #1: oversized payloads must surface as PayloadTooLarge, not broken-pipe ──
167
168    #[test]
169    fn push_rejects_over_hard_cap_locally() {
170        // A payload above the protocol hard cap is rejected locally, before any
171        // bytes hit the wire — no round-trip, and the connection stays usable.
172        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
173        let mut c = WeirClient::from_stream(client_end);
174        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
175        let err = c.push(&oversized, Durability::Durable).unwrap_err();
176        assert!(
177            matches!(err, ClientError::PayloadTooLarge { len, limit }
178                if len == MAX_PAYLOAD_HARD_CAP + 1 && limit == MAX_PAYLOAD_HARD_CAP),
179            "expected a local PayloadTooLarge, got {err:?}"
180        );
181        assert!(
182            !c.poisoned,
183            "a local rejection must not poison the connection"
184        );
185    }
186
187    #[test]
188    fn push_surfaces_payload_too_large_nack_when_server_closes_mid_write() {
189        // Mirrors the daemon's over-configured-cap path: read only the header, send
190        // Nack(PayloadTooLarge), then close without draining the payload. Before the
191        // fix the client's large write hit the closed socket and returned a bare
192        // broken-pipe, hiding the Nack. Now the Nack is read from the receive buffer
193        // and surfaced — whether the write fails partway or just-barely succeeds.
194        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
195        let mut c = WeirClient::from_stream(client_end);
196
197        let server = std::thread::spawn(move || {
198            use std::io::{Read, Write};
199            let mut hdr = [0u8; weir_core::HEADER_LEN];
200            server_end.read_exact(&mut hdr).unwrap();
201            let nack = weir_core::Envelope::new(
202                weir_core::Header::new(
203                    weir_core::MessageType::Nack,
204                    weir_core::Durability::Durable,
205                    0,
206                ),
207                vec![NackReason::PayloadTooLarge as u8],
208            )
209            .encode();
210            server_end.write_all(&nack).unwrap();
211            // drop server_end here → connection closes without reading the payload.
212        });
213
214        // 2 MiB: above any default socket buffer (so the write blocks then fails
215        // once the server stops reading and closes), but under the hard cap (so it
216        // is not caught by the local pre-check).
217        let payload = vec![0u8; 2 * 1024 * 1024];
218        let err = c.push(&payload, Durability::Durable).unwrap_err();
219        server.join().unwrap();
220        assert!(
221            matches!(err, ClientError::Nack(NackReason::PayloadTooLarge)),
222            "expected Nack(PayloadTooLarge) surfaced from the buffered reply, got {err:?}"
223        );
224    }
225
226    // ── Bug #2: a connection-closing Nack must give a clear next-call error ──────
227
228    // Reads one full frame (header + payload + CRC) off a server-side stream.
229    fn drain_one_frame(s: &mut std::os::unix::net::UnixStream) {
230        use std::io::Read;
231        let mut hdr = [0u8; weir_core::HEADER_LEN];
232        s.read_exact(&mut hdr).unwrap();
233        let n = weir_core::Header::decode(&hdr).unwrap().payload_len() as usize + 4;
234        let mut rest = vec![0u8; n];
235        s.read_exact(&mut rest).unwrap();
236    }
237
238    fn nack_frame(reason: NackReason) -> Vec<u8> {
239        weir_core::Envelope::new(
240            weir_core::Header::new(
241                weir_core::MessageType::Nack,
242                weir_core::Durability::Durable,
243                0,
244            ),
245            vec![reason as u8],
246        )
247        .encode()
248    }
249
250    #[test]
251    fn closing_nack_makes_next_call_fail_with_a_clear_reconnect_error() {
252        // The daemon closes the connection after a validation Nack. Before the fix,
253        // the next push hit the dead socket and returned a bare broken-pipe; now it
254        // fails fast with a clear "reconnect" error.
255        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
256        let mut c = WeirClient::from_stream(client_end);
257        let server = std::thread::spawn(move || {
258            use std::io::Write;
259            drain_one_frame(&mut server_end);
260            server_end
261                .write_all(&nack_frame(NackReason::EmptyPayload))
262                .unwrap();
263            // drop server_end → connection closes, as the daemon does after a Nack.
264        });
265        let first = c.push(b"x", Durability::Durable).unwrap_err();
266        server.join().unwrap();
267        assert!(
268            matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
269            "first push should surface the real reason, got {first:?}"
270        );
271        // Second call must NOT be a broken-pipe — it must clearly say reconnect.
272        match c.push(b"y", Durability::Durable).unwrap_err() {
273            ClientError::Protocol(msg) => assert!(
274                msg.contains("closed by the daemon after a Nack") && msg.contains("reconnect"),
275                "unexpected message: {msg}"
276            ),
277            other => panic!("expected a clear reconnect Protocol error, got {other:?}"),
278        }
279    }
280
281    #[test]
282    fn internal_error_nack_keeps_connection_usable() {
283        // InternalError is transient — the daemon keeps the connection open — so the
284        // client must NOT mark it closed, and a retry on the same connection works.
285        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
286        let mut c = WeirClient::from_stream(client_end);
287        let server = std::thread::spawn(move || {
288            use std::io::Write;
289            drain_one_frame(&mut server_end);
290            server_end
291                .write_all(&nack_frame(NackReason::InternalError))
292                .unwrap();
293            drain_one_frame(&mut server_end); // the retry
294            let ack = weir_core::Envelope::new(
295                weir_core::Header::new(
296                    weir_core::MessageType::Ack,
297                    weir_core::Durability::Durable,
298                    0,
299                ),
300                vec![],
301            )
302            .encode();
303            server_end.write_all(&ack).unwrap();
304        });
305        let first = c.push(b"a", Durability::Durable).unwrap_err();
306        assert!(
307            matches!(first, ClientError::Nack(NackReason::InternalError)),
308            "got {first:?}"
309        );
310        assert!(
311            !c.closed_after_nack,
312            "InternalError is transient and must not close the connection"
313        );
314        c.push(b"b", Durability::Durable).unwrap(); // retry on the same connection succeeds
315        server.join().unwrap();
316    }
317
318    // ── Hostile / malformed daemon responses: refuse + poison, never false-ack ──
319
320    /// F44: a daemon-declared response payload_len above the hard cap must be
321    /// refused BEFORE allocating ~4 GiB, and poison the connection.
322    #[test]
323    fn response_oversized_payload_len_refuses_to_allocate_and_poisons() {
324        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
325        let mut c = WeirClient::from_stream(client_end);
326        let server = std::thread::spawn(move || {
327            use std::io::Write;
328            drain_one_frame(&mut server_end);
329            // A valid 16-byte header whose payload_len is CAP+1, with a recomputed
330            // header CRC; then EOF (no payload) — the client must refuse before it
331            // would try to read/allocate the declared payload.
332            let mut hdr = Header::new(MessageType::Ack, Durability::Durable, 0).encode();
333            let big = (MAX_PAYLOAD_HARD_CAP as u32).wrapping_add(1);
334            hdr[8..12].copy_from_slice(&big.to_le_bytes());
335            let crc = crc32fast::hash(&hdr[0..12]);
336            hdr[12..16].copy_from_slice(&crc.to_le_bytes());
337            server_end.write_all(&hdr).unwrap();
338        });
339        let err = c.push(b"x", Durability::Durable).unwrap_err();
340        server.join().unwrap();
341        match err {
342            ClientError::Protocol(msg) => assert!(
343                msg.contains("refusing to allocate"),
344                "expected the cap refusal, got: {msg}"
345            ),
346            other => panic!("expected a Protocol cap refusal, got {other:?}"),
347        }
348        assert!(
349            c.is_poisoned(),
350            "an oversized response must poison the client"
351        );
352    }
353
354    /// An empty Nack payload is a stream desync: `nack_error` reports `Protocol`,
355    /// and the connection must be poisoned so a stale frame can't be mis-read as a
356    /// later ack (a false ack).
357    #[test]
358    fn empty_nack_payload_is_protocol_desync_and_poisons() {
359        // (1) the helper maps an empty payload to a Protocol error.
360        match nack_error(&[]) {
361            ClientError::Protocol(msg) => assert!(msg.contains("empty payload"), "{msg}"),
362            other => panic!("expected Protocol for empty Nack, got {other:?}"),
363        }
364        // (2) end-to-end: an empty-payload Nack poisons + is not recoverable.
365        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
366        let mut c = WeirClient::from_stream(client_end);
367        let server = std::thread::spawn(move || {
368            use std::io::Write;
369            drain_one_frame(&mut server_end);
370            let frame = Envelope::new(
371                Header::new(MessageType::Nack, Durability::Durable, 0),
372                vec![],
373            )
374            .encode();
375            server_end.write_all(&frame).unwrap();
376        });
377        let err = c.push(b"x", Durability::Durable).unwrap_err();
378        server.join().unwrap();
379        assert!(matches!(err, ClientError::Protocol(_)), "got {err:?}");
380        assert!(
381            !err.is_recoverable(),
382            "a desync Protocol error is not recoverable"
383        );
384        assert!(
385            c.is_poisoned(),
386            "an empty-Nack desync must poison the client"
387        );
388    }
389
390    /// A corrupted response payload CRC is a protocol violation: surface it and
391    /// poison (the stream tail is now untrustworthy).
392    #[test]
393    fn response_payload_crc_mismatch_poisons() {
394        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
395        let mut c = WeirClient::from_stream(client_end);
396        let server = std::thread::spawn(move || {
397            use std::io::Write;
398            drain_one_frame(&mut server_end);
399            // A Nack frame with a flipped trailing CRC byte.
400            let mut frame = nack_frame(NackReason::InternalError);
401            *frame.last_mut().unwrap() ^= 0xff;
402            server_end.write_all(&frame).unwrap();
403        });
404        let err = c.push(b"x", Durability::Durable).unwrap_err();
405        server.join().unwrap();
406        match err {
407            ClientError::Protocol(msg) => assert!(msg.contains("CRC mismatch"), "{msg}"),
408            other => panic!("expected a CRC-mismatch Protocol error, got {other:?}"),
409        }
410        assert!(
411            c.is_poisoned(),
412            "a CRC-mismatched response must poison the client"
413        );
414    }
415
416    /// An unrecognised Nack reason byte (reserved range) surfaces as
417    /// `UnknownNack(byte)` and closes the connection.
418    #[test]
419    fn unknown_nack_reason_byte_surfaces_unknown_nack() {
420        assert!(matches!(
421            nack_error(&[0x0A]),
422            ClientError::UnknownNack(0x0A)
423        ));
424
425        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
426        let mut c = WeirClient::from_stream(client_end);
427        let server = std::thread::spawn(move || {
428            use std::io::Write;
429            drain_one_frame(&mut server_end);
430            let frame = Envelope::new(
431                Header::new(MessageType::Nack, Durability::Durable, 0),
432                vec![0x0A],
433            )
434            .encode();
435            server_end.write_all(&frame).unwrap();
436        });
437        let err = c.push(b"x", Durability::Durable).unwrap_err();
438        server.join().unwrap();
439        assert!(matches!(err, ClientError::UnknownNack(0x0A)), "got {err:?}");
440        assert!(
441            c.is_poisoned(),
442            "an unknown closing Nack must mark the client unusable"
443        );
444    }
445
446    /// A VersionMismatch Nack surfaces the daemon's version through push() and is
447    /// non-recoverable + connection-closing.
448    #[test]
449    fn version_mismatch_surfaces_through_push() {
450        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
451        let mut c = WeirClient::from_stream(client_end);
452        let server = std::thread::spawn(move || {
453            use std::io::Write;
454            drain_one_frame(&mut server_end);
455            let frame = Envelope::new(
456                Header::new(MessageType::Nack, Durability::Durable, 0),
457                vec![NackReason::VersionMismatch as u8, 9],
458            )
459            .encode();
460            server_end.write_all(&frame).unwrap();
461        });
462        let err = c.push(b"x", Durability::Durable).unwrap_err();
463        server.join().unwrap();
464        assert!(
465            matches!(err, ClientError::VersionMismatch { daemon_version: 9 }),
466            "got {err:?}"
467        );
468        assert!(!err.is_recoverable());
469        assert!(c.is_poisoned());
470    }
471
472    /// health_check() against a daemon that replies with the wrong frame type is a
473    /// desync: Protocol error + poison + non-recoverable.
474    #[test]
475    fn health_check_unexpected_frame_type_poisons() {
476        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
477        let mut c = WeirClient::from_stream(client_end);
478        let server = std::thread::spawn(move || {
479            use std::io::Write;
480            drain_one_frame(&mut server_end);
481            // Reply to a HealthCheck with an Ack — never expected here.
482            let frame = Envelope::new(
483                Header::new(MessageType::Ack, Durability::Durable, 0),
484                vec![],
485            )
486            .encode();
487            server_end.write_all(&frame).unwrap();
488        });
489        let err = c.health_check().unwrap_err();
490        server.join().unwrap();
491        match &err {
492            ClientError::Protocol(msg) => {
493                assert!(msg.contains("expected HealthCheckResponse"), "{msg}")
494            }
495            other => panic!("expected a Protocol desync, got {other:?}"),
496        }
497        assert!(!err.is_recoverable());
498        assert!(c.is_poisoned());
499    }
500
501    // ── F43: opt-in socket timeouts ───────────────────────────────────────────
502
503    #[test]
504    fn set_read_timeout_applies_to_socket() {
505        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
506        let c = WeirClient::from_stream(a);
507        c.set_read_timeout(Some(Duration::from_millis(250)))
508            .unwrap();
509        assert_eq!(
510            c.stream.read_timeout().unwrap(),
511            Some(Duration::from_millis(250))
512        );
513        // None clears it back to blocking.
514        c.set_read_timeout(None).unwrap();
515        assert_eq!(c.stream.read_timeout().unwrap(), None);
516    }
517
518    #[test]
519    fn read_timeout_bounds_a_silent_daemon_instead_of_blocking_forever() {
520        // `_b` is the daemon end: it's held open (so the connection stays up)
521        // but never replies. Without a read timeout, push would block forever.
522        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
523        let mut c = WeirClient::from_stream(a);
524        c.set_read_timeout(Some(Duration::from_millis(150)))
525            .unwrap();
526        // push writes the (tiny) frame, then blocks reading the reply that never
527        // comes → the timeout fires → an Io error rather than an indefinite hang.
528        let err = c.push(b"x", Durability::Durable).unwrap_err();
529        assert!(
530            matches!(err, ClientError::Io(_)),
531            "expected Io timeout, got {err:?}"
532        );
533    }
534
535    #[test]
536    fn client_is_poisoned_after_a_read_failure() {
537        // G04: after a response read fails (here a timeout), the stream may hold
538        // leftover bytes that a subsequent read would mis-attribute to the next
539        // request — a false ack. The client must poison itself and reject further
540        // use fast, instead of reading again.
541        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
542        let mut c = WeirClient::from_stream(a);
543        c.set_read_timeout(Some(Duration::from_millis(100)))
544            .unwrap();
545
546        // First push: the peer never replies → read times out → Io error.
547        let first = c.push(b"x", Durability::Durable).unwrap_err();
548        assert!(matches!(first, ClientError::Io(_)), "{first:?}");
549
550        // Second push must fail FAST as poisoned, not block on another read.
551        let started = std::time::Instant::now();
552        let second = c.push(b"y", Durability::Durable).unwrap_err();
553        assert!(
554            started.elapsed() < Duration::from_millis(50),
555            "poisoned client must reject immediately, not read again"
556        );
557        match second {
558            ClientError::Protocol(msg) => assert!(msg.contains("poisoned"), "{msg}"),
559            other => panic!("expected a poisoned Protocol error, got {other:?}"),
560        }
561    }
562
563    // ── is_poisoned / is_recoverable helpers ─────────────────────────────────
564
565    #[test]
566    fn is_poisoned_flips_after_a_read_failure() {
567        // A fresh client is not poisoned; after a response-read failure (here a
568        // timeout) the flag flips, mirroring `client_is_poisoned_after_a_read_failure`.
569        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
570        let mut c = WeirClient::from_stream(a);
571        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
572
573        c.set_read_timeout(Some(Duration::from_millis(100)))
574            .unwrap();
575        // Peer never replies → the response read times out → the client poisons.
576        let err = c.push(b"x", Durability::Durable).unwrap_err();
577        assert!(matches!(err, ClientError::Io(_)), "{err:?}");
578        assert!(
579            c.is_poisoned(),
580            "a read failure must flip is_poisoned() to true"
581        );
582        // And the poisoning error itself is non-recoverable.
583        assert!(!err.is_recoverable(), "an Io failure is not recoverable");
584    }
585
586    #[test]
587    fn is_poisoned_after_a_connection_closing_nack() {
588        // A connection-closing Nack (every reason except InternalError) leaves the
589        // connection dead. `is_poisoned()` must report that — it reflects
590        // `closed_after_nack`, not just `poisoned` — and the connection must be
591        // refused by `ensure_usable` on the next call. (Models the closing-nack
592        // test `closing_nack_makes_next_call_fail_with_a_clear_reconnect_error`.)
593        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
594        let mut c = WeirClient::from_stream(client_end);
595        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
596        let server = std::thread::spawn(move || {
597            use std::io::Write;
598            drain_one_frame(&mut server_end);
599            server_end
600                .write_all(&nack_frame(NackReason::EmptyPayload))
601                .unwrap();
602            // drop server_end → connection closes, as the daemon does after a Nack.
603        });
604        let first = c.push(b"x", Durability::Durable).unwrap_err();
605        server.join().unwrap();
606        assert!(
607            matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
608            "expected the real Nack reason, got {first:?}"
609        );
610        // The closing Nack is non-recoverable, and the client reports poisoned.
611        assert!(
612            !first.is_recoverable(),
613            "a connection-closing Nack is non-recoverable"
614        );
615        assert!(
616            c.is_poisoned(),
617            "a connection-closing Nack must flip is_poisoned() to true"
618        );
619        // And the connection is refused fast on the next attempt.
620        assert!(
621            matches!(c.ensure_usable(), Err(ClientError::Protocol(_))),
622            "ensure_usable must reject a closed-after-nack connection"
623        );
624    }
625
626    #[test]
627    fn is_poisoned_and_is_recoverable_agree_after_a_desync() {
628        // A stream desync — the daemon sends an unexpected message type where an
629        // Ack/Nack was expected — poisons the connection: leftover/unexpected bytes
630        // could be mis-read as a later reply (a false ack). `is_poisoned()` must be
631        // true AND the returned error must be non-recoverable, i.e. the two agree.
632        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
633        let mut c = WeirClient::from_stream(client_end);
634        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
635        let server = std::thread::spawn(move || {
636            use std::io::Write;
637            drain_one_frame(&mut server_end);
638            // A HealthCheck frame is a valid frame, but it is NOT a reply push expects
639            // (push expects Ack or Nack) — a genuine stream desync.
640            let bogus = weir_core::Envelope::new(
641                weir_core::Header::new(
642                    weir_core::MessageType::HealthCheck,
643                    weir_core::Durability::Durable,
644                    0,
645                ),
646                vec![],
647            )
648            .encode();
649            server_end.write_all(&bogus).unwrap();
650        });
651        let err = c.push(b"x", Durability::Durable).unwrap_err();
652        server.join().unwrap();
653        // The error is a Protocol desync, which is non-recoverable...
654        assert!(
655            matches!(err, ClientError::Protocol(_)),
656            "expected a Protocol desync error, got {err:?}"
657        );
658        assert!(!err.is_recoverable(), "a stream desync is non-recoverable");
659        // ...and is_poisoned() agrees (the two must never disagree).
660        assert!(
661            c.is_poisoned(),
662            "a stream desync must flip is_poisoned() to true"
663        );
664        assert_eq!(
665            c.is_poisoned(),
666            !err.is_recoverable(),
667            "is_poisoned() and !is_recoverable() must agree"
668        );
669    }
670
671    #[test]
672    fn is_recoverable_true_for_local_over_cap_rejection() {
673        // The local pre-send PayloadTooLarge rejection does not touch the socket, so
674        // it is recoverable and must not poison the client.
675        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
676        let mut c = WeirClient::from_stream(client_end);
677        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
678        let err = c.push(&oversized, Durability::Durable).unwrap_err();
679        assert!(
680            matches!(err, ClientError::PayloadTooLarge { .. }),
681            "expected a local PayloadTooLarge, got {err:?}"
682        );
683        assert!(
684            err.is_recoverable(),
685            "a local over-cap rejection keeps the connection usable"
686        );
687        assert!(!c.is_poisoned(), "a recoverable error must not poison");
688    }
689
690    #[test]
691    fn push_rejects_empty_payload_locally_without_poisoning() {
692        // An empty payload is the WAB end-of-records sentinel; the daemon would
693        // Nack+close it. The local guard rejects it before any bytes are sent, so
694        // the connection stays usable (recoverable, not poisoned) — mirroring the
695        // over-cap guard.
696        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
697        let mut c = WeirClient::from_stream(client_end);
698        let err = c.push(b"", Durability::Durable).unwrap_err();
699        assert!(
700            matches!(err, ClientError::EmptyPayload),
701            "expected a local EmptyPayload, got {err:?}"
702        );
703        assert!(
704            err.is_recoverable(),
705            "a local empty-payload rejection keeps the connection usable"
706        );
707        assert!(!c.is_poisoned(), "a recoverable error must not poison");
708    }
709
710    #[test]
711    fn is_recoverable_true_for_no_default_durability() {
712        // A local misconfiguration returned before any I/O is recoverable.
713        let err = ClientError::NoDefaultDurability;
714        assert!(err.is_recoverable());
715    }
716
717    #[test]
718    fn is_recoverable_true_for_internal_error_nack() {
719        // InternalError is the one Nack the daemon keeps the connection open for.
720        let err = ClientError::Nack(NackReason::InternalError);
721        assert!(
722            err.is_recoverable(),
723            "a transient InternalError Nack keeps the connection usable"
724        );
725    }
726
727    #[test]
728    fn is_recoverable_false_for_connection_closing_nacks() {
729        // Every Nack reason except InternalError closes the connection, so it is
730        // non-recoverable. VersionMismatch and UnknownNack are Nacks too.
731        for err in [
732            ClientError::Nack(NackReason::EmptyPayload),
733            ClientError::Nack(NackReason::PayloadTooLarge),
734            ClientError::Nack(NackReason::UnknownMessage),
735            ClientError::VersionMismatch { daemon_version: 7 },
736            ClientError::UnknownNack(0xff),
737        ] {
738            assert!(
739                !err.is_recoverable(),
740                "{err:?} closes the connection and must be non-recoverable"
741            );
742        }
743    }
744
745    #[test]
746    fn is_recoverable_false_for_io_and_protocol() {
747        // A socket failure poisons; a protocol violation poisons or IS the
748        // reconnect guard — both non-recoverable.
749        let io = ClientError::Io(io::Error::new(io::ErrorKind::BrokenPipe, "pipe"));
750        assert!(!io.is_recoverable());
751        let proto = ClientError::Protocol("connection poisoned".into());
752        assert!(!proto.is_recoverable());
753    }
754}