Skip to main content

actioncable/
websocket.rs

1use std::io;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use futures_util::stream::{SplitSink, SplitStream};
6use futures_util::{SinkExt, StreamExt};
7use http::HeaderValue;
8use http::header::{SEC_WEBSOCKET_EXTENSIONS, SEC_WEBSOCKET_PROTOCOL, USER_AGENT};
9use tokio::io::{AsyncRead, AsyncWrite};
10use tokio::sync::watch;
11use tokio_tungstenite::WebSocketStream;
12use tokio_tungstenite::tungstenite::error::{CapacityError, Error as TungsteniteError};
13use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
14use tokio_tungstenite::tungstenite::protocol::frame::CloseFrame;
15use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
16use tokio_tungstenite::tungstenite::{Message as Frame, client::IntoClientRequest};
17
18use crate::error::Error;
19use crate::transport::{Conn, DialOptions, Transport};
20
21const USER_AGENT_VALUE: &str = concat!("actioncable/", env!("CARGO_PKG_VERSION"));
22const CLOSE_TIMEOUT: Duration = Duration::from_secs(1);
23
24/// RFC 6455 §7.4.1's code that stands in for a close frame carrying none.
25const CLOSE_NO_STATUS: u16 = 1005;
26
27/// What fits in a close frame after the code: a control frame's payload is at most 125
28/// bytes.
29const MAX_CLOSE_REASON_BYTES: usize = 123;
30
31/// The built-in transport: tokio-tungstenite over rustls, trusting the platform's root
32/// certificates. It handles the upgrade handshake, answers pings, and reassembles
33/// fragmented messages. The request carries `User-Agent: actioncable/<version>` unless the
34/// caller set one, and never negotiates extensions.
35#[derive(Debug, Clone)]
36pub struct WebSocketTransport {
37    handshake_timeout: Duration,
38    write_timeout: Duration,
39    max_message_size: usize,
40}
41
42impl WebSocketTransport {
43    /// The transport with its defaults: a ten second handshake, a ten second write, and
44    /// messages up to 8 MiB.
45    pub fn new() -> WebSocketTransport {
46        WebSocketTransport {
47            handshake_timeout: Duration::from_secs(10),
48            write_timeout: Duration::from_secs(10),
49            max_message_size: 8 << 20,
50        }
51    }
52
53    /// Bounds the TCP connect, the TLS handshake and the upgrade together. Ten seconds by
54    /// default.
55    pub fn handshake_timeout(mut self, timeout: Duration) -> WebSocketTransport {
56        self.handshake_timeout = timeout;
57        self
58    }
59
60    /// Bounds one write. A peer that has stopped reading would otherwise hold a `perform`
61    /// forever; past the timeout the write fails and the connection is dropped and redialed,
62    /// the same as a failed read. Ten seconds by default.
63    pub fn write_timeout(mut self, timeout: Duration) -> WebSocketTransport {
64        self.write_timeout = timeout;
65        self
66    }
67
68    /// The largest message accepted, in bytes. 8 MiB by default.
69    pub fn max_message_size(mut self, bytes: usize) -> WebSocketTransport {
70        self.max_message_size = bytes;
71        self
72    }
73}
74
75impl Default for WebSocketTransport {
76    fn default() -> WebSocketTransport {
77        WebSocketTransport::new()
78    }
79}
80
81#[async_trait]
82impl Transport for WebSocketTransport {
83    async fn dial(&self, url: &str, options: DialOptions) -> Result<Box<dyn Conn>, Error> {
84        let request = upgrade_request(url, options)?;
85        // The frame limit matches the message limit, so an oversized frame is refused by
86        // its header, before any of it is read in, the way the Go client refuses one.
87        let config = WebSocketConfig::default()
88            .max_message_size(Some(self.max_message_size))
89            .max_frame_size(Some(self.max_message_size));
90        let connecting = tokio_tungstenite::connect_async_with_config(request, Some(config), false);
91
92        let (stream, response) =
93            match tokio::time::timeout(self.handshake_timeout, connecting).await {
94                Ok(connected) => connected.map_err(failure),
95                Err(elapsed) => Err(Error::transport(elapsed)),
96            }?;
97
98        let subprotocol = response
99            .headers()
100            .get(SEC_WEBSOCKET_PROTOCOL)
101            .map(|value| String::from_utf8_lossy(value.as_bytes()).into_owned());
102
103        Ok(Box::new(WebSocketConn::new(
104            stream,
105            subprotocol,
106            self.write_timeout,
107        )))
108    }
109}
110
111/// The caller's headers under the ones the handshake needs, so nothing given to the builder
112/// can break the upgrade itself. Extensions are never negotiated.
113fn upgrade_request(url: &str, options: DialOptions) -> Result<http::Request<()>, Error> {
114    let mut request = url.into_client_request().map_err(Error::transport)?;
115    let required = request.headers().clone();
116
117    let headers = request.headers_mut();
118    headers.extend(options.headers);
119    if !headers.contains_key(USER_AGENT) {
120        headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
121    }
122    headers.remove(SEC_WEBSOCKET_EXTENSIONS);
123    headers.remove(SEC_WEBSOCKET_PROTOCOL);
124    if !options.subprotocols.is_empty() {
125        let offered = options.subprotocols.join(", ");
126        headers.insert(
127            SEC_WEBSOCKET_PROTOCOL,
128            HeaderValue::from_str(&offered).map_err(Error::transport)?,
129        );
130    }
131    headers.extend(required);
132
133    Ok(request)
134}
135
136/// One upgraded socket, split so the client can read on one task and write on another. It
137/// is generic over the byte stream only so a test can hand it a pipe with a peer that
138/// never reads; a dial always puts a TCP or TLS stream in it.
139struct WebSocketConn<S> {
140    subprotocol: Option<String>,
141    write_timeout: Duration,
142    sink: tokio::sync::Mutex<SplitSink<WebSocketStream<S>, Frame>>,
143    stream: tokio::sync::Mutex<SplitStream<WebSocketStream<S>>>,
144    closed: watch::Sender<bool>,
145}
146
147impl<S> WebSocketConn<S>
148where
149    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
150{
151    fn new(
152        socket: WebSocketStream<S>,
153        subprotocol: Option<String>,
154        write_timeout: Duration,
155    ) -> WebSocketConn<S> {
156        let (sink, stream) = socket.split();
157        WebSocketConn {
158            subprotocol,
159            write_timeout,
160            sink: tokio::sync::Mutex::new(sink),
161            stream: tokio::sync::Mutex::new(stream),
162            closed: watch::Sender::new(false),
163        }
164    }
165
166    /// Sends one close frame and interrupts a read in flight. A peer that won't take the
167    /// frame within a second is left behind: the socket goes when the connection is
168    /// dropped.
169    async fn hang_up(&self, frame: Option<CloseFrame>) {
170        if self.closed.send_replace(true) {
171            return;
172        }
173
174        let closing = async {
175            let mut sink = self.sink.lock().await;
176            let _sent = sink.send(Frame::Close(frame)).await;
177            sink.close().await
178        };
179        if let Err(error) = tokio::time::timeout(CLOSE_TIMEOUT, closing).await {
180            tracing::debug!(%error, "the close frame did not go out");
181        }
182    }
183}
184
185#[async_trait]
186impl<S> Conn for WebSocketConn<S>
187where
188    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
189{
190    fn subprotocol(&self) -> Option<&str> {
191        self.subprotocol.as_deref()
192    }
193
194    /// Pings are answered by tungstenite itself; they and the pongs only need skipping.
195    async fn read(&self) -> Result<Vec<u8>, Error> {
196        let mut closed = self.closed.subscribe();
197        let mut stream = self.stream.lock().await;
198
199        loop {
200            let next = tokio::select! {
201                biased;
202                _ = closed.wait_for(|closed| *closed) => None,
203                next = stream.next() => next,
204            };
205
206            match next {
207                Some(Ok(Frame::Text(text))) => break Ok(Vec::from(text.as_str())),
208                Some(Ok(Frame::Binary(bytes))) => break Ok(bytes.to_vec()),
209                Some(Ok(Frame::Ping(_) | Frame::Pong(_) | Frame::Frame(_))) => {}
210                Some(Ok(Frame::Close(frame))) => break Err(server_closed(frame.as_ref())),
211                Some(Err(error)) => break Err(failure(error)),
212                None => {
213                    break Err(Error::transport(io::Error::new(
214                        io::ErrorKind::NotConnected,
215                        "the connection is closed",
216                    )));
217                }
218            }
219        }
220    }
221
222    /// A write the peer won't take within `write_timeout` fails, and a write cut off partway
223    /// leaves the socket unusable, so the client hangs up after either.
224    async fn write(&self, payload: &[u8]) -> Result<(), Error> {
225        let text = String::from_utf8(payload.to_vec()).map_err(Error::transport)?;
226        let sending = async { self.sink.lock().await.send(Frame::text(text)).await };
227        match tokio::time::timeout(self.write_timeout, sending).await {
228            Ok(sent) => sent.map_err(failure),
229            Err(elapsed) => Err(Error::transport(elapsed)),
230        }
231    }
232
233    /// Hangs up with 1000 Normal Closure.
234    async fn close(&self) {
235        self.hang_up(Some(CloseFrame {
236            code: CloseCode::Normal,
237            reason: String::new().into(),
238        }))
239        .await;
240    }
241
242    /// Hangs up with the code and reason given, and as much of the reason as a control
243    /// frame has room for.
244    async fn close_with_status(&self, code: u16, reason: &str) {
245        self.hang_up(Some(CloseFrame {
246            code: CloseCode::from(code),
247            reason: truncate(reason, MAX_CLOSE_REASON_BYTES).into(),
248        }))
249        .await;
250    }
251}
252
253/// What the server said when it closed the connection: the code the frame carried, 1005
254/// when it carried none, and the text after it.
255fn server_closed(frame: Option<&CloseFrame>) -> Error {
256    match frame {
257        Some(frame) => Error::Close {
258            code: u16::from(frame.code),
259            reason: frame.reason.to_string(),
260        },
261        None => Error::Close {
262            code: CLOSE_NO_STATUS,
263            reason: String::new(),
264        },
265    }
266}
267
268/// The two failures a caller may want to act on rather than read — an upgrade the server
269/// refused and a message past the limit, which tungstenite refuses by its header before
270/// reading it in — and everything else as the transport error it is.
271fn failure(error: TungsteniteError) -> Error {
272    match error {
273        TungsteniteError::Http(response) => Error::Handshake {
274            status: response.status(),
275        },
276        TungsteniteError::Capacity(CapacityError::MessageTooLong { size, max_size }) => {
277            Error::MessageTooBig {
278                size,
279                limit: max_size,
280            }
281        }
282        other => Error::transport(other),
283    }
284}
285
286/// As much of `text` as fits in `bytes`, cut on a character boundary.
287fn truncate(text: &str, bytes: usize) -> &str {
288    if text.len() <= bytes {
289        text
290    } else {
291        let mut end = bytes;
292        while !text.is_char_boundary(end) {
293            end -= 1;
294        }
295        &text[..end]
296    }
297}
298
299/// The transport is driven over a real socket by `tests/websocket.rs`. What is left here is
300/// what needs the inside of it: a stream with a peer that never reads, and the arithmetic
301/// around a close frame.
302#[cfg(test)]
303#[allow(
304    clippy::unwrap_used,
305    clippy::expect_used,
306    reason = "a test that can't have what it asked for has nothing left to assert"
307)]
308mod tests {
309    use std::time::Instant;
310
311    use tokio_tungstenite::tungstenite::protocol::Role;
312
313    use super::*;
314
315    #[tokio::test]
316    async fn a_write_the_peer_never_reads_times_out() {
317        let (ours, _theirs) = tokio::io::duplex(64);
318        let socket = WebSocketStream::from_raw_socket(ours, Role::Client, None).await;
319        let conn = WebSocketConn::new(socket, None, Duration::from_millis(100));
320        let started = Instant::now();
321
322        let outcome = conn.write("cable".repeat(1_000).as_bytes()).await;
323
324        assert!(matches!(outcome, Err(Error::Transport(_))), "{outcome:?}");
325        assert!(started.elapsed() >= Duration::from_millis(100));
326        assert!(
327            started.elapsed() < Duration::from_secs(2),
328            "the write hung past its timeout"
329        );
330    }
331
332    #[test]
333    fn a_close_reason_is_cut_to_what_the_frame_holds() {
334        assert_eq!("done here", truncate("done here", MAX_CLOSE_REASON_BYTES));
335        assert_eq!(
336            MAX_CLOSE_REASON_BYTES,
337            truncate(&"r".repeat(200), MAX_CLOSE_REASON_BYTES).len()
338        );
339        assert_eq!("ab", truncate("abçd", 3), "a cut lands on a character");
340    }
341}