1use 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
17impl WeirClient<UnixStream> {
20 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 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 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 pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
71 self.stream.set_read_timeout(timeout)
72 }
73
74 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]
92 fn health_check_write_failure_poisons_like_push_does() {
93 let (a, b) = std::os::unix::net::UnixStream::pair().unwrap();
94 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 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 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 (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 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 #[test]
227 fn ack_tracked_payload_passes_the_cap_that_an_ack_would_fail() {
228 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 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 #[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 #[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 #[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], )
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 #[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 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 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 #[test]
414 fn push_rejects_over_hard_cap_locally() {
415 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 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 });
458
459 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 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 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 });
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 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 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); 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(); server.join().unwrap();
561 }
562
563 #[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 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 #[test]
603 fn empty_nack_payload_is_protocol_desync_and_poisons() {
604 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 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 #[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 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 #[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 #[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 #[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 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 #[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 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 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 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 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 let first = c.push(b"x", Durability::Durable).unwrap_err();
793 assert!(matches!(first, ClientError::Io(_)), "{first:?}");
794
795 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 #[test]
811 fn is_poisoned_flips_after_a_read_failure() {
812 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 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 assert!(!err.is_recoverable(), "an Io failure is not recoverable");
829 }
830
831 #[test]
832 fn is_poisoned_after_a_connection_closing_nack() {
833 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 });
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 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 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 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 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 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 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 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 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 let err = ClientError::NoDefaultDurability;
959 assert!(err.is_recoverable());
960 }
961
962 #[test]
963 fn is_recoverable_true_for_internal_error_nack() {
964 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 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 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}