Skip to main content

rs_teststand_websocket/client/
mod.rs

1//! Connecting to a host from the other side.
2//!
3//! The counterpart to [`WebSocketBridge`](crate::WebSocketBridge). A host
4//! serves; this connects, sends [`Command`]s and reads what comes back.
5//!
6//! It adds no vocabulary of its own. The wire types are [`Command`], [`Ack`]
7//! and [`MessageEvent`] exactly as the host uses them, so someone writing a
8//! front end learns the engine's model rather than this crate's. The single
9//! type defined here is [`Inbound`], because the socket really does carry two
10//! different things and Rust needs a name for that choice.
11
12use futures_util::{SinkExt, StreamExt};
13use tokio_tungstenite::tungstenite::Message;
14
15use rs_teststand_bridge::{Ack, Command, Error, MessageEvent};
16
17/// Renders a socket failure as the transport error the crate already has.
18mod backoff;
19mod inbound;
20
21pub use backoff::Backoff;
22pub use inbound::Inbound;
23
24fn transport(error: &tokio_tungstenite::tungstenite::Error) -> Error {
25    Error::Transport(std::io::Error::other(error.to_string()))
26}
27
28/// Largest payload a control frame may carry, in bytes.
29///
30/// RFC 6455 section 5.5 fixes this: every control frame must be 125 bytes or
31/// less and must not be fragmented.
32pub const MAX_CONTROL_PAYLOAD: usize = 125;
33
34/// A connection to a host.
35///
36/// Async because the transport is. A caller that wants blocking behavior runs
37/// it on a runtime it owns; the crate does not choose one on its behalf.
38#[derive(Debug)]
39pub struct Client {
40    socket: tokio_tungstenite::WebSocketStream<
41        tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
42    >,
43}
44
45impl Client {
46    /// Connects to a host.
47    ///
48    /// `address` is a websocket URL, such as `ws://127.0.0.1:50751`.
49    ///
50    /// # Errors
51    /// [`Error::Transport`] if the connection or handshake fails.
52    pub async fn connect(address: &str) -> Result<Self, Error> {
53        let (socket, _) = tokio_tungstenite::connect_async(address)
54            .await
55            .map_err(|error| transport(&error))?;
56        Ok(Self { socket })
57    }
58
59    /// Connects, retrying while the host is unreachable.
60    ///
61    /// For a panel meant to survive a host restart. Returns the last failure
62    /// once the attempts run out, rather than looping for ever, so a caller
63    /// still learns that the host is gone.
64    ///
65    /// # Errors
66    /// [`Error::Transport`] carrying the final failure.
67    pub async fn connect_with_backoff(address: &str, backoff: Backoff) -> Result<Self, Error> {
68        // RFC 6455 section 7.2.3: "The first reconnect attempt SHOULD be
69        // delayed by a random amount of time." Not the second one, the first.
70        // Every client of a host that dropped out wakes at the same instant, so
71        // an immediate first attempt puts the whole crowd on the doorstep
72        // together, which is the denial of service that section describes.
73        tokio::time::sleep(Backoff::first_delay()).await;
74
75        let mut last = None;
76        for attempt in 0..backoff.attempts.max(1) {
77            match Self::connect(address).await {
78                Ok(client) => return Ok(client),
79                Err(error) => last = Some(error),
80            }
81            if attempt + 1 < backoff.attempts {
82                tokio::time::sleep(backoff.delay(attempt)).await;
83            }
84        }
85        Err(last.unwrap_or_else(|| {
86            Error::Transport(std::io::Error::other("no connection attempt was made"))
87        }))
88    }
89
90    /// Sends one command.
91    ///
92    /// # Errors
93    /// [`Error::Payload`] if the command cannot be encoded, or
94    /// [`Error::Transport`] if the socket refuses it.
95    pub async fn send(&mut self, command: &Command) -> Result<(), Error> {
96        let text = serde_json::to_string(command)?;
97        self.socket
98            .send(Message::Text(text.into()))
99            .await
100            .map_err(|error| transport(&error))
101    }
102
103    /// Reads the next acknowledgement or event.
104    ///
105    /// Returns `None` once the host closes the connection. Frames that are not
106    /// text are skipped, so a ping or a pong does not look like an answer.
107    ///
108    /// # Errors
109    /// [`Error::Transport`] if the socket fails, or [`Error::Payload`]
110    /// if a text frame is neither shape.
111    pub async fn next(&mut self) -> Result<Option<Inbound>, Error> {
112        while let Some(frame) = self.socket.next().await {
113            match frame.map_err(|error| transport(&error))? {
114                Message::Text(text) => return Inbound::parse(&text).map(Some),
115                Message::Close(frame) => {
116                    // RFC 6455 section 5.5.1: an endpoint receiving a Close
117                    // that has not sent one MUST send one in response, and
118                    // typically echoes the status code it was given. Passing
119                    // the frame straight back does that. Skipping the reply
120                    // leaves the peer waiting until it times out instead of
121                    // closing cleanly. A send failure here means the peer has
122                    // already gone, which is the outcome being asked for.
123                    let _ = self.socket.send(Message::Close(frame)).await;
124                    return Ok(None);
125                }
126                // Ping and pong are answered inside the library, which owns the
127                // protocol layer. Binary frames are not part of this protocol.
128                _ => (),
129            }
130        }
131        Ok(None)
132    }
133
134    /// Starts a close, then reads until the peer closes back.
135    ///
136    /// RFC 6455 section 5.5.1 makes closing a handshake rather than a hang-up:
137    /// each side sends a Close and waits for the other. Dropping the socket
138    /// without it leaves the peer to time out.
139    ///
140    /// No data frame goes out after the Close, which the same section forbids.
141    /// This only reads from that point on.
142    ///
143    /// Messages arriving before the peer's Close are handed to `observe`, since
144    /// a run can still be reporting when a client decides to leave.
145    ///
146    /// # Errors
147    /// [`Error::Transport`] if the Close cannot be sent.
148    pub async fn close(mut self, mut observe: impl FnMut(Inbound)) -> Result<(), Error> {
149        self.socket
150            .send(Message::Close(None))
151            .await
152            .map_err(|error| transport(&error))?;
153
154        while let Some(frame) = self.socket.next().await {
155            match frame {
156                Ok(Message::Close(_)) | Err(_) => break,
157                Ok(Message::Text(text)) => {
158                    if let Ok(inbound) = Inbound::parse(&text) {
159                        observe(inbound);
160                    }
161                }
162                Ok(_) => (),
163            }
164        }
165        Ok(())
166    }
167
168    /// Sends a ping carrying `payload`.
169    ///
170    /// RFC 6455 section 5.5.2: an endpoint receiving a Ping MUST answer with a
171    /// Pong, unless it has already received a Close. The section names this use
172    /// directly, as a keepalive or a way to check the peer still responds,
173    /// which is what a connection count alone cannot tell you.
174    ///
175    /// The pong is consumed by the library rather than surfaced here, so this
176    /// proves liveness by the absence of a transport error rather than by a
177    /// returned value.
178    ///
179    /// A payload over [`MAX_CONTROL_PAYLOAD`] is refused here. Section 5.5
180    /// caps every control frame at 125 bytes, and measured against a live peer
181    /// an oversized ping is not rejected on the way out: the send reports
182    /// success and the connection is then torn down, so the caller is told the
183    /// ping worked and loses the session. Refusing before the send turns a
184    /// silent kill into an error the caller can act on.
185    ///
186    /// # Errors
187    /// [`Error::ControlFrameTooLarge`] if the payload exceeds the cap, or
188    /// [`Error::Transport`] if the ping cannot be sent.
189    pub async fn ping(&mut self, payload: Vec<u8>) -> Result<(), Error> {
190        if payload.len() > MAX_CONTROL_PAYLOAD {
191            return Err(Error::ControlFrameTooLarge {
192                bytes: payload.len(),
193                limit: MAX_CONTROL_PAYLOAD,
194            });
195        }
196        self.socket
197            .send(Message::Ping(payload.into()))
198            .await
199            .map_err(|error| transport(&error))
200    }
201
202    /// Sends a command and reads until its acknowledgement arrives.
203    ///
204    /// Events that arrive first are handed to `observe` rather than dropped,
205    /// because a run reports progress while a command is in flight and a
206    /// caller that discards those loses the trace.
207    ///
208    /// # Errors
209    /// [`Error`] if the send fails, the socket fails, or the host closes
210    /// before answering.
211    pub async fn request(
212        &mut self,
213        command: &Command,
214        mut observe: impl FnMut(&MessageEvent),
215    ) -> Result<Ack, Error> {
216        self.send(command).await?;
217        while let Some(inbound) = self.next().await? {
218            match inbound {
219                Inbound::Ack(ack) => return Ok(ack),
220                Inbound::Event(event) => observe(&event),
221            }
222        }
223        Err(Error::Transport(std::io::Error::new(
224            std::io::ErrorKind::UnexpectedEof,
225            "the host closed before acknowledging",
226        )))
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use std::time::Duration;
233
234    use super::{Backoff, Inbound};
235    use rs_teststand_bridge::{Ack, MessageEvent};
236
237    #[test]
238    fn an_acknowledgement_is_recognized_by_its_command_field() {
239        let text = serde_json::to_string(&Ack::ok("start", "started")).unwrap_or_default();
240        let parsed = Inbound::parse(&text).ok();
241        assert!(
242            parsed.as_ref().and_then(Inbound::as_ack).is_some(),
243            "{text}"
244        );
245    }
246
247    #[test]
248    fn an_event_is_recognized_by_the_absence_of_one() {
249        // The same rule the panel uses. Both shapes carry `code`, so only the
250        // presence of `command` separates them.
251        let event = MessageEvent {
252            code: 2,
253            numeric: 0.0,
254            text: "stopped".to_owned(),
255            payload: None,
256            synchronous: false,
257            execution_id: Some(4),
258        };
259        let text = serde_json::to_string(&event).unwrap_or_default();
260        let parsed = Inbound::parse(&text).ok();
261        assert_eq!(
262            parsed.as_ref().and_then(Inbound::as_event).map(|e| e.code),
263            Some(2),
264            "{text}"
265        );
266    }
267
268    #[test]
269    fn the_delay_grows_and_then_stops_growing() {
270        let backoff = Backoff {
271            first: Duration::from_secs(1),
272            longest: Duration::from_secs(30),
273            attempts: 10,
274        };
275        // Doubling, before jitter: 1, 2, 4, 8.
276        assert!(backoff.delay(0) >= Duration::from_secs(1));
277        assert!(backoff.delay(1) >= Duration::from_secs(2));
278        assert!(backoff.delay(3) >= Duration::from_secs(8));
279
280        // Capped, and the cap holds however far out the attempt is. Without
281        // this a long outage would push the wait into hours.
282        let ceiling = Duration::from_secs(30) + Duration::from_secs(30) / 4;
283        for attempt in 5..40 {
284            assert!(
285                backoff.delay(attempt) <= ceiling,
286                "attempt {attempt} exceeded the cap"
287            );
288        }
289    }
290
291    #[test]
292    fn the_first_attempt_is_delayed_by_a_random_amount() {
293        // Section 7.2.3 asks for this on the first attempt specifically. Zero
294        // would put every client of a restarting host on the doorstep at once.
295        let first = Backoff::first_delay();
296        assert!(
297            first <= Duration::from_secs(5),
298            "{first:?} exceeds the range"
299        );
300    }
301
302    #[test]
303    fn the_delay_carries_jitter_above_the_plain_doubling() {
304        // The point of the jitter is that clients do not return in lockstep, so
305        // the delay must be able to exceed the bare doubled value.
306        let backoff = Backoff::default();
307        assert!(backoff.delay(2) >= Duration::from_secs(4));
308        assert!(backoff.delay(2) <= Duration::from_secs(5));
309    }
310
311    #[test]
312    fn an_oversized_control_payload_is_refused_before_it_is_sent() {
313        // Measured: sending 126 bytes reports success and then kills the
314        // connection, so the caller believes the ping worked and has lost the
315        // session. 125 is the ceiling RFC 6455 section 5.5 sets.
316        assert_eq!(super::MAX_CONTROL_PAYLOAD, 125);
317    }
318
319    #[test]
320    fn a_frame_that_is_neither_shape_is_an_error_rather_than_a_guess() {
321        assert!(Inbound::parse("not json").is_err());
322    }
323}