weir_client/client.rs
1//! Transport-agnostic client core: the error type, the `WeirClient` struct, and
2//! every method that only needs a blocking `Read + Write`.
3//!
4//! This lives apart from `unix.rs` for one reason: none of it depends on Unix.
5//! While it sat inside the Unix module, `tls.rs` — which uses `TcpStream` and
6//! rustls and nothing else — had to be gated `#[cfg(all(unix, feature = "tls"))]`
7//! purely because it imports these two types. That made the mTLS client
8//! Unix-only by file layout rather than by any real constraint.
9
10use std::io::{self, Read, Write};
11
12use weir_core::{
13 Durability, Envelope, HEADER_LEN, Header, MAX_PAYLOAD_HARD_CAP, MessageType, NackReason,
14};
15
16/// The transport [`WeirClient`] uses when no type parameter is given.
17///
18/// Unix keeps the Unix-domain socket it has always defaulted to, so
19/// `WeirClient` continues to mean `WeirClient<UnixStream>` there and no
20/// existing signature changes. Elsewhere there is no such socket, so the
21/// default is the TCP stream the TLS connector wraps.
22#[cfg(unix)]
23pub type DefaultTransport = std::os::unix::net::UnixStream;
24/// The transport [`WeirClient`] uses when no type parameter is given.
25#[cfg(not(unix))]
26pub type DefaultTransport = std::net::TcpStream;
27
28/// All errors that can be returned by [`WeirClient`] methods.
29///
30/// `#[non_exhaustive]`: the client error set may grow post-1.0 (it already gained
31/// `VersionMismatch` during the 1.0 hardening), so downstream matches must carry
32/// a wildcard arm and adding a variant later is not a breaking change.
33#[derive(Debug)]
34#[non_exhaustive]
35pub enum ClientError {
36 /// An I/O error on the underlying socket.
37 Io(io::Error),
38 /// The daemon sent a Nack with a recognised reason.
39 Nack(NackReason),
40 /// The daemon rejected the frame because its wire version differs from the
41 /// client's. Carries the daemon's `WIRE_VERSION` (the second Nack-payload
42 /// byte) so the caller can report both sides and decide whether to upgrade
43 /// the daemon or downgrade the client.
44 VersionMismatch {
45 /// The wire-protocol version the daemon speaks.
46 daemon_version: u8,
47 },
48 /// The daemon sent a Nack with an unrecognised reason byte.
49 UnknownNack(u8),
50 /// The payload exceeds the protocol hard cap and was rejected locally,
51 /// before any bytes were sent. The daemon would always reject a payload this
52 /// large (and a large frame can race the daemon's Nack-and-close, surfacing as
53 /// a bare broken-pipe), so the client rejects it up front with no round-trip.
54 /// A deployment's configured `max_payload_bytes` may be lower than this cap; a
55 /// payload over that lower limit is reported by the daemon as
56 /// [`ClientError::Nack`]`(`[`NackReason::PayloadTooLarge`]`)`.
57 PayloadTooLarge {
58 /// The rejected payload's length in bytes.
59 len: usize,
60 /// The protocol hard cap ([`weir_core::MAX_PAYLOAD_HARD_CAP`]).
61 limit: usize,
62 },
63 /// The daemon's response violated the wire protocol.
64 Protocol(String),
65 /// `push_default` was called but no default durability was set.
66 NoDefaultDurability,
67 /// `push` was called with an empty payload, rejected locally before any bytes
68 /// are sent. An empty payload IS the WAB end-of-records sentinel, so the daemon
69 /// Nacks it ([`NackReason::EmptyPayload`]) and closes the connection; rejecting
70 /// up front keeps the connection usable, mirroring the local
71 /// [`PayloadTooLarge`][Self::PayloadTooLarge] reject.
72 EmptyPayload,
73}
74
75impl ClientError {
76 /// Whether the connection is still usable after this error.
77 ///
78 /// - **Recoverable (`true`)** — the connection is still usable: the error is a
79 /// *local* or *per-record* rejection that does **not** poison the client.
80 /// Retry a different payload, or continue, on the **same** client.
81 /// - **Non-recoverable (`false`)** — the connection is dead or in an
82 /// indeterminate state: the error poisoned the client (set its internal
83 /// `poisoned`/`closed_after_nack` flag). **Drop this client and reconnect**;
84 /// every subsequent call on it will fail fast (see [`WeirClient::is_poisoned`]).
85 ///
86 /// This is the stable way to branch on whether to reconnect: [`ClientError`] is
87 /// `#[non_exhaustive]`, so an exhaustive `match` on its variants will not compile
88 /// downstream, and the variant set may grow post-1.0. Calling `is_recoverable`
89 /// (or checking [`WeirClient::is_poisoned`] after a failed call) keeps working
90 /// across additions.
91 ///
92 /// The classification is kept in lock-step with the client's poison logic: a
93 /// variant is recoverable **iff** producing it does not set the client's
94 /// `poisoned` or `closed_after_nack` flag.
95 ///
96 /// | Variant | Recoverable? | Why |
97 /// |---|---|---|
98 /// | [`PayloadTooLarge`][Self::PayloadTooLarge] | yes | local pre-send rejection; no bytes sent, connection untouched |
99 /// | [`EmptyPayload`][Self::EmptyPayload] | yes | local pre-send rejection; no bytes sent, connection untouched |
100 /// | [`NoDefaultDurability`][Self::NoDefaultDurability] | yes | local misconfiguration; returned before any I/O |
101 /// | [`Nack`][Self::Nack]`(`[`InternalError`][NackReason::InternalError]`)` | yes | transient daemon condition; the daemon keeps the connection open |
102 /// | [`Nack`][Self::Nack]`(`any other reason`)` | no | the daemon closes the connection after the Nack |
103 /// | [`VersionMismatch`][Self::VersionMismatch] | no | a Nack reason; the daemon closes the connection |
104 /// | [`UnknownNack`][Self::UnknownNack] | no | a Nack reason; the daemon closes the connection |
105 /// | [`Io`][Self::Io] | no | a socket read/write failure poisons the client |
106 /// | [`Protocol`][Self::Protocol] | no | a malformed/aborted response poisons the client (also the "reconnect" guard error) |
107 ///
108 /// Note: a *server* `Nack(PayloadTooLarge)` (a payload over the daemon's
109 /// configured cap) is **not** recoverable — the daemon Nacks then closes the
110 /// connection — whereas the *local* [`PayloadTooLarge`][Self::PayloadTooLarge]
111 /// error (a payload over the protocol hard cap, rejected before any bytes are
112 /// sent) **is**. They are distinct variants despite the shared name.
113 ///
114 /// New `#[non_exhaustive]` variants default to **non-recoverable** (`false`):
115 /// "drop and reconnect" is the safe assumption for an error this build does not
116 /// yet understand.
117 #[must_use]
118 pub fn is_recoverable(&self) -> bool {
119 match self {
120 // Local, pre-I/O rejections: the connection was never touched.
121 Self::PayloadTooLarge { .. } | Self::NoDefaultDurability | Self::EmptyPayload => true,
122 // The only Nack the daemon keeps the connection open for (transient);
123 // every other Nack reason closes it (see `WeirClient::note_nack`).
124 Self::Nack(NackReason::InternalError) => true,
125 // Every other Nack reason closes the connection.
126 Self::Nack(_) => false,
127 // VersionMismatch / UnknownNack are Nack reasons too — the daemon
128 // closes the connection after them (they route through `note_nack`).
129 Self::VersionMismatch { .. } | Self::UnknownNack(_) => false,
130 // A socket failure poisons the client; a protocol violation either
131 // poisons it (bad response) or IS the "drop and reconnect" guard error.
132 Self::Io(_) | Self::Protocol(_) => false,
133 // `#[non_exhaustive]`: a future variant we don't recognise — assume the
134 // worst (reconnect) rather than risk reusing a poisoned connection.
135 // Unreachable within this crate (all current variants are matched
136 // above), but required so the match stays total if a variant is added.
137 #[allow(unreachable_patterns)]
138 _ => false,
139 }
140 }
141}
142
143impl std::fmt::Display for ClientError {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 Self::Io(e) => write!(f, "socket I/O error: {e}"),
147 Self::Nack(r) => write!(f, "server nack: {r:?}"),
148 Self::VersionMismatch { daemon_version } => write!(
149 f,
150 "wire version mismatch: daemon speaks v{daemon_version}, this client speaks v{}",
151 weir_core::WIRE_VERSION
152 ),
153 Self::UnknownNack(b) => write!(f, "server nack with unknown reason {b:#04x}"),
154 Self::PayloadTooLarge { len, limit } => write!(
155 f,
156 "payload too large: {len} bytes exceeds the {limit}-byte protocol hard cap"
157 ),
158 Self::Protocol(msg) => write!(f, "protocol error: {msg}"),
159 Self::NoDefaultDurability => {
160 write!(f, "push_default called but no default durability was set")
161 }
162 Self::EmptyPayload => write!(
163 f,
164 "payload is empty; rejected locally (an empty payload is the WAB end-of-records sentinel)"
165 ),
166 }
167 }
168}
169
170impl std::error::Error for ClientError {
171 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
172 match self {
173 Self::Io(e) => Some(e),
174 _ => None,
175 }
176 }
177}
178
179impl From<io::Error> for ClientError {
180 fn from(e: io::Error) -> Self {
181 Self::Io(e)
182 }
183}
184
185/// A synchronous blocking client for one connection to the weir daemon.
186///
187/// The type parameter `S` is the underlying transport. The default is
188/// [`DefaultTransport`] — a Unix domain socket on Unix. When the `tls` feature
189/// is enabled, `S` can also be `TlsStream` (TCP + mutual TLS).
190///
191/// The underlying stream runs in blocking mode. Each method sends one request
192/// frame and reads one response frame before returning. Requests are not
193/// pipelined; for concurrent producers, create one `WeirClient` per thread.
194///
195/// Drop the client to close the connection. The daemon treats EOF as a clean
196/// disconnect.
197pub struct WeirClient<S = DefaultTransport> {
198 // pub(crate) so the TLS connector (a sibling module) can reach the
199 // underlying TcpStream to apply socket timeouts (F43).
200 pub(crate) stream: S,
201 pub(crate) default_durability: Option<Durability>,
202 /// Set once a response read fails (a timeout mid-frame, a partial/aborted
203 /// read, or a protocol violation). The stream is then in an indeterminate
204 /// state — leftover bytes from the aborted response could be mis-read as the
205 /// NEXT request's reply, acking a record the daemon never confirmed (a false
206 /// ack). Once poisoned, every method fails fast; the caller must reconnect
207 /// (G04).
208 pub(crate) poisoned: bool,
209 /// Set once the daemon Nacked a frame for a reason that closes the connection
210 /// (every Nack except the transient `InternalError`). The connection is then
211 /// cleanly dead — the next call would otherwise just hit a broken pipe — so we
212 /// fail fast with a clear "reconnect" error instead (QA#2).
213 pub(crate) closed_after_nack: bool,
214}
215
216// Manual Debug (not derived): a `#[derive(Debug)]` would require `S: Debug`, which
217// excludes `WeirClient<TlsStream>` (rustls's `StreamOwned` is not `Debug`). Print
218// the non-secret fields and omit the transport so every client type — Unix and
219// TLS — implements `Debug` (C-DEBUG, S40).
220impl<S> std::fmt::Debug for WeirClient<S> {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 f.debug_struct("WeirClient")
223 .field("default_durability", &self.default_durability)
224 .field("poisoned", &self.poisoned)
225 .field("closed_after_nack", &self.closed_after_nack)
226 .finish_non_exhaustive()
227 }
228}
229
230// ── Shared methods over any blocking Read+Write transport ──────────────────────
231
232impl<S: Read + Write> WeirClient<S> {
233 /// Sets the default durability tier used by [`push_default`][Self::push_default].
234 pub fn set_default_durability(&mut self, durability: Durability) {
235 self.default_durability = Some(durability);
236 }
237
238 /// Pushes `payload` to the daemon with the given `durability` tier.
239 ///
240 /// Blocks until the daemon replies. On `Ack` the record is durably stored
241 /// according to the requested tier. On `Nack` returns the reason as
242 /// [`ClientError::Nack`].
243 ///
244 /// An `Ack` means the record is durably **buffered** at the requested tier —
245 /// **not** that it has reached the downstream sink yet (the daemon drains in
246 /// batches once a segment seals). See the crate-level "Ack vs. delivery" note.
247 ///
248 /// The ack is **per record, not a liveness probe for the next one.** If the
249 /// daemon dies (or the socket is severed) between two pushes, the *first*
250 /// push after the failure can still return `Ok` — its frame was written and
251 /// acked, or the breakage isn't observed until the next read/write — and only
252 /// the *following* push surfaces the broken pipe. So a single `Ok` does not
253 /// guarantee the connection is healthy for subsequent pushes. After any push
254 /// error, branch on [`ClientError::is_recoverable`] /
255 /// [`is_poisoned`][Self::is_poisoned] to decide whether to keep using this
256 /// client or drop and reconnect, rather than assuming a prior `Ok` proved the
257 /// connection good.
258 #[must_use = "a dropped push result hides whether the record was acked; an \
259 unhandled Nack also closes the connection"]
260 pub fn push(
261 &mut self,
262 payload: impl AsRef<[u8]>,
263 durability: Durability,
264 ) -> Result<(), ClientError> {
265 self.ensure_usable()?;
266 let bytes = payload.as_ref();
267 // Local guard against an empty payload. An empty payload IS the WAB
268 // end-of-records sentinel, so the daemon Nacks it (NackReason::EmptyPayload)
269 // and closes the connection — which would poison this client. Reject it up
270 // front, before any bytes are sent, so the connection stays usable
271 // (recoverable), mirroring the hard-cap guard below.
272 if bytes.is_empty() {
273 return Err(ClientError::EmptyPayload);
274 }
275 // Local guard against the protocol hard cap. The daemon always rejects a
276 // payload this large, and for a big frame it Nacks then closes the
277 // connection before we finish streaming — so the Nack races our write and
278 // would surface as a bare broken-pipe. Reject up front, no round-trip, with
279 // a clear typed error. (A deployment's configured cap may be lower than the
280 // hard cap; a payload over that lower limit still goes to the daemon and is
281 // handled by the write-error recovery below.)
282 if bytes.len() > MAX_PAYLOAD_HARD_CAP {
283 return Err(ClientError::PayloadTooLarge {
284 len: bytes.len(),
285 limit: MAX_PAYLOAD_HARD_CAP,
286 });
287 }
288 // copy_from_slice at the API boundary so downstream handling is zero-copy.
289 let payload = weir_core::Payload::copy_from_slice(bytes);
290 let header = Header::new(MessageType::Push, durability, 0);
291 let frame = Envelope::new(header, payload).encode();
292
293 if let Err(write_err) = self.stream.write_all(&frame) {
294 // The write failed partway. The daemon may have Nacked (e.g. a payload
295 // over its configured cap) and closed the connection before we finished
296 // streaming — its Nack can already be sitting in our receive buffer.
297 // Read it so the caller sees the real reason instead of a broken-pipe.
298 // Either way the connection is now dead.
299 if let Ok(resp) = self.read_response()
300 && resp.header().message_type() == MessageType::Nack
301 {
302 self.closed_after_nack = true;
303 return Err(nack_error(resp.payload()));
304 }
305 self.poisoned = true;
306 return Err(write_err.into());
307 }
308
309 let resp = self.read_response()?;
310 match resp.header().message_type() {
311 MessageType::Ack => Ok(()),
312 // A Nack may carry an empty payload, which `nack_error` reports as a
313 // `Protocol` desync; poison in that case (see `surface_nack`).
314 MessageType::Nack => Err(self.surface_nack(resp.payload())),
315 // Any other frame type is a stream desync: the daemon sent something we
316 // never expect here, so leftover/unexpected bytes could be mis-read as a
317 // later reply (a false ack). Poison the connection (Protocol → not
318 // recoverable, matching `ensure_usable`).
319 other => {
320 self.poisoned = true;
321 Err(ClientError::Protocol(format!(
322 "expected Ack or Nack, got {other:?}"
323 )))
324 }
325 }
326 }
327
328 /// Records that a Nack closed the connection (every reason except the
329 /// transient `InternalError`, which the daemon keeps open) so the next call
330 /// fails fast with a clear reconnect error rather than a broken pipe (QA#2).
331 /// Returns the error unchanged for convenient `Err(self.note_nack(e))` use.
332 fn note_nack(&mut self, err: ClientError) -> ClientError {
333 if !matches!(err, ClientError::Nack(NackReason::InternalError)) {
334 self.closed_after_nack = true;
335 }
336 err
337 }
338
339 /// Decodes a Nack frame's payload and records its connection effect.
340 ///
341 /// `nack_error` maps a well-formed Nack to a `Nack`/`VersionMismatch`/`UnknownNack`
342 /// error (routed through [`note_nack`][Self::note_nack] to mark a closing Nack),
343 /// but an **empty** Nack payload is a stream desync that it reports as
344 /// [`ClientError::Protocol`]. A desync means leftover/unexpected bytes could be
345 /// mis-read as a later reply (a false ack), so we poison the connection in that
346 /// case — keeping `Protocol` non-recoverable and `is_poisoned()` in agreement.
347 fn surface_nack(&mut self, payload: &[u8]) -> ClientError {
348 match nack_error(payload) {
349 ClientError::Protocol(msg) => {
350 self.poisoned = true;
351 ClientError::Protocol(msg)
352 }
353 other => self.note_nack(other),
354 }
355 }
356
357 /// Pushes at the connection's default durability tier.
358 ///
359 /// Returns [`ClientError::NoDefaultDurability`] if no default was set via
360 /// [`set_default_durability`][Self::set_default_durability] (or via a
361 /// `connect_with_default` / `connect_tls` constructor that accepted a
362 /// `default_durability`).
363 pub fn push_default(&mut self, payload: impl AsRef<[u8]>) -> Result<(), ClientError> {
364 let d = self
365 .default_durability
366 .ok_or(ClientError::NoDefaultDurability)?;
367 self.push(payload, d)
368 }
369
370 /// Sends a `HealthCheck` frame and returns `Ok(())` on a valid
371 /// `HealthCheckResponse`. Returns an error if the daemon is unreachable
372 /// or responds with a Nack.
373 pub fn health_check(&mut self) -> Result<(), ClientError> {
374 self.ensure_usable()?;
375 let header = Header::new(MessageType::HealthCheck, Durability::Durable, 0);
376 let frame = Envelope::new(header, vec![]).encode();
377 self.stream.write_all(&frame)?;
378
379 let resp = self.read_response()?;
380 match resp.header().message_type() {
381 MessageType::HealthCheckResponse => Ok(()),
382 // An empty Nack payload is a desync `nack_error` reports as `Protocol`;
383 // poison in that case (see `surface_nack`).
384 MessageType::Nack => Err(self.surface_nack(resp.payload())),
385 // Any other frame type is a stream desync: poison the connection so a
386 // later call can't mis-read a stale frame as its reply (Protocol → not
387 // recoverable, matching `ensure_usable`).
388 other => {
389 self.poisoned = true;
390 Err(ClientError::Protocol(format!(
391 "expected HealthCheckResponse, got {other:?}"
392 )))
393 }
394 }
395 }
396
397 /// Whether this client's connection is dead and must be rebuilt.
398 ///
399 /// Reports the union of the two ways a connection becomes unusable, so it is the
400 /// exact complement of [`is_recoverable`][ClientError::is_recoverable] for the
401 /// error that caused it:
402 ///
403 /// - an **indeterminate stream** (the internal `poisoned` flag) — a
404 /// response-read failure/timeout, a protocol violation in the reply, or any
405 /// stream desync (an unexpected daemon message type, an empty-payload Nack);
406 /// leftover/unexpected bytes could be mis-read as a later reply (a false ack);
407 /// - a **connection-closing Nack** (the internal `closed_after_nack` flag) —
408 /// every Nack reason except the transient
409 /// [`InternalError`][NackReason::InternalError], after which the daemon closes
410 /// the connection.
411 ///
412 /// In either state **every** method fails fast (it will not touch the socket
413 /// again); the caller must drop this client and open a fresh connection.
414 ///
415 /// A long-lived producer thread (e.g. one driven from an async runtime) should
416 /// check this after a failed call — or, equivalently, branch on
417 /// [`ClientError::is_recoverable`] — and rebuild the connection when it reports
418 /// poisoned / non-recoverable, rather than retrying on the dead client:
419 ///
420 /// ```no_run
421 /// # use weir_client::{WeirClient, ClientError};
422 /// # use weir_core::Durability;
423 /// # fn reconnect() -> WeirClient { unreachable!() }
424 /// # let mut client = reconnect();
425 /// if let Err(e) = client.push(b"record", Durability::Durable) {
426 /// if !e.is_recoverable() || client.is_poisoned() {
427 /// client = reconnect(); // drop the dead client, rebuild
428 /// }
429 /// }
430 /// ```
431 ///
432 /// See [`ClientError::is_recoverable`] for the per-error view of the same
433 /// distinction (recoverable = connection still usable; non-recoverable = drop
434 /// and reconnect). The two are kept equivalent: `is_poisoned()` is true after a
435 /// failed call exactly when that call's error is non-recoverable.
436 #[must_use]
437 pub fn is_poisoned(&self) -> bool {
438 self.poisoned || self.closed_after_nack
439 }
440
441 /// Returns the poisoned-connection error if a prior response read failed.
442 /// Once poisoned the stream may hold leftover bytes from an aborted response
443 /// that would be mis-read as this call's reply — a false ack — so we refuse
444 /// to use it (G04). The caller must drop the client and reconnect.
445 pub(crate) fn ensure_usable(&self) -> Result<(), ClientError> {
446 if self.closed_after_nack {
447 return Err(ClientError::Protocol(
448 "connection closed by the daemon after a Nack; \
449 drop this client and reconnect before sending again"
450 .into(),
451 ));
452 }
453 if self.poisoned {
454 return Err(ClientError::Protocol(
455 "client connection poisoned by a prior response-read error/timeout; \
456 drop this client and reconnect before sending again"
457 .into(),
458 ));
459 }
460 Ok(())
461 }
462
463 fn read_response(&mut self) -> Result<Envelope, ClientError> {
464 // Any failure here leaves the stream in an indeterminate state (a timeout
465 // mid-frame, a partial/aborted read, or a protocol violation): the unread
466 // tail of this response could be mis-read as the NEXT request's reply.
467 // Poison the client so every subsequent call fails fast (G04).
468 let result = self.read_response_inner();
469 if result.is_err() {
470 self.poisoned = true;
471 }
472 result
473 }
474
475 fn read_response_inner(&mut self) -> Result<Envelope, ClientError> {
476 let mut header_buf = [0u8; HEADER_LEN];
477 self.stream.read_exact(&mut header_buf)?;
478
479 let header =
480 Header::decode(&header_buf).map_err(|e| ClientError::Protocol(e.to_string()))?;
481
482 let payload_len = header.payload_len() as usize;
483 // Cap before allocating, mirroring the server's pre-allocation guard
484 // (Envelope::decode). A malformed or hostile daemon could otherwise
485 // declare payload_len up to u32::MAX and make the client allocate ~4 GiB
486 // for a response (F44). Daemon responses are tiny, so this never trips
487 // legitimately.
488 if payload_len > MAX_PAYLOAD_HARD_CAP {
489 return Err(ClientError::Protocol(format!(
490 "response payload_len {payload_len} exceeds MAX_PAYLOAD_HARD_CAP \
491 ({MAX_PAYLOAD_HARD_CAP}); refusing to allocate"
492 )));
493 }
494 let mut payload_buf = vec![0u8; payload_len];
495 if payload_len > 0 {
496 self.stream.read_exact(&mut payload_buf)?;
497 }
498
499 let mut crc_buf = [0u8; 4];
500 self.stream.read_exact(&mut crc_buf)?;
501 let expected = u32::from_le_bytes(crc_buf);
502 let computed = crc32fast::hash(&payload_buf);
503 if expected != computed {
504 return Err(ClientError::Protocol(format!(
505 "response payload CRC mismatch: expected {expected:#010x}, computed {computed:#010x}"
506 )));
507 }
508
509 Ok(Envelope::new(header, payload_buf))
510 }
511
512 /// Crate-internal constructor so `tls.rs` can build the struct without
513 /// going through a Unix-socket constructor.
514 #[cfg_attr(not(feature = "tls"), allow(dead_code))]
515 pub(crate) fn from_parts(stream: S, default_durability: Option<Durability>) -> Self {
516 Self {
517 stream,
518 default_durability,
519 poisoned: false,
520 closed_after_nack: false,
521 }
522 }
523}
524
525pub(crate) fn nack_error(payload: &[u8]) -> ClientError {
526 match payload.first().copied() {
527 Some(b) => match NackReason::try_from(b) {
528 // A VersionMismatch Nack carries the daemon's WIRE_VERSION as a
529 // second payload byte (`[0x02, daemon_version]`). Surface it so the
530 // caller can report both sides; fall back to the bare reason if the
531 // daemon (somehow) omitted it.
532 Ok(NackReason::VersionMismatch) => match payload.get(1).copied() {
533 Some(daemon_version) => ClientError::VersionMismatch { daemon_version },
534 None => ClientError::Nack(NackReason::VersionMismatch),
535 },
536 Ok(r) => ClientError::Nack(r),
537 Err(_) => ClientError::UnknownNack(b),
538 },
539 None => ClientError::Protocol("Nack frame had empty payload".into()),
540 }
541}