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_ACK_BATCH_PAYLOAD_LEN,
14 MAX_BATCH_RECORDS_HARD_CAP, MAX_PAYLOAD_HARD_CAP, MAX_TRACKED_ACK_PAYLOAD_LEN, MessageType,
15 NackReason, RecordCoordinate, decode_ack_batch, encode_batch_body,
16};
17
18/// Per-record outcomes for a [`push_batch`](WeirClient::push_batch).
19///
20/// `accepted[i]` is `true` when record `i` is durable at the requested tier.
21///
22/// **A cleared bit does not mean the record was never written.** It means *not
23/// durable as of this reply* — retry it, and expect it may nonetheless have been
24/// committed, because weir is at-least-once. A set bit inherits the crown
25/// invariant and is the strong statement; a cleared bit is the weak one.
26#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct BatchOutcome {
29 /// One entry per record, in the order they were sent.
30 pub accepted: Vec<bool>,
31}
32
33impl BatchOutcome {
34 /// Whether every record in the batch is durable.
35 #[must_use]
36 pub fn all_accepted(&self) -> bool {
37 self.accepted.iter().all(|&ok| ok)
38 }
39
40 /// Indices of the records that are not durable, in order — the set to retry.
41 #[must_use]
42 pub fn rejected_indices(&self) -> Vec<usize> {
43 self.accepted
44 .iter()
45 .enumerate()
46 .filter_map(|(i, &ok)| (!ok).then_some(i))
47 .collect()
48 }
49}
50
51/// The largest payload the *v1 response set* carries: `Ack` and
52/// `HealthCheckResponse` are empty, `Nack` is one reason byte, and
53/// `VersionMismatch` adds the daemon's wire version — 2 bytes.
54///
55/// A larger declared length on one of those responses is a desync or a non-weir
56/// peer. A future wire version that grew this would arrive with a version bump
57/// the client already rejects before reaching here.
58const MAX_RESPONSE_PAYLOAD_LEN: usize = 2;
59
60/// The pre-allocation cap for a response, chosen by the type the header
61/// declares.
62///
63/// The cap is per message type rather than one flat number because
64/// `AckTracked` — the only response that carries more than two bytes — is only
65/// ever sent to a client that asked for it with a `PushTracked`. A client that
66/// never sends one keeps the old 2-byte bound on every response it can receive,
67/// which is exactly why adding the coordinate did not weaken anybody's
68/// allocation guard.
69fn max_response_payload_len(message_type: MessageType) -> usize {
70 match message_type {
71 MessageType::AckTracked => MAX_TRACKED_ACK_PAYLOAD_LEN,
72 // The bitmap is bounded by MAX_BATCH_RECORDS_HARD_CAP, chosen so this
73 // stays under MAX_TRACKED_ACK_PAYLOAD_LEN — weir's largest response
74 // payload does not grow, so no allocation guard anywhere is weakened.
75 MessageType::AckBatch => MAX_ACK_BATCH_PAYLOAD_LEN,
76 _ => MAX_RESPONSE_PAYLOAD_LEN,
77 }
78}
79
80/// The transport [`WeirClient`] uses when no type parameter is given.
81///
82/// Unix keeps the Unix-domain socket it has always defaulted to, so
83/// `WeirClient` continues to mean `WeirClient<UnixStream>` there and no
84/// existing signature changes. Elsewhere there is no such socket, so the
85/// default is the TCP stream the TLS connector wraps.
86#[cfg(unix)]
87pub type DefaultTransport = std::os::unix::net::UnixStream;
88/// The transport [`WeirClient`] uses when no type parameter is given.
89#[cfg(not(unix))]
90pub type DefaultTransport = std::net::TcpStream;
91
92/// All errors that can be returned by [`WeirClient`] methods.
93///
94/// `#[non_exhaustive]`: the client error set may grow post-1.0 (it already gained
95/// `VersionMismatch` during the 1.0 hardening), so downstream matches must carry
96/// a wildcard arm and adding a variant later is not a breaking change.
97#[derive(Debug)]
98#[non_exhaustive]
99pub enum ClientError {
100 /// An I/O error on the underlying socket.
101 Io(io::Error),
102 /// The daemon sent a Nack with a recognised reason.
103 Nack(NackReason),
104 /// The daemon rejected the frame because its wire version differs from the
105 /// client's. Carries the daemon's `WIRE_VERSION` (the second Nack-payload
106 /// byte) so the caller can report both sides and decide whether to upgrade
107 /// the daemon or downgrade the client.
108 VersionMismatch {
109 /// The wire-protocol version the daemon speaks.
110 daemon_version: u8,
111 },
112 /// The daemon sent a Nack with an unrecognised reason byte.
113 UnknownNack(u8),
114 /// The payload exceeds the protocol hard cap and was rejected locally,
115 /// before any bytes were sent. The daemon would always reject a payload this
116 /// large (and a large frame can race the daemon's Nack-and-close, surfacing as
117 /// a bare broken-pipe), so the client rejects it up front with no round-trip.
118 /// A deployment's configured `max_payload_bytes` may be lower than this cap; a
119 /// payload over that lower limit is reported by the daemon as
120 /// [`ClientError::Nack`]`(`[`NackReason::PayloadTooLarge`]`)`.
121 PayloadTooLarge {
122 /// The rejected payload's length in bytes.
123 len: usize,
124 /// The protocol hard cap ([`weir_core::MAX_PAYLOAD_HARD_CAP`]).
125 limit: usize,
126 },
127 /// The daemon's response violated the wire protocol.
128 Protocol(String),
129 /// `push_default` was called but no default durability was set.
130 NoDefaultDurability,
131 /// `push` was called with an empty payload, rejected locally before any bytes
132 /// are sent. An empty payload IS the WAB end-of-records sentinel, so the daemon
133 /// Nacks it ([`NackReason::EmptyPayload`]) and closes the connection; rejecting
134 /// up front keeps the connection usable, mirroring the local
135 /// [`PayloadTooLarge`][Self::PayloadTooLarge] reject.
136 EmptyPayload,
137}
138
139impl ClientError {
140 /// Whether the connection is still usable after this error.
141 ///
142 /// - **Recoverable (`true`)** — the connection is still usable: the error is a
143 /// *local* or *per-record* rejection that does **not** poison the client.
144 /// Retry a different payload, or continue, on the **same** client.
145 /// - **Non-recoverable (`false`)** — the connection is dead or in an
146 /// indeterminate state: the error poisoned the client (set its internal
147 /// `poisoned`/`closed_after_nack` flag). **Drop this client and reconnect**;
148 /// every subsequent call on it will fail fast (see [`WeirClient::is_poisoned`]).
149 ///
150 /// This is the stable way to branch on whether to reconnect: [`ClientError`] is
151 /// `#[non_exhaustive]`, so an exhaustive `match` on its variants will not compile
152 /// downstream, and the variant set may grow post-1.0. Calling `is_recoverable`
153 /// (or checking [`WeirClient::is_poisoned`] after a failed call) keeps working
154 /// across additions.
155 ///
156 /// The classification is kept in lock-step with the client's poison logic: a
157 /// variant is recoverable **iff** producing it does not set the client's
158 /// `poisoned` or `closed_after_nack` flag.
159 ///
160 /// | Variant | Recoverable? | Why |
161 /// |---|---|---|
162 /// | [`PayloadTooLarge`][Self::PayloadTooLarge] | yes | local pre-send rejection; no bytes sent, connection untouched |
163 /// | [`EmptyPayload`][Self::EmptyPayload] | yes | local pre-send rejection; no bytes sent, connection untouched |
164 /// | [`NoDefaultDurability`][Self::NoDefaultDurability] | yes | local misconfiguration; returned before any I/O |
165 /// | [`Nack`][Self::Nack]`(`[`InternalError`][NackReason::InternalError]`)` | yes | transient daemon condition; the daemon keeps the connection open |
166 /// | [`Nack`][Self::Nack]`(`any other reason`)` | no | the daemon closes the connection after the Nack |
167 /// | [`VersionMismatch`][Self::VersionMismatch] | no | a Nack reason; the daemon closes the connection |
168 /// | [`UnknownNack`][Self::UnknownNack] | no | a Nack reason; the daemon closes the connection |
169 /// | [`Io`][Self::Io] | no | a socket read/write failure poisons the client |
170 /// | [`Protocol`][Self::Protocol] | no | a malformed/aborted response poisons the client (also the "reconnect" guard error) |
171 ///
172 /// Note: a *server* `Nack(PayloadTooLarge)` (a payload over the daemon's
173 /// configured cap) is **not** recoverable — the daemon Nacks then closes the
174 /// connection — whereas the *local* [`PayloadTooLarge`][Self::PayloadTooLarge]
175 /// error (a payload over the protocol hard cap, rejected before any bytes are
176 /// sent) **is**. They are distinct variants despite the shared name.
177 ///
178 /// New `#[non_exhaustive]` variants default to **non-recoverable** (`false`):
179 /// "drop and reconnect" is the safe assumption for an error this build does not
180 /// yet understand.
181 #[must_use]
182 pub fn is_recoverable(&self) -> bool {
183 match self {
184 // Local, pre-I/O rejections: the connection was never touched.
185 Self::PayloadTooLarge { .. } | Self::NoDefaultDurability | Self::EmptyPayload => true,
186 // The only Nack the daemon keeps the connection open for (transient);
187 // every other Nack reason closes it (see `WeirClient::note_nack`).
188 Self::Nack(NackReason::InternalError) => true,
189 // Every other Nack reason closes the connection.
190 Self::Nack(_) => false,
191 // VersionMismatch / UnknownNack are Nack reasons too — the daemon
192 // closes the connection after them (they route through `note_nack`).
193 Self::VersionMismatch { .. } | Self::UnknownNack(_) => false,
194 // A socket failure poisons the client; a protocol violation either
195 // poisons it (bad response) or IS the "drop and reconnect" guard error.
196 Self::Io(_) | Self::Protocol(_) => false,
197 // `#[non_exhaustive]`: a future variant we don't recognise — assume the
198 // worst (reconnect) rather than risk reusing a poisoned connection.
199 // Unreachable within this crate (all current variants are matched
200 // above), but required so the match stays total if a variant is added.
201 #[allow(unreachable_patterns)]
202 _ => false,
203 }
204 }
205}
206
207impl std::fmt::Display for ClientError {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 match self {
210 Self::Io(e) => write!(f, "socket I/O error: {e}"),
211 Self::Nack(r) => write!(f, "server nack: {r:?}"),
212 Self::VersionMismatch { daemon_version } => write!(
213 f,
214 "wire version mismatch: daemon speaks v{daemon_version}, this client speaks v{}",
215 weir_core::WIRE_VERSION
216 ),
217 Self::UnknownNack(b) => write!(f, "server nack with unknown reason {b:#04x}"),
218 Self::PayloadTooLarge { len, limit } => write!(
219 f,
220 "payload too large: {len} bytes exceeds the {limit}-byte protocol hard cap"
221 ),
222 Self::Protocol(msg) => write!(f, "protocol error: {msg}"),
223 Self::NoDefaultDurability => {
224 write!(f, "push_default called but no default durability was set")
225 }
226 Self::EmptyPayload => write!(
227 f,
228 "payload is empty; rejected locally (an empty payload is the WAB end-of-records sentinel)"
229 ),
230 }
231 }
232}
233
234impl std::error::Error for ClientError {
235 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
236 match self {
237 Self::Io(e) => Some(e),
238 _ => None,
239 }
240 }
241}
242
243impl From<io::Error> for ClientError {
244 fn from(e: io::Error) -> Self {
245 Self::Io(e)
246 }
247}
248
249/// A synchronous blocking client for one connection to the weir daemon.
250///
251/// The type parameter `S` is the underlying transport. The default is
252/// [`DefaultTransport`] — a Unix domain socket on Unix. When the `tls` feature
253/// is enabled, `S` can also be `TlsStream` (TCP + mutual TLS).
254///
255/// The underlying stream runs in blocking mode. Each method sends one request
256/// frame and reads one response frame before returning. Requests are not
257/// pipelined; for concurrent producers, create one `WeirClient` per thread.
258///
259/// Drop the client to close the connection. The daemon treats EOF as a clean
260/// disconnect.
261pub struct WeirClient<S = DefaultTransport> {
262 // pub(crate) so the TLS connector (a sibling module) can reach the
263 // underlying TcpStream to apply socket timeouts (F43).
264 pub(crate) stream: S,
265 pub(crate) default_durability: Option<Durability>,
266 /// Set once a response read fails (a timeout mid-frame, a partial/aborted
267 /// read, or a protocol violation). The stream is then in an indeterminate
268 /// state — leftover bytes from the aborted response could be mis-read as the
269 /// NEXT request's reply, acking a record the daemon never confirmed (a false
270 /// ack). Once poisoned, every method fails fast; the caller must reconnect
271 /// (G04).
272 pub(crate) poisoned: bool,
273 /// Set once the daemon Nacked a frame for a reason that closes the connection
274 /// (every Nack except the transient `InternalError`). The connection is then
275 /// cleanly dead — the next call would otherwise just hit a broken pipe — so we
276 /// fail fast with a clear "reconnect" error instead (QA#2).
277 pub(crate) closed_after_nack: bool,
278}
279
280// Manual Debug (not derived): a `#[derive(Debug)]` would require `S: Debug`, which
281// excludes `WeirClient<TlsStream>` (rustls's `StreamOwned` is not `Debug`). Print
282// the non-secret fields and omit the transport so every client type — Unix and
283// TLS — implements `Debug` (C-DEBUG, S40).
284impl<S> std::fmt::Debug for WeirClient<S> {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.debug_struct("WeirClient")
287 .field("default_durability", &self.default_durability)
288 .field("poisoned", &self.poisoned)
289 .field("closed_after_nack", &self.closed_after_nack)
290 .finish_non_exhaustive()
291 }
292}
293
294// ── Shared methods over any blocking Read+Write transport ──────────────────────
295
296impl<S: Read + Write> WeirClient<S> {
297 /// Sets the default durability tier used by [`push_default`][Self::push_default].
298 pub fn set_default_durability(&mut self, durability: Durability) {
299 self.default_durability = Some(durability);
300 }
301
302 /// Pushes `payload` to the daemon with the given `durability` tier.
303 ///
304 /// Blocks until the daemon replies. On `Ack` the record is durably stored
305 /// according to the requested tier. On `Nack` returns the reason as
306 /// [`ClientError::Nack`].
307 ///
308 /// An `Ack` means the record is durably **buffered** at the requested tier —
309 /// **not** that it has reached the downstream sink yet (the daemon drains in
310 /// batches once a segment seals). See the crate-level "Ack vs. delivery" note.
311 ///
312 /// The ack is **per record, not a liveness probe for the next one.** If the
313 /// daemon dies (or the socket is severed) between two pushes, the *first*
314 /// push after the failure can still return `Ok` — its frame was written and
315 /// acked, or the breakage isn't observed until the next read/write — and only
316 /// the *following* push surfaces the broken pipe. So a single `Ok` does not
317 /// guarantee the connection is healthy for subsequent pushes. After any push
318 /// error, branch on [`ClientError::is_recoverable`] /
319 /// [`is_poisoned`][Self::is_poisoned] to decide whether to keep using this
320 /// client or drop and reconnect, rather than assuming a prior `Ok` proved the
321 /// connection good.
322 #[must_use = "a dropped push result hides whether the record was acked; an \
323 unhandled Nack also closes the connection"]
324 pub fn push(
325 &mut self,
326 payload: impl AsRef<[u8]>,
327 durability: Durability,
328 ) -> Result<(), ClientError> {
329 let resp = self.send_record(MessageType::Push, payload, durability)?;
330 match resp.header().message_type() {
331 MessageType::Ack => Ok(()),
332 // A Nack may carry an empty payload, which `nack_error` reports as a
333 // `Protocol` desync; poison in that case (see `surface_nack`).
334 MessageType::Nack => Err(self.surface_nack(resp.payload())),
335 // Any other frame type is a stream desync: the daemon sent something we
336 // never expect here, so leftover/unexpected bytes could be mis-read as a
337 // later reply (a false ack). Poison the connection (Protocol → not
338 // recoverable, matching `ensure_usable`).
339 other => {
340 self.poisoned = true;
341 Err(ClientError::Protocol(format!(
342 "expected Ack or Nack, got {other:?}"
343 )))
344 }
345 }
346 }
347
348 /// Pushes `payload` and returns the [`RecordCoordinate`] the daemon assigned
349 /// it — where the record landed in the write-ahead buffer, and the id the
350 /// downstream sink will see for it.
351 ///
352 /// Identical to [`push`][Self::push] in every other respect: same durability
353 /// tiers, same local guards, same Nack reasons, same connection effects. It
354 /// sends `PushTracked` instead of `Push` and reads back an `AckTracked`.
355 ///
356 /// # What the coordinate is for
357 ///
358 /// The [`record_id`][RecordCoordinate::record_id] is byte-identical to the
359 /// per-record idempotency key the drain hands the sink (the HTTP sink's
360 /// `Idempotency-Key: sha256:<hex>` header, the S3 sink's object name), so a
361 /// producer can reconcile what it sent against what the downstream received.
362 /// That is the question `push` alone cannot answer.
363 ///
364 /// # What it is not
365 ///
366 /// - **Not a delivery receipt.** Like `push`, an ack means the record is
367 /// durably buffered at the requested tier — not that it has reached the
368 /// sink. The coordinate says where it will be read from, not that it has
369 /// been read.
370 /// - **Not a gap-free sequence.** The coordinate is a buffer address. Other
371 /// producers' records interleave, so one producer's indices have holes.
372 /// - **Not a durability upgrade.** A `Buffered` push gets a coordinate too,
373 /// and can still be lost on power loss; the tier still decides that.
374 ///
375 /// # Talking to a daemon that predates this
376 ///
377 /// A daemon built before `PushTracked` existed rejects the frame with
378 /// [`NackReason::UnknownMessage`] and closes the connection — the protocol's
379 /// existing permanent-error path for version skew. So
380 /// `Err(ClientError::Nack(NackReason::UnknownMessage))` from this method
381 /// means "this daemon does not support tracked pushes"; fall back to
382 /// [`push`][Self::push] on a fresh connection.
383 #[must_use = "a dropped push result hides whether the record was acked; an \
384 unhandled Nack also closes the connection"]
385 pub fn push_tracked(
386 &mut self,
387 payload: impl AsRef<[u8]>,
388 durability: Durability,
389 ) -> Result<RecordCoordinate, ClientError> {
390 let resp = self.send_record(MessageType::PushTracked, payload, durability)?;
391 match resp.header().message_type() {
392 MessageType::AckTracked => {
393 RecordCoordinate::decode(resp.payload()).map_err(|e| {
394 // The frame was well-formed and fully consumed, so the stream
395 // is still in sync — but a daemon whose coordinate layout this
396 // build cannot read is a version skew that every subsequent
397 // tracked push would hit again. Poison so the caller
398 // reconnects (and so `is_poisoned` stays the exact complement
399 // of `is_recoverable`, which the crate promises).
400 self.poisoned = true;
401 ClientError::Protocol(format!("malformed record coordinate: {e}"))
402 })
403 }
404 MessageType::Nack => Err(self.surface_nack(resp.payload())),
405 // A bare Ack here is NOT success: the daemon accepted the record but
406 // did not answer the question that was asked, and reporting Ok would
407 // hand the caller a coordinate it never received. Treat it as the
408 // protocol violation it is.
409 other => {
410 self.poisoned = true;
411 Err(ClientError::Protocol(format!(
412 "expected AckTracked or Nack, got {other:?}"
413 )))
414 }
415 }
416 }
417
418 /// The shared send half of [`push`][Self::push] and
419 /// [`push_tracked`][Self::push_tracked]: the local guards, the frame write,
420 /// and the response read. Returns the response frame for the caller to
421 /// interpret, because that is the only part the two differ on.
422 fn send_record(
423 &mut self,
424 message_type: MessageType,
425 payload: impl AsRef<[u8]>,
426 durability: Durability,
427 ) -> Result<Envelope, ClientError> {
428 self.ensure_usable()?;
429 let bytes = payload.as_ref();
430 // Local guard against an empty payload. An empty payload IS the WAB
431 // end-of-records sentinel, so the daemon Nacks it (NackReason::EmptyPayload)
432 // and closes the connection — which would poison this client. Reject it up
433 // front, before any bytes are sent, so the connection stays usable
434 // (recoverable), mirroring the hard-cap guard below.
435 if bytes.is_empty() {
436 return Err(ClientError::EmptyPayload);
437 }
438 // Local guard against the protocol hard cap. The daemon always rejects a
439 // payload this large, and for a big frame it Nacks then closes the
440 // connection before we finish streaming — so the Nack races our write and
441 // would surface as a bare broken-pipe. Reject up front, no round-trip, with
442 // a clear typed error. (A deployment's configured cap may be lower than the
443 // hard cap; a payload over that lower limit still goes to the daemon and is
444 // handled by the write-error recovery below.)
445 if bytes.len() > MAX_PAYLOAD_HARD_CAP {
446 return Err(ClientError::PayloadTooLarge {
447 len: bytes.len(),
448 limit: MAX_PAYLOAD_HARD_CAP,
449 });
450 }
451 // copy_from_slice at the API boundary so downstream handling is zero-copy.
452 let payload = weir_core::Payload::copy_from_slice(bytes);
453 let header = Header::new(message_type, durability, 0);
454 let frame = Envelope::new(header, payload).encode();
455
456 self.send_frame(&frame)
457 }
458
459 /// Writes one frame and reads its reply, with the recovery every caller
460 /// needs: a daemon that Nacks and closes mid-write leaves its reason in our
461 /// receive buffer, and surfacing that beats a bare broken-pipe.
462 fn send_frame(&mut self, frame: &[u8]) -> Result<Envelope, ClientError> {
463 if let Err(write_err) = self.stream.write_all(frame) {
464 if let Ok(resp) = self.read_response()
465 && resp.header().message_type() == MessageType::Nack
466 {
467 self.closed_after_nack = true;
468 return Err(nack_error(resp.payload()));
469 }
470 self.poisoned = true;
471 return Err(write_err.into());
472 }
473
474 self.read_response()
475 }
476
477 /// Records that a Nack closed the connection (every reason except the
478 /// transient `InternalError`, which the daemon keeps open) so the next call
479 /// fails fast with a clear reconnect error rather than a broken pipe (QA#2).
480 /// Returns the error unchanged for convenient `Err(self.note_nack(e))` use.
481 fn note_nack(&mut self, err: ClientError) -> ClientError {
482 if !matches!(err, ClientError::Nack(NackReason::InternalError)) {
483 self.closed_after_nack = true;
484 }
485 err
486 }
487
488 /// Decodes a Nack frame's payload and records its connection effect.
489 ///
490 /// `nack_error` maps a well-formed Nack to a `Nack`/`VersionMismatch`/`UnknownNack`
491 /// error (routed through [`note_nack`][Self::note_nack] to mark a closing Nack),
492 /// but an **empty** Nack payload is a stream desync that it reports as
493 /// [`ClientError::Protocol`]. A desync means leftover/unexpected bytes could be
494 /// mis-read as a later reply (a false ack), so we poison the connection in that
495 /// case — keeping `Protocol` non-recoverable and `is_poisoned()` in agreement.
496 fn surface_nack(&mut self, payload: &[u8]) -> ClientError {
497 match nack_error(payload) {
498 ClientError::Protocol(msg) => {
499 self.poisoned = true;
500 ClientError::Protocol(msg)
501 }
502 other => self.note_nack(other),
503 }
504 }
505
506 /// Pushes at the connection's default durability tier.
507 ///
508 /// Returns [`ClientError::NoDefaultDurability`] if no default was set via
509 /// [`set_default_durability`][Self::set_default_durability] (or via a
510 /// `connect_with_default` / `connect_tls` constructor that accepted a
511 /// `default_durability`).
512 pub fn push_default(&mut self, payload: impl AsRef<[u8]>) -> Result<(), ClientError> {
513 let d = self
514 .default_durability
515 .ok_or(ClientError::NoDefaultDurability)?;
516 self.push(payload, d)
517 }
518
519 /// [`push_tracked`][Self::push_tracked] at the connection's default
520 /// durability tier.
521 ///
522 /// Returns [`ClientError::NoDefaultDurability`] if no default was set.
523 /// Pushes N records in one round trip and reports each one's fate.
524 ///
525 /// Returns a [`BatchOutcome`] whose `accepted[i]` says whether record `i` is
526 /// durable at the requested tier. **A partial result is `Ok`, not `Err`** —
527 /// [`ClientError`] describes the *connection*, and after a partial batch the
528 /// connection is fine. Inspect the outcome; do not assume success from `Ok`.
529 ///
530 /// # What a cleared bit means
531 ///
532 /// Not *"this record was never written"*. It means **not durable as of this
533 /// reply — retry it, and expect that it may nonetheless have been written**.
534 /// weir is at-least-once, and a record whose ack timed out may still be
535 /// committed by the flusher afterwards. A set bit is the strong statement
536 /// (it inherits the crown invariant: acked ⇒ durable); a cleared bit is the
537 /// weak one. Building exactly-once retry on cleared bits does not work.
538 ///
539 /// Every *validation* failure — an empty record, one over the cap, a framing
540 /// disagreement — rejects the whole frame with a `Nack` and closes the
541 /// connection, exactly as a malformed single push does. So a bitmap only
542 /// ever reports transient, retryable failures.
543 ///
544 /// A daemon predating `PushBatch` answers `Nack(UnknownMessage)` and closes;
545 /// that surfaces as a poisoning [`ClientError`], so reconnect and use
546 /// [`push`](WeirClient::push).
547 #[must_use = "a batch can partially succeed; inspect `accepted` or call \
548 `all_accepted()` — discarding it hides which records need retrying"]
549 pub fn push_batch(
550 &mut self,
551 records: &[impl AsRef<[u8]>],
552 durability: Durability,
553 ) -> Result<BatchOutcome, ClientError> {
554 self.ensure_usable()?;
555
556 // Local guards, before any bytes are sent. Each of these would be a
557 // Nack-and-close at the daemon, which would poison this client; refusing
558 // here keeps the connection usable, mirroring `send_record`.
559 if records.is_empty() {
560 return Err(ClientError::EmptyPayload);
561 }
562 if records.len() > MAX_BATCH_RECORDS_HARD_CAP {
563 return Err(ClientError::PayloadTooLarge {
564 len: records.len(),
565 limit: MAX_BATCH_RECORDS_HARD_CAP,
566 });
567 }
568 let slices: Vec<&[u8]> = records.iter().map(AsRef::as_ref).collect();
569 for s in &slices {
570 if s.is_empty() {
571 return Err(ClientError::EmptyPayload);
572 }
573 if s.len() > MAX_PAYLOAD_HARD_CAP {
574 return Err(ClientError::PayloadTooLarge {
575 len: s.len(),
576 limit: MAX_PAYLOAD_HARD_CAP,
577 });
578 }
579 }
580
581 let body = encode_batch_body(&slices);
582 let header = Header::new(MessageType::PushBatch, durability, 0);
583 let frame = Envelope::new(header, body).encode();
584 let resp = self.send_frame(&frame)?;
585
586 match resp.header().message_type() {
587 MessageType::AckBatch => {
588 let accepted = decode_ack_batch(resp.payload(), slices.len()).map_err(|e| {
589 // The frame was well-formed and fully consumed, so the
590 // stream is in sync — but a reply this build cannot read
591 // is a version skew every later batch would hit again.
592 // Poison so the caller reconnects.
593 self.poisoned = true;
594 ClientError::Protocol(format!("malformed AckBatch payload: {e}"))
595 })?;
596 Ok(BatchOutcome { accepted })
597 }
598 // A bare Ack means the peer did not understand what was asked. The
599 // request determines the response shape, so treating this as success
600 // would report N records durable on the strength of a reply that
601 // describes one.
602 MessageType::Ack => {
603 self.poisoned = true;
604 Err(ClientError::Protocol(
605 "daemon answered a PushBatch with a bare Ack; the records may be \
606 durable but their individual outcomes are unknown"
607 .to_string(),
608 ))
609 }
610 other => {
611 self.poisoned = true;
612 Err(ClientError::Protocol(format!(
613 "unexpected response to a PushBatch: {other:?}"
614 )))
615 }
616 }
617 }
618
619 /// [`push_tracked`][Self::push_tracked] at the connection's default
620 /// durability tier.
621 ///
622 /// Returns [`ClientError::NoDefaultDurability`] if no default was set.
623 pub fn push_tracked_default(
624 &mut self,
625 payload: impl AsRef<[u8]>,
626 ) -> Result<RecordCoordinate, ClientError> {
627 let d = self
628 .default_durability
629 .ok_or(ClientError::NoDefaultDurability)?;
630 self.push_tracked(payload, d)
631 }
632
633 /// Sends a `HealthCheck` frame and returns `Ok(())` on a valid
634 /// `HealthCheckResponse`. Returns an error if the daemon is unreachable
635 /// or responds with a Nack.
636 pub fn health_check(&mut self) -> Result<(), ClientError> {
637 self.ensure_usable()?;
638 let header = Header::new(MessageType::HealthCheck, Durability::Durable, 0);
639 let frame = Envelope::new(header, vec![]).encode();
640 // Poison on a write failure, exactly as `push` does. A bare `?` here
641 // left the client in a state the crate documents as impossible: the
642 // error is non-recoverable while `is_poisoned()` returns false, and
643 // those two are promised to be equivalent (see `is_poisoned`). A partial
644 // write of the frame also desyncs the stream, which is precisely what
645 // poisoning exists to stop the caller from ignoring.
646 if let Err(e) = self.stream.write_all(&frame) {
647 self.poisoned = true;
648 return Err(e.into());
649 }
650
651 let resp = self.read_response()?;
652 match resp.header().message_type() {
653 MessageType::HealthCheckResponse => Ok(()),
654 // An empty Nack payload is a desync `nack_error` reports as `Protocol`;
655 // poison in that case (see `surface_nack`).
656 MessageType::Nack => Err(self.surface_nack(resp.payload())),
657 // Any other frame type is a stream desync: poison the connection so a
658 // later call can't mis-read a stale frame as its reply (Protocol → not
659 // recoverable, matching `ensure_usable`).
660 other => {
661 self.poisoned = true;
662 Err(ClientError::Protocol(format!(
663 "expected HealthCheckResponse, got {other:?}"
664 )))
665 }
666 }
667 }
668
669 /// Whether this client's connection is dead and must be rebuilt.
670 ///
671 /// Reports the union of the two ways a connection becomes unusable, so it is the
672 /// exact complement of [`is_recoverable`][ClientError::is_recoverable] for the
673 /// error that caused it:
674 ///
675 /// - an **indeterminate stream** (the internal `poisoned` flag) — a
676 /// response-read failure/timeout, a protocol violation in the reply, or any
677 /// stream desync (an unexpected daemon message type, an empty-payload Nack);
678 /// leftover/unexpected bytes could be mis-read as a later reply (a false ack);
679 /// - a **connection-closing Nack** (the internal `closed_after_nack` flag) —
680 /// every Nack reason except the transient
681 /// [`InternalError`][NackReason::InternalError], after which the daemon closes
682 /// the connection.
683 ///
684 /// In either state **every** method fails fast (it will not touch the socket
685 /// again); the caller must drop this client and open a fresh connection.
686 ///
687 /// A long-lived producer thread (e.g. one driven from an async runtime) should
688 /// check this after a failed call — or, equivalently, branch on
689 /// [`ClientError::is_recoverable`] — and rebuild the connection when it reports
690 /// poisoned / non-recoverable, rather than retrying on the dead client:
691 ///
692 /// ```no_run
693 /// # use weir_client::{WeirClient, ClientError};
694 /// # use weir_core::Durability;
695 /// # fn reconnect() -> WeirClient { unreachable!() }
696 /// # let mut client = reconnect();
697 /// if let Err(e) = client.push(b"record", Durability::Durable) {
698 /// if !e.is_recoverable() || client.is_poisoned() {
699 /// client = reconnect(); // drop the dead client, rebuild
700 /// }
701 /// }
702 /// ```
703 ///
704 /// See [`ClientError::is_recoverable`] for the per-error view of the same
705 /// distinction (recoverable = connection still usable; non-recoverable = drop
706 /// and reconnect). The two are kept equivalent: `is_poisoned()` is true after a
707 /// failed call exactly when that call's error is non-recoverable.
708 #[must_use]
709 pub fn is_poisoned(&self) -> bool {
710 self.poisoned || self.closed_after_nack
711 }
712
713 /// Returns the poisoned-connection error if a prior response read failed.
714 /// Once poisoned the stream may hold leftover bytes from an aborted response
715 /// that would be mis-read as this call's reply — a false ack — so we refuse
716 /// to use it (G04). The caller must drop the client and reconnect.
717 pub(crate) fn ensure_usable(&self) -> Result<(), ClientError> {
718 if self.closed_after_nack {
719 return Err(ClientError::Protocol(
720 "connection closed by the daemon after a Nack; \
721 drop this client and reconnect before sending again"
722 .into(),
723 ));
724 }
725 if self.poisoned {
726 return Err(ClientError::Protocol(
727 "client connection poisoned by a prior response-read error/timeout; \
728 drop this client and reconnect before sending again"
729 .into(),
730 ));
731 }
732 Ok(())
733 }
734
735 fn read_response(&mut self) -> Result<Envelope, ClientError> {
736 // Any failure here leaves the stream in an indeterminate state (a timeout
737 // mid-frame, a partial/aborted read, or a protocol violation): the unread
738 // tail of this response could be mis-read as the NEXT request's reply.
739 // Poison the client so every subsequent call fails fast (G04).
740 let result = self.read_response_inner();
741 if result.is_err() {
742 self.poisoned = true;
743 }
744 result
745 }
746
747 fn read_response_inner(&mut self) -> Result<Envelope, ClientError> {
748 let mut header_buf = [0u8; HEADER_LEN];
749 self.stream.read_exact(&mut header_buf)?;
750
751 let header =
752 Header::decode(&header_buf).map_err(|e| ClientError::Protocol(e.to_string()))?;
753
754 let payload_len = header.payload_len() as usize;
755 // Cap before allocating. The bound is the RESPONSE bound, not the record
756 // one: an `Ack` / `HealthCheckResponse` carries 0 bytes, a `Nack` 1 (2 for
757 // `VersionMismatch`), and an `AckTracked` at most one record coordinate —
758 // see the producer checklist in docs/wire_protocol.md, which tells every
759 // other implementer to "cap the response payload_len at a few bytes".
760 //
761 // This used to compare against MAX_PAYLOAD_HARD_CAP (16 MiB), the cap on
762 // a RECORD being sent the other way. So the reference client violated the
763 // checklist it publishes: a desynced or hostile peer could make it
764 // allocate 16 MiB and — with the default `read_timeout` of None — block
765 // forever waiting for bytes that never come.
766 //
767 // The bound is taken from the type the header declares, so a client that
768 // never asks for a coordinate never accepts a payload larger than the two
769 // bytes it always could.
770 let cap = max_response_payload_len(header.message_type());
771 if payload_len > cap {
772 return Err(ClientError::Protocol(format!(
773 "response payload_len {payload_len} exceeds the {cap}-byte cap for \
774 {:?}; refusing to allocate",
775 header.message_type()
776 )));
777 }
778 let mut payload_buf = vec![0u8; payload_len];
779 if payload_len > 0 {
780 self.stream.read_exact(&mut payload_buf)?;
781 }
782
783 let mut crc_buf = [0u8; 4];
784 self.stream.read_exact(&mut crc_buf)?;
785 let expected = u32::from_le_bytes(crc_buf);
786 let computed = crc32fast::hash(&payload_buf);
787 if expected != computed {
788 return Err(ClientError::Protocol(format!(
789 "response payload CRC mismatch: expected {expected:#010x}, computed {computed:#010x}"
790 )));
791 }
792
793 Ok(Envelope::new(header, payload_buf))
794 }
795
796 /// Crate-internal constructor so `tls.rs` can build the struct without
797 /// going through a Unix-socket constructor.
798 #[cfg_attr(not(feature = "tls"), allow(dead_code))]
799 pub(crate) fn from_parts(stream: S, default_durability: Option<Durability>) -> Self {
800 Self {
801 stream,
802 default_durability,
803 poisoned: false,
804 closed_after_nack: false,
805 }
806 }
807}
808
809pub(crate) fn nack_error(payload: &[u8]) -> ClientError {
810 match payload.first().copied() {
811 Some(b) => match NackReason::try_from(b) {
812 // A VersionMismatch Nack carries the daemon's WIRE_VERSION as a
813 // second payload byte (`[0x02, daemon_version]`). Surface it so the
814 // caller can report both sides; fall back to the bare reason if the
815 // daemon (somehow) omitted it.
816 Ok(NackReason::VersionMismatch) => match payload.get(1).copied() {
817 Some(daemon_version) => ClientError::VersionMismatch { daemon_version },
818 None => ClientError::Nack(NackReason::VersionMismatch),
819 },
820 Ok(r) => ClientError::Nack(r),
821 Err(_) => ClientError::UnknownNack(b),
822 },
823 None => ClientError::Protocol("Nack frame had empty payload".into()),
824 }
825}