natnet/net.rs
1//! std UDP clients (`net` feature).
2//!
3//! - [`DataClient`]: receive-only data channel, unicast or multicast.
4//! - [`CommandClient`]: sends validated requests, filters the configured peer
5//! and bounds every exchange by a deadline.
6//! - [`RawCommandClient`]: unrestricted datagrams and socket controls.
7//!
8//! Start with [`CommandClient::connect_handshake`], then use
9//! [`CommandClient::exchange`] for read-only requests; `examples/probe.rs`
10//! shows both steps.
11//!
12//! **Not authenticated.** Peer filtering is hygiene only. Queued datagrams are
13//! drained before each request, but NatNet replies carry no transaction id, so
14//! a late reply arriving after the drain can still be mistaken for the current
15//! one. Treat a failed write as an unknown remote outcome and reconcile it
16//! from your own application state.
17
18use std::fmt;
19use std::io;
20use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
21use std::time::{Duration, Instant};
22
23use crate::command::{
24 self, CommandResponse, ConnectOptions, Request, RequestKind, ResponseKind, WriteTier,
25};
26use crate::error::Error as NatNetError;
27use crate::message::{MessageHeader, NAT_FRAMEOFDATA, NAT_MODELDEF, NAT_RESPONSE, NAT_SERVERINFO};
28use crate::serverinfo::ServerInfo;
29use crate::version::Version;
30
31/// Largest datagram accepted by the safe command receive path.
32pub const MAX_COMMAND_DATAGRAM: usize = 65_535;
33
34/// Largest IPv4 UDP payload. NatNet's `u16` header field is wider than this transport limit,
35/// so a request can fit its NatNet header and still be refused before socket I/O.
36pub const MAX_IPV4_UDP_PAYLOAD: usize = 65_507;
37
38/// Smallest [`DataClient::recv`] buffer that can hold every IPv4 UDP datagram
39/// ([`MAX_IPV4_UDP_PAYLOAD`], 65 507 bytes). A datagram longer than the receive buffer is
40/// truncated by the operating system without a portable indication. With a buffer of at least
41/// this size truncation cannot happen; with a shorter buffer, a datagram that fills it
42/// exactly may have been truncated and is reported as an error.
43pub const MIN_DATA_BUFFER: usize = MAX_IPV4_UDP_PAYLOAD;
44
45/// Winsock's `WSAEMSGSIZE`: a datagram was longer than the receive buffer and was truncated.
46#[cfg(windows)]
47const WSAEMSGSIZE: i32 = 10_040;
48
49/// Response for an unsupported NatNet command request. [`CommandClient`] returns
50/// [`io::ErrorKind::Unsupported`] when it receives this reply.
51pub use crate::message::NAT_UNRECOGNIZED_REQUEST;
52
53/// A receive-only NatNet data-channel UDP client.
54/// # Socket options
55/// The bind constructors use `std::net::UdpSocket` and do not configure `SO_REUSEADDR` or
56/// `SO_RCVBUF`. Configure a socket yourself, join its multicast group if needed, then adopt
57/// it with [`DataClient::from_socket`].
58#[derive(Debug)]
59pub struct DataClient {
60 socket: UdpSocket,
61}
62
63impl DataClient {
64 /// Joins `group` on the explicitly selected local interface. The socket binds to
65 /// `0.0.0.0:port` so multicast datagrams are deliverable. Joining can cause the operating
66 /// system's IGMP membership report; this type has no NatNet datagram-send operation.
67 /// # Errors
68 /// Returns the operating-system error from binding or joining the group.
69 pub fn bind_multicast(interface: Ipv4Addr, group: Ipv4Addr, port: u16) -> io::Result<Self> {
70 let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port))?;
71 socket.join_multicast_v4(&group, &interface)?;
72 Ok(Self { socket })
73 }
74
75 /// Binds a unicast data socket to an explicitly selected local interface.
76 /// # Errors
77 /// Returns the operating-system error from binding the socket.
78 pub fn bind_unicast(interface: Ipv4Addr, port: u16) -> io::Result<Self> {
79 let socket = UdpSocket::bind(SocketAddrV4::new(interface, port))?;
80 Ok(Self { socket })
81 }
82
83 /// Adopts a socket the caller has already created, configured, bound and, for multicast,
84 /// joined to its group. This is the seam for socket options `std` cannot set, such as
85 /// `SO_REUSEADDR` and `SO_RCVBUF` (see [the type docs](DataClient)). Nothing is transmitted,
86 /// and the socket's options and blocking mode are left as the caller set them. The
87 /// receive-only guarantee covers this type's API: a clone of the socket the caller kept is
88 /// outside it.
89 #[must_use]
90 pub fn from_socket(socket: UdpSocket) -> Self {
91 Self { socket }
92 }
93
94 /// Puts the receive socket into (non-)blocking mode.
95 /// # Errors
96 /// Returns the operating-system error from changing the socket mode.
97 pub fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> {
98 self.socket.set_nonblocking(nonblocking)
99 }
100
101 /// Sets the data receive timeout. `None` restores an indefinite wait.
102 /// # Errors
103 /// Returns the operating-system error from changing the timeout.
104 pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
105 self.socket.set_read_timeout(timeout)
106 }
107
108 /// Returns the local address selected for the data socket.
109 /// # Errors
110 /// Returns the operating-system error from querying the socket.
111 pub fn local_addr(&self) -> io::Result<SocketAddr> {
112 self.socket.local_addr()
113 }
114
115 /// Receives one datagram and preserves its source address. Pass a buffer of at least
116 /// [`MIN_DATA_BUFFER`] bytes. A shorter buffer still works for datagrams shorter than it, but
117 /// a datagram that fills it exactly may have been truncated by the operating system, so that
118 /// case is returned as an error rather than as possibly partial bytes.
119 /// # Errors
120 /// Returns [`io::ErrorKind::InvalidData`] when the received datagram fills a buffer shorter
121 /// than [`MIN_DATA_BUFFER`] or, on Windows, is reported as truncated (the datagram is
122 /// consumed and cannot be recovered; retry with a larger buffer for the next one). Otherwise
123 /// returns the operating-system receive error, including `WouldBlock` or `TimedOut` when
124 /// configured by the caller.
125 pub fn recv(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
126 let (received, source) = match self.socket.recv_from(buf) {
127 Ok(received) => received,
128 // Windows fails a receive that truncates the datagram instead of
129 // returning the length of the bytes that fit.
130 #[cfg(windows)]
131 Err(error) if error.raw_os_error() == Some(WSAEMSGSIZE) => {
132 return Err(invalid_data(format_args!(
133 "datagram was truncated to the {}-byte receive buffer; use a buffer of at \
134 least MIN_DATA_BUFFER ({MIN_DATA_BUFFER}) bytes",
135 buf.len()
136 )));
137 }
138 Err(error) => return Err(error),
139 };
140 if received == buf.len() && buf.len() < MIN_DATA_BUFFER {
141 return Err(invalid_data(format_args!(
142 "datagram from {source} filled the {received}-byte receive buffer and may be \
143 truncated; use a buffer of at least MIN_DATA_BUFFER ({MIN_DATA_BUFFER}) bytes"
144 )));
145 }
146 Ok((received, source))
147 }
148}
149
150/// An unrestricted command-channel UDP client. Raw operations intentionally live on this
151/// distinct type. The safe client can be converted into it only by consuming the idle client
152/// with [`CommandClient::into_raw`]. A raw client can transmit arbitrary bytes, receive from
153/// any peer, and change socket options; those operations carry no command validation or
154/// transaction guarantee.
155#[derive(Debug)]
156pub struct RawCommandClient {
157 socket: UdpSocket,
158 server: SocketAddr,
159}
160
161impl RawCommandClient {
162 /// Binds an unrestricted command socket to `interface` and records the default destination
163 /// `server`.
164 /// # Errors
165 /// Returns the operating-system error from binding the socket.
166 pub fn bind(interface: Ipv4Addr, server: SocketAddrV4) -> io::Result<Self> {
167 let socket = UdpSocket::bind(SocketAddrV4::new(interface, 0))?;
168 Ok(Self {
169 socket,
170 server: SocketAddr::V4(server),
171 })
172 }
173
174 /// The configured default destination.
175 #[must_use]
176 pub fn server(&self) -> SocketAddr {
177 self.server
178 }
179
180 /// The local address selected for this socket.
181 /// # Errors
182 /// Returns the operating-system error from querying the socket.
183 pub fn local_addr(&self) -> io::Result<SocketAddr> {
184 self.socket.local_addr()
185 }
186
187 /// Sends arbitrary bytes to the configured default destination.
188 /// # Errors
189 /// Returns the operating-system send error.
190 pub fn send(&self, datagram: &[u8]) -> io::Result<usize> {
191 self.socket.send_to(datagram, self.server)
192 }
193
194 /// Sends arbitrary bytes to any destination.
195 /// # Errors
196 /// Returns the operating-system send error.
197 pub fn send_to(&self, datagram: &[u8], destination: SocketAddr) -> io::Result<usize> {
198 self.socket.send_to(datagram, destination)
199 }
200
201 /// Receives arbitrary bytes and returns their source address.
202 /// # Errors
203 /// Returns the operating-system receive error.
204 pub fn recv(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
205 self.socket.recv_from(buf)
206 }
207
208 /// Changes raw receive blocking behavior.
209 /// # Errors
210 /// Returns the operating-system error from changing the socket mode.
211 pub fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> {
212 self.socket.set_nonblocking(nonblocking)
213 }
214
215 /// Changes the raw receive timeout.
216 /// # Errors
217 /// Returns the operating-system error from changing the timeout.
218 pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
219 self.socket.set_read_timeout(timeout)
220 }
221
222 /// Changes the raw send timeout.
223 /// # Errors
224 /// Returns the operating-system error from changing the timeout.
225 pub fn set_write_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
226 self.socket.set_write_timeout(timeout)
227 }
228
229 /// Reads the current raw receive timeout.
230 /// # Errors
231 /// Returns the operating-system error from querying the timeout.
232 pub fn read_timeout(&self) -> io::Result<Option<Duration>> {
233 self.socket.read_timeout()
234 }
235
236 /// Reads the current raw send timeout.
237 /// # Errors
238 /// Returns the operating-system error from querying the timeout.
239 pub fn write_timeout(&self) -> io::Result<Option<Duration>> {
240 self.socket.write_timeout()
241 }
242}
243
244/// A bounded command-channel UDP client. The configured `server` is checked by the transport
245/// before a datagram is accepted as a reply. Each transaction also filters message IDs and
246/// uses an absolute deadline, so unrelated same-peer traffic cannot extend a wait. Receive
247/// controls belong to the transaction; socket access and raw conversion are unavailable while
248/// keeping the safe client:
249/// ```compile_fail
250/// use natnet::net::CommandClient;
251/// fn invalid(client: &CommandClient) {
252/// let mut bytes = [0; 64];
253/// client.recv(&mut bytes);
254/// }
255/// ```
256/// ```compile_fail
257/// use natnet::net::CommandClient;
258/// fn invalid(client: &CommandClient) {
259/// client.set_read_timeout(None);
260/// }
261/// ```
262/// ```compile_fail
263/// use natnet::net::CommandClient;
264/// fn invalid(client: &CommandClient) {
265/// let _socket = client.socket;
266/// }
267/// ```
268/// A live mutable borrow prevents another client operation:
269/// ```compile_fail
270/// use natnet::net::CommandClient;
271/// use std::net::{Ipv4Addr, SocketAddrV4};
272/// fn invalid(client: &mut CommandClient) {
273/// let active = &mut *client;
274/// let _second = client.local_addr();
275/// let _ = active.local_addr();
276/// }
277/// ```
278/// Conversion to the raw API consumes the safe client:
279/// ```compile_fail
280/// use natnet::net::CommandClient;
281/// fn invalid(client: CommandClient) {
282/// let _raw = client.into_raw();
283/// let _ = client.local_addr();
284/// }
285/// ```
286#[derive(Debug)]
287pub struct CommandClient {
288 socket: UdpSocket,
289 server: SocketAddr,
290}
291
292impl CommandClient {
293 /// Binds an explicit local interface and records the configured server IP and port for
294 /// transport-owned peer filtering.
295 /// # Errors
296 /// Returns the operating-system error from binding the socket.
297 pub fn bind(interface: Ipv4Addr, server: SocketAddrV4) -> io::Result<Self> {
298 let socket = UdpSocket::bind(SocketAddrV4::new(interface, 0))?;
299 let server = SocketAddr::V4(server);
300 Ok(Self { socket, server })
301 }
302
303 /// The configured command peer.
304 #[must_use]
305 pub fn server(&self) -> SocketAddr {
306 self.server
307 }
308
309 /// The local address selected for this client.
310 /// # Errors
311 /// Returns the operating-system error from querying the socket.
312 pub fn local_addr(&self) -> io::Result<SocketAddr> {
313 self.socket.local_addr()
314 }
315
316 /// Consumes the idle safe client and transfers its socket to the unrestricted raw API.
317 #[must_use]
318 pub fn into_raw(self) -> RawCommandClient {
319 RawCommandClient {
320 socket: self.socket,
321 server: self.server,
322 }
323 }
324
325 /// Sends one validated command request and receives its matching reply. The receive buffer is
326 /// owned by this operation and can hold every valid UDP datagram. The caller cannot silently
327 /// accept a truncated response by supplying a short buffer. A zero duration, deadline
328 /// overflow, an IPv4 request larger than the UDP payload limit, or a transport failure is
329 /// returned before a successful exchange is reported. Datagrams already queued when the call
330 /// starts are discarded before the request is sent, so a reply to an earlier, timed-out
331 /// request cannot be returned here. A same-peer datagram arriving after the send is still
332 /// accepted if its message id matches (see the module docs).
333 /// # Errors
334 /// Returns `InvalidInput` for an invalid timeout or oversized request; also returns
335 /// `InvalidInput` when a Tier-1 SetProperty is supplied outside
336 /// [`CommandClient::execute_tier1`]; `TimedOut` when the absolute deadline expires, on every
337 /// platform (including while discarding stale datagrams before the send); `Unsupported` as
338 /// soon as the configured peer replies [`NAT_UNRECOGNIZED_REQUEST`]; `InvalidData` for a
339 /// malformed matching reply; and the underlying socket error for send, receive, or
340 /// option-restore failures.
341 pub fn exchange(&mut self, request: &Request, timeout: Duration) -> io::Result<Vec<u8>> {
342 if request.kind() == RequestKind::SetProperty && request.tier() == Some(WriteTier::Tier1) {
343 return Err(invalid_input(
344 "Tier-1 SetProperty must run through execute_tier1",
345 ));
346 }
347 self.exchange_inner(request, timeout)
348 }
349
350 /// Performs a bounded NAT_CONNECT exchange and validates the negotiated protocol layout, the
351 /// server's own version. An unsupported layout is returned with the decoded SERVERINFO
352 /// attached for diagnostics.
353 /// # Errors
354 /// Returns a transport, decode, request-construction, or unsupported protocol error.
355 /// Unsupported replies retain their decoded SERVERINFO.
356 pub fn connect_handshake(&mut self, timeout: Duration) -> Result<ServerInfo, HandshakeError> {
357 let request = command::connect_request().map_err(HandshakeError::Command)?;
358 self.handshake(&request, timeout, |info| info.protocol_version)
359 }
360
361 /// Performs a bounded NAT_CONNECT exchange carrying `options` and validates the layout the
362 /// server will stream, [`ConnectOptions::stream_version`], rather than the server's own
363 /// version. Request a version only with unicast data; see [`ConnectOptions`].
364 /// # Errors
365 /// As [`CommandClient::connect_handshake`].
366 pub fn connect_handshake_with(
367 &mut self,
368 options: ConnectOptions,
369 timeout: Duration,
370 ) -> Result<ServerInfo, HandshakeError> {
371 let request = command::connect_request_with(options).map_err(HandshakeError::Command)?;
372 self.handshake(&request, timeout, |info| options.stream_version(info))
373 }
374
375 fn handshake(
376 &mut self,
377 request: &Request,
378 timeout: Duration,
379 layout: impl FnOnce(&ServerInfo) -> Version,
380 ) -> Result<ServerInfo, HandshakeError> {
381 let datagram = self
382 .exchange(request, timeout)
383 .map_err(HandshakeError::Io)?;
384 let info = ServerInfo::parse(&datagram).map_err(HandshakeError::Decode)?;
385 if let Err(source) = layout(&info).validate() {
386 return Err(HandshakeError::UnsupportedVersion { info, source });
387 }
388 Ok(info)
389 }
390
391 fn exchange_inner(&mut self, request: &Request, timeout: Duration) -> io::Result<Vec<u8>> {
392 if timeout.is_zero() {
393 return Err(invalid_input("command timeout must be nonzero"));
394 }
395 let deadline = Instant::now()
396 .checked_add(timeout)
397 .ok_or_else(|| invalid_input("command timeout overflows the monotonic clock"))?;
398 if request.as_bytes().len() > MAX_IPV4_UDP_PAYLOAD {
399 return Err(invalid_input(format_args!(
400 "command datagram length {} exceeds IPv4 UDP limit {MAX_IPV4_UDP_PAYLOAD}",
401 request.as_bytes().len()
402 )));
403 }
404
405 let previous_read = self.socket.read_timeout()?;
406 let previous_write = self.socket.write_timeout()?;
407 let result = self.exchange_with_deadline(request, deadline);
408 let restore_read = self.socket.set_read_timeout(previous_read);
409 let restore_write = self.socket.set_write_timeout(previous_write);
410
411 match (restore_read, restore_write) {
412 (Err(error), _) | (_, Err(error)) => Err(error),
413 (Ok(()), Ok(())) => result,
414 }
415 }
416
417 fn exchange_with_deadline(&self, request: &Request, deadline: Instant) -> io::Result<Vec<u8>> {
418 let mut buffer = vec![0u8; MAX_COMMAND_DATAGRAM].into_boxed_slice();
419 self.drain_queued(&mut buffer, deadline)?;
420
421 let write_remaining = remaining(deadline)?;
422 self.socket.set_write_timeout(Some(write_remaining))?;
423 self.socket.send_to(request.as_bytes(), self.server)?;
424
425 let expected_id = expected_message_id(request.response_kind());
426 loop {
427 let remaining = remaining(deadline)?;
428 self.socket.set_read_timeout(Some(remaining))?;
429 let (received, source) = match self.socket.recv_from(&mut buffer) {
430 Ok(received) => received,
431 // Linux reports an `SO_RCVTIMEO` expiry as `EAGAIN`; the
432 // documented contract is one timeout kind on every platform.
433 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
434 return Err(io::Error::new(
435 io::ErrorKind::TimedOut,
436 "command deadline elapsed",
437 ));
438 }
439 Err(error) => return Err(error),
440 };
441 if source != self.server {
442 continue;
443 }
444 if received < 2 {
445 continue;
446 }
447 let found = u16::from_le_bytes([buffer[0], buffer[1]]);
448 if found == NAT_UNRECOGNIZED_REQUEST {
449 return Err(io::Error::new(
450 io::ErrorKind::Unsupported,
451 "server replied NAT_UNRECOGNIZED_REQUEST",
452 ));
453 }
454 if found != expected_id {
455 continue;
456 }
457 MessageHeader::parse(&buffer[..received])
458 .map_err(|error| invalid_data(error.to_string()))?;
459 return Ok(buffer[..received].to_vec());
460 }
461 }
462
463 /// Discards every datagram already queued on the socket before a request is sent, so a reply
464 /// that arrived after an earlier transaction's deadline cannot be accepted as the answer to
465 /// this one. The drain is bounded by the transaction deadline, so a peer flooding the socket
466 /// cannot hold it open. Blocking mode is restored on every exit; the drain error takes
467 /// precedence over a restore error.
468 fn drain_queued(&self, buffer: &mut [u8], deadline: Instant) -> io::Result<()> {
469 self.socket.set_nonblocking(true)?;
470 let drained = loop {
471 if let Err(error) = remaining(deadline) {
472 break Err(error);
473 }
474 match self.socket.recv_from(buffer) {
475 Ok(_) => {}
476 Err(error) if error.kind() == io::ErrorKind::WouldBlock => break Ok(()),
477 // Windows reports an ICMP port-unreachable caused by an
478 // earlier send as `ConnectionReset` on a later UDP receive.
479 // Anything queued before this request is stale by definition.
480 Err(error)
481 if matches!(
482 error.kind(),
483 io::ErrorKind::Interrupted | io::ErrorKind::ConnectionReset
484 ) => {}
485 Err(error) => break Err(error),
486 }
487 };
488 let restored = self.socket.set_nonblocking(false);
489 drained?;
490 restored
491 }
492}
493
494fn expected_message_id(response: ResponseKind) -> u16 {
495 match response {
496 ResponseKind::ServerInfo => NAT_SERVERINFO,
497 ResponseKind::ModelDefinition => NAT_MODELDEF,
498 ResponseKind::FrameOfData => NAT_FRAMEOFDATA,
499 ResponseKind::Status | ResponseKind::Value => NAT_RESPONSE,
500 }
501}
502
503fn remaining(deadline: Instant) -> io::Result<Duration> {
504 let duration = deadline.saturating_duration_since(Instant::now());
505 if duration.is_zero() {
506 Err(io::Error::new(
507 io::ErrorKind::TimedOut,
508 "command deadline elapsed",
509 ))
510 } else {
511 Ok(duration)
512 }
513}
514
515fn invalid_input(message: impl fmt::Display) -> io::Error {
516 io::Error::new(io::ErrorKind::InvalidInput, message.to_string())
517}
518
519fn invalid_data(message: impl fmt::Display) -> io::Error {
520 io::Error::new(io::ErrorKind::InvalidData, message.to_string())
521}
522
523/// A bounded handshake failure. Unsupported replies retain their decoded SERVERINFO so
524/// callers can log the producer's reported version.
525#[derive(Debug)]
526pub enum HandshakeError {
527 /// Socket or deadline failure.
528 Io(io::Error),
529 /// The typed NAT_CONNECT request could not be constructed.
530 Command(command::CommandError),
531 /// The peer's datagram was not a valid SERVERINFO.
532 Decode(NatNetError),
533 /// SERVERINFO decoded, but the layout the server will stream is not supported: the server's
534 /// own version after [`CommandClient::connect_handshake`], or
535 /// [`ConnectOptions::stream_version`] after [`CommandClient::connect_handshake_with`], which
536 /// is the requested version when the server honours requests.
537 UnsupportedVersion {
538 /// Decoded SERVERINFO retained for diagnostics.
539 info: ServerInfo,
540 /// The version validation failure, naming the stream version.
541 source: NatNetError,
542 },
543}
544
545impl HandshakeError {
546 /// Returns the decoded SERVERINFO when the stream layout was unsupported.
547 #[must_use]
548 pub fn server_info(&self) -> Option<&ServerInfo> {
549 match self {
550 Self::UnsupportedVersion { info, .. } => Some(info),
551 _ => None,
552 }
553 }
554}
555
556impl fmt::Display for HandshakeError {
557 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
558 match self {
559 Self::Io(error) => write!(formatter, "command handshake I/O error: {error}"),
560 Self::Command(error) => write!(formatter, "command handshake request error: {error}"),
561 Self::Decode(error) => write!(formatter, "invalid SERVERINFO reply: {error}"),
562 Self::UnsupportedVersion { info, source } => {
563 write!(
564 formatter,
565 "unsupported stream protocol version (SERVERINFO reports {}): {source}",
566 info.protocol_version
567 )
568 }
569 }
570 }
571}
572
573impl std::error::Error for HandshakeError {
574 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
575 match self {
576 Self::Io(error) => Some(error),
577 Self::Command(error) => Some(error),
578 Self::Decode(error) => Some(error),
579 Self::UnsupportedVersion { source, .. } => Some(source),
580 }
581 }
582}
583
584/// The point in a Tier-1 transaction at which a failure occurred.
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
586pub enum Tier1Phase {
587 /// Waiting for the SetProperty status.
588 SetPropertyStatus,
589 /// Waiting for or validating the GetProperty re-read.
590 GetPropertyReread,
591}
592
593/// A transport or command failure recorded by the Tier-1 executor.
594#[derive(Debug)]
595pub enum Tier1Failure {
596 /// The socket exchange failed or timed out. This also covers a preflight failure before the
597 /// write reached the socket, so the error does not by itself prove that a remote write was
598 /// attempted.
599 Io(io::Error),
600 /// The received datagram failed command decoding or verification.
601 Command(command::CommandError),
602}
603
604impl fmt::Display for Tier1Failure {
605 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
606 match self {
607 Self::Io(error) => error.fmt(formatter),
608 Self::Command(error) => error.fmt(formatter),
609 }
610 }
611}
612
613impl std::error::Error for Tier1Failure {
614 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
615 match self {
616 Self::Io(error) => Some(error),
617 Self::Command(error) => Some(error),
618 }
619 }
620}
621
622/// A failed sequential Tier-1 execution.
623#[derive(Debug)]
624pub enum Tier1ExecutionError {
625 /// A nonzero server status explicitly rejected the current write.
626 StatusFailure {
627 /// The target whose write was rejected.
628 target: String,
629 /// The producer's nonzero status code.
630 status: i32,
631 /// Earlier targets whose write and re-read were verified.
632 progress: Vec<command::ProgressRecord>,
633 },
634 /// The write or its transport preflight failed, so the remote outcome could not be
635 /// established. The executor has stopped and never retries automatically.
636 UnknownRemoteOutcome {
637 /// The target associated with the failed operation. Its write may not have reached the
638 /// producer.
639 target: String,
640 /// The operation stage that failed.
641 phase: Tier1Phase,
642 /// The transport or verification failure.
643 source: Box<Tier1Failure>,
644 /// Earlier targets whose write and re-read were verified.
645 progress: Vec<command::ProgressRecord>,
646 },
647}
648
649impl Tier1ExecutionError {
650 /// Returns the ordered records completed before this failure.
651 #[must_use]
652 pub fn progress(&self) -> &[command::ProgressRecord] {
653 match self {
654 Self::StatusFailure { progress, .. } | Self::UnknownRemoteOutcome { progress, .. } => {
655 progress
656 }
657 }
658 }
659}
660
661impl fmt::Display for Tier1ExecutionError {
662 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
663 match self {
664 Self::StatusFailure { target, status, .. } => {
665 write!(
666 formatter,
667 "target {target:?} rejected write with status {status}"
668 )
669 }
670 Self::UnknownRemoteOutcome {
671 target,
672 phase,
673 source,
674 ..
675 } => write!(
676 formatter,
677 "target {target:?} has unknown remote outcome during {phase:?}: {source}"
678 ),
679 }
680 }
681}
682
683impl std::error::Error for Tier1ExecutionError {
684 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
685 match self {
686 Self::UnknownRemoteOutcome { source, .. } => Some(source.as_ref()),
687 Self::StatusFailure { .. } => None,
688 }
689 }
690}
691
692/// Executes a validated Tier-1 batch one target at a time. A write status and request-context
693/// re-read must both succeed before the next target is sent. Every failure after a write
694/// attempt stops progression; a preflight failure also stops before any send. The returned
695/// error records the target, prior progress and any unknown remote outcome. No retry or
696/// rollback is attempted.
697impl CommandClient {
698 /// Executes a validated Tier-1 write sequence with one write/status and request-context
699 /// re-read per target.
700 /// # Errors
701 /// Returns a structured status or unknown-remote-outcome failure. Once a write is attempted,
702 /// the sequence stops on any later transport or verification failure and never retries
703 /// automatically. Every failure carries the progress records completed before the failed
704 /// target.
705 pub fn execute_tier1(
706 &mut self,
707 write: command::Tier1Write,
708 timeout: Duration,
709 ) -> Result<Vec<command::ProgressRecord>, Tier1ExecutionError> {
710 let mut step = write.begin();
711 let mut completed = Vec::new();
712 loop {
713 let target = step.target().to_owned();
714 let status = self
715 .exchange_inner(step.set_request(), timeout)
716 .map_err(|source| Tier1ExecutionError::UnknownRemoteOutcome {
717 target: target.clone(),
718 phase: Tier1Phase::SetPropertyStatus,
719 source: Box::new(Tier1Failure::Io(source)),
720 progress: completed.clone(),
721 })?;
722
723 match CommandResponse::parse(&status) {
724 Ok(CommandResponse::Status(0)) => {}
725 Ok(CommandResponse::Status(status)) => {
726 return Err(Tier1ExecutionError::StatusFailure {
727 target,
728 status,
729 progress: completed,
730 });
731 }
732 Ok(CommandResponse::Value(_)) => {
733 return Err(Tier1ExecutionError::UnknownRemoteOutcome {
734 target,
735 phase: Tier1Phase::SetPropertyStatus,
736 source: Box::new(Tier1Failure::Command(
737 command::CommandError::StatusExpected,
738 )),
739 progress: completed,
740 });
741 }
742 Err(source) => {
743 return Err(Tier1ExecutionError::UnknownRemoteOutcome {
744 target,
745 phase: Tier1Phase::SetPropertyStatus,
746 source: Box::new(Tier1Failure::Command(command::CommandError::Wire(
747 source,
748 ))),
749 progress: completed,
750 });
751 }
752 }
753
754 let reread = self
755 .exchange_inner(step.reread_request(), timeout)
756 .map_err(|source| Tier1ExecutionError::UnknownRemoteOutcome {
757 target: target.clone(),
758 phase: Tier1Phase::GetPropertyReread,
759 source: Box::new(Tier1Failure::Io(source)),
760 progress: completed.clone(),
761 })?;
762 let verified = step.verify_status(&status, &reread).map_err(|source| {
763 Tier1ExecutionError::UnknownRemoteOutcome {
764 target: target.clone(),
765 phase: Tier1Phase::GetPropertyReread,
766 source: Box::new(Tier1Failure::Command(source)),
767 progress: completed.clone(),
768 }
769 })?;
770 let progress = verified.progress().to_vec();
771 match verified.next() {
772 Some(next) => {
773 completed = progress;
774 step = next;
775 }
776 None => return Ok(progress),
777 }
778 }
779 }
780}