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