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    // ── Tracked push: the coordinate round trip ──────────────────────────────
162
163    /// Runs one request/response exchange against a scripted peer: reads the
164    /// client's frame, hands the caller its decoded header + payload, and writes
165    /// back whatever frame the caller returns.
166    fn scripted_peer(
167        server_end: std::os::unix::net::UnixStream,
168        respond: impl FnOnce(weir_core::Header, Vec<u8>) -> Vec<u8> + Send + 'static,
169    ) -> std::thread::JoinHandle<(weir_core::Header, Vec<u8>)> {
170        std::thread::spawn(move || {
171            use std::io::{Read, Write};
172            let mut server_end = server_end;
173            let mut hdr = [0u8; weir_core::HEADER_LEN];
174            server_end.read_exact(&mut hdr).unwrap();
175            let h = weir_core::Header::decode(&hdr).unwrap();
176            let mut rest = vec![0u8; h.payload_len() as usize + 4];
177            server_end.read_exact(&mut rest).unwrap();
178            let payload = rest[..h.payload_len() as usize].to_vec();
179            let reply = respond(h, payload.clone());
180            server_end.write_all(&reply).unwrap();
181            (h, payload)
182        })
183    }
184
185    fn ack_tracked_frame(coordinate: &weir_core::RecordCoordinate) -> Vec<u8> {
186        weir_core::Envelope::new(
187            weir_core::Header::new(
188                weir_core::MessageType::AckTracked,
189                weir_core::Durability::Durable,
190                0,
191            ),
192            coordinate.encode(),
193        )
194        .encode()
195    }
196
197    #[test]
198    fn push_tracked_sends_the_tracked_type_and_returns_the_coordinate() {
199        let (client_end, server_end) = std::os::unix::net::UnixStream::pair().unwrap();
200        let mut c = WeirClient::from_stream(client_end);
201
202        let expected = weir_core::RecordCoordinate::new(
203            "shard_02/seg_00000009.wab.sealed".into(),
204            42,
205            [0x7C; 32],
206        )
207        .unwrap();
208        let reply = ack_tracked_frame(&expected);
209        let peer = scripted_peer(server_end, move |_, _| reply);
210
211        let got = c.push_tracked(b"hello", Durability::Durable).unwrap();
212        let (header, payload) = peer.join().unwrap();
213
214        assert_eq!(
215            header.message_type(),
216            weir_core::MessageType::PushTracked,
217            "push_tracked must put 0x06 on the wire, not 0x01"
218        );
219        assert_eq!(payload, b"hello");
220        assert_eq!(got, expected);
221        assert!(!c.is_poisoned());
222    }
223
224    /// The read-side cap is chosen by the declared type. An `AckTracked` may
225    /// exceed two bytes; every other response still may not.
226    #[test]
227    fn ack_tracked_payload_passes_the_cap_that_an_ack_would_fail() {
228        // A coordinate is well over the 2-byte bound that applies to Ack/Nack.
229        let coordinate =
230            weir_core::RecordCoordinate::new("shard_00/seg_00000001.wab.sealed".into(), 1, [0; 32])
231                .unwrap();
232        assert!(coordinate.encode().len() > 2);
233
234        // Same bytes, mislabelled as a plain Ack: rejected before allocating.
235        let (client_end, server_end) = std::os::unix::net::UnixStream::pair().unwrap();
236        let mut c = WeirClient::from_stream(client_end);
237        let bad = weir_core::Envelope::new(
238            weir_core::Header::new(
239                weir_core::MessageType::Ack,
240                weir_core::Durability::Durable,
241                0,
242            ),
243            coordinate.encode(),
244        )
245        .encode();
246        let peer = scripted_peer(server_end, move |_, _| bad);
247        let err = c.push(b"hello", Durability::Durable).unwrap_err();
248        peer.join().unwrap();
249        assert!(
250            matches!(&err, ClientError::Protocol(m) if m.contains("refusing to allocate")),
251            "an over-cap Ack must still be refused; got {err:?}"
252        );
253    }
254
255    /// A daemon that answers a tracked push with a bare Ack accepted the record
256    /// but did not answer the question. Reporting Ok would hand the caller a
257    /// coordinate it never received, so it is a protocol error.
258    #[test]
259    fn push_tracked_refuses_a_bare_ack() {
260        let (client_end, server_end) = std::os::unix::net::UnixStream::pair().unwrap();
261        let mut c = WeirClient::from_stream(client_end);
262        let ack = weir_core::Envelope::new(
263            weir_core::Header::new(
264                weir_core::MessageType::Ack,
265                weir_core::Durability::Durable,
266                0,
267            ),
268            vec![],
269        )
270        .encode();
271        let peer = scripted_peer(server_end, move |_, _| ack);
272        let err = c.push_tracked(b"hello", Durability::Durable).unwrap_err();
273        peer.join().unwrap();
274        assert!(
275            matches!(&err, ClientError::Protocol(m) if m.contains("expected AckTracked")),
276            "got {err:?}"
277        );
278        assert!(!err.is_recoverable() && c.is_poisoned());
279    }
280
281    /// The negotiation, from the client's side: a daemon that predates
282    /// PushTracked rejects the type with UnknownMessage and closes. That error —
283    /// not a special handshake — is how a client learns the feature is absent.
284    #[test]
285    fn push_tracked_against_an_old_daemon_surfaces_unknown_message() {
286        let (client_end, server_end) = std::os::unix::net::UnixStream::pair().unwrap();
287        let mut c = WeirClient::from_stream(client_end);
288        let nack = weir_core::Envelope::new(
289            weir_core::Header::new(
290                weir_core::MessageType::Nack,
291                weir_core::Durability::Durable,
292                0,
293            ),
294            vec![NackReason::UnknownMessage as u8],
295        )
296        .encode();
297        let peer = scripted_peer(server_end, move |_, _| nack);
298        let err = c.push_tracked(b"hello", Durability::Durable).unwrap_err();
299        peer.join().unwrap();
300        assert!(matches!(err, ClientError::Nack(NackReason::UnknownMessage)));
301        assert!(
302            !err.is_recoverable() && c.is_poisoned(),
303            "the daemon closes after this Nack, so the client must report itself unusable"
304        );
305    }
306
307    /// A coordinate this build cannot parse is a version skew every subsequent
308    /// tracked push would hit again, so the client reports itself unusable —
309    /// keeping `is_poisoned` the exact complement of `is_recoverable`.
310    #[test]
311    fn push_tracked_poisons_on_a_malformed_coordinate() {
312        let (client_end, server_end) = std::os::unix::net::UnixStream::pair().unwrap();
313        let mut c = WeirClient::from_stream(client_end);
314        let bogus = weir_core::Envelope::new(
315            weir_core::Header::new(
316                weir_core::MessageType::AckTracked,
317                weir_core::Durability::Durable,
318                0,
319            ),
320            vec![0xFFu8; 8], // shorter than the fixed prefix
321        )
322        .encode();
323        let peer = scripted_peer(server_end, move |_, _| bogus);
324        let err = c.push_tracked(b"hello", Durability::Durable).unwrap_err();
325        peer.join().unwrap();
326        assert!(
327            matches!(&err, ClientError::Protocol(m) if m.contains("malformed record coordinate")),
328            "got {err:?}"
329        );
330        assert!(!err.is_recoverable() && c.is_poisoned());
331    }
332
333    /// The local guards are the push guards: they fire before any byte reaches
334    /// the wire, and they leave the connection usable.
335    #[test]
336    fn push_tracked_keeps_the_local_guards() {
337        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
338        let mut c = WeirClient::from_stream(client_end);
339        assert!(matches!(
340            c.push_tracked(b"", Durability::Durable).unwrap_err(),
341            ClientError::EmptyPayload
342        ));
343        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
344        assert!(matches!(
345            c.push_tracked(&oversized, Durability::Durable).unwrap_err(),
346            ClientError::PayloadTooLarge { .. }
347        ));
348        assert!(
349            !c.is_poisoned(),
350            "local pre-send rejections must not poison the connection"
351        );
352    }
353
354    #[test]
355    fn push_tracked_default_uses_the_stored_tier() {
356        let (client_end, server_end) = std::os::unix::net::UnixStream::pair().unwrap();
357        let mut c = WeirClient::from_stream(client_end);
358        c.set_default_durability(Durability::Buffered);
359
360        let coordinate =
361            weir_core::RecordCoordinate::new("shard_00/seg_00000001.wab.sealed".into(), 3, [1; 32])
362                .unwrap();
363        let reply = ack_tracked_frame(&coordinate);
364        let peer = scripted_peer(server_end, move |_, _| reply);
365
366        assert_eq!(c.push_tracked_default(b"hello").unwrap(), coordinate);
367        let (header, _) = peer.join().unwrap();
368        assert_eq!(header.durability(), Durability::Buffered);
369    }
370
371    #[test]
372    fn push_tracked_default_without_default_errors() {
373        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
374        let mut c = WeirClient::from_stream(a);
375        assert!(matches!(
376            c.push_tracked_default(b"x").unwrap_err(),
377            ClientError::NoDefaultDurability
378        ));
379    }
380
381    #[test]
382    fn nack_error_surfaces_daemon_version_on_version_mismatch() {
383        // Daemon sends `[VersionMismatch (0x02), daemon_wire_version]`.
384        let payload = [NackReason::VersionMismatch as u8, 7];
385        match nack_error(&payload) {
386            ClientError::VersionMismatch { daemon_version } => assert_eq!(daemon_version, 7),
387            other => panic!("expected VersionMismatch, got {other:?}"),
388        }
389    }
390
391    #[test]
392    fn nack_error_version_mismatch_without_version_byte_falls_back() {
393        // A malformed VersionMismatch Nack with no second byte must not panic;
394        // it degrades to the bare reason.
395        let payload = [NackReason::VersionMismatch as u8];
396        assert!(matches!(
397            nack_error(&payload),
398            ClientError::Nack(NackReason::VersionMismatch)
399        ));
400    }
401
402    #[test]
403    fn nack_error_other_reasons_unaffected() {
404        let payload = [NackReason::PayloadTooLarge as u8];
405        assert!(matches!(
406            nack_error(&payload),
407            ClientError::Nack(NackReason::PayloadTooLarge)
408        ));
409    }
410
411    // ── Bug #1: oversized payloads must surface as PayloadTooLarge, not broken-pipe ──
412
413    #[test]
414    fn push_rejects_over_hard_cap_locally() {
415        // A payload above the protocol hard cap is rejected locally, before any
416        // bytes hit the wire — no round-trip, and the connection stays usable.
417        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
418        let mut c = WeirClient::from_stream(client_end);
419        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
420        let err = c.push(&oversized, Durability::Durable).unwrap_err();
421        assert!(
422            matches!(err, ClientError::PayloadTooLarge { len, limit }
423                if len == MAX_PAYLOAD_HARD_CAP + 1 && limit == MAX_PAYLOAD_HARD_CAP),
424            "expected a local PayloadTooLarge, got {err:?}"
425        );
426        assert!(
427            !c.poisoned,
428            "a local rejection must not poison the connection"
429        );
430    }
431
432    #[test]
433    fn push_surfaces_payload_too_large_nack_when_server_closes_mid_write() {
434        // Mirrors the daemon's over-configured-cap path: read only the header, send
435        // Nack(PayloadTooLarge), then close without draining the payload. Before the
436        // fix the client's large write hit the closed socket and returned a bare
437        // broken-pipe, hiding the Nack. Now the Nack is read from the receive buffer
438        // and surfaced — whether the write fails partway or just-barely succeeds.
439        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
440        let mut c = WeirClient::from_stream(client_end);
441
442        let server = std::thread::spawn(move || {
443            use std::io::{Read, Write};
444            let mut hdr = [0u8; weir_core::HEADER_LEN];
445            server_end.read_exact(&mut hdr).unwrap();
446            let nack = weir_core::Envelope::new(
447                weir_core::Header::new(
448                    weir_core::MessageType::Nack,
449                    weir_core::Durability::Durable,
450                    0,
451                ),
452                vec![NackReason::PayloadTooLarge as u8],
453            )
454            .encode();
455            server_end.write_all(&nack).unwrap();
456            // drop server_end here → connection closes without reading the payload.
457        });
458
459        // 2 MiB: above any default socket buffer (so the write blocks then fails
460        // once the server stops reading and closes), but under the hard cap (so it
461        // is not caught by the local pre-check).
462        let payload = vec![0u8; 2 * 1024 * 1024];
463        let err = c.push(&payload, Durability::Durable).unwrap_err();
464        server.join().unwrap();
465        assert!(
466            matches!(err, ClientError::Nack(NackReason::PayloadTooLarge)),
467            "expected Nack(PayloadTooLarge) surfaced from the buffered reply, got {err:?}"
468        );
469    }
470
471    // ── Bug #2: a connection-closing Nack must give a clear next-call error ──────
472
473    // Reads one full frame (header + payload + CRC) off a server-side stream.
474    fn drain_one_frame(s: &mut std::os::unix::net::UnixStream) {
475        use std::io::Read;
476        let mut hdr = [0u8; weir_core::HEADER_LEN];
477        s.read_exact(&mut hdr).unwrap();
478        let n = weir_core::Header::decode(&hdr).unwrap().payload_len() as usize + 4;
479        let mut rest = vec![0u8; n];
480        s.read_exact(&mut rest).unwrap();
481    }
482
483    fn nack_frame(reason: NackReason) -> Vec<u8> {
484        weir_core::Envelope::new(
485            weir_core::Header::new(
486                weir_core::MessageType::Nack,
487                weir_core::Durability::Durable,
488                0,
489            ),
490            vec![reason as u8],
491        )
492        .encode()
493    }
494
495    #[test]
496    fn closing_nack_makes_next_call_fail_with_a_clear_reconnect_error() {
497        // The daemon closes the connection after a validation Nack. Before the fix,
498        // the next push hit the dead socket and returned a bare broken-pipe; now it
499        // fails fast with a clear "reconnect" error.
500        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
501        let mut c = WeirClient::from_stream(client_end);
502        let server = std::thread::spawn(move || {
503            use std::io::Write;
504            drain_one_frame(&mut server_end);
505            server_end
506                .write_all(&nack_frame(NackReason::EmptyPayload))
507                .unwrap();
508            // drop server_end → connection closes, as the daemon does after a Nack.
509        });
510        let first = c.push(b"x", Durability::Durable).unwrap_err();
511        server.join().unwrap();
512        assert!(
513            matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
514            "first push should surface the real reason, got {first:?}"
515        );
516        // Second call must NOT be a broken-pipe — it must clearly say reconnect.
517        match c.push(b"y", Durability::Durable).unwrap_err() {
518            ClientError::Protocol(msg) => assert!(
519                msg.contains("closed by the daemon after a Nack") && msg.contains("reconnect"),
520                "unexpected message: {msg}"
521            ),
522            other => panic!("expected a clear reconnect Protocol error, got {other:?}"),
523        }
524    }
525
526    #[test]
527    fn internal_error_nack_keeps_connection_usable() {
528        // InternalError is transient — the daemon keeps the connection open — so the
529        // client must NOT mark it closed, and a retry on the same connection works.
530        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
531        let mut c = WeirClient::from_stream(client_end);
532        let server = std::thread::spawn(move || {
533            use std::io::Write;
534            drain_one_frame(&mut server_end);
535            server_end
536                .write_all(&nack_frame(NackReason::InternalError))
537                .unwrap();
538            drain_one_frame(&mut server_end); // the retry
539            let ack = weir_core::Envelope::new(
540                weir_core::Header::new(
541                    weir_core::MessageType::Ack,
542                    weir_core::Durability::Durable,
543                    0,
544                ),
545                vec![],
546            )
547            .encode();
548            server_end.write_all(&ack).unwrap();
549        });
550        let first = c.push(b"a", Durability::Durable).unwrap_err();
551        assert!(
552            matches!(first, ClientError::Nack(NackReason::InternalError)),
553            "got {first:?}"
554        );
555        assert!(
556            !c.closed_after_nack,
557            "InternalError is transient and must not close the connection"
558        );
559        c.push(b"b", Durability::Durable).unwrap(); // retry on the same connection succeeds
560        server.join().unwrap();
561    }
562
563    // ── Hostile / malformed daemon responses: refuse + poison, never false-ack ──
564
565    /// F44: a daemon-declared response payload_len above the hard cap must be
566    /// refused BEFORE allocating ~4 GiB, and poison the connection.
567    #[test]
568    fn response_oversized_payload_len_refuses_to_allocate_and_poisons() {
569        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
570        let mut c = WeirClient::from_stream(client_end);
571        let server = std::thread::spawn(move || {
572            use std::io::Write;
573            drain_one_frame(&mut server_end);
574            // A valid 16-byte header whose payload_len is CAP+1, with a recomputed
575            // header CRC; then EOF (no payload) — the client must refuse before it
576            // would try to read/allocate the declared payload.
577            let mut hdr = Header::new(MessageType::Ack, Durability::Durable, 0).encode();
578            let big = (MAX_PAYLOAD_HARD_CAP as u32).wrapping_add(1);
579            hdr[8..12].copy_from_slice(&big.to_le_bytes());
580            let crc = crc32fast::hash(&hdr[0..12]);
581            hdr[12..16].copy_from_slice(&crc.to_le_bytes());
582            server_end.write_all(&hdr).unwrap();
583        });
584        let err = c.push(b"x", Durability::Durable).unwrap_err();
585        server.join().unwrap();
586        match err {
587            ClientError::Protocol(msg) => assert!(
588                msg.contains("refusing to allocate"),
589                "expected the cap refusal, got: {msg}"
590            ),
591            other => panic!("expected a Protocol cap refusal, got {other:?}"),
592        }
593        assert!(
594            c.is_poisoned(),
595            "an oversized response must poison the client"
596        );
597    }
598
599    /// An empty Nack payload is a stream desync: `nack_error` reports `Protocol`,
600    /// and the connection must be poisoned so a stale frame can't be mis-read as a
601    /// later ack (a false ack).
602    #[test]
603    fn empty_nack_payload_is_protocol_desync_and_poisons() {
604        // (1) the helper maps an empty payload to a Protocol error.
605        match nack_error(&[]) {
606            ClientError::Protocol(msg) => assert!(msg.contains("empty payload"), "{msg}"),
607            other => panic!("expected Protocol for empty Nack, got {other:?}"),
608        }
609        // (2) end-to-end: an empty-payload Nack poisons + is not recoverable.
610        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
611        let mut c = WeirClient::from_stream(client_end);
612        let server = std::thread::spawn(move || {
613            use std::io::Write;
614            drain_one_frame(&mut server_end);
615            let frame = Envelope::new(
616                Header::new(MessageType::Nack, Durability::Durable, 0),
617                vec![],
618            )
619            .encode();
620            server_end.write_all(&frame).unwrap();
621        });
622        let err = c.push(b"x", Durability::Durable).unwrap_err();
623        server.join().unwrap();
624        assert!(matches!(err, ClientError::Protocol(_)), "got {err:?}");
625        assert!(
626            !err.is_recoverable(),
627            "a desync Protocol error is not recoverable"
628        );
629        assert!(
630            c.is_poisoned(),
631            "an empty-Nack desync must poison the client"
632        );
633    }
634
635    /// A corrupted response payload CRC is a protocol violation: surface it and
636    /// poison (the stream tail is now untrustworthy).
637    #[test]
638    fn response_payload_crc_mismatch_poisons() {
639        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
640        let mut c = WeirClient::from_stream(client_end);
641        let server = std::thread::spawn(move || {
642            use std::io::Write;
643            drain_one_frame(&mut server_end);
644            // A Nack frame with a flipped trailing CRC byte.
645            let mut frame = nack_frame(NackReason::InternalError);
646            *frame.last_mut().unwrap() ^= 0xff;
647            server_end.write_all(&frame).unwrap();
648        });
649        let err = c.push(b"x", Durability::Durable).unwrap_err();
650        server.join().unwrap();
651        match err {
652            ClientError::Protocol(msg) => assert!(msg.contains("CRC mismatch"), "{msg}"),
653            other => panic!("expected a CRC-mismatch Protocol error, got {other:?}"),
654        }
655        assert!(
656            c.is_poisoned(),
657            "a CRC-mismatched response must poison the client"
658        );
659    }
660
661    /// An unrecognised Nack reason byte (reserved range) surfaces as
662    /// `UnknownNack(byte)` and closes the connection.
663    #[test]
664    fn unknown_nack_reason_byte_surfaces_unknown_nack() {
665        assert!(matches!(
666            nack_error(&[0x0A]),
667            ClientError::UnknownNack(0x0A)
668        ));
669
670        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
671        let mut c = WeirClient::from_stream(client_end);
672        let server = std::thread::spawn(move || {
673            use std::io::Write;
674            drain_one_frame(&mut server_end);
675            let frame = Envelope::new(
676                Header::new(MessageType::Nack, Durability::Durable, 0),
677                vec![0x0A],
678            )
679            .encode();
680            server_end.write_all(&frame).unwrap();
681        });
682        let err = c.push(b"x", Durability::Durable).unwrap_err();
683        server.join().unwrap();
684        assert!(matches!(err, ClientError::UnknownNack(0x0A)), "got {err:?}");
685        assert!(
686            c.is_poisoned(),
687            "an unknown closing Nack must mark the client unusable"
688        );
689    }
690
691    /// A VersionMismatch Nack surfaces the daemon's version through push() and is
692    /// non-recoverable + connection-closing.
693    #[test]
694    fn version_mismatch_surfaces_through_push() {
695        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
696        let mut c = WeirClient::from_stream(client_end);
697        let server = std::thread::spawn(move || {
698            use std::io::Write;
699            drain_one_frame(&mut server_end);
700            let frame = Envelope::new(
701                Header::new(MessageType::Nack, Durability::Durable, 0),
702                vec![NackReason::VersionMismatch as u8, 9],
703            )
704            .encode();
705            server_end.write_all(&frame).unwrap();
706        });
707        let err = c.push(b"x", Durability::Durable).unwrap_err();
708        server.join().unwrap();
709        assert!(
710            matches!(err, ClientError::VersionMismatch { daemon_version: 9 }),
711            "got {err:?}"
712        );
713        assert!(!err.is_recoverable());
714        assert!(c.is_poisoned());
715    }
716
717    /// health_check() against a daemon that replies with the wrong frame type is a
718    /// desync: Protocol error + poison + non-recoverable.
719    #[test]
720    fn health_check_unexpected_frame_type_poisons() {
721        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
722        let mut c = WeirClient::from_stream(client_end);
723        let server = std::thread::spawn(move || {
724            use std::io::Write;
725            drain_one_frame(&mut server_end);
726            // Reply to a HealthCheck with an Ack — never expected here.
727            let frame = Envelope::new(
728                Header::new(MessageType::Ack, Durability::Durable, 0),
729                vec![],
730            )
731            .encode();
732            server_end.write_all(&frame).unwrap();
733        });
734        let err = c.health_check().unwrap_err();
735        server.join().unwrap();
736        match &err {
737            ClientError::Protocol(msg) => {
738                assert!(msg.contains("expected HealthCheckResponse"), "{msg}")
739            }
740            other => panic!("expected a Protocol desync, got {other:?}"),
741        }
742        assert!(!err.is_recoverable());
743        assert!(c.is_poisoned());
744    }
745
746    // ── F43: opt-in socket timeouts ───────────────────────────────────────────
747
748    #[test]
749    fn set_read_timeout_applies_to_socket() {
750        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
751        let c = WeirClient::from_stream(a);
752        c.set_read_timeout(Some(Duration::from_millis(250)))
753            .unwrap();
754        assert_eq!(
755            c.stream.read_timeout().unwrap(),
756            Some(Duration::from_millis(250))
757        );
758        // None clears it back to blocking.
759        c.set_read_timeout(None).unwrap();
760        assert_eq!(c.stream.read_timeout().unwrap(), None);
761    }
762
763    #[test]
764    fn read_timeout_bounds_a_silent_daemon_instead_of_blocking_forever() {
765        // `_b` is the daemon end: it's held open (so the connection stays up)
766        // but never replies. Without a read timeout, push would block forever.
767        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
768        let mut c = WeirClient::from_stream(a);
769        c.set_read_timeout(Some(Duration::from_millis(150)))
770            .unwrap();
771        // push writes the (tiny) frame, then blocks reading the reply that never
772        // comes → the timeout fires → an Io error rather than an indefinite hang.
773        let err = c.push(b"x", Durability::Durable).unwrap_err();
774        assert!(
775            matches!(err, ClientError::Io(_)),
776            "expected Io timeout, got {err:?}"
777        );
778    }
779
780    #[test]
781    fn client_is_poisoned_after_a_read_failure() {
782        // G04: after a response read fails (here a timeout), the stream may hold
783        // leftover bytes that a subsequent read would mis-attribute to the next
784        // request — a false ack. The client must poison itself and reject further
785        // use fast, instead of reading again.
786        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
787        let mut c = WeirClient::from_stream(a);
788        c.set_read_timeout(Some(Duration::from_millis(100)))
789            .unwrap();
790
791        // First push: the peer never replies → read times out → Io error.
792        let first = c.push(b"x", Durability::Durable).unwrap_err();
793        assert!(matches!(first, ClientError::Io(_)), "{first:?}");
794
795        // Second push must fail FAST as poisoned, not block on another read.
796        let started = std::time::Instant::now();
797        let second = c.push(b"y", Durability::Durable).unwrap_err();
798        assert!(
799            started.elapsed() < Duration::from_millis(50),
800            "poisoned client must reject immediately, not read again"
801        );
802        match second {
803            ClientError::Protocol(msg) => assert!(msg.contains("poisoned"), "{msg}"),
804            other => panic!("expected a poisoned Protocol error, got {other:?}"),
805        }
806    }
807
808    // ── is_poisoned / is_recoverable helpers ─────────────────────────────────
809
810    #[test]
811    fn is_poisoned_flips_after_a_read_failure() {
812        // A fresh client is not poisoned; after a response-read failure (here a
813        // timeout) the flag flips, mirroring `client_is_poisoned_after_a_read_failure`.
814        let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
815        let mut c = WeirClient::from_stream(a);
816        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
817
818        c.set_read_timeout(Some(Duration::from_millis(100)))
819            .unwrap();
820        // Peer never replies → the response read times out → the client poisons.
821        let err = c.push(b"x", Durability::Durable).unwrap_err();
822        assert!(matches!(err, ClientError::Io(_)), "{err:?}");
823        assert!(
824            c.is_poisoned(),
825            "a read failure must flip is_poisoned() to true"
826        );
827        // And the poisoning error itself is non-recoverable.
828        assert!(!err.is_recoverable(), "an Io failure is not recoverable");
829    }
830
831    #[test]
832    fn is_poisoned_after_a_connection_closing_nack() {
833        // A connection-closing Nack (every reason except InternalError) leaves the
834        // connection dead. `is_poisoned()` must report that — it reflects
835        // `closed_after_nack`, not just `poisoned` — and the connection must be
836        // refused by `ensure_usable` on the next call. (Models the closing-nack
837        // test `closing_nack_makes_next_call_fail_with_a_clear_reconnect_error`.)
838        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
839        let mut c = WeirClient::from_stream(client_end);
840        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
841        let server = std::thread::spawn(move || {
842            use std::io::Write;
843            drain_one_frame(&mut server_end);
844            server_end
845                .write_all(&nack_frame(NackReason::EmptyPayload))
846                .unwrap();
847            // drop server_end → connection closes, as the daemon does after a Nack.
848        });
849        let first = c.push(b"x", Durability::Durable).unwrap_err();
850        server.join().unwrap();
851        assert!(
852            matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
853            "expected the real Nack reason, got {first:?}"
854        );
855        // The closing Nack is non-recoverable, and the client reports poisoned.
856        assert!(
857            !first.is_recoverable(),
858            "a connection-closing Nack is non-recoverable"
859        );
860        assert!(
861            c.is_poisoned(),
862            "a connection-closing Nack must flip is_poisoned() to true"
863        );
864        // And the connection is refused fast on the next attempt.
865        assert!(
866            matches!(c.ensure_usable(), Err(ClientError::Protocol(_))),
867            "ensure_usable must reject a closed-after-nack connection"
868        );
869    }
870
871    #[test]
872    fn is_poisoned_and_is_recoverable_agree_after_a_desync() {
873        // A stream desync — the daemon sends an unexpected message type where an
874        // Ack/Nack was expected — poisons the connection: leftover/unexpected bytes
875        // could be mis-read as a later reply (a false ack). `is_poisoned()` must be
876        // true AND the returned error must be non-recoverable, i.e. the two agree.
877        let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
878        let mut c = WeirClient::from_stream(client_end);
879        assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
880        let server = std::thread::spawn(move || {
881            use std::io::Write;
882            drain_one_frame(&mut server_end);
883            // A HealthCheck frame is a valid frame, but it is NOT a reply push expects
884            // (push expects Ack or Nack) — a genuine stream desync.
885            let bogus = weir_core::Envelope::new(
886                weir_core::Header::new(
887                    weir_core::MessageType::HealthCheck,
888                    weir_core::Durability::Durable,
889                    0,
890                ),
891                vec![],
892            )
893            .encode();
894            server_end.write_all(&bogus).unwrap();
895        });
896        let err = c.push(b"x", Durability::Durable).unwrap_err();
897        server.join().unwrap();
898        // The error is a Protocol desync, which is non-recoverable...
899        assert!(
900            matches!(err, ClientError::Protocol(_)),
901            "expected a Protocol desync error, got {err:?}"
902        );
903        assert!(!err.is_recoverable(), "a stream desync is non-recoverable");
904        // ...and is_poisoned() agrees (the two must never disagree).
905        assert!(
906            c.is_poisoned(),
907            "a stream desync must flip is_poisoned() to true"
908        );
909        assert_eq!(
910            c.is_poisoned(),
911            !err.is_recoverable(),
912            "is_poisoned() and !is_recoverable() must agree"
913        );
914    }
915
916    #[test]
917    fn is_recoverable_true_for_local_over_cap_rejection() {
918        // The local pre-send PayloadTooLarge rejection does not touch the socket, so
919        // it is recoverable and must not poison the client.
920        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
921        let mut c = WeirClient::from_stream(client_end);
922        let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
923        let err = c.push(&oversized, Durability::Durable).unwrap_err();
924        assert!(
925            matches!(err, ClientError::PayloadTooLarge { .. }),
926            "expected a local PayloadTooLarge, got {err:?}"
927        );
928        assert!(
929            err.is_recoverable(),
930            "a local over-cap rejection keeps the connection usable"
931        );
932        assert!(!c.is_poisoned(), "a recoverable error must not poison");
933    }
934
935    #[test]
936    fn push_rejects_empty_payload_locally_without_poisoning() {
937        // An empty payload is the WAB end-of-records sentinel; the daemon would
938        // Nack+close it. The local guard rejects it before any bytes are sent, so
939        // the connection stays usable (recoverable, not poisoned) — mirroring the
940        // over-cap guard.
941        let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
942        let mut c = WeirClient::from_stream(client_end);
943        let err = c.push(b"", Durability::Durable).unwrap_err();
944        assert!(
945            matches!(err, ClientError::EmptyPayload),
946            "expected a local EmptyPayload, got {err:?}"
947        );
948        assert!(
949            err.is_recoverable(),
950            "a local empty-payload rejection keeps the connection usable"
951        );
952        assert!(!c.is_poisoned(), "a recoverable error must not poison");
953    }
954
955    #[test]
956    fn is_recoverable_true_for_no_default_durability() {
957        // A local misconfiguration returned before any I/O is recoverable.
958        let err = ClientError::NoDefaultDurability;
959        assert!(err.is_recoverable());
960    }
961
962    #[test]
963    fn is_recoverable_true_for_internal_error_nack() {
964        // InternalError is the one Nack the daemon keeps the connection open for.
965        let err = ClientError::Nack(NackReason::InternalError);
966        assert!(
967            err.is_recoverable(),
968            "a transient InternalError Nack keeps the connection usable"
969        );
970    }
971
972    #[test]
973    fn is_recoverable_false_for_connection_closing_nacks() {
974        // Every Nack reason except InternalError closes the connection, so it is
975        // non-recoverable. VersionMismatch and UnknownNack are Nacks too.
976        for err in [
977            ClientError::Nack(NackReason::EmptyPayload),
978            ClientError::Nack(NackReason::PayloadTooLarge),
979            ClientError::Nack(NackReason::UnknownMessage),
980            ClientError::VersionMismatch { daemon_version: 7 },
981            ClientError::UnknownNack(0xff),
982        ] {
983            assert!(
984                !err.is_recoverable(),
985                "{err:?} closes the connection and must be non-recoverable"
986            );
987        }
988    }
989
990    #[test]
991    fn is_recoverable_false_for_io_and_protocol() {
992        // A socket failure poisons; a protocol violation poisons or IS the
993        // reconnect guard — both non-recoverable.
994        let io = ClientError::Io(io::Error::new(io::ErrorKind::BrokenPipe, "pipe"));
995        assert!(!io.is_recoverable());
996        let proto = ClientError::Protocol("connection poisoned".into());
997        assert!(!proto.is_recoverable());
998    }
999}