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]
87 fn push_default_without_default_errors() {
88 let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
89 let mut c = WeirClient::from_stream(a);
90 assert!(matches!(
91 c.push_default(b"x").unwrap_err(),
92 ClientError::NoDefaultDurability
93 ));
94 }
95
96 #[test]
97 fn set_default_durability_used_by_push_default() {
98 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
99 let mut c = WeirClient::from_stream(client_end);
100 c.set_default_durability(Durability::Buffered);
107
108 let reader = std::thread::spawn(move || {
109 use std::io::{Read, Write};
110 let mut hdr = [0u8; weir_core::HEADER_LEN];
111 server_end.read_exact(&mut hdr).unwrap();
112 let h = weir_core::Header::decode(&hdr).unwrap();
113 let mut rest = vec![0u8; h.payload_len() as usize + 4];
114 server_end.read_exact(&mut rest).unwrap();
115 let ack = weir_core::Envelope::new(
117 weir_core::Header::new(
118 weir_core::MessageType::Ack,
119 weir_core::Durability::Durable,
120 0,
121 ),
122 vec![],
123 )
124 .encode();
125 server_end.write_all(&ack).unwrap();
126 (h.durability(), hdr[6])
128 });
129
130 c.push_default(b"hello").unwrap();
131 let (durability, wire_byte) = reader.join().unwrap();
132 assert_eq!(durability, Durability::Buffered);
133 assert_eq!(wire_byte, 0x03);
134 }
135
136 #[test]
137 fn nack_error_surfaces_daemon_version_on_version_mismatch() {
138 let payload = [NackReason::VersionMismatch as u8, 7];
140 match nack_error(&payload) {
141 ClientError::VersionMismatch { daemon_version } => assert_eq!(daemon_version, 7),
142 other => panic!("expected VersionMismatch, got {other:?}"),
143 }
144 }
145
146 #[test]
147 fn nack_error_version_mismatch_without_version_byte_falls_back() {
148 let payload = [NackReason::VersionMismatch as u8];
151 assert!(matches!(
152 nack_error(&payload),
153 ClientError::Nack(NackReason::VersionMismatch)
154 ));
155 }
156
157 #[test]
158 fn nack_error_other_reasons_unaffected() {
159 let payload = [NackReason::PayloadTooLarge as u8];
160 assert!(matches!(
161 nack_error(&payload),
162 ClientError::Nack(NackReason::PayloadTooLarge)
163 ));
164 }
165
166 #[test]
169 fn push_rejects_over_hard_cap_locally() {
170 let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
173 let mut c = WeirClient::from_stream(client_end);
174 let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
175 let err = c.push(&oversized, Durability::Durable).unwrap_err();
176 assert!(
177 matches!(err, ClientError::PayloadTooLarge { len, limit }
178 if len == MAX_PAYLOAD_HARD_CAP + 1 && limit == MAX_PAYLOAD_HARD_CAP),
179 "expected a local PayloadTooLarge, got {err:?}"
180 );
181 assert!(
182 !c.poisoned,
183 "a local rejection must not poison the connection"
184 );
185 }
186
187 #[test]
188 fn push_surfaces_payload_too_large_nack_when_server_closes_mid_write() {
189 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
195 let mut c = WeirClient::from_stream(client_end);
196
197 let server = std::thread::spawn(move || {
198 use std::io::{Read, Write};
199 let mut hdr = [0u8; weir_core::HEADER_LEN];
200 server_end.read_exact(&mut hdr).unwrap();
201 let nack = weir_core::Envelope::new(
202 weir_core::Header::new(
203 weir_core::MessageType::Nack,
204 weir_core::Durability::Durable,
205 0,
206 ),
207 vec![NackReason::PayloadTooLarge as u8],
208 )
209 .encode();
210 server_end.write_all(&nack).unwrap();
211 });
213
214 let payload = vec![0u8; 2 * 1024 * 1024];
218 let err = c.push(&payload, Durability::Durable).unwrap_err();
219 server.join().unwrap();
220 assert!(
221 matches!(err, ClientError::Nack(NackReason::PayloadTooLarge)),
222 "expected Nack(PayloadTooLarge) surfaced from the buffered reply, got {err:?}"
223 );
224 }
225
226 fn drain_one_frame(s: &mut std::os::unix::net::UnixStream) {
230 use std::io::Read;
231 let mut hdr = [0u8; weir_core::HEADER_LEN];
232 s.read_exact(&mut hdr).unwrap();
233 let n = weir_core::Header::decode(&hdr).unwrap().payload_len() as usize + 4;
234 let mut rest = vec![0u8; n];
235 s.read_exact(&mut rest).unwrap();
236 }
237
238 fn nack_frame(reason: NackReason) -> Vec<u8> {
239 weir_core::Envelope::new(
240 weir_core::Header::new(
241 weir_core::MessageType::Nack,
242 weir_core::Durability::Durable,
243 0,
244 ),
245 vec![reason as u8],
246 )
247 .encode()
248 }
249
250 #[test]
251 fn closing_nack_makes_next_call_fail_with_a_clear_reconnect_error() {
252 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
256 let mut c = WeirClient::from_stream(client_end);
257 let server = std::thread::spawn(move || {
258 use std::io::Write;
259 drain_one_frame(&mut server_end);
260 server_end
261 .write_all(&nack_frame(NackReason::EmptyPayload))
262 .unwrap();
263 });
265 let first = c.push(b"x", Durability::Durable).unwrap_err();
266 server.join().unwrap();
267 assert!(
268 matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
269 "first push should surface the real reason, got {first:?}"
270 );
271 match c.push(b"y", Durability::Durable).unwrap_err() {
273 ClientError::Protocol(msg) => assert!(
274 msg.contains("closed by the daemon after a Nack") && msg.contains("reconnect"),
275 "unexpected message: {msg}"
276 ),
277 other => panic!("expected a clear reconnect Protocol error, got {other:?}"),
278 }
279 }
280
281 #[test]
282 fn internal_error_nack_keeps_connection_usable() {
283 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
286 let mut c = WeirClient::from_stream(client_end);
287 let server = std::thread::spawn(move || {
288 use std::io::Write;
289 drain_one_frame(&mut server_end);
290 server_end
291 .write_all(&nack_frame(NackReason::InternalError))
292 .unwrap();
293 drain_one_frame(&mut server_end); let ack = weir_core::Envelope::new(
295 weir_core::Header::new(
296 weir_core::MessageType::Ack,
297 weir_core::Durability::Durable,
298 0,
299 ),
300 vec![],
301 )
302 .encode();
303 server_end.write_all(&ack).unwrap();
304 });
305 let first = c.push(b"a", Durability::Durable).unwrap_err();
306 assert!(
307 matches!(first, ClientError::Nack(NackReason::InternalError)),
308 "got {first:?}"
309 );
310 assert!(
311 !c.closed_after_nack,
312 "InternalError is transient and must not close the connection"
313 );
314 c.push(b"b", Durability::Durable).unwrap(); server.join().unwrap();
316 }
317
318 #[test]
323 fn response_oversized_payload_len_refuses_to_allocate_and_poisons() {
324 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
325 let mut c = WeirClient::from_stream(client_end);
326 let server = std::thread::spawn(move || {
327 use std::io::Write;
328 drain_one_frame(&mut server_end);
329 let mut hdr = Header::new(MessageType::Ack, Durability::Durable, 0).encode();
333 let big = (MAX_PAYLOAD_HARD_CAP as u32).wrapping_add(1);
334 hdr[8..12].copy_from_slice(&big.to_le_bytes());
335 let crc = crc32fast::hash(&hdr[0..12]);
336 hdr[12..16].copy_from_slice(&crc.to_le_bytes());
337 server_end.write_all(&hdr).unwrap();
338 });
339 let err = c.push(b"x", Durability::Durable).unwrap_err();
340 server.join().unwrap();
341 match err {
342 ClientError::Protocol(msg) => assert!(
343 msg.contains("refusing to allocate"),
344 "expected the cap refusal, got: {msg}"
345 ),
346 other => panic!("expected a Protocol cap refusal, got {other:?}"),
347 }
348 assert!(
349 c.is_poisoned(),
350 "an oversized response must poison the client"
351 );
352 }
353
354 #[test]
358 fn empty_nack_payload_is_protocol_desync_and_poisons() {
359 match nack_error(&[]) {
361 ClientError::Protocol(msg) => assert!(msg.contains("empty payload"), "{msg}"),
362 other => panic!("expected Protocol for empty Nack, got {other:?}"),
363 }
364 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
366 let mut c = WeirClient::from_stream(client_end);
367 let server = std::thread::spawn(move || {
368 use std::io::Write;
369 drain_one_frame(&mut server_end);
370 let frame = Envelope::new(
371 Header::new(MessageType::Nack, Durability::Durable, 0),
372 vec![],
373 )
374 .encode();
375 server_end.write_all(&frame).unwrap();
376 });
377 let err = c.push(b"x", Durability::Durable).unwrap_err();
378 server.join().unwrap();
379 assert!(matches!(err, ClientError::Protocol(_)), "got {err:?}");
380 assert!(
381 !err.is_recoverable(),
382 "a desync Protocol error is not recoverable"
383 );
384 assert!(
385 c.is_poisoned(),
386 "an empty-Nack desync must poison the client"
387 );
388 }
389
390 #[test]
393 fn response_payload_crc_mismatch_poisons() {
394 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
395 let mut c = WeirClient::from_stream(client_end);
396 let server = std::thread::spawn(move || {
397 use std::io::Write;
398 drain_one_frame(&mut server_end);
399 let mut frame = nack_frame(NackReason::InternalError);
401 *frame.last_mut().unwrap() ^= 0xff;
402 server_end.write_all(&frame).unwrap();
403 });
404 let err = c.push(b"x", Durability::Durable).unwrap_err();
405 server.join().unwrap();
406 match err {
407 ClientError::Protocol(msg) => assert!(msg.contains("CRC mismatch"), "{msg}"),
408 other => panic!("expected a CRC-mismatch Protocol error, got {other:?}"),
409 }
410 assert!(
411 c.is_poisoned(),
412 "a CRC-mismatched response must poison the client"
413 );
414 }
415
416 #[test]
419 fn unknown_nack_reason_byte_surfaces_unknown_nack() {
420 assert!(matches!(
421 nack_error(&[0x0A]),
422 ClientError::UnknownNack(0x0A)
423 ));
424
425 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
426 let mut c = WeirClient::from_stream(client_end);
427 let server = std::thread::spawn(move || {
428 use std::io::Write;
429 drain_one_frame(&mut server_end);
430 let frame = Envelope::new(
431 Header::new(MessageType::Nack, Durability::Durable, 0),
432 vec![0x0A],
433 )
434 .encode();
435 server_end.write_all(&frame).unwrap();
436 });
437 let err = c.push(b"x", Durability::Durable).unwrap_err();
438 server.join().unwrap();
439 assert!(matches!(err, ClientError::UnknownNack(0x0A)), "got {err:?}");
440 assert!(
441 c.is_poisoned(),
442 "an unknown closing Nack must mark the client unusable"
443 );
444 }
445
446 #[test]
449 fn version_mismatch_surfaces_through_push() {
450 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
451 let mut c = WeirClient::from_stream(client_end);
452 let server = std::thread::spawn(move || {
453 use std::io::Write;
454 drain_one_frame(&mut server_end);
455 let frame = Envelope::new(
456 Header::new(MessageType::Nack, Durability::Durable, 0),
457 vec![NackReason::VersionMismatch as u8, 9],
458 )
459 .encode();
460 server_end.write_all(&frame).unwrap();
461 });
462 let err = c.push(b"x", Durability::Durable).unwrap_err();
463 server.join().unwrap();
464 assert!(
465 matches!(err, ClientError::VersionMismatch { daemon_version: 9 }),
466 "got {err:?}"
467 );
468 assert!(!err.is_recoverable());
469 assert!(c.is_poisoned());
470 }
471
472 #[test]
475 fn health_check_unexpected_frame_type_poisons() {
476 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
477 let mut c = WeirClient::from_stream(client_end);
478 let server = std::thread::spawn(move || {
479 use std::io::Write;
480 drain_one_frame(&mut server_end);
481 let frame = Envelope::new(
483 Header::new(MessageType::Ack, Durability::Durable, 0),
484 vec![],
485 )
486 .encode();
487 server_end.write_all(&frame).unwrap();
488 });
489 let err = c.health_check().unwrap_err();
490 server.join().unwrap();
491 match &err {
492 ClientError::Protocol(msg) => {
493 assert!(msg.contains("expected HealthCheckResponse"), "{msg}")
494 }
495 other => panic!("expected a Protocol desync, got {other:?}"),
496 }
497 assert!(!err.is_recoverable());
498 assert!(c.is_poisoned());
499 }
500
501 #[test]
504 fn set_read_timeout_applies_to_socket() {
505 let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
506 let c = WeirClient::from_stream(a);
507 c.set_read_timeout(Some(Duration::from_millis(250)))
508 .unwrap();
509 assert_eq!(
510 c.stream.read_timeout().unwrap(),
511 Some(Duration::from_millis(250))
512 );
513 c.set_read_timeout(None).unwrap();
515 assert_eq!(c.stream.read_timeout().unwrap(), None);
516 }
517
518 #[test]
519 fn read_timeout_bounds_a_silent_daemon_instead_of_blocking_forever() {
520 let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
523 let mut c = WeirClient::from_stream(a);
524 c.set_read_timeout(Some(Duration::from_millis(150)))
525 .unwrap();
526 let err = c.push(b"x", Durability::Durable).unwrap_err();
529 assert!(
530 matches!(err, ClientError::Io(_)),
531 "expected Io timeout, got {err:?}"
532 );
533 }
534
535 #[test]
536 fn client_is_poisoned_after_a_read_failure() {
537 let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
542 let mut c = WeirClient::from_stream(a);
543 c.set_read_timeout(Some(Duration::from_millis(100)))
544 .unwrap();
545
546 let first = c.push(b"x", Durability::Durable).unwrap_err();
548 assert!(matches!(first, ClientError::Io(_)), "{first:?}");
549
550 let started = std::time::Instant::now();
552 let second = c.push(b"y", Durability::Durable).unwrap_err();
553 assert!(
554 started.elapsed() < Duration::from_millis(50),
555 "poisoned client must reject immediately, not read again"
556 );
557 match second {
558 ClientError::Protocol(msg) => assert!(msg.contains("poisoned"), "{msg}"),
559 other => panic!("expected a poisoned Protocol error, got {other:?}"),
560 }
561 }
562
563 #[test]
566 fn is_poisoned_flips_after_a_read_failure() {
567 let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
570 let mut c = WeirClient::from_stream(a);
571 assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
572
573 c.set_read_timeout(Some(Duration::from_millis(100)))
574 .unwrap();
575 let err = c.push(b"x", Durability::Durable).unwrap_err();
577 assert!(matches!(err, ClientError::Io(_)), "{err:?}");
578 assert!(
579 c.is_poisoned(),
580 "a read failure must flip is_poisoned() to true"
581 );
582 assert!(!err.is_recoverable(), "an Io failure is not recoverable");
584 }
585
586 #[test]
587 fn is_poisoned_after_a_connection_closing_nack() {
588 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
594 let mut c = WeirClient::from_stream(client_end);
595 assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
596 let server = std::thread::spawn(move || {
597 use std::io::Write;
598 drain_one_frame(&mut server_end);
599 server_end
600 .write_all(&nack_frame(NackReason::EmptyPayload))
601 .unwrap();
602 });
604 let first = c.push(b"x", Durability::Durable).unwrap_err();
605 server.join().unwrap();
606 assert!(
607 matches!(first, ClientError::Nack(NackReason::EmptyPayload)),
608 "expected the real Nack reason, got {first:?}"
609 );
610 assert!(
612 !first.is_recoverable(),
613 "a connection-closing Nack is non-recoverable"
614 );
615 assert!(
616 c.is_poisoned(),
617 "a connection-closing Nack must flip is_poisoned() to true"
618 );
619 assert!(
621 matches!(c.ensure_usable(), Err(ClientError::Protocol(_))),
622 "ensure_usable must reject a closed-after-nack connection"
623 );
624 }
625
626 #[test]
627 fn is_poisoned_and_is_recoverable_agree_after_a_desync() {
628 let (client_end, mut server_end) = std::os::unix::net::UnixStream::pair().unwrap();
633 let mut c = WeirClient::from_stream(client_end);
634 assert!(!c.is_poisoned(), "a fresh client must not be poisoned");
635 let server = std::thread::spawn(move || {
636 use std::io::Write;
637 drain_one_frame(&mut server_end);
638 let bogus = weir_core::Envelope::new(
641 weir_core::Header::new(
642 weir_core::MessageType::HealthCheck,
643 weir_core::Durability::Durable,
644 0,
645 ),
646 vec![],
647 )
648 .encode();
649 server_end.write_all(&bogus).unwrap();
650 });
651 let err = c.push(b"x", Durability::Durable).unwrap_err();
652 server.join().unwrap();
653 assert!(
655 matches!(err, ClientError::Protocol(_)),
656 "expected a Protocol desync error, got {err:?}"
657 );
658 assert!(!err.is_recoverable(), "a stream desync is non-recoverable");
659 assert!(
661 c.is_poisoned(),
662 "a stream desync must flip is_poisoned() to true"
663 );
664 assert_eq!(
665 c.is_poisoned(),
666 !err.is_recoverable(),
667 "is_poisoned() and !is_recoverable() must agree"
668 );
669 }
670
671 #[test]
672 fn is_recoverable_true_for_local_over_cap_rejection() {
673 let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
676 let mut c = WeirClient::from_stream(client_end);
677 let oversized = vec![0u8; MAX_PAYLOAD_HARD_CAP + 1];
678 let err = c.push(&oversized, Durability::Durable).unwrap_err();
679 assert!(
680 matches!(err, ClientError::PayloadTooLarge { .. }),
681 "expected a local PayloadTooLarge, got {err:?}"
682 );
683 assert!(
684 err.is_recoverable(),
685 "a local over-cap rejection keeps the connection usable"
686 );
687 assert!(!c.is_poisoned(), "a recoverable error must not poison");
688 }
689
690 #[test]
691 fn push_rejects_empty_payload_locally_without_poisoning() {
692 let (client_end, _server_end) = std::os::unix::net::UnixStream::pair().unwrap();
697 let mut c = WeirClient::from_stream(client_end);
698 let err = c.push(b"", Durability::Durable).unwrap_err();
699 assert!(
700 matches!(err, ClientError::EmptyPayload),
701 "expected a local EmptyPayload, got {err:?}"
702 );
703 assert!(
704 err.is_recoverable(),
705 "a local empty-payload rejection keeps the connection usable"
706 );
707 assert!(!c.is_poisoned(), "a recoverable error must not poison");
708 }
709
710 #[test]
711 fn is_recoverable_true_for_no_default_durability() {
712 let err = ClientError::NoDefaultDurability;
714 assert!(err.is_recoverable());
715 }
716
717 #[test]
718 fn is_recoverable_true_for_internal_error_nack() {
719 let err = ClientError::Nack(NackReason::InternalError);
721 assert!(
722 err.is_recoverable(),
723 "a transient InternalError Nack keeps the connection usable"
724 );
725 }
726
727 #[test]
728 fn is_recoverable_false_for_connection_closing_nacks() {
729 for err in [
732 ClientError::Nack(NackReason::EmptyPayload),
733 ClientError::Nack(NackReason::PayloadTooLarge),
734 ClientError::Nack(NackReason::UnknownMessage),
735 ClientError::VersionMismatch { daemon_version: 7 },
736 ClientError::UnknownNack(0xff),
737 ] {
738 assert!(
739 !err.is_recoverable(),
740 "{err:?} closes the connection and must be non-recoverable"
741 );
742 }
743 }
744
745 #[test]
746 fn is_recoverable_false_for_io_and_protocol() {
747 let io = ClientError::Io(io::Error::new(io::ErrorKind::BrokenPipe, "pipe"));
750 assert!(!io.is_recoverable());
751 let proto = ClientError::Protocol("connection poisoned".into());
752 assert!(!proto.is_recoverable());
753 }
754}