Skip to main content

spdy_mux/
transport.rs

1//! Transport abstraction for the SPDY multiplexer.
2//!
3//! The mux is generic over [`WsFrameWriter`] and [`WsFrameReader`] traits,
4//! decoupling SPDY framing from the  transport. Two adapters ship
5//! with the crate:
6//!
7//! - [`FastWsWriter`] / [`FastWsReader`]: WebSocket transport via the
8//!   `fastwebsockets` library. Used when SPDY frames are tunnelled inside
9//!   WebSocket binary messages (`SPDY/3.1+portforward.k8s.io`).
10//! - [`RawSpdyWriter`] / [`RawSpdyReader`]: raw transport over any `AsyncRead`
11//!   / `AsyncWrite`. Used when SPDY frames flow directly over an HTTP-upgraded
12//!   connection (legacy `kubectl port-forward` wire protocol, no WebSocket
13//!   framing).
14//!
15//! # Masking copy budget
16//!
17//! WebSocket client role requires frame masking. The adapter costs exactly
18//! one copy per write:
19//!
20//! - `Payload::Borrowed`: `write_frame` copies the borrowed slice into an
21//!   internal buffer and masks there. One allocation plus one copy.
22//! - `Payload::Bytes(BytesMut)`: `write_frame` masks the `BytesMut` in-place.
23//!   One allocation (the `Bytes` to `BytesMut` conversion) plus zero copy for
24//!   masking. Net: one copy total, same as Borrowed.
25//!
26//! `Payload::Borrowed` keeps the simpler path; both copy once. If profiling
27//! shows masking as a bottleneck, switch to `Payload::Bytes` with
28//! `BytesMut::from(&payload[..])` to eliminate the second allocation inside
29//! fastwebsockets (mask in-place instead of allocate-copy-mask).
30
31use std::future::Future;
32
33use bytes::{
34    Bytes,
35    BytesMut,
36};
37use tokio::io::{
38    AsyncRead,
39    AsyncReadExt,
40    AsyncWrite,
41    AsyncWriteExt,
42};
43
44/// Transport-level error for WebSocket read/write operations.
45#[derive(Debug)]
46pub enum TransportError {
47    /// Error from the fastwebsockets library.
48    FastWebSocket(fastwebsockets::WebSocketError),
49    /// Generic I/O error (e.g., broken pipe, connection reset).
50    Io(std::io::Error),
51}
52
53impl std::fmt::Display for TransportError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::FastWebSocket(e) => write!(f, "fastwebsockets: {e}"),
57            Self::Io(e) => write!(f, "transport I/O: {e}"),
58        }
59    }
60}
61
62impl std::error::Error for TransportError {
63    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
64        match self {
65            Self::FastWebSocket(e) => Some(e),
66            Self::Io(e) => Some(e),
67        }
68    }
69}
70
71impl From<fastwebsockets::WebSocketError> for TransportError {
72    fn from(e: fastwebsockets::WebSocketError) -> Self {
73        Self::FastWebSocket(e)
74    }
75}
76
77impl From<std::io::Error> for TransportError {
78    fn from(e: std::io::Error) -> Self {
79        Self::Io(e)
80    }
81}
82
83/// Decoded WebSocket message returned by [`WsFrameReader`].
84///
85/// The mux reader matches on these variants to dispatch SPDY frames, handle
86/// WS-level keepalive (Ping triggers Pong), and detect connection close.
87///
88/// Skip-class frames (Pong, Text, Continuation) are absorbed inside the
89/// adapter's read loop and never reach the mux. The mux reader's idle timer
90/// resets on any successful return from `read_message()`, so absorbed frames
91/// still count as activity.
92pub enum WsMessage {
93    /// Binary payload containing one or more SPDY frames.
94    Binary(Bytes),
95    /// WebSocket-level PING from the peer. The mux reader enqueues a
96    /// `SendWsPong` command so the writer responds.
97    Ping(Bytes),
98    /// WebSocket close frame. The mux reader breaks its loop.
99    Close,
100}
101
102/// Async writer for WebSocket binary frames.
103///
104/// The fastwebsockets adapter writes directly to the socket. Callers MUST
105/// call [`flush`](WsFrameWriter::flush) after a batch of
106/// [`write_binary`](WsFrameWriter::write_binary) calls to ensure data reaches
107/// the peer.
108pub trait WsFrameWriter: Send {
109    /// Write a binary WebSocket frame. May buffer internally.
110    ///
111    /// The `payload` is the complete SPDY frame (header + data), already
112    /// encoded by the codec. The adapter wraps it in a WS binary frame.
113    fn write_binary(
114        &mut self, payload: Bytes,
115    ) -> impl Future<Output = Result<(), TransportError>> + Send;
116
117    /// Write a WebSocket PONG frame in response to a peer PING.
118    fn write_pong(
119        &mut self, payload: Bytes,
120    ) -> impl Future<Output = Result<(), TransportError>> + Send;
121
122    /// Flush all buffered data to the underlying transport.
123    ///
124    /// For tungstenite, flushes the Sink's internal buffer.
125    /// For fastwebsockets, this is a no-op. See [`FastWsWriter::flush`]
126    /// for the rationale.
127    fn flush(&mut self) -> impl Future<Output = Result<(), TransportError>> + Send;
128
129    /// Send a WebSocket Close frame and shut down the write half.
130    fn close(&mut self) -> impl Future<Output = Result<(), TransportError>> + Send;
131}
132
133/// Async reader for WebSocket frames.
134///
135/// Returns [`WsMessage`] variants for binary data, pings, and close.
136/// Skip-class frames (Pong, Text, Continuation) are absorbed inside the
137/// read loop; the mux never sees them.
138pub trait WsFrameReader: Send {
139    /// Read the next actionable WebSocket message.
140    ///
141    /// Returns `None` when the connection is closed (EOF). Returns
142    /// `Some(Err(_))` on transport error. The mux reader breaks its loop
143    /// on both cases.
144    ///
145    /// Pong, Text, and Continuation frames are silently consumed inside
146    /// the adapter's loop and never returned.
147    fn read_message(
148        &mut self,
149    ) -> impl Future<Output = Option<Result<WsMessage, TransportError>>> + Send;
150}
151
152/// fastwebsockets write half adapter.
153///
154/// Wraps `WebSocketWrite<WriteHalf<S>>`. Each `write_binary` call writes
155/// directly to the underlying stream via `AsyncWrite::write_all` /
156/// `write_vectored` (no WS-level buffering).
157pub struct FastWsWriter<S: AsyncWrite + Unpin + Send> {
158    ws: fastwebsockets::WebSocketWrite<S>,
159}
160
161impl<S: AsyncWrite + Unpin + Send> WsFrameWriter for FastWsWriter<S> {
162    async fn write_binary(&mut self, payload: Bytes) -> Result<(), TransportError> {
163        // Payload::Borrowed(&payload) borrows from the function parameter.
164        // the borrow lives for the duration of write_frame. `payload` is owned
165        // by this stack frame and outlives the await point.
166        //
167        // fastwebsockets copies the borrowed slice during masking (one copy,
168        // unavoidable for WS client role). See module-level doc for the full
169        // copy budget analysis.
170        let frame = fastwebsockets::Frame::binary(fastwebsockets::Payload::Borrowed(&payload));
171        self.ws.write_frame(frame).await?;
172        Ok(())
173    }
174
175    async fn write_pong(&mut self, payload: Bytes) -> Result<(), TransportError> {
176        // same lifetime reasoning as write_binary: payload is owned by this
177        // stack frame, borrow is valid across the write_frame await.
178        let frame = fastwebsockets::Frame::pong(fastwebsockets::Payload::Borrowed(&payload));
179        self.ws.write_frame(frame).await?;
180        Ok(())
181    }
182
183    async fn flush(&mut self) -> Result<(), TransportError> {
184        // no-op. fastwebsockets writes through AsyncWrite::write_all (for
185        // small frames) or write_vectored (for frames > writev_threshold),
186        // both of which guarantee data has been written to the underlying
187        // stream before returning.
188        //
189        // no application-level buffer exists at this layer:
190        //
191        // - fastwebsockets has no internal write queue. write_frame writes directly to
192        //   the stream and returns only after write_all completes.
193        //
194        // - the TLS layer (openssl, configured by kube-rs) emits a TLS record per write
195        //   call. There is no record-coalescing buffer that flush() would drain.
196        //
197        // - the TCP layer has TCP_NODELAY set (in client.rs socket config), disabling
198        //   Nagle's algorithm. Data enters the kernel send buffer and is transmitted
199        //   immediately.
200        //
201        // if a future change introduces a buffering layer between this
202        // adapter and the socket (e.g., BufWriter for write coalescing),
203        // this method must be updated to propagate flush.
204        Ok(())
205    }
206
207    async fn close(&mut self) -> Result<(), TransportError> {
208        let frame = fastwebsockets::Frame::close(1000, &[]);
209        self.ws.write_frame(frame).await?;
210        Ok(())
211    }
212}
213
214/// fastwebsockets read half adapter.
215///
216/// Wraps `FragmentCollectorRead<ReadHalf<S>>` for safe handling of any
217/// unexpected WS fragmentation (negligible overhead in the common non-
218/// fragmented case: one branch per frame, no allocation).
219///
220/// `auto_pong` and `auto_close` are disabled. PING and Close frames are
221/// surfaced as [`WsMessage::Ping`] and [`WsMessage::Close`] so the mux
222/// reader handles them through the existing command path (SendWsPong,
223/// break loop). Pong, Text, and Continuation frames are absorbed in the
224/// read loop; the mux never sees them.
225pub struct FastWsReader<S: AsyncRead + Unpin + Send> {
226    ws: fastwebsockets::FragmentCollectorRead<S>,
227}
228
229impl<S: AsyncRead + Unpin + Send> FastWsReader<S> {
230    pub const fn new(ws: fastwebsockets::FragmentCollectorRead<S>) -> Self {
231        Self { ws }
232    }
233}
234
235impl<S: AsyncRead + Unpin + Send> WsFrameReader for FastWsReader<S> {
236    async fn read_message(&mut self) -> Option<Result<WsMessage, TransportError>> {
237        // loop absorbs skip-class frames (Pong, Text, Continuation).
238        // the mux reader's idle timer resets on any successful return,
239        // so absorbed frames still count as activity.
240        loop {
241            // no-op send_fn: auto_pong and auto_close are disabled, so
242            // the callback is never invoked. PING/PONG is handled at the
243            // mux level via MuxCommand::SendWsPong.
244            let frame = match self
245                .ws
246                .read_frame(&mut |_| async { Ok::<(), fastwebsockets::WebSocketError>(()) })
247                .await
248            {
249                Ok(f) => f,
250                // ConnectionClose: peer initiated a clean close, treat as EOF.
251                Err(fastwebsockets::WebSocketError::ConnectionClosed) => return None,
252                // all other errors (IoError, protocol violations) are
253                // transport failures. Both EOF (None) and transport error
254                // (Some(Err)) reach the same mux teardown: the reader breaks
255                // its loop, calls cancel.cancel(), the supervisor fires,
256                // pending_replies fail with MuxClosed, and send windows are
257                // poisoned. The distinction affects only the log level
258                // (debug for EOF, warn for error).
259                Err(e) => return Some(Err(TransportError::FastWebSocket(e))),
260            };
261
262            match frame.opcode {
263                fastwebsockets::OpCode::Binary => {
264                    // zero-copy path: fastwebsockets stores read payloads as
265                    // Payload::Bytes(BytesMut) via split_to from internal
266                    // buffer. freeze() converts to Bytes via refcount bump,
267                    // O(1), no copy.
268                    let bytes = payload_to_bytes(frame.payload);
269                    return Some(Ok(WsMessage::Binary(bytes)));
270                }
271                fastwebsockets::OpCode::Ping => {
272                    let bytes = payload_to_bytes(frame.payload);
273                    return Some(Ok(WsMessage::Ping(bytes)));
274                }
275                fastwebsockets::OpCode::Close => return Some(Ok(WsMessage::Close)),
276                // skip-class: absorb and re-read. the mux reader's idle
277                // timer resets on any successful read_message() return,
278                // not on specific variants.
279                fastwebsockets::OpCode::Pong => {}
280                fastwebsockets::OpCode::Text | fastwebsockets::OpCode::Continuation => {
281                    tracing::warn!(
282                        opcode = ?frame.opcode,
283                        "fastwebsockets reader: unexpected opcode from K8s apiserver, \
284                         absorbing (SPDY tunnel expects only Binary frames)"
285                    );
286                }
287            }
288        }
289    }
290}
291
292/// Extract `Bytes` from a fastwebsockets `Payload` with minimal copying.
293///
294/// - `Payload::Bytes(BytesMut)`: `freeze()`, O(1) zero-copy.
295/// - `Payload::Owned(Vec<u8>)`: `Bytes::from(vec)`, O(1) takes ownership.
296/// - `Payload::Borrowed` / `BorrowedMut`: forces a copy. Should not happen on
297///   the read path since fastwebsockets always returns `Payload::Bytes` for
298///   data read from the socket.
299fn payload_to_bytes(payload: fastwebsockets::Payload<'_>) -> Bytes {
300    match payload {
301        fastwebsockets::Payload::Bytes(bm) => bm.freeze(),
302        fastwebsockets::Payload::Owned(v) => Bytes::from(v),
303        fastwebsockets::Payload::Borrowed(b) => Bytes::copy_from_slice(b),
304        fastwebsockets::Payload::BorrowedMut(b) => Bytes::copy_from_slice(b),
305    }
306}
307
308/// Construct fastwebsockets writer/reader adapters from a raw upgraded
309/// HTTP connection.
310///
311/// Uses [`fastwebsockets::after_handshake_split`] to create pre-split
312/// read/write halves from the already-upgraded stream. Configures the
313/// WebSocket for SPDY tunnel use:
314///
315/// - `auto_pong = false`: PING/PONG handled at the mux level.
316/// - `auto_close = false`: Close handled at the mux level.
317/// - `set_auto_apply_mask = true` (default): Client role masking.
318/// - `set_writev = true` (default): Vectored I/O for large frames.
319pub fn split_fastws<S>(
320    stream: S,
321) -> (
322    FastWsWriter<tokio::io::WriteHalf<S>>,
323    FastWsReader<tokio::io::ReadHalf<S>>,
324)
325where
326    S: AsyncRead + AsyncWrite + Unpin + Send,
327{
328    let (read_half, write_half) = tokio::io::split(stream);
329
330    let (mut ws_read, ws_write) =
331        fastwebsockets::after_handshake_split(read_half, write_half, fastwebsockets::Role::Client);
332
333    // disable auto_pong/auto_close on the read half so PING and Close
334    // frames surface as WsMessage variants for the mux to handle.
335    ws_read.set_auto_pong(false);
336    ws_read.set_auto_close(false);
337
338    let frag_read = fastwebsockets::FragmentCollectorRead::new(ws_read);
339
340    (FastWsWriter { ws: ws_write }, FastWsReader::new(frag_read))
341}
342
343/// Read buffer size for the raw SPDY transport. Sized to drain a typical
344/// 256 KiB socket receive buffer in a few reads while keeping the per-task
345/// stack allocation modest.
346const RAW_READ_BUF_SIZE: usize = 16 * 1024;
347
348/// Raw-transport write half: writes SPDY frame bytes directly to the
349/// underlying [`AsyncWrite`] with no WebSocket framing.
350///
351/// Used when SPDY/3.1 flows over an HTTP/1.1 upgraded connection (the
352/// legacy `kubectl port-forward` wire protocol). Each `write_binary` call
353/// writes one full SPDY frame.
354pub struct RawSpdyWriter<W: AsyncWrite + Unpin + Send> {
355    inner: W,
356}
357
358impl<W: AsyncWrite + Unpin + Send> RawSpdyWriter<W> {
359    pub const fn new(inner: W) -> Self {
360        Self { inner }
361    }
362}
363
364impl<W: AsyncWrite + Unpin + Send> WsFrameWriter for RawSpdyWriter<W> {
365    async fn write_binary(&mut self, payload: Bytes) -> Result<(), TransportError> {
366        // SPDY frames are self-delimiting (8-byte header carries the
367        // payload length), so writing the encoded frame bytes verbatim
368        // is correct.
369        self.inner.write_all(&payload).await?;
370        Ok(())
371    }
372
373    async fn write_pong(&mut self, _payload: Bytes) -> Result<(), TransportError> {
374        // raw SPDY has no WebSocket-level PING/PONG. the mux only invokes
375        // this in response to a `WsMessage::Ping`, which the raw reader
376        // never produces, so this path is unreachable in practice.
377        Ok(())
378    }
379
380    async fn flush(&mut self) -> Result<(), TransportError> {
381        self.inner.flush().await?;
382        Ok(())
383    }
384
385    async fn close(&mut self) -> Result<(), TransportError> {
386        self.inner.shutdown().await?;
387        Ok(())
388    }
389}
390
391/// Raw-transport read half: reads SPDY frame bytes from the underlying
392/// [`AsyncRead`] and yields them as [`WsMessage::Binary`] chunks. The mux
393/// reader accumulates the chunks into its own buffer and decodes complete
394/// SPDY frames from there, so chunk boundaries need not align with frame
395/// boundaries.
396pub struct RawSpdyReader<R: AsyncRead + Unpin + Send> {
397    inner: R,
398    buf: BytesMut,
399}
400
401impl<R: AsyncRead + Unpin + Send> RawSpdyReader<R> {
402    pub fn new(inner: R) -> Self {
403        Self {
404            inner,
405            buf: BytesMut::with_capacity(RAW_READ_BUF_SIZE),
406        }
407    }
408}
409
410impl<R: AsyncRead + Unpin + Send> WsFrameReader for RawSpdyReader<R> {
411    async fn read_message(&mut self) -> Option<Result<WsMessage, TransportError>> {
412        // ensure the buffer has spare capacity. read_buf appends in-place
413        // and grows the buffer if needed.
414        if self.buf.capacity() == self.buf.len() {
415            self.buf.reserve(RAW_READ_BUF_SIZE);
416        }
417        match self.inner.read_buf(&mut self.buf).await {
418            Ok(0) => None,
419            Ok(_) => {
420                let chunk = self.buf.split().freeze();
421                Some(Ok(WsMessage::Binary(chunk)))
422            }
423            Err(e) => Some(Err(TransportError::Io(e))),
424        }
425    }
426}
427
428/// Construct raw SPDY writer/reader adapters from an already-upgraded
429/// connection. Use this for the legacy `Upgrade: SPDY/3.1` path where the
430/// apiserver speaks raw SPDY frames (no WebSocket envelope).
431pub fn split_raw_spdy<S>(
432    stream: S,
433) -> (
434    RawSpdyWriter<tokio::io::WriteHalf<S>>,
435    RawSpdyReader<tokio::io::ReadHalf<S>>,
436)
437where
438    S: AsyncRead + AsyncWrite + Unpin + Send,
439{
440    let (read_half, write_half) = tokio::io::split(stream);
441    (
442        RawSpdyWriter::new(write_half),
443        RawSpdyReader::new(read_half),
444    )
445}