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 #[test]
162 fn nack_error_surfaces_daemon_version_on_version_mismatch() {
163 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 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 #[test]
194 fn push_rejects_over_hard_cap_locally() {
195 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 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 });
238
239 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 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 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 });
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 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 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); 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(); server.join().unwrap();
341 }
342
343 #[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 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 #[test]
383 fn empty_nack_payload_is_protocol_desync_and_poisons() {
384 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 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 #[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 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 #[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 #[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 #[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 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 #[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 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 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 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 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 let first = c.push(b"x", Durability::Durable).unwrap_err();
573 assert!(matches!(first, ClientError::Io(_)), "{first:?}");
574
575 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 #[test]
591 fn is_poisoned_flips_after_a_read_failure() {
592 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 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 assert!(!err.is_recoverable(), "an Io failure is not recoverable");
609 }
610
611 #[test]
612 fn is_poisoned_after_a_connection_closing_nack() {
613 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 });
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 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 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 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 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 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 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 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 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 let err = ClientError::NoDefaultDurability;
739 assert!(err.is_recoverable());
740 }
741
742 #[test]
743 fn is_recoverable_true_for_internal_error_nack() {
744 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 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 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}