Skip to main content

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}