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}